/* ─── 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 = `
`;
// 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 = `
`;
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 = `
${esc(sr.title)}
${STATUS_LABEL[sr.status] || sr.status}
${TYPE_LABEL[sr.sr_type] || sr.sr_type}
${PRIORITY_LABEL[sr.priority] || sr.priority}
${sr.sr_id}
요약 정보
요청자: ${esc(sr.requested_by || "")}
담당자: ${esc(sr.assigned_to || "미지정")}
대상 서버: ${esc(sr.target_server || "-")}
생성일: ${fmtDate(sr.created_at)}
${sr.description ? `
설명
${esc(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 = `
${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; }
}