- 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>
447 lines
19 KiB
JavaScript
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 || "")} <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, "&").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; }
|
|
}
|