zioinfo-mail/itsm/static/app.js
DESKTOP-TKLFCPR\ython 79061ee89c feat(itsm): Jira-like ITSM 시스템 구현
- FastAPI + SQLAlchemy(aiosqlite) 기반 SR 상태 머신
  (RECEIVED → PARSED → PENDING_APPROVAL → APPROVED → IN_PROGRESS
   → PENDING_PM_VALIDATION → COMPLETED / FAILED_ROLLBACK)
- PM 승인 워크플로우 (ApprovalFlow 테이블)
- SHA-256 해시 체인 감사 로그 (위변조 방지)
- AES-256-GCM 서버 자격증명 암호화 (IP/PW API 미노출)
- CMDB: 기관(MOF/MOIS/MSS) + 서버 정보 관리
- 더미 데이터 자동 시딩 (6개 SR, 3개 기관, 6개 서버)
- Dark-theme SPA: 대시보드 / 칸반 보드 / SR 목록 / 감사 로그 / CMDB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:31:09 +09:00

447 lines
19 KiB
JavaScript

/* ─── 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 = `
<div class="stat-card accent">
<div class="stat-value">${s.total || 0}</div>
<div class="stat-label">전체 SR</div>
</div>
<div class="stat-card yellow">
<div class="stat-value">${pending}</div>
<div class="stat-label">승인 대기</div>
</div>
<div class="stat-card accent">
<div class="stat-value">${active}</div>
<div class="stat-label">진행 중</div>
</div>
<div class="stat-card green">
<div class="stat-value">${completed}</div>
<div class="stat-label">완료</div>
</div>
<div class="stat-card red">
<div class="stat-value">${failed}</div>
<div class="stat-label">롤백 실패</div>
</div>
`;
// Recent SR list
const recent = [...srCache].slice(0, 10);
document.getElementById("recent-list").innerHTML = recent.map(sr => `
<div class="recent-row" onclick="openDetail('${sr.sr_id}')">
<span class="recent-sr-id">${sr.sr_id}</span>
<span class="recent-title">${esc(sr.title)}</span>
<span class="badge badge-${sr.status}">${STATUS_LABEL[sr.status] || sr.status}</span>
<span class="recent-time">${fmtDate(sr.created_at)}</span>
</div>
`).join("") || '<div style="padding:12px 16px;color:var(--text-muted)">SR이 없습니다.</div>';
// Status bar chart
const total = s.total || 1;
const items = Object.entries(bs)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `
<div class="status-bar-item">
<div class="status-bar-label">
<span>${STATUS_LABEL[k] || k}</span>
<span>${v}</span>
</div>
<div class="status-bar-track">
<div class="status-bar-fill" style="width:${Math.round(v/total*100)}%;background:${STATUS_COLORS[k]||'#8b949e'}"></div>
</div>
</div>`).join("");
document.getElementById("status-chart").innerHTML =
`<div class="status-bar-list">${items}</div>`;
}
/* ─── 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 = `
<div class="kanban-col-header">
<span class="badge badge-${col.key}">${col.label}</span>
<span class="col-count">${cards.length}</span>
</div>
<div class="kanban-cards" id="col-${col.key}"></div>`;
board.appendChild(colEl);
const cardsEl = colEl.querySelector(`#col-${col.key}`);
cards.forEach(sr => {
const card = document.createElement("div");
card.className = "kanban-card";
card.innerHTML = `
<div class="kanban-card-id">${sr.sr_id}</div>
<div class="kanban-card-title">${esc(sr.title)}</div>
<div class="kanban-card-meta">
<span class="badge badge-type-${sr.sr_type}">${TYPE_LABEL[sr.sr_type] || sr.sr_type}</span>
<span class="badge badge-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority] || sr.priority}</span>
</div>`;
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 => `
<tr onclick="openDetail('${sr.sr_id}')">
<td><code style="font-size:11px">${sr.sr_id}</code></td>
<td><span class="badge badge-type-${sr.sr_type}">${TYPE_LABEL[sr.sr_type] || sr.sr_type}</span></td>
<td>${esc(sr.title)}</td>
<td><span class="badge badge-${sr.status}">${STATUS_LABEL[sr.status] || sr.status}</span></td>
<td><span class="badge badge-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority] || sr.priority}</span></td>
<td>${esc(sr.requested_by || "")}</td>
<td style="font-size:12px;color:var(--text-muted)">${fmtDate(sr.created_at)}</td>
</tr>
`).join("") || `<tr><td colspan="7" style="color:var(--text-muted);text-align:center;padding:20px">결과 없음</td></tr>`;
}
/* ─── 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 => `
<div style="font-size:13px;padding:6px 0;border-bottom:1px solid var(--border)">
<strong>${esc(a.approver)}</strong>
<span class="badge badge-${a.result === "APPROVED" ? "COMPLETED" : a.result === "REJECTED" ? "REJECTED" : "PENDING_APPROVAL"}" style="margin-left:8px">
${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"}
</span>
${a.comment ? `<span style="color:var(--text-muted);margin-left:8px">${esc(a.comment)}</span>` : ""}
</div>`).join("")
: '<div style="color:var(--text-muted);font-size:13px">승인 기록 없음</div>';
const auditHTML = auditRes.slice(0, 10).map(log => `
<div class="timeline-item done">
<div class="timeline-action">${esc(log.action)} <span style="color:var(--text-muted);font-weight:400">by ${esc(log.actor || "system")}</span></div>
<div class="timeline-detail">${esc(log.detail || "")} &nbsp;<span class="hash-code">#${(log.log_hash || "").slice(0, 12)}</span></div>
</div>`).join("") || '<div style="color:var(--text-muted);font-size:13px">기록 없음</div>';
const canApprove = sr.status === "PENDING_APPROVAL";
document.getElementById("modal-body").innerHTML = `
<div class="modal-title">${esc(sr.title)}</div>
<div class="modal-meta">
<span class="badge badge-${sr.status}">${STATUS_LABEL[sr.status] || sr.status}</span>
<span class="badge badge-type-${sr.sr_type}">${TYPE_LABEL[sr.sr_type] || sr.sr_type}</span>
<span class="badge badge-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority] || sr.priority}</span>
<code style="font-size:11px;color:var(--text-muted)">${sr.sr_id}</code>
</div>
<div class="modal-section">
<div class="modal-section-title">요약 정보</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px">
<div><span style="color:var(--text-muted)">요청자:</span> ${esc(sr.requested_by || "")}</div>
<div><span style="color:var(--text-muted)">담당자:</span> ${esc(sr.assigned_to || "미지정")}</div>
<div><span style="color:var(--text-muted)">대상 서버:</span> ${esc(sr.target_server || "-")}</div>
<div><span style="color:var(--text-muted)">생성일:</span> ${fmtDate(sr.created_at)}</div>
</div>
</div>
${sr.description ? `
<div class="modal-section">
<div class="modal-section-title">설명</div>
<div class="modal-desc">${esc(sr.description)}</div>
</div>` : ""}
<div class="modal-section">
<div class="modal-section-title">승인 현황</div>
${approvalHTML}
</div>
<div class="modal-section">
<div class="modal-section-title">감사 로그</div>
<div class="timeline">${auditHTML}</div>
</div>
${canApprove ? `
<div class="modal-actions" id="approval-actions">
<input type="text" id="approver-name" placeholder="승인자 이름" class="search-box" style="max-width:160px">
<input type="text" id="approver-comment" placeholder="코멘트(선택)" class="search-box" style="max-width:200px">
<button class="btn btn-approve" onclick="doApproval('${srId}', 'APPROVED')">✅ 승인</button>
<button class="btn btn-reject" onclick="doApproval('${srId}', 'REJECTED')">❌ 반려</button>
</div>` : ""}
`;
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) => `
<tr>
<td style="color:var(--text-muted)">${i + 1}</td>
<td><code style="font-size:11px">${esc(log.sr_id || "—")}</code></td>
<td>${esc(log.actor || "system")}</td>
<td><strong>${esc(log.action)}</strong></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(log.detail || "")}</td>
<td class="hash-code">${(log.log_hash || "").slice(0, 12)}</td>
<td style="font-size:12px;color:var(--text-muted)">${fmtDate(log.created_at)}</td>
</tr>`).join("") || `<tr><td colspan="7" style="text-align:center;padding:20px;color:var(--text-muted)">기록 없음</td></tr>`;
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 = `
<div class="cmdb-card-header">
<span>${esc(inst.inst_name)}</span>
<span style="font-size:11px;color:var(--text-muted)">${esc(inst.inst_code)}</span>
</div>
<div class="cmdb-servers">
${servers.map(s => `
<div class="cmdb-server-row">
<span class="server-role-badge role-${s.server_role}">${s.server_role}</span>
<span>${esc(s.server_name)}</span>
<span style="font-size:11px;color:var(--text-muted)">${esc(s.os_type || "")}</span>
<span class="${s.is_active ? "server-active" : "server-inactive"}">${s.is_active ? "● 정상" : "● 비활성"}</span>
</div>`).join("") || '<div style="padding:8px 16px;color:var(--text-muted);font-size:12px">서버 없음</div>'}
</div>
${inst.contact_pm ? `<div style="padding:8px 16px;font-size:12px;color:var(--text-muted);border-top:1px solid var(--border)">PM: ${esc(inst.contact_pm)}</div>` : ""}
`;
grid.appendChild(card);
}));
}
/* ─── Helpers ───────────────────────────────────── */
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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; }
}