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>
417 lines
15 KiB
JavaScript
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가 맥락을 기억합니다 | " +
|
|
"<strong>초기화</strong>: '대화 초기화' 입력";
|
|
} else {
|
|
hintEl.innerHTML =
|
|
"<strong>@bot 도움</strong> — 명령어 목록 | " +
|
|
"<strong>@bot 배포</strong> — 배포 SR 생성 | " +
|
|
"<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, "&").replace(/</g, "<")
|
|
.replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
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";
|
|
});
|