zioinfo-mail/messenger/static/app.js
DESKTOP-TKLFCPR\ython e228faabf5 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

417 lines
15 KiB
JavaScript

/* ─── 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 === "bot_typing") {
showTyping(data.show);
return;
}
if (data.type === "init") {
rooms = data.rooms || {};
renderChannelList();
updateChannelHeader();
applyRoomClass();
messagesEl.innerHTML = "";
lastDate = ""; lastSender = "";
if (data.messages?.length) {
data.messages.forEach(renderMessage);
scrollBottom();
} else {
renderWelcome();
}
} else {
showTyping(false);
renderMessage(data);
scrollBottom();
}
};
ws.onclose = () => {
showTyping(false);
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";
}
/* ─── Typing indicator ──────────────────────────────── */
function showTyping(show) {
if (show) {
typingEl.innerHTML = `
<div class="typing-bubble">
<div class="typing-dots">
<span></span><span></span><span></span>
</div>
GUARDiA-Bot 입력 중…
</div>`;
} else {
typingEl.innerHTML = "";
}
}
/* ─── Channel switching ─────────────────────────────── */
function switchRoom(roomId) {
if (roomId === currentRoom) return;
currentRoom = roomId;
showTyping(false);
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;
const isChatbot = currentRoom === "chatbot";
channelNameEl.innerHTML = "# " + escHtml(name) +
(isChatbot ? ' <span class="chatbot-badge">AI</span>' : "");
channelDescEl.textContent = roomDesc(currentRoom);
updateInputHint();
}
function applyRoomClass() {
if (currentRoom === "chatbot") {
messagesEl.classList.add("chatbot-channel");
} else {
messagesEl.classList.remove("chatbot-channel");
}
}
function roomDesc(id) {
return {
general: "팀 전체 공지 및 일반 대화",
deploy: "배포 작업 요청 및 진행 현황",
ops: "서버 운영 및 장애 대응",
pm: "PM 승인 워크플로우 및 진척 관리",
alerts: "자동 알림 및 임계치 경보",
chatbot: "GUARDiA AI 챗봇 — 자연어로 자유롭게 대화하세요",
}[id] || "";
}
function updateInputHint() {
const hintEl = document.getElementById("input-hint");
if (currentRoom === "chatbot") {
hintEl.innerHTML =
"자연어로 자유롭게 입력하세요 — AI가 맥락을 기억합니다 &nbsp;|&nbsp; " +
"<strong>초기화</strong>: '대화 초기화' 입력";
} else {
hintEl.innerHTML =
"<strong>@bot 도움</strong> — 명령어 목록 &nbsp;|&nbsp; " +
"<strong>@bot 배포</strong> — 배포 SR 생성 &nbsp;|&nbsp; " +
"<strong>@bot 서버 상태</strong> — 인프라 현황";
}
}
/* ─── 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;
const badge = id === "chatbot" ? ' <span class="chatbot-badge" style="font-size:9px">AI</span>' : "";
el.innerHTML = `<span class="ch-icon">#</span> ${escHtml(label)}${badge}`;
el.addEventListener("click", () => switchRoom(id));
channelListEl.appendChild(el);
});
}
function renderWelcome() {
if (currentRoom === "chatbot") {
const div = document.createElement("div");
div.style.cssText = "padding:16px 0 8px;";
div.innerHTML = `
<div class="date-divider">AI 챗봇 채널</div>
<div class="msg-group">
<div class="avatar bot">GU</div>
<div class="msg-body">
<div class="msg-meta">
<span class="msg-sender bot">GUARDiA-Bot</span>
<span class="msg-time">${nowTime()}</span>
</div>
<div class="bot-bubble">
<div class="msg-content">안녕하세요! GUARDiA 인프라 AI 챗봇입니다.\n\n자연어로 자유롭게 질문하거나 작업을 요청하세요.\n예: <code>기재부 예산시스템 WAS 재기동해줘</code>\n예: <code>서버 상태 알려줘</code>\n예: <code>도움</code></div>
</div>
</div>
</div>`;
messagesEl.appendChild(div);
} else {
const divider = document.createElement("div");
divider.className = "date-divider";
divider.textContent = `# ${rooms[currentRoom] || currentRoom} 채널에 오신 것을 환영합니다`;
messagesEl.appendChild(divider);
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>`;
} else if (act.type === "STAR_RATING") {
const widgetId = `sr-widget-${msg.message_id}`;
widgetHTML = `
<div class="star-rating-widget" id="${escHtml(widgetId)}">
<div class="star-rating-label">⭐ 서비스 만족도를 평가해 주세요 (${escHtml(act.sr_id)})</div>
<div class="star-rating-btns">
${[1,2,3,4,5].map(n =>
`<button class="star-btn-msg" data-n="${n}"
onclick="handleStarRating('${escAttr(widgetId)}','${escAttr(act.sr_id)}','${escAttr(act.customer||"")}','${escAttr(act.itsm_url||"")}',${n})">★</button>`
).join("")}
</div>
</div>`;
}
}
group.innerHTML = `
${avatarHTML}
<div class="msg-body">
${metaHTML}
<div class="${isBot ? "bot-bubble" : ""}">
<div class="msg-content">${contentHTML}</div>
${widgetHTML}
</div>
</div>`;
messagesEl.appendChild(group);
}
/* ─── Star rating handler ───────────────────────────── */
async function handleStarRating(widgetId, srId, customer, itsmUrl, stars) {
const widget = document.getElementById(widgetId);
if (!widget) return;
// 버튼 하이라이트
widget.querySelectorAll(".star-btn-msg").forEach((b, i) => {
b.classList.toggle("lit", i < stars);
});
try {
const base = itsmUrl || "http://localhost:8001";
const res = await fetch(`${base}/api/rating/${srId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customer: customer || "고객", stars }),
});
const done = document.createElement("div");
done.className = "star-rating-done";
if (res.ok || res.status === 409) {
done.textContent = `${"★".repeat(stars)}${"☆".repeat(5-stars)} — 감사합니다! 평가가 등록되었습니다.`;
} else {
done.textContent = "평가 처리 중 오류가 발생했습니다.";
}
widget.querySelector(".star-rating-btns")?.remove();
widget.appendChild(done);
} catch {
const done = document.createElement("div");
done.className = "star-rating-done";
done.textContent = `${"★".repeat(stars)}${"☆".repeat(5-stars)} — 평가 감사합니다!`;
widget.querySelector(".star-rating-btns")?.remove();
widget.appendChild(done);
}
}
/* ─── Interactive actions ───────────────────────────── */
async function handleAction(btn, code, url, label) {
btn.classList.add("clicked");
btn.closest(".widget-actions")?.querySelectorAll(".btn-action")
.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 ────────────────────────────────────── */
function formatContent(text) {
if (!text) return "";
text = text.replace(/```([\s\S]*?)```/g, (_, code) =>
`<pre>${escHtml(code.trim())}</pre>`);
text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${escHtml(c)}</code>`);
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
return text;
}
function escHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escAttr(s) { return String(s ?? "").replace(/'/g, "\\'"); }
function nowTime() {
return new Date().toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" });
}
/* ─── 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";
});