feat(messenger): Slack형 실시간 채팅 메신저 구현
- FastAPI + WebSocket 백엔드 (ws_relay, webhook, messages 라우터) - GUARDiA-Bot: @bot 명령 응답 + 선제적 맥락 분석 (DB 지연, 디스크, 장애 감지) - 승인 워크플로우: 재기동/배포 SR → 승인/반려 인터랙티브 버튼 - 다크 테마 Slack형 프론트엔드 (5개 채널, 실시간 메시지) - 채널: 일반/배포/운영/PM관리/알림 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
45f96176a6
commit
85e4901541
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "guardia-messenger",
|
||||
"runtimeExecutable": "uvicorn",
|
||||
"runtimeArgs": ["main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"],
|
||||
"cwd": "C:\\GUARDiA\\messenger",
|
||||
"port": 8000
|
||||
}
|
||||
]
|
||||
}
|
||||
0
messenger/core/__init__.py
Normal file
0
messenger/core/__init__.py
Normal file
178
messenger/core/bot.py
Normal file
178
messenger/core/bot.py
Normal file
@ -0,0 +1,178 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
# In-memory message store (room_id -> list of message dicts)
|
||||
message_store: dict[str, list] = {}
|
||||
|
||||
ROOMS: dict[str, str] = {
|
||||
"general": "일반",
|
||||
"deploy": "배포",
|
||||
"ops": "운영",
|
||||
"pm": "PM관리",
|
||||
"alerts": "알림",
|
||||
}
|
||||
|
||||
CONTEXT_RULES = {
|
||||
"db_delay": {
|
||||
"keywords": ["DB", "디비", "쿼리", "느리", "타임아웃", "락", "lock", "slow"],
|
||||
"action": "FETCH_DB_LOCKS",
|
||||
"message": "DB 지연 징후가 감지되었습니다. 대기 쿼리를 조회할까요?",
|
||||
},
|
||||
"disk_full": {
|
||||
"keywords": ["용량", "디스크", "로그", "쌓였", "full", "disk", "no space"],
|
||||
"action": "CHECK_DISK_SPACE",
|
||||
"message": "디스크 공간 관련 문제가 감지되었습니다. 용량을 확인할까요?",
|
||||
},
|
||||
"service_down": {
|
||||
"keywords": ["다운", "죽었", "응답없", "timeout", "503", "502", "500", "오류", "장애"],
|
||||
"action": "CHECK_SERVICE_STATUS",
|
||||
"message": "서비스 장애 징후가 감지되었습니다. 서비스 상태를 확인할까요?",
|
||||
},
|
||||
"ssl_expire": {
|
||||
"keywords": ["ssl", "인증서", "만료", "expire", "https"],
|
||||
"action": "CHECK_SSL_CERTS",
|
||||
"message": "SSL 인증서 관련 문의가 감지되었습니다. 만료일을 확인할까요?",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _new_sr() -> str:
|
||||
return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
|
||||
|
||||
|
||||
def get_bot_response(text: str) -> Optional[dict]:
|
||||
"""@bot 멘션 또는 guardia 키워드 → 명령 응답 생성."""
|
||||
t = text.lower()
|
||||
if "@bot" not in t and "guardia" not in t:
|
||||
return None
|
||||
|
||||
if "도움" in text or "help" in t or "명령" in text:
|
||||
return {
|
||||
"content": (
|
||||
"**GUARDiA Bot** 사용 가능한 명령어:\n"
|
||||
"• `@bot 배포` — 배포 SR 생성\n"
|
||||
"• `@bot 재기동` — WAS 재기동 요청 (PM 승인 필요)\n"
|
||||
"• `@bot 로그 확인` — 에러 로그 분석\n"
|
||||
"• `@bot 서버 상태` — 인프라 현황 조회\n"
|
||||
"• `@bot SSL 확인` — 인증서 만료일 조회\n"
|
||||
"• `@bot 정지` — 진행 중 작업 즉시 중단"
|
||||
),
|
||||
"is_widget": False,
|
||||
}
|
||||
|
||||
if "배포" in text or "deploy" in t:
|
||||
sr_id = _new_sr()
|
||||
return {
|
||||
"content": (
|
||||
f"배포 SR이 생성되었습니다: `{sr_id}`\n"
|
||||
"배포 파일을 첨부하고 대상 서버(기관명/시스템명)를 지정해주세요."
|
||||
),
|
||||
"is_widget": True,
|
||||
"interactive_action": {
|
||||
"type": "BUTTON",
|
||||
"label": "배포 취소",
|
||||
"command_code": f"CANCEL_SR:{sr_id}",
|
||||
},
|
||||
}
|
||||
|
||||
if "재기동" in text or "restart" in t or "리스타트" in text:
|
||||
sr_id = _new_sr()
|
||||
return {
|
||||
"content": (
|
||||
f"WAS 재기동 요청 `{sr_id}`이 접수되었습니다.\n"
|
||||
"PM 승인 후 롤링 방식으로 실행됩니다."
|
||||
),
|
||||
"is_widget": True,
|
||||
"interactive_action": {
|
||||
"type": "APPROVAL_BUTTONS",
|
||||
"approve_url": f"/api/sr/approve/{sr_id}",
|
||||
"reject_url": f"/api/sr/reject/{sr_id}",
|
||||
},
|
||||
}
|
||||
|
||||
if "로그" in text or "log" in t:
|
||||
return {
|
||||
"content": (
|
||||
"로그 분석 결과 (모의 데이터):\n"
|
||||
"```\n"
|
||||
"[ERROR] 2026-05-24 17:32:11 - ORA-01555: snapshot too old\n"
|
||||
"[WARN] 2026-05-24 17:33:05 - Connection pool exhausted (200/200)\n"
|
||||
"[ERROR] 2026-05-24 17:34:22 - java.lang.OutOfMemoryError: GC overhead\n"
|
||||
"```\n"
|
||||
"OutOfMemoryError 감지 — WAS 재기동을 권장합니다."
|
||||
),
|
||||
"is_widget": True,
|
||||
"interactive_action": {
|
||||
"type": "BUTTON",
|
||||
"label": "WAS 재기동 요청",
|
||||
"command_code": "REQUEST_RESTART",
|
||||
},
|
||||
}
|
||||
|
||||
if "서버 상태" in text or "상태" in text or "status" in t:
|
||||
return {
|
||||
"content": (
|
||||
"**인프라 상태 요약** (모의 데이터)\n"
|
||||
"• WEB-01 ✅ 정상 CPU 12% MEM 45%\n"
|
||||
"• WAS-01 ✅ 정상 CPU 34% MEM 62%\n"
|
||||
"• WAS-02 ⚠️ 경고 CPU 87% MEM 78%\n"
|
||||
"• DB-01 ✅ 정상 연결 42/200\n"
|
||||
"• DB-02 ✅ 정상 연결 11/200"
|
||||
),
|
||||
"is_widget": False,
|
||||
}
|
||||
|
||||
if "ssl" in t or "인증서" in text:
|
||||
return {
|
||||
"content": (
|
||||
"**SSL 인증서 만료일 조회** (모의 데이터)\n"
|
||||
"• portal.mof.go.kr — 잔여 **D-14** ⚠️\n"
|
||||
"• api.molit.go.kr — 잔여 D-87 ✅\n"
|
||||
"• www.nts.go.kr — 잔여 D-203 ✅"
|
||||
),
|
||||
"is_widget": True,
|
||||
"interactive_action": {
|
||||
"type": "BUTTON",
|
||||
"label": "갱신 SR 생성",
|
||||
"command_code": "RENEW_SSL:portal.mof.go.kr",
|
||||
},
|
||||
}
|
||||
|
||||
if "정지" in text or "stop" in t or "중단" in text:
|
||||
return {
|
||||
"content": "⛔ Kill-Switch 실행 — 진행 중인 모든 작업을 중단합니다.",
|
||||
"is_widget": True,
|
||||
"interactive_action": {
|
||||
"type": "APPROVAL_BUTTONS",
|
||||
"approve_url": "/api/killswitch/confirm",
|
||||
"reject_url": "/api/killswitch/cancel",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"content": f"명령을 처리하고 있습니다: \"{text}\"\n자세한 명령은 `@bot 도움`을 입력하세요.",
|
||||
"is_widget": False,
|
||||
}
|
||||
|
||||
|
||||
def check_proactive_context(text: str) -> Optional[dict]:
|
||||
"""명시적 봇 멘션 없이도 대화 맥락에서 선제적 제안 생성."""
|
||||
for rule in CONTEXT_RULES.values():
|
||||
if any(kw in text for kw in rule["keywords"]):
|
||||
return {
|
||||
"content": f"{rule['message']}",
|
||||
"is_widget": True,
|
||||
"interactive_action": {
|
||||
"type": "BUTTON",
|
||||
"label": "즉시 확인",
|
||||
"command_code": rule["action"],
|
||||
},
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def store_message(room_id: str, msg: dict) -> None:
|
||||
message_store.setdefault(room_id, []).append(msg)
|
||||
if len(message_store[room_id]) > 200:
|
||||
message_store[room_id] = message_store[room_id][-200:]
|
||||
18
messenger/main.py
Normal file
18
messenger/main.py
Normal file
@ -0,0 +1,18 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from routers import messages, webhook, ws_relay
|
||||
|
||||
app = FastAPI(title="GUARDiA Messenger", version="1.0.0")
|
||||
|
||||
app.include_router(webhook.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(ws_relay.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse("static/index.html")
|
||||
0
messenger/models/__init__.py
Normal file
0
messenger/models/__init__.py
Normal file
41
messenger/models/message.py
Normal file
41
messenger/models/message.py
Normal file
@ -0,0 +1,41 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def new_msg_id() -> str:
|
||||
return f"MSG-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:8].upper()}"
|
||||
|
||||
|
||||
class InteractiveAction(BaseModel):
|
||||
type: str # BUTTON | APPROVAL_BUTTONS
|
||||
label: Optional[str] = None
|
||||
command_code: Optional[str] = None
|
||||
approve_url: Optional[str] = None
|
||||
reject_url: Optional[str] = None
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
message_id: str = Field(default_factory=new_msg_id)
|
||||
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
room_id: str
|
||||
sender: str
|
||||
sender_type: str = "HUMAN" # HUMAN | BOT
|
||||
msg_type: str = "CHAT"
|
||||
content: str
|
||||
is_widget: bool = False
|
||||
interactive_action: Optional[InteractiveAction] = None
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
user_id: str
|
||||
text: str
|
||||
room_id: str
|
||||
files: List[dict] = []
|
||||
bot: bool = False
|
||||
|
||||
|
||||
class IncomingChat(BaseModel):
|
||||
content: str
|
||||
room_id: str
|
||||
7
messenger/requirements.txt
Normal file
7
messenger/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
websockets>=14.0
|
||||
python-multipart>=0.0.12
|
||||
aiofiles>=24.1.0
|
||||
pydantic>=2.10.0
|
||||
python-dotenv>=1.0.1
|
||||
0
messenger/routers/__init__.py
Normal file
0
messenger/routers/__init__.py
Normal file
15
messenger/routers/messages.py
Normal file
15
messenger/routers/messages.py
Normal file
@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter
|
||||
from core.bot import ROOMS, message_store
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/rooms")
|
||||
async def list_rooms():
|
||||
return {"rooms": ROOMS}
|
||||
|
||||
|
||||
@router.get("/messages/{room_id}")
|
||||
async def get_messages(room_id: str, limit: int = 50):
|
||||
msgs = message_store.get(room_id, [])
|
||||
return {"room_id": room_id, "messages": msgs[-limit:]}
|
||||
60
messenger/routers/webhook.py
Normal file
60
messenger/routers/webhook.py
Normal file
@ -0,0 +1,60 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
WEBHOOK_SECRET = "guardia-webhook-secret-2026"
|
||||
|
||||
|
||||
def _verify(signature: str, body: bytes) -> bool:
|
||||
expected = "sha256=" + hmac.new(
|
||||
WEBHOOK_SECRET.encode(), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _new_sr() -> str:
|
||||
return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
|
||||
|
||||
|
||||
@router.post("/messenger/webhook")
|
||||
async def receive_webhook(request: Request):
|
||||
body = await request.body()
|
||||
sig = request.headers.get("X-Messenger-Signature", "")
|
||||
|
||||
# 개발 환경에서 서명 검증 생략 (프로덕션에서 활성화)
|
||||
# if sig and not _verify(sig, body):
|
||||
# raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
payload = json.loads(body)
|
||||
|
||||
if payload.get("bot") or not payload.get("text", "").strip():
|
||||
return {"status": "IGNORED"}
|
||||
|
||||
sr_id = _new_sr()
|
||||
return {"status": "ACCEPTED", "sr_id": sr_id}
|
||||
|
||||
|
||||
@router.post("/sr/approve/{sr_id}")
|
||||
async def approve_sr(sr_id: str):
|
||||
return {"status": "APPROVED", "sr_id": sr_id, "message": f"SR {sr_id} 승인 완료 — 실행을 시작합니다."}
|
||||
|
||||
|
||||
@router.post("/sr/reject/{sr_id}")
|
||||
async def reject_sr(sr_id: str):
|
||||
return {"status": "REJECTED", "sr_id": sr_id, "message": f"SR {sr_id} 반려 처리되었습니다."}
|
||||
|
||||
|
||||
@router.post("/killswitch/confirm")
|
||||
async def killswitch_confirm():
|
||||
return {"status": "STOPPED", "message": "모든 진행 중인 작업이 중단되었습니다."}
|
||||
|
||||
|
||||
@router.post("/killswitch/cancel")
|
||||
async def killswitch_cancel():
|
||||
return {"status": "CANCELLED", "message": "Kill-Switch 취소되었습니다."}
|
||||
107
messenger/routers/ws_relay.py
Normal file
107
messenger/routers/ws_relay.py
Normal file
@ -0,0 +1,107 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from core.bot import (
|
||||
ROOMS,
|
||||
check_proactive_context,
|
||||
get_bot_response,
|
||||
message_store,
|
||||
store_message,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# room_id → list of connected WebSocket clients
|
||||
room_channels: Dict[str, List[WebSocket]] = {}
|
||||
|
||||
|
||||
def _new_msg_id() -> str:
|
||||
return f"MSG-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:8].upper()}"
|
||||
|
||||
|
||||
async def broadcast(room_id: str, payload: dict) -> None:
|
||||
dead: List[WebSocket] = []
|
||||
for ws in room_channels.get(room_id, []):
|
||||
try:
|
||||
await ws.send_text(json.dumps(payload, ensure_ascii=False))
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
room_channels[room_id].remove(ws)
|
||||
|
||||
|
||||
def _make_bot_msg(room_id: str, reply: dict) -> dict:
|
||||
return {
|
||||
"message_id": _new_msg_id(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"room_id": room_id,
|
||||
"sender": "GUARDiA-Bot",
|
||||
"sender_type": "BOT",
|
||||
"msg_type": "CHAT",
|
||||
"content": reply.get("content", ""),
|
||||
"is_widget": reply.get("is_widget", False),
|
||||
"interactive_action": reply.get("interactive_action"),
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/chat/{room_id}/{client_id}")
|
||||
async def chat_endpoint(ws: WebSocket, room_id: str, client_id: str):
|
||||
await ws.accept()
|
||||
room_channels.setdefault(room_id, []).append(ws)
|
||||
|
||||
# 접속 시 최근 메시지 + 방 목록 전달
|
||||
history = message_store.get(room_id, [])[-50:]
|
||||
await ws.send_text(json.dumps(
|
||||
{"type": "init", "messages": history, "rooms": ROOMS},
|
||||
ensure_ascii=False,
|
||||
))
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_text()
|
||||
data = json.loads(raw)
|
||||
text = data.get("content", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# 사용자 메시지 브로드캐스트
|
||||
user_msg = {
|
||||
"message_id": _new_msg_id(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"room_id": room_id,
|
||||
"sender": client_id,
|
||||
"sender_type": "HUMAN",
|
||||
"msg_type": "CHAT",
|
||||
"content": text,
|
||||
"is_widget": False,
|
||||
"interactive_action": None,
|
||||
}
|
||||
store_message(room_id, user_msg)
|
||||
await broadcast(room_id, user_msg)
|
||||
|
||||
# 봇 명령 응답
|
||||
bot_reply = get_bot_response(text)
|
||||
if bot_reply:
|
||||
await asyncio.sleep(0.4)
|
||||
bot_msg = _make_bot_msg(room_id, bot_reply)
|
||||
store_message(room_id, bot_msg)
|
||||
await broadcast(room_id, bot_msg)
|
||||
continue
|
||||
|
||||
# 선제적 맥락 분석
|
||||
proactive = check_proactive_context(text)
|
||||
if proactive:
|
||||
await asyncio.sleep(1.2)
|
||||
pro_msg = _make_bot_msg(room_id, proactive)
|
||||
store_message(room_id, pro_msg)
|
||||
await broadcast(room_id, pro_msg)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
lst = room_channels.get(room_id, [])
|
||||
if ws in lst:
|
||||
lst.remove(ws)
|
||||
307
messenger/static/app.js
Normal file
307
messenger/static/app.js
Normal file
@ -0,0 +1,307 @@
|
||||
/* ─── State ─────────────────────────────────────────── */
|
||||
let ws = null;
|
||||
let currentRoom = "general";
|
||||
let currentUser = "";
|
||||
let rooms = {};
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = 1000;
|
||||
|
||||
/* ─── DOM refs ──────────────────────────────────────── */
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const msgInput = document.getElementById("msg-input");
|
||||
const channelNameEl = document.getElementById("ch-name");
|
||||
const channelDescEl = document.getElementById("ch-desc");
|
||||
const channelListEl = document.getElementById("channel-list");
|
||||
const userDisplayEl = document.getElementById("user-display");
|
||||
const connStatus = document.getElementById("conn-status");
|
||||
const userModal = document.getElementById("user-modal-overlay");
|
||||
const userNameInput = document.getElementById("username-input");
|
||||
const typingEl = document.getElementById("typing-indicator");
|
||||
|
||||
/* ─── Init ──────────────────────────────────────────── */
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const saved = sessionStorage.getItem("guardia_user");
|
||||
if (saved) { currentUser = saved; startApp(); }
|
||||
else { userModal.classList.add("show"); userNameInput.focus(); }
|
||||
});
|
||||
|
||||
document.getElementById("username-form").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const name = userNameInput.value.trim();
|
||||
if (!name) return;
|
||||
currentUser = name;
|
||||
sessionStorage.setItem("guardia_user", name);
|
||||
userModal.classList.remove("show");
|
||||
startApp();
|
||||
});
|
||||
|
||||
function startApp() {
|
||||
userDisplayEl.textContent = currentUser;
|
||||
connect();
|
||||
}
|
||||
|
||||
/* ─── WebSocket ─────────────────────────────────────── */
|
||||
function connect() {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${proto}://${location.host}/ws/chat/${currentRoom}/${encodeURIComponent(currentUser)}`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
showStatus("reconnecting", "연결 중…");
|
||||
|
||||
ws.onopen = () => {
|
||||
showStatus("online", "연결됨");
|
||||
reconnectDelay = 1000;
|
||||
setTimeout(() => connStatus.classList.remove("show"), 2000);
|
||||
};
|
||||
|
||||
ws.onmessage = e => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === "init") {
|
||||
rooms = data.rooms || {};
|
||||
renderChannelList();
|
||||
updateChannelHeader();
|
||||
messagesEl.innerHTML = "";
|
||||
if (data.messages?.length) {
|
||||
data.messages.forEach(renderMessage);
|
||||
scrollBottom();
|
||||
} else {
|
||||
renderWelcome();
|
||||
}
|
||||
} else {
|
||||
renderMessage(data);
|
||||
scrollBottom();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
showStatus("offline", "연결 끊김 — 재연결 중…");
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const text = msgInput.value.trim();
|
||||
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ content: text }));
|
||||
msgInput.value = "";
|
||||
msgInput.style.height = "auto";
|
||||
}
|
||||
|
||||
/* ─── Channel switching ─────────────────────────────── */
|
||||
function switchRoom(roomId) {
|
||||
if (roomId === currentRoom) return;
|
||||
currentRoom = roomId;
|
||||
if (ws) { ws.onclose = null; ws.close(); }
|
||||
clearTimeout(reconnectTimer);
|
||||
connect();
|
||||
document.querySelectorAll(".channel-item").forEach(el => {
|
||||
el.classList.toggle("active", el.dataset.room === roomId);
|
||||
});
|
||||
updateChannelHeader();
|
||||
}
|
||||
|
||||
function updateChannelHeader() {
|
||||
const name = rooms[currentRoom] || currentRoom;
|
||||
channelNameEl.textContent = "# " + name;
|
||||
channelDescEl.textContent = roomDesc(currentRoom);
|
||||
}
|
||||
|
||||
function roomDesc(id) {
|
||||
const descs = {
|
||||
general: "팀 전체 공지 및 일반 대화",
|
||||
deploy: "배포 작업 요청 및 진행 현황",
|
||||
ops: "서버 운영 및 장애 대응",
|
||||
pm: "PM 승인 워크플로우 및 진척 관리",
|
||||
alerts: "자동 알림 및 임계치 경보",
|
||||
};
|
||||
return descs[id] || "";
|
||||
}
|
||||
|
||||
/* ─── Render ────────────────────────────────────────── */
|
||||
function renderChannelList() {
|
||||
channelListEl.innerHTML = "";
|
||||
Object.entries(rooms).forEach(([id, label]) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "channel-item" + (id === currentRoom ? " active" : "");
|
||||
el.dataset.room = id;
|
||||
el.innerHTML = `<span class="ch-icon">#</span> ${label}`;
|
||||
el.addEventListener("click", () => switchRoom(id));
|
||||
channelListEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderWelcome() {
|
||||
const div = document.createElement("div");
|
||||
div.className = "date-divider";
|
||||
div.textContent = `# ${rooms[currentRoom] || currentRoom} 채널에 오신 것을 환영합니다`;
|
||||
messagesEl.appendChild(div);
|
||||
|
||||
const hint = document.createElement("div");
|
||||
hint.style.cssText = "padding:8px 0;font-size:13px;color:var(--text-muted)";
|
||||
hint.innerHTML = "💡 <code>@bot 도움</code> 을 입력하면 GUARDiA Bot 명령어를 확인할 수 있습니다.";
|
||||
messagesEl.appendChild(hint);
|
||||
}
|
||||
|
||||
let lastDate = "";
|
||||
let lastSender = "";
|
||||
|
||||
function renderMessage(msg) {
|
||||
const msgDate = new Date(msg.timestamp).toLocaleDateString("ko-KR", {
|
||||
month: "long", day: "numeric", weekday: "long"
|
||||
});
|
||||
|
||||
if (msgDate !== lastDate) {
|
||||
const divider = document.createElement("div");
|
||||
divider.className = "date-divider";
|
||||
divider.textContent = msgDate;
|
||||
messagesEl.appendChild(divider);
|
||||
lastDate = msgDate;
|
||||
lastSender = "";
|
||||
}
|
||||
|
||||
const isBot = msg.sender_type === "BOT";
|
||||
const isSame = msg.sender === lastSender && !isBot;
|
||||
lastSender = msg.sender;
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = "msg-group";
|
||||
group.dataset.msgId = msg.message_id;
|
||||
|
||||
const initials = msg.sender.slice(0, 2).toUpperCase();
|
||||
const time = new Date(msg.timestamp).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit", minute: "2-digit"
|
||||
});
|
||||
|
||||
const avatarHTML = isSame
|
||||
? `<div style="width:36px;flex-shrink:0"></div>`
|
||||
: `<div class="avatar ${isBot ? "bot" : "human"}">${initials}</div>`;
|
||||
|
||||
const metaHTML = isSame
|
||||
? ""
|
||||
: `<div class="msg-meta">
|
||||
<span class="msg-sender ${isBot ? "bot" : ""}">${escHtml(msg.sender)}</span>
|
||||
<span class="msg-time">${time}</span>
|
||||
</div>`;
|
||||
|
||||
const contentHTML = formatContent(msg.content);
|
||||
|
||||
let widgetHTML = "";
|
||||
if (msg.is_widget && msg.interactive_action) {
|
||||
const act = msg.interactive_action;
|
||||
if (act.type === "BUTTON") {
|
||||
widgetHTML = `
|
||||
<div class="widget-actions">
|
||||
<button class="btn-action btn-primary"
|
||||
onclick="handleAction(this,'${escAttr(act.command_code)}','','')">
|
||||
${escHtml(act.label || "실행")}
|
||||
</button>
|
||||
</div>`;
|
||||
} else if (act.type === "APPROVAL_BUTTONS") {
|
||||
widgetHTML = `
|
||||
<div class="widget-actions">
|
||||
<button class="btn-action btn-approve"
|
||||
onclick="handleAction(this,'APPROVE','${escAttr(act.approve_url)}','승인')">
|
||||
✅ 승인
|
||||
</button>
|
||||
<button class="btn-action btn-reject"
|
||||
onclick="handleAction(this,'REJECT','${escAttr(act.reject_url)}','반려')">
|
||||
❌ 반려
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
const bubbleClass = isBot ? "bot-bubble" : "";
|
||||
|
||||
group.innerHTML = `
|
||||
${avatarHTML}
|
||||
<div class="msg-body">
|
||||
${metaHTML}
|
||||
<div class="${bubbleClass}">
|
||||
<div class="msg-content">${contentHTML}</div>
|
||||
${widgetHTML}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
messagesEl.appendChild(group);
|
||||
}
|
||||
|
||||
/* ─── Interactive actions ───────────────────────────── */
|
||||
async function handleAction(btn, code, url, label) {
|
||||
btn.classList.add("clicked");
|
||||
const siblings = btn.closest(".widget-actions")?.querySelectorAll(".btn-action");
|
||||
siblings?.forEach(b => b.classList.add("clicked"));
|
||||
|
||||
let resultText = "";
|
||||
if (url) {
|
||||
try {
|
||||
const res = await fetch(url, { method: "POST" });
|
||||
const data = await res.json();
|
||||
resultText = data.message || `${label} 처리 완료`;
|
||||
} catch {
|
||||
resultText = "처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
} else {
|
||||
resultText = `명령 전송됨: ${code}`;
|
||||
}
|
||||
|
||||
const result = document.createElement("div");
|
||||
result.className = "btn-result";
|
||||
result.textContent = "→ " + resultText;
|
||||
btn.closest(".widget-actions").insertAdjacentElement("afterend", result);
|
||||
}
|
||||
|
||||
/* ─── Formatting helpers ────────────────────────────── */
|
||||
function formatContent(text) {
|
||||
// Code blocks
|
||||
text = text.replace(/```([\s\S]*?)```/g, (_, code) =>
|
||||
`<pre>${escHtml(code.trim())}</pre>`
|
||||
);
|
||||
// Inline code
|
||||
text = text.replace(/`([^`]+)`/g, (_, code) =>
|
||||
`<code>${escHtml(code)}</code>`
|
||||
);
|
||||
// Bold
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
// Remaining escaping for non-tagged parts (basic)
|
||||
return text;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return String(s || "").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
/* ─── UI helpers ────────────────────────────────────── */
|
||||
function scrollBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function showStatus(cls, text) {
|
||||
connStatus.className = `show ${cls}`;
|
||||
connStatus.textContent = text;
|
||||
}
|
||||
|
||||
/* ─── Input events ──────────────────────────────────── */
|
||||
document.getElementById("send-btn").addEventListener("click", sendMessage);
|
||||
|
||||
msgInput.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
|
||||
msgInput.addEventListener("input", () => {
|
||||
msgInput.style.height = "auto";
|
||||
msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + "px";
|
||||
});
|
||||
88
messenger/static/index.html
Normal file
88
messenger/static/index.html
Normal file
@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GUARDiA Messenger</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 사용자 이름 설정 모달 -->
|
||||
<div id="user-modal-overlay">
|
||||
<div id="user-modal">
|
||||
<h2>🛡️ GUARDiA Messenger</h2>
|
||||
<p>사용자 이름을 입력하고 입장하세요</p>
|
||||
<form id="username-form">
|
||||
<input id="username-input" type="text" placeholder="이름 입력 (예: ENGINEER_01)"
|
||||
maxlength="30" autocomplete="off" required>
|
||||
<button type="submit">입장</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
|
||||
<!-- ── Sidebar ────────────────────────────────────── -->
|
||||
<nav id="sidebar">
|
||||
<div id="workspace-header">
|
||||
<div id="workspace-name">
|
||||
<span class="status-dot"></span>
|
||||
GUARDiA
|
||||
</div>
|
||||
<div id="user-display">로딩 중…</div>
|
||||
</div>
|
||||
|
||||
<input id="sidebar-search" type="text" placeholder="🔍 검색">
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">▾ 채널</div>
|
||||
<div id="channel-list"><!-- JS 렌더링 --></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" style="margin-top:auto; padding:12px 16px; border-top:1px solid var(--border)">
|
||||
<div style="font-size:11px; color:var(--text-muted); line-height:1.6">
|
||||
<div>📡 WebSocket 실시간 연동</div>
|
||||
<div>🤖 GUARDiA-Bot 내장</div>
|
||||
<div>🔒 ITSM ChatOps v1.0</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Main ──────────────────────────────────────── -->
|
||||
<main id="main">
|
||||
|
||||
<header id="channel-header">
|
||||
<span id="ch-name"># 일반</span>
|
||||
<span id="ch-desc" class="ch-desc"></span>
|
||||
<span id="member-count">🟢 접속 중</span>
|
||||
</header>
|
||||
|
||||
<div id="messages"></div>
|
||||
|
||||
<div id="typing-indicator"></div>
|
||||
|
||||
<div id="input-area">
|
||||
<div id="input-box">
|
||||
<button class="input-btn" title="파일 첨부">📎</button>
|
||||
<textarea
|
||||
id="msg-input"
|
||||
rows="1"
|
||||
placeholder="메시지 입력 — @bot 으로 GUARDiA Bot 명령 / Shift+Enter 줄바꿈"
|
||||
></textarea>
|
||||
<button class="input-btn" id="send-btn" title="전송">➤</button>
|
||||
</div>
|
||||
<div id="input-hint">
|
||||
<strong>@bot 도움</strong> — 명령어 목록 |
|
||||
<strong>@bot 배포</strong> — 배포 SR 생성 |
|
||||
<strong>@bot 서버 상태</strong> — 인프라 현황
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="conn-status"></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
265
messenger/static/style.css
Normal file
265
messenger/static/style.css
Normal file
@ -0,0 +1,265 @@
|
||||
/* ─── Reset & Base ─────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--sidebar-bg: #1a1d21;
|
||||
--sidebar-hover:#222529;
|
||||
--sidebar-active:#1164a3;
|
||||
--main-bg: #222529;
|
||||
--msg-area-bg: #222529;
|
||||
--input-bg: #2c2d30;
|
||||
--border: #363636;
|
||||
--text-primary: #d1d2d3;
|
||||
--text-muted: #8b8c8e;
|
||||
--text-bright: #ffffff;
|
||||
--bot-bubble: #1a3a5c;
|
||||
--bot-border: #1164a3;
|
||||
--accent: #1d9bd1;
|
||||
--green: #2bac76;
|
||||
--yellow: #e8a317;
|
||||
--red: #e01e5a;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
html, body { height: 100%; font-family: var(--font); background: var(--main-bg); color: var(--text-primary); }
|
||||
|
||||
/* ─── Layout ────────────────────────────────────────── */
|
||||
#app { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ─── Sidebar ───────────────────────────────────────── */
|
||||
#sidebar {
|
||||
width: 260px; min-width: 200px;
|
||||
background: var(--sidebar-bg);
|
||||
display: flex; flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#workspace-header {
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#workspace-name {
|
||||
font-size: 16px; font-weight: 700;
|
||||
color: var(--text-bright); cursor: pointer;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
#workspace-name .status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--green); display: inline-block;
|
||||
}
|
||||
#user-display {
|
||||
font-size: 12px; color: var(--text-muted); margin-top: 4px;
|
||||
}
|
||||
|
||||
#sidebar-search {
|
||||
margin: 8px 10px;
|
||||
background: var(--sidebar-hover); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 6px 10px;
|
||||
color: var(--text-primary); font-size: 13px; outline: none;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
#sidebar-search::placeholder { color: var(--text-muted); }
|
||||
|
||||
.sidebar-section { margin: 6px 0; }
|
||||
.sidebar-section-header {
|
||||
padding: 4px 10px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em;
|
||||
cursor: pointer; display: flex; align-items: center; gap: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.sidebar-section-header:hover { color: var(--text-primary); }
|
||||
|
||||
.channel-item {
|
||||
padding: 5px 10px 5px 20px; font-size: 14px;
|
||||
color: var(--text-muted); cursor: pointer; border-radius: var(--radius);
|
||||
margin: 1px 6px; display: flex; align-items: center; gap: 6px;
|
||||
transition: background .12s;
|
||||
}
|
||||
.channel-item:hover { background: var(--sidebar-hover); color: var(--text-primary); }
|
||||
.channel-item.active { background: var(--sidebar-active); color: var(--text-bright); font-weight: 500; }
|
||||
.channel-item .ch-icon { color: var(--text-muted); font-size: 13px; }
|
||||
.channel-item.active .ch-icon { color: var(--text-bright); }
|
||||
.channel-item .unread-badge {
|
||||
margin-left: auto; background: var(--red);
|
||||
color: #fff; font-size: 10px; font-weight: 700;
|
||||
min-width: 16px; height: 16px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ─── User modal ────────────────────────────────────── */
|
||||
#user-modal-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.55); z-index: 100;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
#user-modal-overlay.show { display: flex; }
|
||||
#user-modal {
|
||||
background: var(--sidebar-bg); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 28px 32px; width: 340px; text-align: center;
|
||||
}
|
||||
#user-modal h2 { font-size: 18px; color: var(--text-bright); margin-bottom: 6px; }
|
||||
#user-modal p { font-size: 13px; color: var(--text-muted); margin-bottom: 18px; }
|
||||
#user-modal input {
|
||||
width: 100%; padding: 10px 12px; background: var(--input-bg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text-bright); font-size: 14px; outline: none; margin-bottom: 14px;
|
||||
}
|
||||
#user-modal input:focus { border-color: var(--accent); }
|
||||
#user-modal button {
|
||||
width: 100%; padding: 10px; background: var(--accent);
|
||||
color: #fff; border: none; border-radius: var(--radius);
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .15s;
|
||||
}
|
||||
#user-modal button:hover { opacity: .85; }
|
||||
|
||||
/* ─── Main area ─────────────────────────────────────── */
|
||||
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
#channel-header {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--main-bg);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
#channel-header .ch-name {
|
||||
font-size: 16px; font-weight: 700; color: var(--text-bright);
|
||||
}
|
||||
#channel-header .ch-desc {
|
||||
font-size: 13px; color: var(--text-muted); margin-left: 8px;
|
||||
}
|
||||
#member-count { margin-left: auto; font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* ─── Messages ──────────────────────────────────────── */
|
||||
#messages {
|
||||
flex: 1; overflow-y: auto; padding: 16px 20px 8px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
#messages::-webkit-scrollbar { width: 5px; }
|
||||
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
.msg-group { display: flex; gap: 10px; padding: 4px 0; }
|
||||
.msg-group:hover { background: rgba(255,255,255,.03); border-radius: var(--radius); }
|
||||
|
||||
.avatar {
|
||||
width: 36px; height: 36px; border-radius: var(--radius);
|
||||
font-size: 14px; font-weight: 700; display: flex; align-items: center;
|
||||
justify-content: center; flex-shrink: 0; margin-top: 2px;
|
||||
}
|
||||
.avatar.human { background: #4a9a6e; color: #fff; }
|
||||
.avatar.bot { background: var(--accent); color: #fff; }
|
||||
|
||||
.msg-body { flex: 1; min-width: 0; }
|
||||
.msg-meta { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
|
||||
.msg-sender { font-size: 14px; font-weight: 700; color: var(--text-bright); }
|
||||
.msg-sender.bot { color: var(--accent); }
|
||||
.msg-time { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.msg-content {
|
||||
font-size: 14px; line-height: 1.55; color: var(--text-primary);
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.msg-content code {
|
||||
background: rgba(0,0,0,.35); padding: 1px 5px;
|
||||
border-radius: 3px; font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.msg-content pre {
|
||||
background: rgba(0,0,0,.45); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px 12px; margin-top: 6px;
|
||||
overflow-x: auto; font-size: 12px; line-height: 1.5;
|
||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
}
|
||||
|
||||
/* Bot bubble */
|
||||
.bot-bubble {
|
||||
background: var(--bot-bubble); border-left: 3px solid var(--bot-border);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: 10px 12px; margin-top: 4px;
|
||||
}
|
||||
.bot-bubble .msg-content { color: #c9dff0; }
|
||||
|
||||
/* Interactive widgets */
|
||||
.widget-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.btn-action {
|
||||
padding: 7px 14px; border-radius: var(--radius);
|
||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
border: 1px solid transparent; transition: opacity .15s;
|
||||
}
|
||||
.btn-action:hover { opacity: .8; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-approve { background: var(--green); color: #fff; }
|
||||
.btn-reject { background: var(--red); color: #fff; }
|
||||
.btn-secondary { background: transparent; border-color: var(--border); color: var(--text-primary); }
|
||||
|
||||
.btn-action.clicked { opacity: .5; pointer-events: none; }
|
||||
.btn-result {
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
margin-top: 6px; padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Date divider */
|
||||
.date-divider {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 14px 0; font-size: 12px; color: var(--text-muted);
|
||||
}
|
||||
.date-divider::before, .date-divider::after {
|
||||
content: ""; flex: 1; height: 1px; background: var(--border);
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
#typing-indicator {
|
||||
height: 20px; padding: 0 20px; font-size: 12px;
|
||||
color: var(--text-muted); font-style: italic;
|
||||
}
|
||||
|
||||
/* ─── Input area ────────────────────────────────────── */
|
||||
#input-area {
|
||||
padding: 8px 20px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--main-bg);
|
||||
}
|
||||
#input-box {
|
||||
background: var(--input-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); display: flex; align-items: flex-end; gap: 6px;
|
||||
padding: 8px 10px; transition: border-color .15s;
|
||||
}
|
||||
#input-box:focus-within { border-color: var(--accent); }
|
||||
|
||||
#msg-input {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: var(--text-bright); font-size: 14px; resize: none;
|
||||
max-height: 120px; line-height: 1.5; font-family: var(--font);
|
||||
}
|
||||
#msg-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.input-btn {
|
||||
background: none; border: none; color: var(--text-muted);
|
||||
cursor: pointer; padding: 4px; border-radius: var(--radius);
|
||||
font-size: 18px; line-height: 1; transition: color .12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.input-btn:hover { color: var(--text-primary); }
|
||||
#send-btn { color: var(--accent); font-size: 20px; }
|
||||
#send-btn:hover { color: var(--text-bright); }
|
||||
|
||||
#input-hint {
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
margin-top: 4px; padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ─── Connection status ─────────────────────────────── */
|
||||
#conn-status {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 500;
|
||||
display: none;
|
||||
}
|
||||
#conn-status.show { display: block; }
|
||||
#conn-status.online { background: var(--green); color: #fff; }
|
||||
#conn-status.offline{ background: var(--red); color: #fff; }
|
||||
#conn-status.reconnecting { background: var(--yellow); color: #000; }
|
||||
|
||||
/* ─── Scrollbar ─────────────────────────────────────── */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
Loading…
Reference in New Issue
Block a user