/* ─── State ─────────────────────────────────────── */ let currentView = "dashboard"; let srCache = []; let statsCache = {}; /* ─── Status labels ─────────────────────────────── */ const STATUS_LABEL = { RECEIVED: "접수", PARSED: "파싱 완료", PENDING_APPROVAL: "승인 대기", APPROVED: "승인됨", IN_PROGRESS: "진행 중", PENDING_PM_VALIDATION: "PM 검증 대기", COMPLETED: "완료", FAILED_ROLLBACK: "롤백 실패", REJECTED: "반려", }; const TYPE_LABEL = { DEPLOY: "배포", RESTART: "재기동", LOG: "로그", INQUIRY: "문의", OTHER: "기타", }; const PRIORITY_LABEL = { CRITICAL: "긴급", HIGH: "높음", MEDIUM: "보통", LOW: "낮음" }; const KANBAN_COLS = [ { key: "RECEIVED", label: "접수" }, { key: "PENDING_APPROVAL", label: "승인 대기" }, { key: "APPROVED", label: "승인됨" }, { key: "IN_PROGRESS", label: "진행 중" }, { key: "PENDING_PM_VALIDATION", label: "PM 검증" }, { key: "COMPLETED", label: "완료" }, { key: "FAILED_ROLLBACK", label: "롤백 실패" }, { key: "REJECTED", label: "반려" }, ]; const STATUS_COLORS = { RECEIVED: "#8b949e", PARSED: "#79c0ff", PENDING_APPROVAL: "#e3b341", APPROVED: "#56d364", IN_PROGRESS: "#58a6ff", PENDING_PM_VALIDATION: "#bc8cff", COMPLETED: "#3fb950", FAILED_ROLLBACK: "#f85149", REJECTED: "#da3633", }; /* ─── Init ──────────────────────────────────────── */ window.addEventListener("DOMContentLoaded", async () => { setupNav(); setupNewSR(); setupListFilters(); await loadAll(); }); async function loadAll() { await Promise.all([loadStats(), loadSRs()]); renderCurrentView(); } /* ─── Nav ───────────────────────────────────────── */ function setupNav() { document.querySelectorAll(".nav-item").forEach(el => { el.addEventListener("click", () => { const view = el.dataset.view; switchView(view); }); }); } function switchView(view) { currentView = view; document.querySelectorAll(".nav-item").forEach(el => el.classList.toggle("active", el.dataset.view === view) ); document.querySelectorAll(".view").forEach(el => el.classList.toggle("active", el.id === `view-${view}`) ); const titles = { dashboard: "대시보드", board: "칸반 보드", list: "SR 목록", audit: "감사 로그", cmdb: "CMDB", }; document.getElementById("page-title").textContent = titles[view] || view; renderCurrentView(); } function renderCurrentView() { if (currentView === "dashboard") renderDashboard(); else if (currentView === "board") renderKanban(); else if (currentView === "list") renderList(); else if (currentView === "audit") loadAudit(); else if (currentView === "cmdb") loadCmdb(); } /* ─── Data loading ──────────────────────────────── */ async function loadStats() { try { const r = await fetch("/api/tasks/stats"); statsCache = await r.json(); } catch { statsCache = {}; } } async function loadSRs(params = {}) { const qs = new URLSearchParams(params).toString(); const r = await fetch(`/api/tasks?limit=200${qs ? "&" + qs : ""}`); srCache = await r.json(); } /* ─── Dashboard ─────────────────────────────────── */ function renderDashboard() { const s = statsCache; const bs = s.by_status || {}; const bt = s.by_type || {}; const pending = (bs.PENDING_APPROVAL || 0) + (bs.PENDING_PM_VALIDATION || 0); const active = bs.IN_PROGRESS || 0; const completed = bs.COMPLETED || 0; const failed = bs.FAILED_ROLLBACK || 0; document.getElementById("stats-row").innerHTML = `
${s.total || 0}
전체 SR
${pending}
승인 대기
${active}
진행 중
${completed}
완료
${failed}
롤백 실패
`; // Recent SR list const recent = [...srCache].slice(0, 10); document.getElementById("recent-list").innerHTML = recent.map(sr => `
${sr.sr_id} ${esc(sr.title)} ${STATUS_LABEL[sr.status] || sr.status} ${fmtDate(sr.created_at)}
`).join("") || '
SR이 없습니다.
'; // Status bar chart const total = s.total || 1; const items = Object.entries(bs) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `
${STATUS_LABEL[k] || k} ${v}
`).join(""); document.getElementById("status-chart").innerHTML = `
${items}
`; } /* ─── Kanban ────────────────────────────────────── */ function renderKanban() { const board = document.getElementById("kanban-board"); board.innerHTML = ""; KANBAN_COLS.forEach(col => { const cards = srCache.filter(sr => sr.status === col.key); const colEl = document.createElement("div"); colEl.className = "kanban-col"; colEl.innerHTML = `
${col.label} ${cards.length}
`; board.appendChild(colEl); const cardsEl = colEl.querySelector(`#col-${col.key}`); cards.forEach(sr => { const card = document.createElement("div"); card.className = "kanban-card"; card.innerHTML = `
${sr.sr_id}
${esc(sr.title)}
${TYPE_LABEL[sr.sr_type] || sr.sr_type} ${PRIORITY_LABEL[sr.priority] || sr.priority}
`; card.addEventListener("click", () => openDetail(sr.sr_id)); cardsEl.appendChild(card); }); }); } /* ─── SR List ───────────────────────────────────── */ function setupListFilters() { document.getElementById("search-input").addEventListener("input", renderList); document.getElementById("filter-status").addEventListener("change", renderList); document.getElementById("filter-type").addEventListener("change", renderList); } function renderList() { const keyword = document.getElementById("search-input").value.toLowerCase(); const fStatus = document.getElementById("filter-status").value; const fType = document.getElementById("filter-type").value; let rows = srCache; if (keyword) rows = rows.filter(r => r.title.toLowerCase().includes(keyword) || r.sr_id.toLowerCase().includes(keyword)); if (fStatus) rows = rows.filter(r => r.status === fStatus); if (fType) rows = rows.filter(r => r.sr_type === fType); document.getElementById("sr-tbody").innerHTML = rows.map(sr => ` ${sr.sr_id} ${TYPE_LABEL[sr.sr_type] || sr.sr_type} ${esc(sr.title)} ${STATUS_LABEL[sr.status] || sr.status} ${PRIORITY_LABEL[sr.priority] || sr.priority} ${esc(sr.requested_by || "")} ${fmtDate(sr.created_at)} `).join("") || `결과 없음`; } /* ─── SR Detail Modal ───────────────────────────── */ async function openDetail(srId) { const sr = srCache.find(s => s.sr_id === srId); if (!sr) return; const [approvalsRes, auditRes] = await Promise.all([ fetch(`/api/approvals/${srId}`).then(r => r.json()).catch(() => []), fetch(`/api/audit?sr_id=${srId}`).then(r => r.json()).catch(() => []), ]); const approvalHTML = approvalsRes.length ? approvalsRes.map(a => `
${esc(a.approver)} ${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"} ${a.comment ? `${esc(a.comment)}` : ""}
`).join("") : '
승인 기록 없음
'; const auditHTML = auditRes.slice(0, 10).map(log => `
${esc(log.action)} by ${esc(log.actor || "system")}
${esc(log.detail || "")}  #${(log.log_hash || "").slice(0, 12)}
`).join("") || '
기록 없음
'; const canApprove = sr.status === "PENDING_APPROVAL"; document.getElementById("modal-body").innerHTML = ` ${sr.description ? ` ` : ""} ${canApprove ? ` ` : ""} `; document.getElementById("modal-overlay").classList.remove("hidden"); } async function doApproval(srId, result) { const approver = document.getElementById("approver-name").value.trim(); const comment = document.getElementById("approver-comment").value.trim(); if (!approver) { alert("승인자 이름을 입력하세요."); return; } const r = await fetch(`/api/approvals/${srId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approver, result, comment: comment || null }), }); if (r.ok) { document.getElementById("modal-overlay").classList.add("hidden"); await loadAll(); } else { const err = await r.json().catch(() => ({})); alert(err.detail || "처리 중 오류 발생"); } } document.getElementById("modal-close-btn").addEventListener("click", () => document.getElementById("modal-overlay").classList.add("hidden") ); document.getElementById("modal-overlay").addEventListener("click", e => { if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); }); /* ─── New SR Modal ──────────────────────────────── */ function setupNewSR() { document.getElementById("btn-new-sr").addEventListener("click", () => document.getElementById("new-sr-overlay").classList.remove("hidden") ); document.getElementById("new-sr-close").addEventListener("click", () => document.getElementById("new-sr-overlay").classList.add("hidden") ); document.getElementById("new-sr-overlay").addEventListener("click", e => { if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); }); document.getElementById("new-sr-form").addEventListener("submit", async e => { e.preventDefault(); const fd = new FormData(e.target); const payload = Object.fromEntries(fd.entries()); // remove empty optional fields if (!payload.description) delete payload.description; if (!payload.target_server) delete payload.target_server; if (!payload.inst_code) delete payload.inst_code; const r = await fetch("/api/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (r.ok) { document.getElementById("new-sr-overlay").classList.add("hidden"); e.target.reset(); await loadAll(); } else { const err = await r.json().catch(() => ({})); alert(err.detail || "SR 생성 실패"); } }); } /* ─── Audit ─────────────────────────────────────── */ async function loadAudit() { const data = await fetch("/api/audit?limit=100").then(r => r.json()).catch(() => []); document.getElementById("audit-tbody").innerHTML = data.map((log, i) => ` ${i + 1} ${esc(log.sr_id || "—")} ${esc(log.actor || "system")} ${esc(log.action)} ${esc(log.detail || "")} ${(log.log_hash || "").slice(0, 12)} ${fmtDate(log.created_at)} `).join("") || `기록 없음`; document.getElementById("btn-verify").addEventListener("click", async () => { const res = await fetch("/api/audit/verify").then(r => r.json()); const el = document.getElementById("verify-result"); if (res.intact) { el.textContent = "✅ 체인 무결성 확인됨"; el.className = "ok"; } else { el.textContent = `❌ 변조 감지 (ID: ${res.broken_at_id})`; el.className = "fail"; } }); } /* ─── CMDB ───────────────────────────────────────── */ async function loadCmdb() { const institutions = await fetch("/api/cmdb/institutions").then(r => r.json()).catch(() => []); const grid = document.getElementById("cmdb-grid"); grid.innerHTML = ""; await Promise.all(institutions.map(async inst => { const servers = await fetch(`/api/cmdb/institutions/${inst.inst_code}/servers`) .then(r => r.json()).catch(() => []); const card = document.createElement("div"); card.className = "cmdb-card"; card.innerHTML = `
${esc(inst.inst_name)} ${esc(inst.inst_code)}
${servers.map(s => `
${s.server_role} ${esc(s.server_name)} ${esc(s.os_type || "")} ${s.is_active ? "● 정상" : "● 비활성"}
`).join("") || '
서버 없음
'}
${inst.contact_pm ? `
PM: ${esc(inst.contact_pm)}
` : ""} `; grid.appendChild(card); })); } /* ─── Helpers ───────────────────────────────────── */ function esc(s) { return String(s ?? "") .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function fmtDate(iso) { if (!iso) return ""; try { return new Date(iso).toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch { return iso; } }