guardia-itsm/static/app.js
2026-06-03 08:48:51 +09:00

4764 lines
246 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ══════════════════════════════════════════════════
F-4: PWA Service Worker 등록
══════════════════════════════════════════════════ */
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js', { scope: '/' })
.then(reg => {
console.log('[GUARDiA PWA] SW 등록 성공:', reg.scope);
// 새 버전 감지 시 사용자에게 알림
reg.addEventListener('updatefound', () => {
const newSW = reg.installing;
newSW.addEventListener('statechange', () => {
if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[GUARDiA PWA] 새 버전 사용 가능 — 새로고침하면 업데이트됩니다.');
}
});
});
})
.catch(err => console.warn('[GUARDiA PWA] SW 등록 실패:', err));
});
}
/* ══════════════════════════════════════════════════
Nifty 사이드바 계층 메뉴 토글
══════════════════════════════════════════════════ */
function toggleNavGroup(header) {
const body = header.nextElementSibling;
const isOpen = body.classList.contains('open');
body.classList.toggle('open', !isOpen);
header.setAttribute('aria-expanded', String(!isOpen));
}
// 현재 URL에 해당하는 메뉴 자동 열기
(function autoOpenNavGroup() {
document.querySelectorAll('.nav-group-body .nav-sub-item').forEach(item => {
const href = item.getAttribute('href') || '';
if (href && location.pathname.startsWith(href.split('?')[0])) {
const body = item.closest('.nav-group-body');
const header = body?.previousElementSibling;
if (body && header) {
body.classList.add('open');
header.setAttribute('aria-expanded', 'true');
item.classList.add('active');
}
}
});
})();
/* ══════════════════════════════════════════════════
테마 관리 (스크립트 최상단 — FOUC 방지)
══════════════════════════════════════════════════ */
function applyTheme(key) {
document.body.dataset.theme = key;
localStorage.setItem("guardia_theme", key);
document.querySelectorAll(".theme-swatch").forEach(b =>
b.classList.toggle("active", b.dataset.theme === key)
);
}
/* ─── Auth ───────────────────────────────────────── */
const _token = localStorage.getItem("guardia_token");
const _userInfo = JSON.parse(localStorage.getItem("guardia_userinfo") || "{}");
// 로그인 안 된 경우 → /login으로
if (!_token) {
window.location.replace("/login");
}
// 비밀번호 변경 필요 → /change-password로
if (_userInfo.must_change_pw) {
window.location.replace("/change-password");
}
/**
* Bearer 토큰을 자동으로 포함하는 fetch 래퍼.
* 401 응답 시 로그인 페이지로 리다이렉트.
*/
async function authFetch(url, opts = {}) {
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (_token) headers["Authorization"] = `Bearer ${_token}`;
const res = await fetch(url, { ...opts, headers });
if (res.status === 401) {
localStorage.removeItem("guardia_token");
localStorage.removeItem("guardia_userinfo");
window.location.replace("/login");
throw new Error("Unauthorized");
}
return res;
}
/** 로그아웃 */
function logout() {
localStorage.removeItem("guardia_token");
localStorage.removeItem("guardia_userinfo");
window.location.replace("/login");
}
/* ─── State ─────────────────────────────────────── */
let currentView = "dashboard";
let srCache = [];
let statsCache = {};
let workloadCache = [];
let dashCache = {}; // 역할별 대시보드 데이터
/* ─── 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 () => {
// 사용자 정보 표시
const roleLabel = { ADMIN:"관리자", ENGINEER:"엔지니어", PM:"PM", CUSTOMER:"고객" };
const roleColor = {
ADMIN:"#818cf8", ENGINEER:"#34d399", PM:"#fbbf24", CUSTOMER:"#a78bfa"
};
const role = _userInfo.role || "";
document.getElementById("user-display-name").textContent =
_userInfo.display_name || _userInfo.username || "사용자";
const roleBadge = document.getElementById("user-role-badge");
roleBadge.textContent = roleLabel[role] || role;
roleBadge.style.background = (roleColor[role] || "#64748b") + "22";
roleBadge.style.color = roleColor[role] || "#64748b";
// 테마 버튼 active 상태 동기화
applyTheme(document.body.dataset.theme || "dark");
setupNav();
setupNewSR();
setupListFilters();
initChat();
await loadAll();
initSSE(); // 실시간 이벤트 연결
startPoll(); // 30초 폴백 폴링
});
async function loadAll() {
await Promise.all([loadDashboardMe(), loadSRs(), loadWorkload()]);
renderCurrentView();
}
/* ══════════════════════════════════════════════════
SSE 실시간 연결
══════════════════════════════════════════════════ */
let _sseSource = null;
let _sseRetry = 0;
let _refreshPending = false;
let _pollTimer = null;
function initSSE() {
if (!_token) return;
if (_sseSource) { _sseSource.close(); _sseSource = null; }
const url = `/api/dashboard/events?token=${encodeURIComponent(_token)}`;
_sseSource = new EventSource(url);
_sseSource.onopen = () => {
_sseRetry = 0;
setSseDot(true);
};
_sseSource.onmessage = (e) => {
if (!e.data || e.data.trim() === "") return;
try { handleSSEEvent(JSON.parse(e.data)); } catch {}
};
_sseSource.onerror = () => {
setSseDot(false);
_sseSource.close();
_sseSource = null;
_sseRetry++;
const delay = Math.min(3000 * _sseRetry, 60000);
setTimeout(initSSE, delay);
};
}
function setSseDot(connected) {
const dot = document.getElementById("sse-dot");
const lbl = document.getElementById("sse-label");
if (!dot) return;
dot.className = `sse-dot ${connected ? "green" : "red"}`;
if (lbl) lbl.textContent = connected ? "실시간 연결" : "재연결 중…";
}
async function handleSSEEvent(evt) {
switch (evt.type) {
case "sr_created":
showToast(`🆕 새 SR 접수: ${evt.title || evt.sr_id}`, "info");
debouncedRefresh();
break;
case "sr_updated":
showToast(`📋 ${evt.sr_id}${STATUS_LABEL[evt.new_status] || evt.new_status}`, "info");
debouncedRefresh();
break;
case "approval_done": {
const icon = evt.result === "APPROVED" ? "✅" : "❌";
showToast(`${icon} ${evt.sr_id} 결재 완료 (${evt.approver})`,
evt.result === "APPROVED" ? "success" : "warning");
debouncedRefresh();
break;
}
case "stats":
await loadDashboardMe();
if (currentView === "dashboard") renderDashboard();
break;
}
}
function debouncedRefresh() {
if (_refreshPending) return;
_refreshPending = true;
setTimeout(async () => {
await loadAll();
if (["ADMIN","PM"].includes(_userInfo.role)) loadTrend();
_refreshPending = false;
showRefreshIndicator();
}, 600);
}
function showRefreshIndicator() {
const el = document.getElementById("topbar-refresh-indicator");
if (!el) return;
el.classList.remove("hidden");
setTimeout(() => el.classList.add("hidden"), 2500);
}
/* 폴백 폴링 (SSE 장애 대비, 30초) */
function startPoll() {
if (_pollTimer) clearInterval(_pollTimer);
_pollTimer = setInterval(async () => {
if (_sseSource && _sseSource.readyState === EventSource.OPEN) return;
await loadDashboardMe();
if (currentView === "dashboard") renderDashboard();
}, 30000);
}
/* ══════════════════════════════════════════════════
토스트 알림
══════════════════════════════════════════════════ */
function showToast(msg, type = "info") {
const el = document.createElement("div");
el.className = `toast toast-${type}`;
el.textContent = msg;
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add("show"));
});
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 350);
}, 3500);
}
async function loadDashboardMe() {
try {
const r = await authFetch("/api/dashboard/me");
dashCache = await r.json();
// stats도 함께 채워두기 (호환성)
if (dashCache.kpi) {
statsCache = {
total: dashCache.kpi.total,
by_status: {},
by_type: {},
};
}
} catch { dashCache = {}; }
}
/* ─── 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",
kb: "기술 문서 KB",
institutions: "기관 관리", scripts: "스크립트 관리",
timetable: "작업 타임테이블",
// ── Upstage OCR ──
ocr_parse: "문서 파싱 (Upstage OCR)", ocr_contract: "계약서 자동 처리",
ocr_brand_contract: "브랜드 계약서 처리", ocr_server_spec: "납품서 → CMDB 등록",
ocr_invoice: "청구서 처리", ocr_incident: "장애보고서 → SR 생성",
ocr_meeting: "회의록 → 액션아이템", ocr_history: "OCR 처리 이력",
doc_templates: "추출 템플릿 관리",
// ── GUARDiA 확장 v3 ──
rag_search: "RAG 하이브리드 검색", ai_insights: "AI 운영 인사이트",
ai_workflow: "자율 워크플로우", learning_loop: "Learning Loop",
multimodal: "멀티모달 AI 분석",
kpi_dashboard: "KPI 대시보드", bi_dashboard: "BI 대시보드",
predictive: "예측 분석", benchmark: "벤치마킹",
auto_report: "자동 보고서", cohort: "코호트 분석",
kubernetes: "Kubernetes 관리", container_alerts: "컨테이너 알림",
ncloud: "NCloud 관리",
jira_sync: "Jira 동기화", servicenow: "ServiceNow",
slack_config: "Slack 설정", sso_config: "SSO 인증",
erp_config: "ERP 연동", kakao_config: "카카오 알림톡",
tenant_portal: "테넌트 포털", billing: "구독 · 과금",
white_label: "브랜딩 설정",
};
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();
else if (currentView === "kb") loadKBView();
else if (currentView === "institutions") loadInstitutions();
else if (currentView === "scripts") loadScripts();
else if (currentView === "timetable") loadTimetable();
// ── GUARDiA 기능 개선 v4 뷰 ──
else if (currentView === "app_deploy" || currentView === "app_versions" || currentView === "app_stats") renderAppDeploy();
else if (currentView === "batch_ssh") renderBatchSsh();
else if (currentView === "asset_qr") renderAssetQr();
else if (currentView === "notification_rules") renderNotificationRules();
// ── GUARDiA Brain 뷰 ──
else if (currentView === "brain_dashboard") renderBrainDashboard();
else if (currentView === "ai_memory") renderAiMemory();
else if (currentView === "knowledge_graph_view") renderKnowledgeGraph();
else if (currentView === "skill_registry_view") renderSkillRegistry();
else if (currentView === "skill_miner_view") renderSkillMiner();
else if (currentView === "finetune_view") renderFinetune();
else if (currentView === "brain_plugins") renderBrainPlugins();
// ── GUARDiA 차세대 확장 뷰 ──
else if (currentView === "agentic_aiops") renderAgenticAiops();
else if (currentView === "auto_remediation_v2") renderAutoRemediation();
else if (currentView === "otel_tracing") renderOtelTracing();
else if (currentView === "mlsecops") renderMlsecops();
else if (currentView === "ztna") renderZtna();
else if (currentView === "sbom") renderSbom();
else if (currentView === "n2sf") renderN2sf();
else if (currentView === "idp_catalog") renderIdpCatalog();
else if (currentView === "idp_template") renderIdpTemplate();
else if (currentView === "idp_portal") renderIdpPortal();
else if (currentView === "greenops") renderGreenops();
else if (currentView === "edge_monitor") renderEdgeMonitor();
else if (currentView === "energy_optimizer") renderEnergyOptimizer();
// ── GUARDiA 확장 v3 뷰 ──
else loadExpansionView(currentView);
}
/* ─── Data loading ──────────────────────────────── */
async function loadWorkload() {
try {
const r = await authFetch("/api/assign/workload");
workloadCache = await r.json();
renderWorkload();
} catch { workloadCache = []; }
}
async function loadStats() {
try {
const r = await authFetch("/api/tasks/stats");
statsCache = await r.json();
} catch { statsCache = {}; }
}
async function loadSRs(params = {}) {
const qs = new URLSearchParams(params).toString();
const r = await authFetch(`/api/tasks?limit=200${qs ? "&" + qs : ""}`);
srCache = await r.json();
}
/* ══════════════════════════════════════════════════
7일 추이 차트 (순수 SVG)
══════════════════════════════════════════════════ */
async function loadTrend() {
try {
const r = await authFetch("/api/dashboard/stats/trend");
const data = await r.json();
renderTrendChart(data);
const lbl = document.getElementById("trend-last-updated");
if (lbl) lbl.textContent = new Date().toLocaleTimeString("ko-KR", { hour:"2-digit", minute:"2-digit" }) + " 기준";
} catch {}
}
function renderTrendChart(days) {
const card = document.getElementById("trend-chart-card");
const el = document.getElementById("trend-chart");
if (!card || !el || !days?.length) return;
card.style.display = "";
const W = el.clientWidth || 520;
const H = 130;
const pad = { t: 16, r: 16, b: 32, l: 36 };
const cw = W - pad.l - pad.r;
const ch = H - pad.t - pad.b;
const maxVal = Math.max(...days.flatMap(d => [d.created, d.completed]), 1);
const step = cw / days.length;
const bw = Math.floor(step * 0.32);
// Y 격자선 + 레이블
const yLines = [0, .5, 1].map(f => {
const y = pad.t + ch * (1 - f);
const v = Math.round(maxVal * f);
return `<line x1="${pad.l}" y1="${y}" x2="${pad.l + cw}" y2="${y}" stroke="rgba(255,255,255,.05)" stroke-width="1"/>
<text x="${pad.l - 4}" y="${y + 4}" fill="#64748b" font-size="9" text-anchor="end">${v}</text>`;
}).join("");
// 막대 + X 레이블
const bars = days.map((d, i) => {
const cx = pad.l + i * step + step * 0.5;
const x1 = cx - bw - 1;
const x2 = cx + 1;
const h1 = Math.max(d.created / maxVal * ch, d.created ? 2 : 0);
const h2 = Math.max(d.completed / maxVal * ch, d.completed ? 2 : 0);
const y1 = pad.t + ch - h1;
const y2 = pad.t + ch - h2;
return `
<rect x="${x1}" y="${y1}" width="${bw}" height="${h1}" fill="#818cf8" opacity=".85" rx="3"/>
<rect x="${x2}" y="${y2}" width="${bw}" height="${h2}" fill="#34d399" opacity=".85" rx="3"/>
<text x="${cx}" y="${H - 4}" fill="#64748b" font-size="9" text-anchor="middle">${d.label}</text>`;
}).join("");
el.innerHTML = `
<svg width="100%" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="overflow:visible">
${yLines}
<line x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${pad.t + ch}" stroke="rgba(255,255,255,.08)" stroke-width="1"/>
<line x1="${pad.l}" y1="${pad.t + ch}" x2="${pad.l + cw}" y2="${pad.t + ch}" stroke="rgba(255,255,255,.08)" stroke-width="1"/>
${bars}
</svg>`;
}
/* ─── Dashboard (역할별 분기) ─────────────────────── */
function renderDashboard() {
const role = _userInfo.role || "ADMIN";
const d = dashCache || {};
const view = document.getElementById("view-dashboard");
if (!view) return;
// 역할 전환 시 이전 역할 전용 동적 요소 정리
document.getElementById("eng-wl-bar")?.remove();
document.getElementById("workload-mini-card")?.remove();
if (role === "ADMIN") renderDashboardAdmin(d);
else if (role === "ENGINEER") renderDashboardEngineer(d);
else if (role === "PM") renderDashboardPM(d);
else if (role === "CUSTOMER") renderDashboardCustomer(d);
else renderDashboardAdmin(d);
}
/* ── 공통 헬퍼 ─────────────────────────────────── */
function _srRow(sr, extra = "") {
return `
<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>
${extra}
</div>`;
}
function _statCard(value, label, color = "accent", sub = "", icon = "") {
return `<div class="stat-card ${color}">
${icon ? `<div class="stat-icon">${icon}</div>` : ""}
<div class="stat-value">${value}</div>
<div class="stat-label">${label}</div>
${sub ? `<div class="stat-sub">${sub}</div>` : ""}
</div>`;
}
function _statusBarChart(byStatus) {
const total = Object.values(byStatus).reduce((a, b) => a + b, 0) || 1;
return `<div class="status-bar-list">` +
Object.entries(byStatus).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("") + `</div>`;
}
/* ── ADMIN 대시보드 ─────────────────────────────── */
function renderDashboardAdmin(d) {
const kpi = d.kpi || {};
document.getElementById("dash-greeting")?.remove();
document.getElementById("stats-row").innerHTML = `
${_statCard(kpi.total || 0, "전체 SR", "accent", "", "📋")}
${_statCard(kpi.pending_approval || 0, "승인 대기", "yellow", "", "🔔")}
${_statCard(kpi.in_progress || 0, "진행 중", "cyan", "", "⚙️")}
${_statCard(kpi.completed_today || 0, "오늘 완료", "green", "", "✅")}
${_statCard(kpi.failed || 0, "롤백 실패", "red", "", "⚠️")}
${_statCard((kpi.auto_rate || 0) + "%", "AI 자동처리율","purple", "", "🤖")}
`;
// 최근 SR
const recent = (d.recent_srs || []).slice(0, 10);
document.getElementById("recent-list").innerHTML = recent.map(sr => _srRow(sr,
`<span class="recent-time">${fmtDate(sr.created_at)}</span>`
)).join("") || '<div style="padding:12px 18px;color:var(--text-muted)">SR이 없습니다.</div>';
// 상태별 차트 (srCache 기반)
const bs = {};
srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; });
document.getElementById("status-chart").innerHTML = _statusBarChart(bs);
// 7일 추이 차트
loadTrend();
// 승인 대기 목록 (workload 카드에 표시)
const pendEl = document.getElementById("workload-body");
const pendList = (d.pending_srs || []);
const pendHTML = pendList.length
? pendList.map(sr => `
<div class="recent-row pending-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-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority]||sr.priority}</span>
<span style="font-size:11px;color:var(--text-muted)">${esc(sr.requested_by||"")}</span>
</div>`).join("")
: '<div style="color:var(--text-muted);font-size:13px;padding:8px">승인 대기 SR 없음</div>';
// workload 카드 헤더 변경
const wlCard = document.getElementById("workload-card");
if (wlCard) {
wlCard.querySelector(".card-header").innerHTML = `
<span>🟡 승인 대기 (${pendList.length}건)</span>
<span style="font-size:12px;color:var(--text-muted);font-weight:400">클릭하여 상세 확인</span>`;
if (pendEl) pendEl.innerHTML = pendHTML;
}
// 엔지니어 워크로드 mini (status-chart 하단에 추가)
renderWorkloadMini(d.workload || workloadCache);
}
function renderWorkloadMini(wl) {
// 기존 mini 제거
document.getElementById("workload-mini-card")?.remove();
const main = document.getElementById("view-dashboard");
if (!main || !wl?.length) return;
const div = document.createElement("div");
div.id = "workload-mini-card";
div.className = "card";
div.style.marginTop = "16px";
div.innerHTML = `
<div class="card-header">👷 엔지니어 워크로드</div>
<div class="card-body" style="padding:12px 16px">
<div class="workload-grid">
${wl.map(e => {
const pct = e.utilization || 0;
const color = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950";
return `
<div class="workload-eng-card" onclick="openAutoAssignModal(event,'${e.username}')">
<div class="workload-eng-header">
<div class="eng-avatar">${(e.display_name||"?").charAt(0)}</div>
<div>
<div class="eng-name">${esc(e.display_name||e.username)}</div>
<div style="font-size:10px;color:var(--text-muted)">${(e.skill_types||"").split(",").filter(Boolean).join("·")}</div>
</div>
<div class="eng-count" style="color:${color}">${e.active}<span style="color:var(--text-muted);font-size:11px">/${e.max_workload}</span></div>
</div>
<div class="workload-bar-track">
<div class="workload-bar-fill" style="width:${pct}%;background:${color}"></div>
</div>
</div>`;
}).join("")}
</div>
</div>`;
main.appendChild(div);
}
/* ── ENGINEER 대시보드 ──────────────────────────── */
function renderDashboardEngineer(d) {
const wl = d.my_workload || {};
const pct = wl.utilization || 0;
const barColor = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950";
const skills = (wl.skills || "").split(",").filter(Boolean)
.map(s => ({ DEPLOY:"배포", RESTART:"재기동", LOG:"로그", INQUIRY:"문의" }[s]||s));
document.getElementById("stats-row").innerHTML = `
<div class="stat-card accent" style="flex:2;min-width:200px">
<div style="display:flex;align-items:center;gap:12px">
<div class="eng-avatar" style="width:44px;height:44px;font-size:20px">${(_userInfo.display_name||"E").charAt(0)}</div>
<div>
<div class="stat-value" style="font-size:16px">${esc(d.greeting||"")}</div>
<div class="stat-sub" style="margin-top:4px">${skills.map(s=>`<span class="skill-tag">${s}</span>`).join("")}</div>
</div>
</div>
</div>
${_statCard(wl.active||0, "담당 중", "accent", `최대 ${wl.max||5}`)}
${_statCard(wl.completed_month||0, "이번 달 완료", "green")}
${_statCard(wl.completed_total||0, "누적 완료", "purple")}
`;
// 워크로드 바 (중복 방지)
document.getElementById("eng-wl-bar")?.remove();
const statsRowEl = document.getElementById("stats-row");
statsRowEl.insertAdjacentHTML("afterend", `
<div id="eng-wl-bar" class="eng-workload-bar-wrap">
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);margin-bottom:4px">
<span>현재 워크로드</span><span>${wl.active||0}/${wl.max||5}건 (${pct}%)</span>
</div>
<div class="workload-bar-track" style="height:8px">
<div class="workload-bar-fill" style="width:${pct}%;background:${barColor};height:8px"></div>
</div>
</div>`);
// 처리 대기 (APPROVED — 바로 시작 가능)
const ready = d.action_required || [];
document.getElementById("recent-list").innerHTML = `
<div style="font-size:11px;font-weight:700;color:var(--accent);text-transform:uppercase;
letter-spacing:.05em;padding:8px 16px;border-bottom:1px solid var(--border)">
⚡ 즉시 실행 가능 (APPROVED, ${ready.length}건)
</div>` +
(ready.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-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority]||sr.priority}</span>
<button class="btn btn-primary" style="font-size:11px;padding:2px 10px;margin-left:auto"
onclick="event.stopPropagation();runSimulate('${sr.sr_id}')">▶ 실행</button>
</div>`).join("") ||
'<div style="padding:12px 16px;color:var(--text-muted);font-size:13px">처리 대기 SR 없음</div>');
// 진행 중
const inprog = d.in_progress || [];
document.getElementById("status-chart").innerHTML = `
<div style="font-size:11px;font-weight:700;color:var(--text-muted);text-transform:uppercase;
letter-spacing:.05em;padding:8px 16px 4px;border-bottom:1px solid var(--border)">
🔄 진행 중 (${inprog.length}건)
</div>` +
(inprog.map(sr => _srRow(sr, `<span class="recent-time">${fmtDate(sr.updated_at)}</span>`)).join("") ||
'<div style="padding:12px 16px;color:var(--text-muted);font-size:13px">진행 중 SR 없음</div>');
// 워크로드 카드: 최근 완료
const done = d.recent_completed || [];
const wlEl = document.getElementById("workload-card");
if (wlEl) {
wlEl.querySelector(".card-header").innerHTML = `<span>✅ 최근 완료 (${done.length}건)</span>`;
const body = document.getElementById("workload-body");
if (body) body.innerHTML = done.map(sr =>
_srRow(sr, `<span class="recent-time">${fmtDate(sr.updated_at)}</span>`)
).join("") || '<div style="padding:12px 16px;color:var(--text-muted);font-size:13px">완료 이력 없음</div>';
}
}
/* ── PM 대시보드 ────────────────────────────────── */
function renderDashboardPM(d) {
const pendCnt = d.pending_count || 0;
document.getElementById("stats-row").innerHTML = `
<div class="stat-card yellow" style="flex:1.5">
<div class="stat-value">${pendCnt}</div>
<div class="stat-label">승인 대기</div>
${pendCnt > 0 ? '<div class="stat-sub" style="color:#e3b341">즉시 처리 필요</div>' : ""}
</div>
${(d.inst_stats||[]).map(i => _statCard(i.total, i.inst_name, "accent", `진행 ${i.active} · 완료 ${i.completed}`)).join("")}
`;
// 승인 대기 큐 (메인 카드)
const pend = d.pending_srs || [];
document.getElementById("recent-list").innerHTML = `
<div style="font-size:11px;font-weight:700;color:var(--yellow);text-transform:uppercase;
letter-spacing:.05em;padding:8px 16px;border-bottom:1px solid var(--border)">
🔔 승인 대기 큐 — 결재 필요
</div>` +
pend.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-priority-${sr.priority}">${PRIORITY_LABEL[sr.priority]||sr.priority}</span>
<span style="font-size:11px;color:var(--text-muted);flex-shrink:0">${esc(sr.requested_by||"")}</span>
<div style="display:flex;gap:4px;margin-left:auto" onclick="event.stopPropagation()">
<button class="btn btn-approve" style="font-size:11px;padding:2px 8px"
onclick="quickApprove('${sr.sr_id}','APPROVED')">✅</button>
<button class="btn btn-reject" style="font-size:11px;padding:2px 8px"
onclick="quickApprove('${sr.sr_id}','REJECTED')">❌</button>
</div>
</div>`).join("") ||
'<div style="padding:12px 16px;color:var(--text-muted);font-size:13px">승인 대기 SR 없음 ✨</div>';
// 엔지니어 워크로드
const wlEl = document.getElementById("workload-card");
if (wlEl) {
wlEl.querySelector(".card-header").innerHTML = `<span>👷 엔지니어 워크로드</span>`;
const body = document.getElementById("workload-body");
if (body) body.innerHTML = `<div class="workload-grid">` +
(d.workload||[]).map(e => {
const pct = e.utilization||0;
const color = pct>=80?"#f85149":pct>=50?"#e3b341":"#3fb950";
return `<div class="workload-eng-card">
<div class="workload-eng-header">
<div class="eng-avatar">${(e.display_name||"?").charAt(0)}</div>
<div><div class="eng-name">${esc(e.display_name||e.username)}</div></div>
<div class="eng-count" style="color:${color}">${e.active}<span style="color:var(--text-muted);font-size:11px">/${e.max_workload}</span></div>
</div>
<div class="workload-bar-track"><div class="workload-bar-fill" style="width:${pct}%;background:${color}"></div></div>
</div>`;
}).join("") + `</div>`;
}
// 상태 차트
const bs = {};
srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; });
document.getElementById("status-chart").innerHTML = _statusBarChart(bs);
// PM도 추이 차트 표시
loadTrend();
}
/* ── CUSTOMER 대시보드 ──────────────────────────── */
function renderDashboardCustomer(d) {
const st = d.stats || {};
document.getElementById("stats-row").innerHTML = `
<div class="stat-card accent" style="flex:2">
<div style="font-size:13px;color:var(--text-muted);margin-bottom:4px">소속 기관</div>
<div class="stat-value" style="font-size:18px">${esc(d.inst_name||d.inst_code||"—")}</div>
</div>
${_statCard(st.total||0, "전체 SR")}
${_statCard(st.active||0, "진행 중", "accent")}
${_statCard(st.completed||0, "완료", "green")}
${d.avg_rating != null ? _statCard("★".repeat(Math.round(d.avg_rating||0)) + ` ${d.avg_rating}`, "평균 만족도", "yellow") : ""}
`;
// 진행 중 SR
const activeSRs = d.active_srs || [];
document.getElementById("recent-list").innerHTML = `
<div style="font-size:11px;font-weight:700;color:var(--accent);text-transform:uppercase;
letter-spacing:.05em;padding:8px 16px;border-bottom:1px solid var(--border)">
🔄 진행 중인 요청 (${activeSRs.length}건)
</div>` +
activeSRs.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 style="font-size:11px;color:var(--text-muted)">${esc(sr.assigned_to||"처리 중")}</span>
</div>`).join("") ||
'<div style="padding:12px 16px;color:var(--text-muted);font-size:13px">진행 중인 요청이 없습니다.</div>';
// 상태 현황
const bs = {};
srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; });
document.getElementById("status-chart").innerHTML = _statusBarChart(bs);
// 워크로드 카드: 최근 완료
const done = d.recent_completed || [];
const wlEl = document.getElementById("workload-card");
if (wlEl) {
wlEl.querySelector(".card-header").innerHTML = `<span>✅ 최근 완료된 요청</span>`;
const body = document.getElementById("workload-body");
if (body) body.innerHTML = done.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-COMPLETED">완료</span>
<span class="recent-time">${fmtDate(sr.updated_at)}</span>
</div>`).join("") ||
'<div style="padding:12px 16px;color:var(--text-muted);font-size:13px">완료된 요청 없음</div>';
}
}
/* ── PM 빠른 승인/반려 ──────────────────────────── */
async function quickApprove(srId, result) {
const actor = _userInfo.display_name || _userInfo.username || "PM";
const r = await authFetch(`/api/approvals/${srId}`, {
method: "POST",
body: JSON.stringify({ approver: actor, result, comment: "대시보드 빠른 처리" }),
});
if (r.ok) { await loadAll(); }
else { const e = await r.json().catch(()=>({})); alert(e.detail||"처리 실패"); }
}
/* ─── 엔지니어 워크로드 패널 ─────────────────────── */
function renderWorkload() {
const el = document.getElementById("workload-body");
if (!el) return;
if (!workloadCache.length) {
el.innerHTML = '<div style="color:var(--text-muted);font-size:13px;padding:8px 0">등록된 엔지니어 프로필 없음</div>';
return;
}
const skillLabel = { DEPLOY:"배포", RESTART:"재기동", LOG:"로그", INQUIRY:"문의", OTHER:"기타" };
const canAssign = ["ADMIN","PM"].includes(_userInfo.role || "");
el.innerHTML = `
<div class="workload-grid">
${workloadCache.map(eng => {
const pct = eng.utilization;
const color = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950";
const skills = (eng.skill_types || "").split(",").filter(Boolean)
.map(s => `<span class="skill-tag">${skillLabel[s]||s}</span>`).join("");
const aff = (eng.inst_affinity || "").split(",").filter(Boolean)
.map(a => `<span class="skill-tag inst-tag">${a}</span>`).join("");
const assignBtn = canAssign
? `<button class="btn btn-secondary" style="font-size:11px;padding:3px 8px;margin-top:4px"
onclick="openAutoAssignModal(event,'${eng.username}')">배정 중인 SR 보기</button>`
: "";
return `
<div class="workload-eng-card">
<div class="workload-eng-header">
<div class="eng-avatar">${eng.display_name.charAt(0)}</div>
<div>
<div class="eng-name">${esc(eng.display_name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${eng.username}</div>
</div>
<div class="eng-count" style="color:${color}">
${eng.active}<span style="color:var(--text-muted);font-size:11px">/${eng.max_workload}</span>
</div>
</div>
<div class="workload-bar-track">
<div class="workload-bar-fill" style="width:${pct}%;background:${color}"></div>
</div>
<div class="workload-tags">${skills}${aff}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">완료 ${eng.completed}건</div>
${assignBtn}
</div>`;
}).join("")}
</div>`;
}
function openAutoAssignModal(e, username) {
e.stopPropagation();
const eng = workloadCache.find(e => e.username === username);
if (!eng) return;
const active = srCache.filter(sr =>
sr.assigned_to === username &&
!["COMPLETED","FAILED_ROLLBACK","REJECTED"].includes(sr.status)
);
const html = active.length
? active.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>
</div>`).join("")
: '<div style="color:var(--text-muted);font-size:13px;padding:12px">현재 담당 중인 SR 없음</div>';
document.getElementById("modal-body").innerHTML = `
<div class="modal-title">👷 ${esc(eng.display_name)} 담당 SR</div>
<div style="font-size:13px;color:var(--text-muted);margin-bottom:12px">
활성 ${eng.active}건 | 완료 ${eng.completed}건 | 이용률 ${eng.utilization}%
</div>
${html}`;
document.getElementById("modal-overlay").classList.remove("hidden");
}
/* ─── 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 => {
const engInfo = workloadCache.find(e => e.username === sr.assigned_to);
const engChip = sr.assigned_to
? `<span class="eng-chip">${esc(engInfo?.display_name || sr.assigned_to)}</span>`
: `<span style="color:var(--text-muted);font-size:12px">미배정</span>`;
return `
<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>${engChip}</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="8" style="color:var(--text-muted);text-align:center;padding:20px">결과 없음</td></tr>`;
}
const WORK_ACTION_LABEL = {
CMDB_CHECK:"🔍 자산 확인", SSH_CONNECT:"🔗 SSH 접속",
SSH_EXEC:"⚡ 명령 실행", SOURCE_MOD:"📦 파일 배포",
HEALTH_CHECK:"💓 헬스체크", RESULT:"📋 결과 기록", COMPLETE:"✅ 완료 처리",
};
/* ─── 첨부파일 헬퍼 ────────────────────────────── */
function _fileIcon(name) {
const ext = (name.split(".").pop() || "").toLowerCase();
return {
pdf:"📄", png:"🖼️", jpg:"🖼️", jpeg:"🖼️", gif:"🖼️", webp:"🖼️",
xlsx:"📊", xls:"📊", docx:"📝", doc:"📝", pptx:"📊", ppt:"📊",
zip:"🗜️", tar:"🗜️", gz:"🗜️",
sh:"⚙️", sql:"🗄️", json:"📋", yaml:"📋", yml:"📋", md:"📋",
log:"📜", txt:"📜", csv:"📊",
}[ext] || "📎";
}
function _fmtSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function _renderAttachments(atts, srId) {
if (!atts || atts.length === 0)
return `<div style="color:var(--text-muted);font-size:13px">첨부파일 없음</div>`;
return `<div class="attachment-list">` + atts.map(a => `
<div class="attachment-item">
<span class="att-icon">${_fileIcon(a.original_name)}</span>
<a class="att-name" href="/api/tasks/${srId}/attachments/${a.id}/download"
download="${esc(a.original_name)}" target="_blank">${esc(a.original_name)}</a>
<span class="att-size">${_fmtSize(a.file_size)}</span>
<span class="att-uploader">${esc(a.uploaded_by)}</span>
<span class="att-date">${fmtDate(a.created_at)}</span>
<button class="att-del" title="삭제" onclick="deleteAttachment('${srId}',${a.id},this)">🗑️</button>
</div>`).join("") + `</div>`;
}
async function uploadAttachments(srId, input) {
if (!input.files.length) return;
const ffd = new FormData();
for (const f of input.files) ffd.append("files", f);
const r = await authFetch(`/api/tasks/${srId}/attachments`, { method: "POST", body: ffd });
if (r.ok) {
const newAtts = await authFetch(`/api/tasks/${srId}/attachments`).then(x => x.json()).catch(() => []);
const el = document.getElementById(`att-list-${srId}`);
if (el) el.innerHTML = _renderAttachments(newAtts, srId);
} else {
const err = await r.json().catch(() => ({}));
alert(err.detail || "파일 업로드 실패");
}
input.value = "";
}
async function deleteAttachment(srId, attId, btn) {
if (!confirm("첨부파일을 삭제하시겠습니까?")) return;
const r = await authFetch(`/api/tasks/${srId}/attachments/${attId}`, { method: "DELETE" });
if (r.ok) {
const newAtts = await authFetch(`/api/tasks/${srId}/attachments`).then(x => x.json()).catch(() => []);
const el = document.getElementById(`att-list-${srId}`);
if (el) el.innerHTML = _renderAttachments(newAtts, srId);
} else {
const err = await r.json().catch(() => ({}));
alert(err.detail || "삭제 실패");
}
}
/* ─── SR Detail Modal ───────────────────────────── */
async function openDetail(srId) {
const sr = srCache.find(s => s.sr_id === srId);
if (!sr) return;
const [approvalsRes, auditRes, workRes, ratingRes, attachmentsRes] = await Promise.all([
authFetch(`/api/approvals/${srId}`).then(r => r.json()).catch(() => []),
authFetch(`/api/audit?sr_id=${srId}`).then(r => r.json()).catch(() => []),
authFetch(`/api/work/${srId}`).then(r => r.json()).catch(() => []),
authFetch(`/api/rating/${srId}`).then(r => r.ok ? r.json() : null).catch(() => null),
authFetch(`/api/tasks/${srId}/attachments`).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";
const canSimulate = !["COMPLETED","FAILED_ROLLBACK","REJECTED"].includes(sr.status);
const canAssign = ["ADMIN","PM"].includes(_userInfo.role || "");
/* 작업 로그 HTML */
const workHTML = workRes.length
? `<div class="timeline">` + workRes.map(w => `
<div class="timeline-item done">
<div class="timeline-action">${WORK_ACTION_LABEL[w.action_type] || w.action_type}
<span style="color:var(--text-muted);font-weight:400;font-size:11px"> by ${esc(w.engineer||"AI")}</span>
</div>
<div class="timeline-detail">${esc(w.content||"")}
${w.result ? `<br><code style="font-size:11px;color:#58a6ff">${esc(w.result.slice(0,120))}</code>` : ""}
</div>
</div>`).join("") + `</div>`
: `<div style="color:var(--text-muted);font-size:13px">작업 이력 없음</div>`;
/* 별점 HTML */
const ratingHTML = ratingRes
? `<div style="font-size:13px;padding:6px 0;color:#e3b341">${"★".repeat(ratingRes.stars)}${"☆".repeat(5-ratingRes.stars)}
<span style="color:var(--text-muted);margin-left:8px">${esc(ratingRes.customer||"")}
${ratingRes.comment ? `${esc(ratingRes.comment)}` : ""}</span></div>`
: sr.status === "COMPLETED"
? `<div id="star-widget" style="margin-top:8px">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">고객 만족도 평가</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
${[1,2,3,4,5].map(n=>`<button class="btn btn-secondary" style="font-size:18px;padding:4px 8px" onclick="rateFromModal('${srId}',${n})">★${n}</button>`).join("")}
</div>
</div>`
: "";
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 id="assign-cell">
<span style="color:var(--text-muted)">담당 엔지니어:</span>
${sr.assigned_to
? `<span class="eng-chip" style="margin-left:4px">${esc(workloadCache.find(e=>e.username===sr.assigned_to)?.display_name || sr.assigned_to)}</span>`
: `<span style="color:var(--text-muted);margin-left:4px">미배정</span>`}
${canAssign ? `<button class="btn btn-secondary" style="font-size:11px;padding:2px 8px;margin-left:6px"
onclick="openReassignPanel('${sr.sr_id}')">재배정</button>` : ""}
</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>
${workHTML}
${canSimulate ? `
<div style="margin-top:10px">
<button class="btn btn-primary" id="btn-simulate-${srId}"
onclick="runSimulate('${srId}')">⚡ AI 작업 실행 시뮬레이션</button>
<span id="sim-status-${srId}" style="font-size:12px;color:var(--text-muted);margin-left:8px"></span>
</div>` : ""}
</div>
${ratingHTML ? `
<div class="modal-section">
<div class="modal-section-title">⭐ 고객 만족도</div>
${ratingHTML}
</div>` : ""}
<div class="modal-section">
<div class="modal-section-title" style="display:flex;align-items:center;justify-content:space-between">
<span>📎 첨부파일</span>
<label class="btn btn-secondary" style="font-size:11px;padding:2px 10px;cursor:pointer">
파일 추가
<input type="file" multiple style="display:none"
accept=".pdf,.txt,.log,.csv,.png,.jpg,.jpeg,.xlsx,.docx,.zip,.sh,.sql,.json,.yaml,.md"
onchange="uploadAttachments('${srId}', this)">
</label>
</div>
<div id="att-list-${srId}">${_renderAttachments(attachmentsRes, srId)}</div>
</div>
<!-- KB 추천 섹션 (비동기 로드) -->
<div class="modal-section">
<div class="modal-section-title" style="display:flex;align-items:center;justify-content:space-between">
<span>📚 관련 기술 문서 추천</span>
<button class="btn btn-secondary" style="font-size:11px;padding:2px 8px"
onclick="loadKBSuggestForSR('${srId}')">새로고침</button>
</div>
<div id="kb-suggest-section">
<div style="color:var(--text-muted);font-size:12px">로딩 중…</div>
</div>
</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");
// KB 추천 비동기 로드 (모달 렌더 후)
setTimeout(() => loadKBSuggestForSR(srId), 100);
}
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 authFetch(`/api/approvals/${srId}`, {
method: "POST",
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 || "처리 중 오류 발생");
}
}
/* ─── Simulate + Rating from modal ─────────────── */
async function runSimulate(srId) {
const btn = document.getElementById(`btn-simulate-${srId}`);
const lbl = document.getElementById(`sim-status-${srId}`);
if (btn) { btn.disabled = true; btn.textContent = "⏳ 실행 중…"; }
if (lbl) lbl.textContent = "CMDB 확인 → SSH 접속 → 작업 수행 중…";
const r = await authFetch(`/api/work/${srId}/simulate`, { method: "POST" });
if (r.ok) {
if (lbl) lbl.textContent = "✅ 완료! 메신저에 알림이 전송됩니다.";
await loadAll();
setTimeout(() => openDetail(srId), 600);
} else {
const err = await r.json().catch(() => ({}));
if (lbl) lbl.textContent = "❌ " + (err.detail || "오류 발생");
if (btn) { btn.disabled = false; btn.textContent = "⚡ AI 작업 실행 시뮬레이션"; }
}
}
async function rateFromModal(srId, stars) {
const customer = prompt("평가자 이름을 입력하세요:", "고객");
if (!customer) return;
const r = await authFetch(`/api/rating/${srId}`, {
method: "POST",
body: JSON.stringify({ customer, stars, comment: null }),
});
if (r.ok) {
const widget = document.getElementById("star-widget");
if (widget) widget.innerHTML = `<div style="color:#e3b341;font-size:14px">${"★".repeat(stars)}${"☆".repeat(5-stars)} — 평가 감사합니다!</div>`;
}
}
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");
});
/* ─── 엔지니어 재배정 패널 ───────────────────────── */
async function openReassignPanel(srId) {
let engineers = [];
try {
const r = await authFetch("/api/assign/engineers");
engineers = await r.json();
} catch {}
const panel = document.createElement("div");
panel.id = "reassign-panel";
panel.style.cssText = "margin-top:10px;padding:10px;background:var(--surface-2);border-radius:6px;border:1px solid var(--border)";
panel.innerHTML = `
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">담당 엔지니어 변경</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select id="reassign-select" class="filter-select" style="flex:1;min-width:120px">
<option value="">— 자동 배정 —</option>
${engineers.map(e => `<option value="${e.username}">${esc(e.display_name)}</option>`).join("")}
</select>
<button class="btn btn-primary" style="font-size:12px;padding:5px 12px"
onclick="submitReassign('${srId}')">배정</button>
<button class="btn btn-secondary" style="font-size:12px;padding:5px 10px"
onclick="document.getElementById('reassign-panel')?.remove()">취소</button>
</div>`;
// 기존 패널 있으면 제거
document.getElementById("reassign-panel")?.remove();
const assignCell = document.getElementById("assign-cell");
if (assignCell) assignCell.appendChild(panel);
}
async function submitReassign(srId) {
const sel = document.getElementById("reassign-select");
const eng = sel ? sel.value : ""; // "" → 자동 배정
const url = `/api/assign/${srId}${eng ? `?engineer=${encodeURIComponent(eng)}` : ""}`;
const r = await authFetch(url, { method: "POST" });
if (r.ok) {
const data = await r.json();
document.getElementById("reassign-panel")?.remove();
await loadWorkload();
await loadAll();
openDetail(srId);
} else {
const err = await r.json().catch(() => ({}));
alert(err.detail || "배정 실패");
}
}
/* ─── 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("sr-file-input").addEventListener("change", e => {
const files = Array.from(e.target.files || []);
const txt = document.getElementById("file-upload-text");
const prev = document.getElementById("file-preview-list");
txt.textContent = files.length
? `${files.length}개 파일 선택됨`
: "첨부파일 선택 (선택사항, 최대 10개 · 파일당 20 MB)";
prev.innerHTML = files.map(f => `
<div class="file-preview-item">
<span class="file-preview-icon">${_fileIcon(f.name)}</span>
<span class="file-preview-name">${esc(f.name)}</span>
<span class="file-preview-size">${_fmtSize(f.size)}</span>
</div>`).join("");
});
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 authFetch("/api/tasks", {
method: "POST",
body: JSON.stringify(payload),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
alert(err.detail || "SR 생성 실패");
return;
}
const sr = await r.json();
// 파일 업로드 (선택된 경우)
const fileInput = document.getElementById("sr-file-input");
if (fileInput.files.length > 0) {
const ffd = new FormData();
for (const f of fileInput.files) ffd.append("files", f);
const ur = await authFetch(`/api/tasks/${sr.sr_id}/attachments`, {
method: "POST",
body: ffd,
});
if (!ur.ok) {
const uerr = await ur.json().catch(() => ({}));
alert(`SR이 생성되었으나 파일 업로드 실패: ${uerr.detail || "알 수 없는 오류"}`);
}
}
document.getElementById("new-sr-overlay").classList.add("hidden");
e.target.reset();
document.getElementById("file-upload-text").textContent = "첨부파일 선택 (선택사항, 최대 10개 · 파일당 20 MB)";
document.getElementById("file-preview-list").innerHTML = "";
await loadAll();
});
}
/* ─── Audit ─────────────────────────────────────── */
async function loadAudit() {
const data = await authFetch("/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 authFetch("/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 authFetch("/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 authFetch(`/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);
}));
}
/* ─── Knowledge Base ────────────────────────────── */
const KB_CAT_COLOR = {
JAVA: "#f0883e", MIDDLEWARE: "#58a6ff", DB: "#e3b341",
WEB: "#3fb950", OS: "#bc8cff", SECURITY: "#f85149",
};
async function loadKBView() {
// 초기 로드: 전체 목록 표시
const cat = document.getElementById("kb-category-filter")?.value || "";
const url = `/api/kb/list${cat ? `?category=${encodeURIComponent(cat)}` : ""}`;
try {
const docs = await authFetch(url).then(r => r.json());
renderKBDocs(docs.map(d => ({ doc: d, score: null, matched_keywords: [] })));
} catch { document.getElementById("kb-results").innerHTML = '<div style="color:var(--text-muted);padding:20px">로드 실패</div>'; }
// 검색 이벤트 연결 (한 번만)
const inp = document.getElementById("kb-search-input");
if (inp && !inp._bound) {
inp._bound = true;
inp.addEventListener("keydown", e => { if (e.key === "Enter") searchKB(); });
}
}
async function searchKB() {
const q = document.getElementById("kb-search-input")?.value.trim();
const cat = document.getElementById("kb-category-filter")?.value || "";
const el = document.getElementById("kb-results");
if (!q) { loadKBView(); return; }
el.innerHTML = '<div style="color:var(--text-muted);padding:20px">검색 중…</div>';
try {
const url = `/api/kb?q=${encodeURIComponent(q)}&limit=10`;
let hits = await authFetch(url).then(r => r.json());
if (cat) hits = hits.filter(h => h.doc.category === cat);
renderKBDocs(hits);
} catch { el.innerHTML = '<div style="color:var(--text-muted);padding:20px">검색 실패</div>'; }
}
function renderKBDocs(hits) {
const el = document.getElementById("kb-results");
if (!hits.length) {
el.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center">관련 문서 없음</div>';
return;
}
el.innerHTML = hits.map(h => renderKBCard(h, false)).join("");
}
function renderKBCard(h, compact = false) {
const d = h.doc;
const color = KB_CAT_COLOR[d.category] || "#8b949e";
const scoreHTML = h.score !== null
? `<span class="kb-score">관련도 ${Math.round(h.score * 100)}%</span>`
: "";
const kwHTML = h.matched_keywords?.length
? `<div class="kb-keywords">${h.matched_keywords.map(k => `<code>${esc(k)}</code>`).join(" ")}</div>`
: "";
if (compact) {
// 모달 내 축약형
return `
<div class="kb-card-compact" onclick="openKBDetail('${d.doc_id}')">
<div class="kb-compact-header">
<span class="kb-cat-badge" style="background:${color}22;color:${color};border-color:${color}44">${d.category}</span>
<span class="kb-compact-title">${esc(d.title)}</span>
${scoreHTML}
</div>
${kwHTML}
</div>`;
}
// 전체 카드
return `
<div class="kb-card" id="kb-card-${d.doc_id}">
<div class="kb-card-header" onclick="toggleKBCard('${d.doc_id}')">
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0">
<span class="kb-cat-badge" style="background:${color}22;color:${color};border-color:${color}44">${d.category}</span>
<span class="kb-card-title">${esc(d.title)}</span>
</div>
<div style="display:flex;align-items:center;gap:10px;flex-shrink:0">
${scoreHTML}
<span class="kb-toggle-icon">▼</span>
</div>
</div>
${kwHTML ? `<div style="padding:6px 16px 0">${kwHTML}</div>` : ""}
<div class="kb-card-body" id="kb-body-${d.doc_id}" style="display:none">
<div class="kb-section">
<div class="kb-section-label">🔍 증상</div>
<div class="kb-section-text">${esc(d.symptoms || "-")}</div>
</div>
<div class="kb-section">
<div class="kb-section-label">💡 원인</div>
<div class="kb-section-text">${esc(d.cause || "-")}</div>
</div>
<div class="kb-section">
<div class="kb-section-label">🛠️ 해결 절차</div>
<pre class="kb-pre">${esc(d.solution || "-")}</pre>
</div>
${d.commands ? `
<div class="kb-section">
<div class="kb-section-label">⚡ 점검 명령어</div>
<pre class="kb-pre cmd">${esc(d.commands)}</pre>
</div>` : ""}
<div style="padding-top:6px">
<span class="kb-doc-id">${d.doc_id}</span>
</div>
</div>
</div>`;
}
function toggleKBCard(docId) {
const body = document.getElementById(`kb-body-${docId}`);
const icon = document.querySelector(`#kb-card-${docId} .kb-toggle-icon`);
if (!body) return;
const open = body.style.display === "none";
body.style.display = open ? "block" : "none";
if (icon) icon.textContent = open ? "▲" : "▼";
}
async function openKBDetail(docId) {
// KB 뷰로 이동 후 해당 카드 펼치기
switchView("kb");
await loadKBView();
setTimeout(() => {
const card = document.getElementById(`kb-card-${docId}`);
if (card) {
card.scrollIntoView({ behavior: "smooth", block: "center" });
const body = document.getElementById(`kb-body-${docId}`);
if (body && body.style.display === "none") toggleKBCard(docId);
}
}, 400);
}
/* SR 모달 내 KB 추천 렌더링 */
async function loadKBSuggestForSR(srId) {
const el = document.getElementById("kb-suggest-section");
if (!el) return;
el.innerHTML = '<div style="color:var(--text-muted);font-size:12px">분석 중…</div>';
try {
const hits = await authFetch(`/api/kb/suggest/${srId}`).then(r => r.json());
if (!hits.length) {
el.innerHTML = '<div style="color:var(--text-muted);font-size:12px">관련 문서 없음</div>';
return;
}
el.innerHTML = hits.map(h => renderKBCard(h, true)).join("");
} catch {
el.innerHTML = '<div style="color:var(--text-muted);font-size:12px">추천 로드 실패</div>';
}
}
/* ─── AI 채팅 어시스턴트 ─────────────────────────── */
const _CHAT_GREET = [
"안녕하세요! GUARDiA AI 어시스턴트입니다.",
"자연어로 ITSM 업무를 처리할 수 있습니다.",
"예시 명령을 클릭하거나 직접 입력해 보세요.",
];
const _CHAT_EXAMPLES = [
"승인 대기 SR 목록 보여줘",
"엔지니어 워크로드 현황",
"KB에서 OOM 검색해줘",
"전체 현황 요약해줘",
"긴급 SR 있어?",
];
let _chatHistory = [];
function initChat() {
const fab = document.getElementById("ai-chat-fab");
const panel = document.getElementById("ai-chat-panel");
const close = document.getElementById("ai-chat-close");
const inp = document.getElementById("ai-chat-input");
const send = document.getElementById("ai-chat-send");
fab.addEventListener("click", () => {
const open = panel.classList.toggle("hidden");
if (!open && _chatHistory.length === 0) {
// 첫 오픈 시 인사 메시지
appendChatMsg("ai", _CHAT_GREET.join("\n"));
renderSuggestions(_CHAT_EXAMPLES);
}
});
close.addEventListener("click", () => panel.classList.add("hidden"));
send.addEventListener("click", sendChat);
inp.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); }
});
}
function appendChatMsg(role, text, data) {
const el = document.getElementById("ai-chat-messages");
const div = document.createElement("div");
div.className = role === "user" ? "chat-msg chat-user" : "chat-msg chat-ai";
// 마크다운-라이크 렌더링 (bold, bullet)
const rendered = text
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^• /gm, '<span class="chat-bullet">▪</span> ')
.replace(/\n/g, '<br>');
div.innerHTML = rendered;
// 데이터 링크 (SR 클릭 가능)
if (data?.length) {
const links = data.filter(d => d.sr_id).map(d =>
`<span class="chat-sr-link" onclick="openDetail('${d.sr_id}')">${d.sr_id}</span>`
);
if (links.length) {
const linksDiv = document.createElement("div");
linksDiv.className = "chat-links";
linksDiv.innerHTML = links.join("");
div.appendChild(linksDiv);
}
}
el.appendChild(div);
el.scrollTop = el.scrollHeight;
_chatHistory.push({ role, text });
}
function renderSuggestions(items) {
const el = document.getElementById("ai-chat-suggestions");
el.innerHTML = items.map(s =>
`<button class="chat-suggestion" onclick="sendChatText('${s.replace(/'/g, "\\'")}')">${esc(s)}</button>`
).join("");
}
function sendChatText(text) {
document.getElementById("ai-chat-input").value = text;
sendChat();
}
async function sendChat() {
const inp = document.getElementById("ai-chat-input");
const text = inp.value.trim();
if (!text) return;
inp.value = "";
document.getElementById("ai-chat-suggestions").innerHTML = "";
appendChatMsg("user", text);
// 로딩 표시
const loadId = "chat-loading-" + Date.now();
const el = document.getElementById("ai-chat-messages");
el.insertAdjacentHTML("beforeend",
`<div id="${loadId}" class="chat-msg chat-ai" style="color:var(--text-muted)">⏳ 처리 중…</div>`
);
el.scrollTop = el.scrollHeight;
try {
const r = await authFetch("/api/nlcmd", {
method: "POST",
body: JSON.stringify({ text }),
});
const data = await r.json();
document.getElementById(loadId)?.remove();
appendChatMsg("ai", data.response, data.data);
// 액션 수행 시 데이터 갱신
if (data.action_taken) {
await loadAll();
}
// 후속 제안
if (data.suggestions?.length) {
renderSuggestions(data.suggestions);
} else if (data.intent === "QUERY_SR_LIST" && data.data?.length) {
const sample = data.data[0];
renderSuggestions([
`${sample.sr_id} 상태 알려줘`,
`${sample.sr_id} 자동 배정해줘`,
]);
} else if (data.intent === "SEARCH_KB" && data.data?.length) {
renderSuggestions(["KB 뷰에서 전체 문서 보기"]);
}
} catch {
document.getElementById(loadId)?.remove();
appendChatMsg("ai", "❌ 명령 처리 중 오류가 발생했습니다.");
}
}
/* ══════════════════════════════════════════════════
기관 관리 뷰
══════════════════════════════════════════════════ */
let _instCache = [];
let _instCurrentCode = null;
async function loadInstitutions() {
try {
const r = await authFetch("/api/institutions");
_instCache = await r.json();
renderInstitutionTable(_instCache);
} catch { _instCache = []; }
}
function filterInstitutions() {
const kw = (document.getElementById("inst-search")?.value || "").toLowerCase();
const region = document.getElementById("inst-region-filter")?.value || "";
const filtered = _instCache.filter(i => {
const matchKw = !kw || i.inst_name?.toLowerCase().includes(kw) || i.inst_code?.toLowerCase().includes(kw);
const matchRegion = !region || i.region === region;
return matchKw && matchRegion;
});
renderInstitutionTable(filtered);
}
function renderInstitutionTable(list) {
const tbody = document.getElementById("inst-tbody");
if (!tbody) return;
// CUSTOMER 역할이면 버튼 숨김
const canEdit = ["ADMIN", "PM"].includes(_userInfo.role || "");
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-muted);padding:24px">등록된 기관이 없습니다.</td></tr>';
return;
}
tbody.innerHTML = list.map(inst => {
const expiry = inst.contract_end ? new Date(inst.contract_end) : null;
const today = new Date();
const daysLeft = expiry ? Math.ceil((expiry - today) / 86400000) : null;
let expiryBadge = expiry ? `<span class="badge" style="background:rgba(99,102,241,.15);color:#818cf8">${inst.contract_end}</span>` : "-";
if (daysLeft !== null && daysLeft <= 30 && daysLeft > 7) expiryBadge = `<span class="badge badge-PENDING_APPROVAL">D-${daysLeft} ⚠</span>`;
if (daysLeft !== null && daysLeft <= 7) expiryBadge = `<span class="badge badge-FAILED_ROLLBACK">D-${daysLeft} 🔴</span>`;
return `<tr onclick="openInstDetail('${esc(inst.inst_code)}')" style="cursor:pointer">
<td><strong>${esc(inst.inst_code)}</strong></td>
<td>${esc(inst.inst_name)}</td>
<td>${esc(inst.region || "-")}</td>
<td>${expiryBadge}</td>
<td>${inst.sla_hours}h</td>
<td style="color:var(--text-muted)">${inst.server_count ?? "-"}대</td>
<td style="color:var(--text-muted)">${inst.contact_count ?? "-"}명</td>
<td><span class="badge ${inst.is_active ? "badge-COMPLETED" : "badge-REJECTED"}">${inst.is_active ? "활성" : "비활성"}</span></td>
<td onclick="event.stopPropagation()">
${canEdit ? `<button class="btn btn-secondary" style="font-size:11px;padding:3px 8px" onclick="openInstModal('${esc(inst.inst_code)}')">수정</button>` : ""}
</td>
</tr>`;
}).join("");
}
async function openInstDetail(instCode) {
_instCurrentCode = instCode;
const inst = _instCache.find(i => i.inst_code === instCode);
if (!inst) return;
// 담당자 목록 로드 후 상세 모달 생성
let contacts = [];
try {
const r = await authFetch(`/api/institutions/${instCode}/contacts`);
contacts = await r.json();
} catch {}
// 상세는 SR 상세 모달 재활용
const canEdit = ["ADMIN", "PM"].includes(_userInfo.role || "");
const roleLabel = {MANAGER:"담당자",ENGINEER:"엔지니어",PM:"PM",SECURITY:"보안",HELPDESK:"헬프데스크"};
const html = `
<div class="modal-section-title">🏢 ${esc(inst.inst_name)} (${esc(inst.inst_code)})</div>
<div class="detail-grid">
<div><span class="detail-label">지역</span><span>${esc(inst.region||"-")}</span></div>
<div><span class="detail-label">SLA</span><span>${inst.sla_hours}시간</span></div>
<div><span class="detail-label">전화</span><span>${esc(inst.phone||"-")}</span></div>
<div><span class="detail-label">계약 기간</span><span>${inst.contract_start||"?"} ~ ${inst.contract_end||"?"}</span></div>
<div><span class="detail-label">주소</span><span>${esc(inst.address||"-")}</span></div>
<div><span class="detail-label">비고</span><span>${esc(inst.note||"-")}</span></div>
</div>
<div class="modal-section-title" style="margin-top:16px;display:flex;align-items:center;justify-content:space-between">
<span>👤 담당자 (${contacts.length}명)</span>
${canEdit ? `<button class="btn btn-primary" style="font-size:12px;padding:4px 12px" onclick="openContactModal('${esc(instCode)}')">+ 담당자 추가</button>` : ""}
</div>
${contacts.length ? `
<table class="sr-table" style="margin:0">
<thead><tr><th>이름</th><th>역할</th><th>부서</th><th>이메일</th><th>전화</th><th>주담</th></tr></thead>
<tbody>
${contacts.map(c => `<tr>
<td>${esc(c.contact_name)}</td>
<td><span class="badge" style="background:rgba(99,102,241,.15);color:#818cf8">${roleLabel[c.role]||c.role}</span></td>
<td style="color:var(--text-muted)">${esc(c.dept||"-")}</td>
<td>${esc(c.email||"-")}</td>
<td>${esc(c.phone||c.mobile||"-")}</td>
<td style="text-align:center">${c.is_primary ? "★" : ""}</td>
</tr>`).join("")}
</tbody>
</table>` : `<div style="color:var(--text-muted);font-size:13px;padding:12px">등록된 담당자가 없습니다.</div>`}
`;
document.getElementById("modal-body").innerHTML = html;
document.getElementById("modal-overlay").classList.remove("hidden");
}
// 기관 등록/수정 모달
let _instEditCode = null;
function openInstModal(instCode = null) {
_instEditCode = instCode;
const overlay = document.getElementById("inst-modal-overlay");
const form = document.getElementById("inst-form");
form.reset();
document.getElementById("inst-modal-title").textContent = instCode ? "기관 수정" : "기관 등록";
if (instCode) {
const inst = _instCache.find(i => i.inst_code === instCode);
if (inst) {
Object.keys(inst).forEach(k => {
const el = form.elements[k];
if (el) el.value = inst[k] ?? "";
});
}
}
overlay.classList.remove("hidden");
}
function closeInstModal() { document.getElementById("inst-modal-overlay").classList.add("hidden"); }
async function submitInstForm(e) {
e.preventDefault();
const form = e.target;
const data = Object.fromEntries(new FormData(form));
// 숫자 변환
if (data.sla_hours) data.sla_hours = parseInt(data.sla_hours);
// 빈 문자열 null로
Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; });
try {
let r;
if (_instEditCode) {
r = await authFetch(`/api/institutions/${_instEditCode}`, {
method: "PATCH", body: JSON.stringify(data),
});
} else {
r = await authFetch("/api/institutions", {
method: "POST", body: JSON.stringify(data),
});
}
if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; }
showToast(_instEditCode ? "기관 정보가 수정됐습니다." : "기관이 등록됐습니다.", "success");
closeInstModal();
loadInstitutions();
} catch { showToast("저장 중 오류가 발생했습니다.", "error"); }
}
// 담당자 등록 모달
let _contactInstCode = null;
function openContactModal(instCode) {
_contactInstCode = instCode;
document.getElementById("contact-form").reset();
document.getElementById("contact-modal-overlay").classList.remove("hidden");
}
function closeContactModal() { document.getElementById("contact-modal-overlay").classList.add("hidden"); }
async function submitContactForm(e) {
e.preventDefault();
const form = e.target;
const data = Object.fromEntries(new FormData(form));
data.is_primary = form.elements.is_primary?.checked || false;
Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; });
try {
const r = await authFetch(`/api/institutions/${_contactInstCode}/contacts`, {
method: "POST", body: JSON.stringify(data),
});
if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; }
showToast("담당자가 등록됐습니다.", "success");
closeContactModal();
openInstDetail(_contactInstCode);
} catch { showToast("저장 중 오류가 발생했습니다.", "error"); }
}
/* ══════════════════════════════════════════════════
스크립트 관리 뷰
══════════════════════════════════════════════════ */
let _scriptCache = [];
const SCRIPT_CATEGORY_KO = {
SM: "SM", REGULAR: "정기점검", ADHOC: "수시점검",
DEPLOY: "배포", SECURITY: "보안", MONITORING: "모니터링",
};
const SCRIPT_CATEGORY_COLOR = {
SM: "#818cf8", REGULAR: "#34d399", ADHOC: "#fbbf24",
DEPLOY: "#38bdf8", SECURITY: "#f87171", MONITORING: "#a78bfa",
};
async function loadScripts() {
try {
const r = await authFetch("/api/shell-scripts?limit=200");
_scriptCache = await r.json();
renderScriptList(_scriptCache);
} catch { _scriptCache = []; }
}
function filterScripts() {
const kw = (document.getElementById("script-search")?.value || "").toLowerCase();
const cat = document.getElementById("script-category-filter")?.value || "";
const layer = document.getElementById("script-layer-filter")?.value || "";
const filtered = _scriptCache.filter(s => {
const matchKw = !kw || s.script_name?.toLowerCase().includes(kw)
|| s.description?.toLowerCase().includes(kw)
|| (s.tags||"").toLowerCase().includes(kw);
const matchCat = !cat || s.category === cat;
const matchLayer = !layer || s.target_layer === layer || s.target_layer === "ALL";
return matchKw && matchCat && matchLayer;
});
renderScriptList(filtered);
}
function renderScriptList(list) {
const body = document.getElementById("script-list-body");
if (!body) return;
const canEdit = ["ADMIN", "PM", "ENGINEER"].includes(_userInfo.role || "");
if (!list.length) {
body.innerHTML = '<div style="padding:24px;color:var(--text-muted);text-align:center">등록된 스크립트가 없습니다.</div>';
return;
}
body.innerHTML = list.map(s => {
const catColor = SCRIPT_CATEGORY_COLOR[s.category] || "#818cf8";
const dangerBadge = s.is_dangerous
? `<span class="badge" style="background:rgba(248,113,113,.15);color:#f87171">⚠ 위험</span>` : "";
const approvalBadge = s.requires_approval
? `<span class="badge" style="background:rgba(251,191,36,.15);color:#fbbf24">승인필요</span>` : "";
return `
<div class="script-card" onclick="openScriptDetail(${s.id})">
<div class="script-card-header">
<div class="script-card-name">${esc(s.script_name)}</div>
<div style="display:flex;gap:6px;align-items:center">
<span class="badge" style="background:${catColor}22;color:${catColor}">${SCRIPT_CATEGORY_KO[s.category]||s.category}</span>
<span class="badge" style="background:rgba(56,189,248,.15);color:#38bdf8">${esc(s.target_layer)}</span>
${dangerBadge}${approvalBadge}
${canEdit ? `<button class="btn btn-secondary" style="font-size:11px;padding:2px 8px" onclick="event.stopPropagation();openScriptModal(${s.id})">수정</button>` : ""}
</div>
</div>
<div class="script-card-desc">${esc(s.description)}</div>
<div class="script-card-meta">
<span>버전 ${esc(s.version)}</span>
<span>사용 ${s.use_count}회</span>
${s.tags ? `<span>${esc(s.tags).split(",").map(t=>`#${t.trim()}`).join(" ")}</span>` : ""}
</div>
</div>`;
}).join("");
}
function openScriptDetail(scriptId) {
const s = _scriptCache.find(x => x.id === scriptId);
if (!s) return;
const catColor = SCRIPT_CATEGORY_COLOR[s.category] || "#818cf8";
const html = `
<div class="modal-section-title">📜 ${esc(s.script_name)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<span class="badge" style="background:${catColor}22;color:${catColor}">${SCRIPT_CATEGORY_KO[s.category]||s.category}</span>
<span class="badge" style="background:rgba(56,189,248,.15);color:#38bdf8">${esc(s.target_layer)}</span>
<span class="badge" style="background:rgba(99,102,241,.15);color:#818cf8">${esc(s.os_type)}</span>
${s.is_dangerous ? `<span class="badge" style="background:rgba(248,113,113,.15);color:#f87171">⚠ 위험 명령 포함</span>` : ""}
${s.requires_approval ? `<span class="badge" style="background:rgba(251,191,36,.15);color:#fbbf24">실행 전 승인 필요</span>` : ""}
</div>
<div class="detail-grid">
<div><span class="detail-label">설명</span><span>${esc(s.description)}</span></div>
<div><span class="detail-label">버전</span><span>${esc(s.version)}</span></div>
<div><span class="detail-label">작성자</span><span>${esc(s.author||"-")}</span></div>
<div><span class="detail-label">사용 횟수</span><span>${s.use_count}회</span></div>
</div>
<div class="modal-section-title" style="margin-top:16px">스크립트 내용</div>
<pre class="code-block">${esc(s.script_body)}</pre>
${s.sample_output ? `<div class="modal-section-title" style="margin-top:12px">예상 출력</div><pre class="code-block">${esc(s.sample_output)}</pre>` : ""}
`;
document.getElementById("modal-body").innerHTML = html;
document.getElementById("modal-overlay").classList.remove("hidden");
}
let _scriptEditId = null;
function openScriptModal(scriptId = null) {
_scriptEditId = scriptId;
const overlay = document.getElementById("script-modal-overlay");
const form = document.getElementById("script-form");
form.reset();
document.getElementById("script-modal-title").textContent = scriptId ? "스크립트 수정" : "스크립트 등록";
if (scriptId) {
const s = _scriptCache.find(x => x.id === scriptId);
if (s) {
Object.keys(s).forEach(k => {
const el = form.elements[k];
if (el) el.value = s[k] ?? "";
});
if (s.is_dangerous) form.elements.is_dangerous.checked = true;
if (s.requires_approval) form.elements.requires_approval.checked = true;
}
}
overlay.classList.remove("hidden");
}
function closeScriptModal() { document.getElementById("script-modal-overlay").classList.add("hidden"); }
async function submitScriptForm(e) {
e.preventDefault();
const form = e.target;
const data = Object.fromEntries(new FormData(form));
data.is_dangerous = form.elements.is_dangerous?.checked || false;
data.requires_approval = form.elements.requires_approval?.checked || false;
Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; });
try {
let r;
if (_scriptEditId) {
r = await authFetch(`/api/shell-scripts/${_scriptEditId}`, {
method: "PATCH", body: JSON.stringify(data),
});
} else {
r = await authFetch("/api/shell-scripts", {
method: "POST", body: JSON.stringify(data),
});
}
if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; }
showToast("스크립트가 저장됐습니다.", "success");
closeScriptModal();
loadScripts();
} catch { showToast("저장 중 오류가 발생했습니다.", "error"); }
}
/* ══════════════════════════════════════════════════
작업 타임테이블 뷰
══════════════════════════════════════════════════ */
let _ttCache = [];
const WORK_TYPE_KO = {
REGULAR_CHECK: "정기점검", PM: "예방정비", SR: "SR작업",
ADHOC: "수시점검", DEPLOY: "배포", EMERGENCY: "긴급대응",
};
const WORK_TYPE_COLOR = {
REGULAR_CHECK: "#34d399", PM: "#818cf8", SR: "#38bdf8",
ADHOC: "#fbbf24", DEPLOY: "#a78bfa", EMERGENCY: "#f87171",
};
const RESULT_STATUS_KO = {
PENDING: "예정", SUCCESS: "완료", FAILED: "실패",
PARTIAL: "부분완료", CANCELLED: "취소",
};
const RESULT_STATUS_BADGE = {
PENDING: "badge-PARSED",
SUCCESS: "badge-COMPLETED",
FAILED: "badge-FAILED_ROLLBACK",
PARTIAL: "badge-PENDING_APPROVAL",
CANCELLED: "badge-REJECTED",
};
async function loadTimetable() {
try {
const r = await authFetch("/api/timetable?limit=200");
_ttCache = await r.json();
// 기관·스크립트 목록 로드 (모달 select 채우기)
_populateTimetableSelects();
renderTimetableTable(_ttCache);
} catch { _ttCache = []; }
}
async function _populateTimetableSelects() {
// 기관 select
const instSel = document.getElementById("tt-inst-select");
if (instSel && instSel.options.length <= 1) {
if (!_instCache.length) {
try { const r = await authFetch("/api/institutions"); _instCache = await r.json(); } catch {}
}
_instCache.forEach(i => {
const opt = document.createElement("option");
opt.value = i.id; opt.textContent = `${i.inst_code} ${i.inst_name}`;
instSel.appendChild(opt);
});
}
// 스크립트 select
const scriptSel = document.getElementById("tt-script-select");
if (scriptSel && scriptSel.options.length <= 1) {
if (!_scriptCache.length) {
try { const r = await authFetch("/api/shell-scripts?limit=200"); _scriptCache = await r.json(); } catch {}
}
_scriptCache.forEach(s => {
const opt = document.createElement("option");
opt.value = s.id; opt.textContent = `[${s.category}] ${s.script_name}`;
opt.dataset.body = s.script_body;
scriptSel.appendChild(opt);
});
}
}
function fillScriptBody(sel) {
const opt = sel.options[sel.selectedIndex];
const ta = document.querySelector("#tt-form textarea[name='command_or_shell']");
if (ta && opt?.dataset.body) ta.value = opt.dataset.body;
else if (ta && !opt?.dataset.body) ta.value = "";
}
function filterTimetable() {
const kw = (document.getElementById("tt-search")?.value || "").toLowerCase();
const type = document.getElementById("tt-type-filter")?.value || "";
const status = document.getElementById("tt-status-filter")?.value || "";
const filtered = _ttCache.filter(t => {
const matchKw = !kw || t.title?.toLowerCase().includes(kw) || (t.content||"").toLowerCase().includes(kw);
const matchType = !type || t.work_type === type;
const matchStatus = !status || t.result_status === status;
return matchKw && matchType && matchStatus;
});
renderTimetableTable(filtered);
}
function renderTimetableTable(list) {
const tbody = document.getElementById("tt-tbody");
if (!tbody) return;
const canEdit = ["ADMIN", "PM", "ENGINEER"].includes(_userInfo.role || "");
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-muted);padding:24px">등록된 작업이 없습니다.</td></tr>';
return;
}
const instMap = {};
_instCache.forEach(i => { instMap[i.id] = i.inst_name; });
tbody.innerHTML = list.map(t => {
const typeColor = WORK_TYPE_COLOR[t.work_type] || "#818cf8";
const instName = t.inst_id ? (instMap[t.inst_id] || "-") : "-";
return `<tr onclick="openTimetableDetail(${t.id})" style="cursor:pointer">
<td><span class="badge" style="background:${typeColor}22;color:${typeColor}">${WORK_TYPE_KO[t.work_type]||t.work_type}</span></td>
<td>${esc(t.title)}</td>
<td style="color:var(--text-muted)">${esc(instName)}</td>
<td style="color:var(--text-muted)">${fmtDate(t.scheduled_at)}</td>
<td style="color:var(--text-muted)">${t.completed_at ? fmtDate(t.completed_at) : "-"}</td>
<td><span class="badge ${RESULT_STATUS_BADGE[t.result_status]||''}">${RESULT_STATUS_KO[t.result_status]||t.result_status}</span></td>
<td style="color:var(--text-muted)">${esc(t.assignee||"-")}</td>
<td style="color:var(--text-muted)">${t.sr_id ? `<a href="#" onclick="event.stopPropagation();openDetail('${esc(t.sr_id)}')">${esc(t.sr_id)}</a>` : "-"}</td>
<td onclick="event.stopPropagation()">
${canEdit ? `<button class="btn btn-secondary" style="font-size:11px;padding:3px 8px" onclick="openTimetableModal(${t.id})">수정</button>` : ""}
</td>
</tr>`;
}).join("");
}
function openTimetableDetail(id) {
const t = _ttCache.find(x => x.id === id);
if (!t) return;
const typeColor = WORK_TYPE_COLOR[t.work_type] || "#818cf8";
const instMap = {};
_instCache.forEach(i => { instMap[i.id] = i.inst_name; });
const instName = t.inst_id ? (instMap[t.inst_id] || "-") : "-";
const duration = (t.started_at && t.completed_at)
? `${Math.ceil((new Date(t.completed_at) - new Date(t.started_at)) / 60000)}` : "-";
const html = `
<div class="modal-section-title">📅 ${esc(t.title)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<span class="badge" style="background:${typeColor}22;color:${typeColor}">${WORK_TYPE_KO[t.work_type]||t.work_type}</span>
<span class="badge ${RESULT_STATUS_BADGE[t.result_status]||''}">${RESULT_STATUS_KO[t.result_status]||t.result_status}</span>
</div>
<div class="detail-grid">
<div><span class="detail-label">기관</span><span>${esc(instName)}</span></div>
<div><span class="detail-label">처리예정</span><span>${fmtDate(t.scheduled_at)}</span></div>
<div><span class="detail-label">시작</span><span>${t.started_at ? fmtDate(t.started_at) : "-"}</span></div>
<div><span class="detail-label">완료</span><span>${t.completed_at ? fmtDate(t.completed_at) : "-"}</span></div>
<div><span class="detail-label">소요</span><span>${duration}</span></div>
<div><span class="detail-label">담당자</span><span>${esc(t.assignee||"-")}</span></div>
<div><span class="detail-label">검토자</span><span>${esc(t.reviewer||"-")}</span></div>
${t.sr_id ? `<div><span class="detail-label">SR</span><span>${esc(t.sr_id)}</span></div>` : ""}
</div>
<div class="modal-section-title" style="margin-top:14px">처리내용</div>
<div class="detail-text-block">${esc(t.content)}</div>
${t.command_or_shell ? `<div class="modal-section-title" style="margin-top:14px">명령어/쉘</div><pre class="code-block">${esc(t.command_or_shell)}</pre>` : ""}
${t.result ? `<div class="modal-section-title" style="margin-top:14px">처리결과</div><div class="detail-text-block">${esc(t.result)}</div>` : ""}
${t.note ? `<div class="modal-section-title" style="margin-top:12px">비고</div><div style="color:var(--text-muted);font-size:13px">${esc(t.note)}</div>` : ""}
`;
document.getElementById("modal-body").innerHTML = html;
document.getElementById("modal-overlay").classList.remove("hidden");
}
let _ttEditId = null;
function openTimetableModal(ttId = null) {
_ttEditId = ttId;
const overlay = document.getElementById("tt-modal-overlay");
const form = document.getElementById("tt-form");
form.reset();
document.getElementById("tt-modal-title").textContent = ttId ? "작업 수정" : "작업 등록";
_populateTimetableSelects();
if (ttId) {
const t = _ttCache.find(x => x.id === ttId);
if (t) {
Object.keys(t).forEach(k => {
const el = form.elements[k];
if (!el) return;
if (k === "scheduled_at" || k === "started_at" || k === "completed_at") {
el.value = t[k] ? t[k].slice(0,16) : "";
} else {
el.value = t[k] ?? "";
}
});
}
} else {
// 기본값: 지금부터 1시간 후
const dt = new Date(Date.now() + 3600000);
const iso = dt.toISOString().slice(0,16);
const el = form.elements.scheduled_at;
if (el) el.value = iso;
}
overlay.classList.remove("hidden");
}
function closeTimetableModal() { document.getElementById("tt-modal-overlay").classList.add("hidden"); }
async function submitTimetableForm(e) {
e.preventDefault();
const form = e.target;
const data = Object.fromEntries(new FormData(form));
// 빈 문자열 → null, 숫자 변환
Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; });
if (data.inst_id) data.inst_id = parseInt(data.inst_id);
if (data.script_id) data.script_id = parseInt(data.script_id);
try {
let r;
if (_ttEditId) {
r = await authFetch(`/api/timetable/${_ttEditId}`, {
method: "PATCH", body: JSON.stringify(data),
});
} else {
r = await authFetch("/api/timetable", {
method: "POST", body: JSON.stringify(data),
});
}
if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; }
showToast("작업이 저장됐습니다.", "success");
closeTimetableModal();
loadTimetable();
} catch { showToast("저장 중 오류가 발생했습니다.", "error"); }
}
async function exportTimetableExcel() {
const type = document.getElementById("tt-type-filter")?.value || "";
const status = document.getElementById("tt-status-filter")?.value || "";
const params = new URLSearchParams();
if (type) params.set("work_type", type);
if (status) params.set("result_status", status);
try {
const r = await authFetch(`/api/timetable/export/excel?${params.toString()}`);
if (!r.ok) { showToast("Excel 생성 실패", "error"); return; }
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const cd = r.headers.get("Content-Disposition") || "";
const match = cd.match(/filename\*=UTF-8''(.+)/);
a.download = match ? decodeURIComponent(match[1]) : "GUARDiA_작업이력.xlsx";
a.href = url;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast("Excel 다운로드 완료", "success");
} catch { showToast("다운로드 중 오류가 발생했습니다.", "error"); }
}
/* ─── 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; }
}
/* ══════════════════════════════════════════════════
GUARDiA 확장 v3 — 뷰 렌더러 (P1~P3 신규 기능)
══════════════════════════════════════════════════ */
function getEl(id) { return document.getElementById(id); }
function renderApiView(containerId, html) {
const el = getEl(containerId) || getEl("main-content") || document.querySelector(".main-content");
if (el) el.innerHTML = html;
}
async function loadExpansionView(view) {
const container = document.getElementById("main-content") || document.querySelector(".main-scroll") || document.body;
const token = localStorage.getItem("token") || "";
const H = { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" };
try {
switch (view) {
// ── RAG 검색 ─────────────────────────────────────
case "rag_search":
container.innerHTML = `
<div class="card" style="padding:20px">
<h3>🔍 RAG 하이브리드 검색</h3>
<p style="color:var(--text-muted);margin-bottom:16px">KB + SR 이력을 BM25 + pgvector로 검색합니다</p>
<div style="display:flex;gap:8px;margin-bottom:16px">
<input id="rag-q" class="form-control" placeholder="검색어 입력..." style="flex:1">
<button class="btn btn-primary" onclick="doRagSearch()">검색</button>
</div>
<div id="rag-results"></div>
</div>`;
break;
// ── AI 인사이트 ───────────────────────────────────
case "ai_insights": {
const r = await fetch("/api/insights/weekly", {headers: H});
const d = await r.json();
container.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:24px">
${[
{label:"신규 SR", val: d.stats?.total || 0, color:"#003366"},
{label:"완료율", val: (d.stats?.completion_rate || 0) + "%", color:"#10B981"},
{label:"미처리", val: d.stats?.open || 0, color:"#EF4444"},
].map(s=>`<div class="stat-card" style="border-left:4px solid ${s.color}">
<div style="font-size:28px;font-weight:700;color:${s.color}">${s.val}</div>
<div style="font-size:13px;color:var(--text-muted)">${s.label}</div>
</div>`).join("")}
</div>
<div class="card" style="padding:20px">
<h4>🤖 AI 주간 인사이트</h4>
<p style="line-height:1.8;white-space:pre-wrap">${esc(d.ai_insight || "데이터 수집 중...")}</p>
</div>
<div class="card" style="padding:20px;margin-top:16px">
<h4>📊 상위 SR 카테고리</h4>
${(d.top_categories||[]).map(c=>`
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<span style="width:120px;font-size:13px">${esc(c.category)}</span>
<div style="flex:1;background:var(--bg-tertiary);border-radius:4px;height:8px">
<div style="width:${Math.min(100,c.count*5)}%;background:#003366;height:8px;border-radius:4px"></div>
</div>
<span style="font-size:13px;font-weight:600">${c.count}건</span>
</div>`).join("")}
</div>`;
break;
}
// ── KPI 대시보드 ──────────────────────────────────
case "kpi_dashboard": {
const r = await fetch("/api/kpi/dashboard", {headers: H});
const d = await r.json();
const statusColor = {"GREEN":"#10B981","YELLOW":"#F59E0B","RED":"#EF4444","NO_DATA":"#6B7280"};
container.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<span style="font-size:28px">${{"GREEN":"✅","YELLOW":"⚠️","RED":"❌","NO_DATA":"❔"}[d.overall_status]||"❔"}</span>
<div>
<h3 style="margin:0">전체 KPI 상태: ${d.overall_status||"N/A"}</h3>
<p style="color:var(--text-muted);margin:0">GREEN:${d.summary?.GREEN||0} · YELLOW:${d.summary?.YELLOW||0} · RED:${d.summary?.RED||0}</p>
</div>
<button class="btn btn-sm btn-secondary" style="margin-left:auto" onclick="applyKpiTemplates()">📋 템플릿 적용</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px">
${(d.kpis||[]).map(k=>`
<div class="card" style="padding:16px;border-left:4px solid ${statusColor[k.status]||"#6B7280"}">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:8px">
<span style="font-weight:600;font-size:14px">${esc(k.display_name)}</span>
<span style="font-size:11px;background:${statusColor[k.status]||"#6B7280"};color:#fff;padding:2px 8px;border-radius:10px">${k.status}</span>
</div>
<div style="font-size:32px;font-weight:700;color:${statusColor[k.status]||"#6B7280"}">
${k.current_value !== null ? k.current_value : "—"}<span style="font-size:14px;margin-left:4px">${esc(k.unit)}</span>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">
목표: ${k.target}${k.unit} · 달성: ${k.achievement_pct !== null ? k.achievement_pct+"%" : "—"}
</div>
<button class="btn btn-sm btn-secondary" style="margin-top:8px;width:100%" onclick="recalcKpi(${k.id})">재계산</button>
</div>`).join("")}
${(d.kpis||[]).length === 0 ? `<div class="card" style="padding:32px;text-align:center;grid-column:1/-1">
<p style="color:var(--text-muted)">KPI가 없습니다. 템플릿을 적용하세요.</p>
<button class="btn btn-primary" onclick="applyKpiTemplates()">내장 템플릿 5개 적용</button>
</div>` : ""}
</div>`;
break;
}
// ── BI 대시보드 ───────────────────────────────────
case "bi_dashboard": {
const [ovr, pie] = await Promise.all([
fetch("/api/bi/overview", {headers: H}).then(r=>r.json()),
fetch("/api/bi/category-pie?days=30", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px">
${(ovr.cards||[]).map(c=>`
<div class="stat-card" style="border-left:4px solid var(--primary)">
<div style="font-size:24px;font-weight:700">${c.value}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(c.label)} <span style="font-size:11px">${esc(c.unit)}</span></div>
${c.change !== undefined ? `<div style="font-size:11px;color:${c.change>=0?"#10B981":"#EF4444"}">${c.change>=0?"+":""}${c.change} ${esc(c.change_label||"")}</div>` : ""}
</div>`).join("")}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div class="card" style="padding:20px">
<h4>📊 SR 카테고리 분포 (30일)</h4>
${(pie.data||[]).slice(0,6).map(d=>`
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:12px;width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.category)}</span>
<div style="flex:1;background:var(--bg-tertiary);border-radius:3px;height:6px">
<div style="width:${d.pct}%;background:#003366;height:6px;border-radius:3px"></div>
</div>
<span style="font-size:11px;min-width:30px;text-align:right">${d.pct}%</span>
</div>`).join("")}
</div>
<div class="card" style="padding:20px">
<h4>🔗 빠른 분석</h4>
${[
["SR 트렌드", "bi_sr_trend"], ["SLA 히트맵", "bi_sla"],
["엔지니어 워크로드", "bi_eng"], ["MTTR 트렌드", "bi_mttr"],
].map(([label, v])=>`
<button class="btn btn-sm btn-secondary" style="width:100%;margin-bottom:6px;text-align:left" onclick="showPage('${v}')">
${esc(label)}
</button>`).join("")}
</div>
</div>`;
break;
}
// ── 예측 분석 ─────────────────────────────────────
case "predictive": {
const r = await fetch("/api/predict/summary", {headers: H});
const d = await r.json();
const [sla, surge] = await Promise.all([
fetch("/api/predict/sla-breach", {headers: H}).then(r=>r.json()),
fetch("/api/predict/sr-surge", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div class="card" style="padding:20px">
<h4>📉 SLA 위반 예측 (7일)</h4>
<div style="font-size:36px;font-weight:700;color:${sla.status==="CRITICAL"?"#EF4444":sla.status==="WARNING"?"#F59E0B":"#10B981"}">
${Math.round((sla.breach_probability_7d||0)*100)}%
</div>
<div style="font-size:13px;color:var(--text-muted)">현재 SLA: ${sla.current_rate||0}% · 목표: ${sla.target||95}%</div>
${sla.insight ? `<p style="font-size:13px;margin-top:12px;line-height:1.6;color:var(--text-secondary)">${esc(sla.insight)}</p>` : ""}
</div>
<div class="card" style="padding:20px">
<h4>📈 SR 급증 감지</h4>
<div style="font-size:36px;font-weight:700;color:${surge.status==="SURGE"?"#EF4444":surge.status==="HIGH"?"#F59E0B":"#10B981"}">
${surge.surge_ratio||1}x
</div>
<div style="font-size:13px;color:var(--text-muted)">오늘 ${surge.today_count||0}건 · 7일 평균 ${surge.avg_7d||0}건</div>
${surge.insight ? `<p style="font-size:13px;margin-top:12px;line-height:1.6">${esc(surge.insight)}</p>` : ""}
</div>
</div>
${(d.alerts||[]).length ? `
<div class="card" style="padding:16px;margin-top:16px;border-left:4px solid #EF4444">
<h4>⚠️ 알림 (${d.alerts.length}건)</h4>
${d.alerts.map(a=>`<div style="margin-bottom:8px"><strong>${esc(a.type)}</strong>: ${esc(a.message)}</div>`).join("")}
</div>` : ""}`;
break;
}
// ── Jira 동기화 ───────────────────────────────────
case "jira_sync": {
const [cfg, mappings] = await Promise.all([
fetch("/api/jira/config", {headers: H}).then(r=>r.json()),
fetch("/api/jira/mappings", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 2fr;gap:16px">
<div class="card" style="padding:20px">
<h4>⚙️ Jira 연동 설정</h4>
${cfg ? `
<div style="font-size:13px;margin-bottom:12px">
<div><strong>URL:</strong> ${esc(cfg.base_url||"")}</div>
<div><strong>프로젝트:</strong> ${esc(cfg.project_key||"")}</div>
<div><strong>자동 동기화:</strong> ${cfg.auto_sync?"켜짐":"꺼짐"}</div>
</div>
<button class="btn btn-sm btn-success" onclick="testJiraConn()">연결 테스트</button>` :
`<p style="color:var(--text-muted)">설정 없음</p>
<button class="btn btn-primary btn-sm" onclick="showJiraConfig()">설정하기</button>`}
</div>
<div class="card" style="padding:20px">
<h4>🔄 SR-Issue 매핑 현황 (${mappings.length}건)</h4>
<table class="table table-sm" style="font-size:13px">
<thead><tr><th>SR ID</th><th>Jira Key</th><th>프로젝트</th><th>동기화 시간</th></tr></thead>
<tbody>
${mappings.slice(0,10).map(m=>`<tr>
<td>SR-${m.sr_id}</td>
<td><a href="#" style="color:var(--accent)">${esc(m.jira_key)}</a></td>
<td>${esc(m.project)}</td>
<td>${fmtDate(m.synced_at)}</td>
</tr>`).join("")}
${mappings.length===0?`<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">매핑 없음</td></tr>`:""}
</tbody>
</table>
</div>
</div>`;
break;
}
// ── 테넌트 포털 ───────────────────────────────────
case "tenant_portal": {
const [me, quota, users] = await Promise.all([
fetch("/api/portal/me", {headers: H}).then(r=>r.json()),
fetch("/api/portal/quota", {headers: H}).then(r=>r.json()),
fetch("/api/portal/users", {headers: H}).then(r=>r.json()),
]);
const pct = (used, limit) => limit > 0 ? Math.min(100, Math.round(used/limit*100)) : 0;
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<div class="card" style="padding:20px">
<h4>🏢 기관 현황</h4>
<div style="font-size:13px;line-height:2">
<div>플랜: <strong>${esc(me.plan||"")}</strong></div>
<div>역할: ${esc(me.my_role||"")}</div>
<div>SR (이번 달): ${me.stats?.sr_this_month||0}건</div>
<div>미처리 SR: ${me.stats?.open_sr||0}건</div>
</div>
</div>
<div class="card" style="padding:20px">
<h4>📊 쿼터 사용량</h4>
${[
["서버", quota.servers_used, quota.servers_limit],
["사용자", quota.users_used, quota.users_limit],
].map(([name, used, limit])=>`
<div style="margin-bottom:12px">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px">
<span>${name}</span><span>${used} / ${limit < 0 ? "∞" : limit}</span>
</div>
<div style="background:var(--bg-tertiary);border-radius:4px;height:6px">
<div style="width:${pct(used, limit)}%;background:#003366;height:6px;border-radius:4px"></div>
</div>
</div>`).join("")}
</div>
</div>
<div class="card" style="padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="margin:0">👥 사용자 관리 (${users.length}명)</h4>
<button class="btn btn-primary btn-sm" onclick="showInviteModal()">+ 초대</button>
</div>
<table class="table table-sm" style="font-size:13px">
<thead><tr><th>이름</th><th>이메일</th><th>역할</th><th>상태</th></tr></thead>
<tbody>
${users.slice(0,10).map(u=>`<tr>
<td>${esc(u.name)}</td><td>${esc(u.email)}</td>
<td><span class="badge badge-secondary">${esc(u.role)}</span></td>
<td>${u.is_active?'<span style="color:#10B981">활성</span>':'<span style="color:#EF4444">비활성</span>'}</td>
</tr>`).join("")}
</tbody>
</table>
</div>`;
break;
}
// ── 구독·과금 ─────────────────────────────────────
case "billing": {
const [sub, usage, invoices] = await Promise.all([
fetch("/api/billing/subscription", {headers: H}).then(r=>r.json()),
fetch("/api/billing/usage", {headers: H}).then(r=>r.json()),
fetch("/api/billing/invoices", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<div class="card" style="padding:20px">
<h4>📋 현재 구독</h4>
<div style="font-size:24px;font-weight:700;color:#003366;margin-bottom:8px">${esc(sub.plan||"COMMUNITY")}</div>
<div style="font-size:13px;color:var(--text-muted)">
${sub.price ? `${sub.price.toLocaleString()}` : "무료"} · ${esc(sub.billing_cycle||"MONTHLY")}
</div>
<div style="margin-top:12px">
<button class="btn btn-sm btn-secondary" onclick="showPage('billing_plans')">플랜 변경</button>
</div>
</div>
<div class="card" style="padding:20px">
<h4>📊 이번 달 사용량</h4>
<div style="font-size:13px;line-height:2">
<div>서버: ${usage.servers?.used||0} / ${usage.servers?.limit < 0 ? "∞" : (usage.servers?.limit||"-")}</div>
<div>사용자: ${usage.users?.used||0} / ${usage.users?.limit < 0 ? "∞" : (usage.users?.limit||"-")}</div>
<div>SR (이번 달): ${usage.sr_this_month||0}건</div>
</div>
</div>
</div>
<div class="card" style="padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="margin:0">🧾 청구서 이력</h4>
<button class="btn btn-sm btn-secondary" onclick="generateInvoice()">청구서 생성</button>
</div>
<table class="table table-sm" style="font-size:13px">
<thead><tr><th>기간</th><th>플랜</th><th>금액</th><th>상태</th></tr></thead>
<tbody>
${invoices.slice(0,10).map(i=>`<tr>
<td>${esc(i.period)}</td><td>${esc(i.plan||"-")}</td>
<td>${i.amount ? i.amount.toLocaleString()+"원" : "무료"}</td>
<td>${esc(i.status)}</td>
</tr>`).join("")}
${!invoices.length ? `<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">청구서 없음</td></tr>` : ""}
</tbody>
</table>
</div>`;
break;
}
// ── Kubernetes ────────────────────────────────────
case "kubernetes": {
const r = await fetch("/api/k8s/clusters", {headers: H});
const clusters = await r.json();
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">☸️ Kubernetes 클러스터 관리</h3>
<button class="btn btn-primary btn-sm" onclick="showAddClusterModal()">+ 클러스터 등록</button>
</div>
${clusters.length ? clusters.map(cl=>`
<div class="card" style="padding:16px;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:24px">☸️</span>
<div style="flex:1">
<div style="font-weight:600">${esc(cl.name)}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(cl.description||"")} · 네임스페이스: ${esc(cl.namespace)}</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" onclick="loadK8sNodes(${cl.id})">노드</button>
<button class="btn btn-sm btn-secondary" onclick="loadK8sPods(${cl.id})">Pod</button>
<button class="btn btn-sm btn-secondary" onclick="loadK8sDeploys(${cl.id})">Deployment</button>
</div>
</div>
<div id="k8s-detail-${cl.id}" style="margin-top:12px"></div>
</div>`).join("") :
`<div class="card" style="padding:40px;text-align:center">
<p style="color:var(--text-muted)">등록된 클러스터가 없습니다.</p>
<button class="btn btn-primary" onclick="showAddClusterModal()">클러스터 등록</button>
</div>`}`;
break;
}
// ── 컨테이너 알림 ─────────────────────────────────
case "container_alerts": {
const [rules, logs] = await Promise.all([
fetch("/api/container-alerts/rules", {headers: H}).then(r=>r.json()),
fetch("/api/container-alerts/list", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 2fr;gap:16px">
<div class="card" style="padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="margin:0">📋 알림 규칙 (${rules.length})</h4>
<button class="btn btn-sm btn-primary" onclick="checkContainers()">즉시 체크</button>
</div>
${rules.map(r=>`<div style="font-size:13px;padding:8px;background:var(--bg-tertiary);border-radius:6px;margin-bottom:8px">
<strong>${esc(r.name)}</strong><br>
<span style="color:var(--text-muted)">서버ID: ${r.server_id} · ${r.auto_sr?"SR자동":"수동"}</span>
</div>`).join("") || `<p style="color:var(--text-muted);font-size:13px">규칙 없음</p>`}
</div>
<div class="card" style="padding:20px">
<h4>🔔 최근 알림 로그</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>유형</th><th>컨테이너</th><th>심각도</th><th>시간</th></tr></thead>
<tbody>
${logs.slice(0,15).map(l=>`<tr>
<td>${esc(l.type)}</td>
<td style="font-family:monospace">${esc(l.container)}</td>
<td><span style="color:${l.severity==="HIGH"?"#EF4444":"#F59E0B"}">${esc(l.severity)}</span></td>
<td>${fmtDate(l.detected_at)}</td>
</tr>`).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">알림 없음</td></tr>`}
</tbody>
</table>
</div>
</div>`;
break;
}
// ── NCloud ────────────────────────────────────────
case "ncloud": {
const r = await fetch("/api/ncloud/summary", {headers: H});
if (r.status === 404) {
container.innerHTML = `<div class="card" style="padding:40px;text-align:center">
<h4>☁️ NCloud 연동 설정</h4>
<p style="color:var(--text-muted)">NCloud API Key를 등록하세요.</p>
<button class="btn btn-primary" onclick="showNcloudConfig()">API Key 설정</button>
</div>`; break;
}
const d = await r.json();
const servers = await fetch("/api/ncloud/servers", {headers: H}).then(r=>r.json());
container.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:16px">
${[
{label:"전체 서버", val: d.server_count||0, icon:"🖥️"},
{label:"실행 중", val: d.running_servers||0, icon:"▶️"},
{label:"LB", val: d.lb_count||0, icon:"⚖️"},
].map(s=>`<div class="stat-card">
<div style="font-size:28px">${s.icon} ${s.val}</div>
<div style="font-size:12px;color:var(--text-muted)">${s.label}</div>
</div>`).join("")}
</div>
<div class="card" style="padding:20px">
<h4>서버 목록</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>이름</th><th></th><th> IP</th><th> IP</th></tr></thead>
<tbody>
${(servers.servers||[]).map(s=>`<tr>
<td>${esc(s.name)}</td>
<td><span style="color:${(s.status||"").includes("RUN")?"#10B981":"#EF4444"}">${esc(s.status)}</span></td>
<td style="font-family:monospace">${esc(s.public_ip||"-")}</td>
<td style="font-family:monospace">${esc(s.private_ip||"-")}</td>
</tr>`).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">서버 없음 또는 API 응답 대기</td></tr>`}
</tbody>
</table>
</div>`;
break;
}
// ── 자율 워크플로우 ───────────────────────────────
case "ai_workflow": {
const rules = await fetch("/api/workflow/rules", {headers: H}).then(r=>r.json());
const history = await fetch("/api/workflow/history?limit=10", {headers: H}).then(r=>r.json());
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">⚙️ 자율 워크플로우 엔진</h3>
<button class="btn btn-primary btn-sm" onclick="showCreateWorkflowModal()">+ 규칙 생성</button>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:16px">
<div>
${rules.map(r=>`<div class="card" style="padding:14px;margin-bottom:10px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:20px">⚙️</span>
<div style="flex:1">
<div style="font-weight:600">${esc(r.name)}</div>
<div style="font-size:12px;color:var(--text-muted)">트리거: ${esc(r.trigger_type)} · 오늘: ${r.run_count_today}회</div>
</div>
<span style="font-size:11px;background:${r.is_active?"#10B981":"#6B7280"};color:#fff;padding:2px 8px;border-radius:10px">${r.is_active?"활성":"비활성"}</span>
<button class="btn btn-sm btn-secondary" onclick="runWorkflow(${r.id})">▶ 실행</button>
</div>
</div>`).join("") || `<div class="card" style="padding:32px;text-align:center"><p style="color:var(--text-muted)">등록된 규칙 없음</p></div>`}
</div>
<div class="card" style="padding:16px">
<h4>최근 실행 이력</h4>
${history.slice(0,8).map(h=>`<div style="font-size:12px;padding:6px 0;border-bottom:1px solid var(--border)">
<div style="font-weight:500">${esc(h.rule_name||"")}</div>
<div style="color:${h.status==="SUCCESS"?"#10B981":"#EF4444"}">${h.status} · ${fmtDate(h.started_at)}</div>
</div>`).join("") || `<p style="color:var(--text-muted);font-size:12px">이력 없음</p>`}
</div>
</div>`;
break;
}
// ── Learning Loop ─────────────────────────────────
case "learning_loop": {
const [status, history, quality] = await Promise.all([
fetch("/api/learn/status", {headers: H}).then(r=>r.json()),
fetch("/api/learn/history?limit=10", {headers: H}).then(r=>r.json()),
fetch("/api/learn/quality", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<div class="card" style="padding:20px">
<h4>🧠 학습 데이터 현황</h4>
<div style="font-size:32px;font-weight:700;color:#003366">${status.available_samples||0}</div>
<div style="font-size:13px;color:var(--text-muted)">수집 가능 샘플</div>
<div style="font-size:12px;margin-top:8px">
RAG 피드백: ${status.high_quality_rag||0} · SR 이력: ${status.sr_samples||0}
</div>
<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-sm btn-primary" ${status.ready_to_train?"":"disabled"} onclick="startLearning()">
🚀 파인튜닝 시작
</button>
<span style="font-size:11px;color:var(--text-muted);line-height:30px">${status.ready_to_train?"준비됨":"샘플 부족"}</span>
</div>
</div>
<div class="card" style="padding:20px">
<h4>📊 모델 품질</h4>
<div style="font-size:36px;font-weight:700;color:#003366">${quality.quality_grade||"N/A"}</div>
<div style="font-size:13px;color:var(--text-muted)">품질 등급</div>
<div style="font-size:12px;margin-top:8px">
평균 평점: ${quality.avg_rating||0} · 긍정 비율: ${quality.positive_rate||0}%
</div>
</div>
</div>
<div class="card" style="padding:20px">
<h4>📋 학습 이력</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>모델</th><th>상태</th><th>샘플</th><th>시작</th></tr></thead>
<tbody>
${history.slice(0,8).map(h=>`<tr>
<td style="font-family:monospace">${esc(h.model_name||"-")}</td>
<td><span style="color:${h.status==="SUCCESS"?"#10B981":h.status==="FAILED"?"#EF4444":"#F59E0B"}">${h.status}</span></td>
<td>${h.samples_used||0}</td>
<td>${fmtDate(h.started_at)}</td>
</tr>`).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">이력 없음</td></tr>`}
</tbody>
</table>
</div>`;
break;
}
// ── 멀티모달 AI ───────────────────────────────────
case "multimodal":
container.innerHTML = `
<div class="card" style="padding:24px">
<h3>🖼️ 멀티모달 AI 분석</h3>
<p style="color:var(--text-muted);margin-bottom:20px">스크린샷·에러 화면을 업로드하면 AI가 자동 분석합니다</p>
<div style="border:2px dashed var(--border);border-radius:12px;padding:40px;text-align:center;margin-bottom:16px" id="mm-dropzone">
<input type="file" id="mm-file" accept="image/*,.log,.txt" style="display:none" onchange="analyzeFile()">
<p>📎 파일 드래그 또는 클릭</p>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('mm-file').click()">파일 선택</button>
<p style="font-size:12px;color:var(--text-muted)">이미지(PNG/JPG) · 로그 파일(.log/.txt) 지원</p>
</div>
<div id="mm-result"></div>
</div>`;
break;
// ── 벤치마킹 ─────────────────────────────────────
case "benchmark": {
const comp = await fetch("/api/benchmark/comparison", {headers: H}).then(r=>r.json());
const rank = await fetch("/api/benchmark/my-rank", {headers: H}).then(r=>r.json());
container.innerHTML = `
<div class="card" style="padding:20px;margin-bottom:16px">
<h4>📊 업계 평균 대비 비교</h4>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:12px">
${(comp.comparison||[]).map(m=>`<div style="text-align:center;padding:16px;background:var(--bg-tertiary);border-radius:8px">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${esc(m.metric)}</div>
<div style="font-size:28px;font-weight:700;color:${m.status==="ABOVE"?"#10B981":"#EF4444"}">${m.mine}<span style="font-size:14px">${esc(m.unit)}</span></div>
<div style="font-size:12px;margin-top:4px">업계 평균: ${m.industry}${esc(m.unit)}</div>
<div style="font-size:11px;color:${m.status==="ABOVE"?"#10B981":"#EF4444"};margin-top:4px">${m.status==="ABOVE"?"▲ 평균 이상":"▼ 평균 이하"}</div>
</div>`).join("")}
</div>
</div>
<div class="card" style="padding:20px">
<h4>🏆 업계 백분위 순위</h4>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:12px">
${[
{label:"SR 완료율", val: rank.completion_rate_percentile||0},
{label:"MTTR", val: rank.mttr_percentile||0},
{label:"SLA 준수율", val: rank.sla_percentile||0},
].map(r=>`<div style="text-align:center;padding:16px;background:var(--bg-tertiary);border-radius:8px">
<div style="font-size:32px;font-weight:700;color:#003366">${r.val}<span style="font-size:14px">%ile</span></div>
<div style="font-size:12px;color:var(--text-muted)">${r.label}</div>
</div>`).join("")}
</div>
<div style="margin-top:16px">
<button class="btn btn-sm btn-secondary" onclick="contributeBenchmark()">📤 익명 데이터 기여</button>
<span style="font-size:11px;color:var(--text-muted);margin-left:8px">기여 정확한 벤치마킹 가능</span>
</div>
</div>`;
break;
}
// ── 자동 보고서 ───────────────────────────────────
case "auto_report": {
const [templates, reports] = await Promise.all([
fetch("/api/auto-report/templates", {headers: H}).then(r=>r.json()),
fetch("/api/auto-report/list", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 2fr;gap:16px">
<div class="card" style="padding:20px">
<h4>📄 보고서 템플릿</h4>
${templates.map(t=>`<div style="padding:10px;background:var(--bg-tertiary);border-radius:6px;margin-bottom:8px">
<div style="font-weight:600;font-size:13px">${esc(t.name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(t.period)} · ${(t.format||[]).join("/")} </div>
<button class="btn btn-sm btn-primary" style="margin-top:6px;width:100%" onclick="generateReport('${t.code}')">즉시 생성</button>
</div>`).join("")}
</div>
<div class="card" style="padding:20px">
<h4>📋 생성된 보고서 (${reports.length}개)</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>템플릿</th><th>기간</th><th>포맷</th><th>상태</th><th></th></tr></thead>
<tbody>
${reports.slice(0,15).map(r=>`<tr>
<td>${esc(r.template)}</td><td style="font-size:11px">${esc(r.period)}</td>
<td>${esc(r.format)}</td>
<td><span style="color:#10B981">${esc(r.status)}</span></td>
<td><a href="/api/auto-report/${r.id}/download" class="btn btn-sm btn-secondary" style="padding:2px 8px;font-size:11px">⬇</a></td>
</tr>`).join("") || `<tr><td colspan="5" style="text-align:center;color:var(--text-muted)">보고서 없음</td></tr>`}
</tbody>
</table>
</div>
</div>`;
break;
}
// ── 브랜딩 설정 ───────────────────────────────────
case "white_label": {
const brand = await fetch("/api/brand/", {headers: H}).then(r=>r.json());
container.innerHTML = `
<div class="card" style="padding:24px">
<h4>🎨 화이트라벨 브랜딩 설정</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:16px">
<div>
<div class="form-group">
<label>기관명</label>
<input class="form-control" id="brand-name" value="${esc(brand.company_name||"")}">
</div>
<div class="form-group">
<label>주 색상</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="color" id="brand-primary" value="${brand.primary_color||"#003366"}" style="width:50px;height:36px;border-radius:6px;border:1px solid var(--border)">
<input class="form-control" id="brand-primary-text" value="${brand.primary_color||"#003366"}">
</div>
</div>
<div class="form-group">
<label>강조 색상</label>
<input type="color" id="brand-accent" value="${brand.accent_color||"#00A0C8"}" style="width:50px;height:36px">
</div>
<div class="form-group">
<label>로고 URL</label>
<input class="form-control" id="brand-logo" value="${esc(brand.logo_url||"")}">
</div>
</div>
<div style="background:var(--bg-tertiary);border-radius:12px;padding:20px">
<p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">미리보기</p>
<div style="background:#003366;padding:12px 16px;border-radius:8px;color:#fff;font-weight:600">
${esc(brand.company_name||"GUARDiA ITSM")}
</div>
</div>
</div>
<div style="margin-top:20px">
<button class="btn btn-primary" onclick="saveBranding()">저장</button>
</div>
</div>`;
break;
}
// ── SSO 설정 ──────────────────────────────────────
case "sso_config": {
const configs = await fetch("/api/sso/config", {headers: H}).then(r=>r.json());
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">🔐 SSO 통합 인증</h3>
<button class="btn btn-primary btn-sm" onclick="showAddSSOModal()">+ IdP 등록</button>
</div>
${configs.length ? configs.map(c=>`<div class="card" style="padding:16px;margin-bottom:10px">
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:24px">${c.provider_type==="SAML"?"🏛️":c.provider_type==="OIDC"?"🔑":"🔗"}</span>
<div style="flex:1">
<div style="font-weight:600">${esc(c.name)}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(c.provider_type)} · ${c.is_active?"활성":"비활성"}</div>
</div>
<a href="/api/sso/login/${c.id}" class="btn btn-sm btn-secondary">테스트 로그인</a>
</div>
</div>`).join("") :
`<div class="card" style="padding:40px;text-align:center">
<h4>SSO IdP 미등록</h4>
<p style="color:var(--text-muted)">행안부 GPKI, Google, Microsoft 등 SSO를 설정하세요.</p>
<button class="btn btn-primary" onclick="showAddSSOModal()">IdP 등록</button>
</div>`}
<div class="card" style="padding:16px;margin-top:16px;background:var(--bg-tertiary)">
<h5>SP Metadata</h5>
<a href="/api/sso/metadata" class="btn btn-sm btn-secondary" target="_blank">📄 메타데이터 XML 다운로드</a>
</div>`;
break;
}
// ── Slack 설정 ────────────────────────────────────
case "slack_config": {
const cfg = await fetch("/api/slack/config", {headers: H}).then(r=>r.json());
container.innerHTML = `
<div class="card" style="padding:24px;max-width:560px">
<h4>💬 Slack 연동 설정</h4>
${cfg ? `<div style="font-size:13px;margin-bottom:16px;padding:12px;background:var(--bg-tertiary);border-radius:8px">
<div>발신번호: <strong>${esc(cfg.sender||"")}</strong></div>
<div>기본 채널: ${esc(cfg.default_channel||"")}</div>
<div>상태: <span style="color:#10B981">연동됨</span></div>
</div>
<button class="btn btn-sm btn-success" onclick="testSlack()">테스트 메시지 발송</button>` :
`<p style="color:var(--text-muted)">설정되지 않음</p>`}
<div style="margin-top:20px">
<div class="form-group"><label>Webhook URL</label>
<input class="form-control" id="slack-webhook" placeholder="https://hooks.slack.com/services/..."></div>
<div class="form-group"><label>기본 채널</label>
<input class="form-control" id="slack-channel" value="#guardia-ops"></div>
<button class="btn btn-primary" onclick="saveSlack()">저장</button>
</div>
</div>`;
break;
}
// ── ERP 연동 ──────────────────────────────────────
case "erp_config": {
const cfgs = await fetch("/api/erp/config", {headers: H}).then(r=>r.json());
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">🏢 ERP / 그룹웨어 연동</h3>
<button class="btn btn-primary btn-sm" onclick="showAddERPModal()">+ 연동 추가</button>
</div>
${cfgs.map(c=>`<div class="card" style="padding:14px;margin-bottom:10px;display:flex;align-items:center;gap:12px">
<span style="font-size:20px">🏢</span>
<div style="flex:1">
<div style="font-weight:600">${esc(c.name)}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(c.erp_type)} · ${esc(c.base_url)}</div>
</div>
<button class="btn btn-sm btn-secondary" onclick="testERP(${c.id})">연결 테스트</button>
</div>`).join("") || `<div class="card" style="padding:40px;text-align:center"><p style="color:var(--text-muted)">등록된 연동 없음</p></div>`}`;
break;
}
// ── 카카오 알림톡 ─────────────────────────────────
case "kakao_config": {
const [cfg, history] = await Promise.all([
fetch("/api/kakao/config", {headers: H}).then(r=>r.json()),
fetch("/api/kakao/history?limit=10", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 2fr;gap:16px">
<div class="card" style="padding:20px">
<h4>💬 카카오 알림톡 설정</h4>
${cfg ? `<div style="font-size:13px;margin-bottom:12px">
<div>발신번호: <strong>${esc(cfg.sender||"")}</strong></div>
<div>ID: ${esc(cfg.userid||"")}</div>
<div style="color:#10B981">연동됨</div>
</div>` : `<p style="color:var(--text-muted)">설정 없음</p>`}
<div class="form-group"><label>API Key</label><input class="form-control" id="kakao-apikey"></div>
<div class="form-group"><label>User ID</label><input class="form-control" id="kakao-userid"></div>
<div class="form-group"><label>Sender Key</label><input class="form-control" id="kakao-senderkey"></div>
<div class="form-group"><label>발신번호</label><input class="form-control" id="kakao-sender" placeholder="0212345678"></div>
<button class="btn btn-primary btn-sm" onclick="saveKakao()">저장</button>
</div>
<div class="card" style="padding:20px">
<h4>📋 발송 이력</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>템플릿</th><th>수신자</th><th>결과</th><th>시간</th></tr></thead>
<tbody>
${history.map(h=>`<tr>
<td>${esc(h.template)}</td><td>${h.receivers}명</td>
<td><span style="color:${h.success?"#10B981":"#EF4444"}">${h.success?"성공":"실패"}</span></td>
<td>${fmtDate(h.sent_at)}</td>
</tr>`).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">이력 없음</td></tr>`}
</tbody>
</table>
</div>
</div>`;
break;
}
// ── 코호트 분석 ───────────────────────────────────
case "cohort": {
const [growth, resolution] = await Promise.all([
fetch("/api/cohort/tenant-growth?cohort_months=6", {headers: H}).then(r=>r.json()),
fetch("/api/cohort/sr-resolution", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div class="card" style="padding:20px;margin-bottom:16px">
<h4>📈 SR 처리 속도 코호트</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>코호트</th><th>SR 수</th><th>평균 처리 시간</th><th>평가</th></tr></thead>
<tbody>
${(resolution.data||[]).map(r=>`<tr>
<td>${esc(r.cohort)}</td><td>${r.sr_count}</td>
<td>${r.avg_resolution_hours}시간</td>
<td><span style="color:${r.benchmark==="빠름"?"#10B981":r.benchmark==="보통"?"#F59E0B":"#EF4444"}">${esc(r.benchmark)}</span></td>
</tr>`).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">데이터 없음</td></tr>`}
</tbody>
</table>
</div>
<div class="card" style="padding:20px">
<h4>🎯 기능 도입률</h4>
<button class="btn btn-sm btn-secondary" onclick="loadFeatureAdoption()">도입률 확인</button>
<div id="feature-adoption-result" style="margin-top:12px"></div>
</div>`;
break;
}
// ── ServiceNow ────────────────────────────────────
case "servicenow": {
const cfg = await fetch("/api/servicenow/config", {headers: H}).then(r=>r.json()).catch(()=>null);
container.innerHTML = `
<div class="card" style="padding:24px;max-width:560px">
<h4>🔗 ServiceNow 연동</h4>
${cfg ? `<div style="font-size:13px;margin-bottom:16px">
<div>인스턴스: ${esc(cfg.instance_url||"")}</div>
<div style="color:#10B981">연동됨</div>
</div>
<button class="btn btn-sm btn-success" onclick="testServiceNow()">연결 테스트</button>
<button class="btn btn-sm btn-secondary" onclick="loadSNowIncidents()" style="margin-left:8px">Incident 조회</button>` :
`<p style="color:var(--text-muted)">설정 없음</p>`}
<div style="margin-top:20px">
<div class="form-group"><label>인스턴스 URL</label>
<input class="form-control" id="snow-url" placeholder="https://company.service-now.com"></div>
<div class="form-group"><label>사용자명</label>
<input class="form-control" id="snow-user"></div>
<div class="form-group"><label>비밀번호</label>
<input type="password" class="form-control" id="snow-pw"></div>
<button class="btn btn-primary" onclick="saveServiceNow()">저장</button>
</div>
<div id="snow-incidents" style="margin-top:16px"></div>
</div>`;
break;
}
// ── Upstage OCR 뷰 ────────────────────────────
case "ocr_parse":
container.innerHTML = `
<div class="card" style="padding:24px">
<h3>📄 문서 파싱 (Upstage OCR)</h3>
<p style="color:var(--text-muted);margin-bottom:16px">PDF·이미지 → 구조화 JSON (텍스트·테이블·레이아웃)</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:32px;text-align:center;margin-bottom:16px">
<input type="file" id="ocr-file" accept=".pdf,.png,.jpg,.jpeg,.tiff" style="display:none" onchange="ocrParse()">
<p>📎 PDF/이미지 파일 선택</p>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('ocr-file').click()">파일 선택</button>
<p style="font-size:12px;color:var(--text-muted);margin-top:8px">최대 20MB · PDF·PNG·JPG·TIFF 지원</p>
</div>
<div id="ocr-parse-result"></div>
<div style="margin-top:16px;padding:12px;background:var(--bg-tertiary);border-radius:8px;font-size:12px">
💡 <strong>Upstage API Key</strong>가 없으면 <button class="btn btn-sm btn-secondary" onclick="showOcrConfig()">설정</button>에서 등록하세요.
</div>
</div>`;
break;
case "ocr_brand_contract":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🏢 브랜드 계약서 처리</h3>
<p style="color:var(--text-muted);margin-bottom:20px">현대백화점·롯데·신세계 등 기업 계약서 자동 분석 → 계약 이력 등록</p>
<div class="form-group">
<label>브랜드사명 (선택)</label>
<input class="form-control" id="brand-name-input" placeholder="예: 현대백화점" />
</div>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="brand-contract-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processBrandContract()">
<p>📄 계약서 PDF 또는 이미지</p>
<button class="btn btn-primary" onclick="document.getElementById('brand-contract-file').click()">계약서 업로드</button>
</div>
<div id="brand-contract-result"></div>
</div>`;
break;
case "ocr_contract":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>📋 나라장터 계약서 자동 처리</h3>
<p style="color:var(--text-muted);margin-bottom:20px">계약서 PDF → 계약정보 추출 → 조달 이력 자동 등록</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="contract-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processContract()">
<button class="btn btn-primary" onclick="document.getElementById('contract-file').click()">계약서 업로드</button>
</div>
<div id="contract-result"></div>
</div>`;
break;
case "ocr_server_spec":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🖥️ 서버 납품서 → CMDB 자동 등록</h3>
<p style="color:var(--text-muted);margin-bottom:20px">납품 명세서에서 서버 사양을 추출하여 CMDB에 자동 등록합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="server-spec-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processServerSpec()">
<button class="btn btn-primary" onclick="document.getElementById('server-spec-file').click()">납품서 업로드</button>
</div>
<div id="server-spec-result"></div>
</div>`;
break;
case "ocr_invoice":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🧾 청구서/세금계산서 처리</h3>
<p style="color:var(--text-muted);margin-bottom:20px">세금계산서·청구서에서 금액 정보를 추출하여 과금 시스템에 연동합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="invoice-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processInvoice()">
<button class="btn btn-primary" onclick="document.getElementById('invoice-file').click()">청구서 업로드</button>
</div>
<div id="invoice-result"></div>
</div>`;
break;
case "ocr_incident":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🚨 장애보고서 → SR 자동 생성</h3>
<p style="color:var(--text-muted);margin-bottom:20px">장애보고서 이미지/PDF에서 에러 내용을 추출하여 SR을 자동 생성합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="incident-file" accept=".pdf,.png,.jpg,.jpeg" style="display:none" onchange="processIncident()">
<button class="btn btn-primary" onclick="document.getElementById('incident-file').click()">보고서/화면 업로드</button>
<p style="font-size:12px;color:var(--text-muted);margin-top:6px">에러 화면 캡처, 장애보고서 모두 지원</p>
</div>
<div id="incident-result"></div>
</div>`;
break;
case "ocr_meeting":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>📝 회의록 → 액션아이템 SR 생성</h3>
<p style="color:var(--text-muted);margin-bottom:20px">회의록에서 결정사항·액션아이템을 추출하여 SR로 자동 생성합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="meeting-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processMeeting()">
<button class="btn btn-primary" onclick="document.getElementById('meeting-file').click()">회의록 업로드</button>
</div>
<div id="meeting-result"></div>
</div>`;
break;
case "ocr_history": {
const r = await fetch("/api/ocr/history?limit=50", {headers: H});
const d = await r.json();
const [ur] = await Promise.all([fetch("/api/ocr/usage", {headers: H}).then(r=>r.json())]);
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 3fr;gap:16px">
<div class="card" style="padding:20px">
<h4>📊 사용량</h4>
<div style="font-size:32px;font-weight:700;color:#003366">${ur.today_pages||0}</div>
<div style="font-size:12px;color:var(--text-muted)">오늘 처리 페이지</div>
<div style="margin-top:8px;background:var(--bg-tertiary);border-radius:4px;height:6px">
<div style="width:${Math.min(100,Math.round((ur.today_pages||0)/(ur.daily_limit||1000)*100))}%;background:#003366;height:6px;border-radius:4px"></div>
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">한도: ${ur.daily_limit||1000}페이지/일</div>
<div style="margin-top:12px;font-size:12px">이번 달: ${ur.month_pages||0}페이지<br>총 문서: ${ur.total_documents||0}건</div>
</div>
<div class="card" style="padding:20px">
<h4>📋 처리 이력 (${d.length}건)</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>파일명</th><th>유형</th><th>페이지</th><th>상태</th><th>연동</th><th>일시</th></tr></thead>
<tbody>
${d.map(h=>`<tr>
<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis">${esc(h.filename)}</td>
<td><span style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:11px">${h.type}</span></td>
<td>${h.pages}</td>
<td style="color:${h.status==='SUCCESS'?'#10B981':'#EF4444'}">${h.status}</td>
<td style="font-size:11px;color:var(--text-muted)">${h.linked_to||'-'} ${h.linked_id?'#'+h.linked_id:''}</td>
<td style="font-size:11px">${fmtDate(h.created_at)}</td>
</tr>`).join('') || `<tr><td colspan="6" style="text-align:center;color:var(--text-muted)">이력 없음</td></tr>`}
</tbody>
</table>
</div>
</div>`;
break;
}
case "doc_templates": {
const [builtin, custom] = await Promise.all([
fetch("/api/doctemplate/builtin", {headers: H}).then(r=>r.json()),
fetch("/api/doctemplate/", {headers: H}).then(r=>r.json()),
]);
container.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">📑 문서 추출 템플릿</h3>
<button class="btn btn-primary btn-sm" onclick="applyAllBuiltinTemplates()">📋 내장 템플릿 7종 모두 적용</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px">
${(custom.length ? custom : builtin).map(t=>`
<div class="card" style="padding:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:18px">${{
narasajang_contract:'📋', server_delivery:'🖥️',
brand_contract:'🏢', invoice:'🧾',
incident_report:'🚨', csap_report:'✅', meeting_minutes:'📝'
}[t.key||t.builtin_key||''] || '📄'}</span>
<strong style="font-size:13px">${esc(t.name)}</strong>
${t.is_builtin?'<span style="font-size:10px;background:#003366;color:#fff;padding:1px 6px;border-radius:8px">내장</span>':''}
</div>
<p style="font-size:12px;color:var(--text-muted);margin:0 0 8px">${esc(t.description||'')}</p>
<div style="font-size:11px;color:var(--text-muted)">${t.field_count}개 필드 · ${esc(t.workflow||'수동')}</div>
</div>`).join('')}
</div>`;
break;
}
default:
container.innerHTML = `<div class="card" style="padding:40px;text-align:center">
<h4>🚧 준비 중</h4>
<p style="color:var(--text-muted)">${esc(view)} 화면을 구현 중입니다.</p>
</div>`;
}
} catch(e) {
container.innerHTML = `<div class="card" style="padding:24px;border-left:4px solid #EF4444">
<h4>오류 발생</h4><p style="color:var(--text-muted)">${esc(e.message||String(e))}</p>
</div>`;
}
}
/* ── 확장 뷰 헬퍼 함수들 ──────────────────────────── */
async function doRagSearch() {
const q = document.getElementById("rag-q")?.value;
if (!q) return;
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/rag/search", {
method: "POST", headers: {"Authorization":`Bearer ${token}`,"Content-Type":"application/json"},
body: JSON.stringify({query: q, top_k: 5, include_sr: true})
});
const results = await r.json();
const el = document.getElementById("rag-results");
if (el) el.innerHTML = results.map(item=>`
<div style="padding:12px;background:var(--bg-tertiary);border-radius:8px;margin-bottom:8px">
<div style="font-weight:600;margin-bottom:4px">${esc(item.title)}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(item.excerpt)}</div>
<div style="font-size:11px;margin-top:4px">관련도: ${item.score} · 출처: ${item.source}</div>
</div>`).join("") || '<p style="color:var(--text-muted)">결과 없음</p>';
}
async function applyKpiTemplates() {
const token = localStorage.getItem("token")||"";
await fetch("/api/kpi/apply-template", {
method:"POST", headers:{"Authorization":`Bearer ${token}`,"Content-Type":"application/json"},
body: JSON.stringify({template_names:["MTTR","FCR","SLA_COMPLIANCE","SR_BACKLOG","DEPLOY_SUCCESS_RATE"]})
});
showPage("kpi_dashboard");
showToast("KPI 템플릿 5개 적용됨", "success");
}
async function recalcKpi(id) {
const token = localStorage.getItem("token")||"";
const r = await fetch(`/api/kpi/${id}/calculate`, {method:"POST", headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
showToast(`KPI 재계산: ${d.name} = ${d.value} ${d.unit}`, "success");
showPage("kpi_dashboard");
}
async function startLearning() {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/learn/train", {method:"POST", headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
showToast(`파인튜닝 시작 (Run #${d.run_id}, ${d.samples}개 샘플)`, "success");
}
async function checkContainers() {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/container-alerts/check", {headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
showToast(`컨테이너 체크 완료 — 알림 ${d.total}`, d.total > 0 ? "warning" : "success");
showPage("container_alerts");
}
async function runWorkflow(id) {
const token = localStorage.getItem("token")||"";
await fetch(`/api/workflow/rules/${id}/run`, {method:"POST", headers:{"Authorization":`Bearer ${token}`}});
showToast("워크플로우 실행 완료", "success");
}
async function generateReport(template) {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/auto-report/generate", {
method:"POST", headers:{"Authorization":`Bearer ${token}`,"Content-Type":"application/json"},
body: JSON.stringify({template, format:"excel"})
});
const d = await r.json();
showToast(`보고서 생성 완료 (ID: ${d.report_id})`, "success");
showPage("auto_report");
}
async function generateInvoice() {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/billing/invoices/generate", {method:"POST", headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
showToast(`청구서 생성 완료 (ID: ${d.invoice_id})`, "success");
showPage("billing");
}
async function contributeBenchmark() {
const token = localStorage.getItem("token")||"";
await fetch("/api/benchmark/contribute", {method:"POST", headers:{"Authorization":`Bearer ${token}`}});
showToast("익명 데이터 기여 완료", "success");
}
async function testSlack() {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/slack/test", {headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
showToast(d.ok ? "Slack 테스트 메시지 발송 성공" : "Slack 발송 실패", d.ok?"success":"error");
}
async function loadFeatureAdoption() {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/cohort/feature-adoption", {headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
const el = document.getElementById("feature-adoption-result");
if (el) el.innerHTML = (d.feature_adoption||[]).map(f=>`
<div style="display:flex;align-items:center;gap:12px;padding:8px;background:var(--bg-tertiary);border-radius:6px;margin-bottom:6px">
<span>${f.adopted?"✅":"❌"}</span>
<span style="flex:1;font-size:13px">${esc(f.feature)}</span>
<span style="font-size:12px;color:var(--text-muted)">${f.usage_count}회</span>
</div>`).join("");
}
async function loadK8sNodes(clusterId) {
const token = localStorage.getItem("token")||"";
const r = await fetch(`/api/k8s/clusters/${clusterId}/nodes`, {headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
const el = document.getElementById(`k8s-detail-${clusterId}`);
if (el) el.innerHTML = `<table class="table table-sm" style="font-size:12px;margin-top:8px">
<thead><tr><th>노드</th><th>상태</th><th>역할</th><th>버전</th></tr></thead>
<tbody>${(d.nodes||[]).map(n=>`<tr>
<td>${esc(n.name)}</td>
<td style="color:${n.status==="Ready"?"#10B981":"#EF4444"}">${n.status}</td>
<td>${esc(n.roles)}</td>
<td style="font-family:monospace;font-size:11px">${esc(n.version)}</td>
</tr>`).join("")}</tbody>
</table>`;
}
async function analyzeFile() {
const file = document.getElementById("mm-file")?.files[0];
if (!file) return;
const token = localStorage.getItem("token")||"";
const form = new FormData();
form.append("file", file);
const el = document.getElementById("mm-result");
if (el) el.innerHTML = '<p style="color:var(--text-muted)">분석 중...</p>';
try {
const r = await fetch("/api/multimodal/upload-and-analyze", {
method:"POST", headers:{"Authorization":`Bearer ${token}`}, body: form
});
const d = await r.json();
if (el) el.innerHTML = `<div class="card" style="padding:16px">
<h4>분석 결과</h4>
<pre style="white-space:pre-wrap;font-size:13px;color:var(--text-secondary)">${esc(JSON.stringify(d.analysis||d, null, 2))}</pre>
</div>`;
} catch(e) {
if (el) el.innerHTML = `<p style="color:#EF4444">분석 실패: ${esc(e.message)}</p>`;
}
}
function showPage(view) { currentView = view; renderCurrentView(); }
function showAddClusterModal() { showToast("K8s 클러스터 등록 모달 준비 중", "info"); }
function showCreateWorkflowModal() { showToast("워크플로우 규칙 생성 모달 준비 중", "info"); }
function showAddSSOModal() { showToast("SSO IdP 등록 모달 준비 중", "info"); }
function showAddERPModal() { showToast("ERP 연동 추가 모달 준비 중", "info"); }
function showNcloudConfig() { showToast("NCloud API 설정 모달 준비 중", "info"); }
function showJiraConfig() { showToast("Jira 설정 모달 준비 중", "info"); }
function showInviteModal() { showToast("사용자 초대 모달 준비 중", "info"); }
function showAddBrandModal() { showToast("브랜딩 설정 저장 중...", "info"); }
async function saveBranding() {
const token = localStorage.getItem("token")||"";
const body = {
company_name: document.getElementById("brand-name")?.value,
primary_color: document.getElementById("brand-primary")?.value,
};
await fetch("/api/brand/", {method:"PUT", headers:{"Authorization":`Bearer ${token}`,"Content-Type":"application/json"}, body:JSON.stringify(body)});
showToast("브랜딩 저장됨", "success");
}
async function saveSlack() {
const token = localStorage.getItem("token")||"";
const body = {
name: "Slack 알림", webhook_url: document.getElementById("slack-webhook")?.value||"",
default_channel: document.getElementById("slack-channel")?.value||"#guardia-ops",
};
await fetch("/api/slack/config", {method:"POST", headers:{"Authorization":`Bearer ${token}`,"Content-Type":"application/json"}, body:JSON.stringify(body)});
showToast("Slack 설정 저장됨", "success");
}
async function saveKakao() {
const token = localStorage.getItem("token")||"";
const body = {
apikey: document.getElementById("kakao-apikey")?.value||"",
userid: document.getElementById("kakao-userid")?.value||"",
senderkey: document.getElementById("kakao-senderkey")?.value||"",
sender: document.getElementById("kakao-sender")?.value||"",
};
await fetch("/api/kakao/config", {method:"POST", headers:{"Authorization":`Bearer ${token}`,"Content-Type":"application/json"}, body:JSON.stringify(body)});
showToast("카카오 설정 저장됨", "success");
}
async function saveServiceNow() {
const token = localStorage.getItem("token")||"";
const body = {
instance_url: document.getElementById("snow-url")?.value||"",
username: document.getElementById("snow-user")?.value||"",
password: document.getElementById("snow-pw")?.value||"",
};
await fetch("/api/servicenow/config", {method:"POST", headers:{"Authorization":`Bearer ${token}`,"Content-Type":"application/json"}, body:JSON.stringify(body)});
showToast("ServiceNow 설정 저장됨", "success");
showPage("servicenow");
}
async function testServiceNow() {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/servicenow/test", {method:"POST", headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
showToast(d.ok ? "ServiceNow 연결 성공" : "연결 실패", d.ok?"success":"error");
}
async function loadSNowIncidents() {
const token = localStorage.getItem("token")||"";
const r = await fetch("/api/servicenow/incidents", {headers:{"Authorization":`Bearer ${token}`}});
const incidents = await r.json();
const el = document.getElementById("snow-incidents");
if (el) el.innerHTML = `<table class="table table-sm" style="font-size:12px;margin-top:12px">
<thead><tr><th>번호</th><th>제목</th><th>우선순위</th></tr></thead>
<tbody>${(Array.isArray(incidents)?incidents:[]).slice(0,5).map(i=>`<tr>
<td>${esc(i.number)}</td><td>${esc(i.title)}</td><td>${esc(i.priority)}</td>
</tr>`).join("")||`<tr><td colspan="3" style="text-align:center;color:var(--text-muted)">없음</td></tr>`}</tbody>
</table>`;
}
async function testERP(id) {
const token = localStorage.getItem("token")||"";
const r = await fetch(`/api/erp/test/${id}`, {method:"POST", headers:{"Authorization":`Bearer ${token}`}});
const d = await r.json();
showToast(d.ok ? "ERP 연결 성공" : `연결 실패: ${d.error||""}`, d.ok?"success":"error");
}
/* ══════════════════════════════════════════════════
Upstage OCR 헬퍼 함수
══════════════════════════════════════════════════ */
function _ocrHeaders() {
const token = localStorage.getItem("token")||"";
return {"Authorization": `Bearer ${token}`};
}
function _showOcrResult(elId, data, successMsg) {
const el = document.getElementById(elId);
if (!el) return;
if (data.ok === false || data.error) {
el.innerHTML = `<div style="padding:12px;background:#fef2f2;border-radius:8px;color:#991b1b;margin-top:12px">❌ ${esc(data.error||data.message||"오류")} </div>`;
return;
}
el.innerHTML = `
<div style="padding:14px;background:#f0fdf4;border-radius:8px;margin-top:12px">
<div style="color:#166534;font-weight:600;margin-bottom:8px">✅ ${esc(successMsg)}</div>
<pre style="white-space:pre-wrap;font-size:12px;max-height:300px;overflow-y:auto;background:#fff;padding:10px;border-radius:6px;border:1px solid #e2e8f0">${esc(JSON.stringify(data.extracted||data.simplified||data.content||data, null, 2).slice(0, 2000))}</pre>
</div>`;
}
async function ocrParse() {
const file = document.getElementById("ocr-file")?.files[0];
if (!file) return;
const el = document.getElementById("ocr-parse-result");
if (el) el.innerHTML = '<p style="color:var(--text-muted)">⏳ 파싱 중...</p>';
const form = new FormData();
form.append("file", file);
try {
const r = await fetch("/api/ocr/parse", {method:"POST", headers:_ocrHeaders(), body:form});
const d = await r.json();
if (el) el.innerHTML = `
<div style="margin-top:16px">
<h4 style="font-size:14px;font-weight:600">파싱 결과</h4>
${d.content?.text ? `<div class="card" style="padding:16px;margin-bottom:12px">
<strong style="font-size:12px">텍스트</strong>
<pre style="white-space:pre-wrap;font-size:12px;max-height:300px;overflow-y:auto">${esc(d.content.text.slice(0,2000))}</pre>
</div>` : ''}
${(d.elements||[]).filter(e=>e.category==='table').length ? `<div class="card" style="padding:16px">
<strong style="font-size:12px">테이블 ${(d.elements||[]).filter(e=>e.category==='table').length}개 감지</strong>
<div style="margin-top:8px;font-size:12px;overflow-x:auto">${(d.elements||[]).filter(e=>e.category==='table')[0]?.content?.html||''}</div>
</div>` : ''}
<div style="font-size:11px;color:var(--text-muted);margin-top:8px">페이지: ${d.usage?.pages||1} · 이력 ID: ${d.history_id||'-'}</div>
</div>`;
showToast("문서 파싱 완료", "success");
} catch(e) {
if (el) el.innerHTML = `<p style="color:#EF4444">오류: ${esc(e.message)}</p>`;
}
}
async function processBrandContract() {
const file = document.getElementById("brand-contract-file")?.files[0];
if (!file) return;
const brandName = document.getElementById("brand-name-input")?.value||"";
const form = new FormData();
form.append("file", file);
form.append("brand_name", brandName);
form.append("auto_register", "true");
try {
const r = await fetch("/api/docflow/brand-contract", {method:"POST", headers:_ocrHeaders(), body:form});
const d = await r.json();
_showOcrResult("brand-contract-result", d, d.message||"브랜드 계약서 처리 완료");
if (d.record_id) showToast(`계약 등록 완료 (ID: ${d.record_id})`, "success");
} catch(e) {
showToast("오류: " + e.message, "error");
}
}
async function processContract() {
const file = document.getElementById("contract-file")?.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
form.append("auto_register", "true");
try {
const r = await fetch("/api/docflow/contract", {method:"POST", headers:_ocrHeaders(), body:form});
const d = await r.json();
_showOcrResult("contract-result", d, d.message||"계약서 처리 완료");
} catch(e) { showToast(e.message, "error"); }
}
async function processServerSpec() {
const file = document.getElementById("server-spec-file")?.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file); form.append("auto_register", "true");
try {
const r = await fetch("/api/docflow/server-spec", {method:"POST", headers:_ocrHeaders(), body:form});
const d = await r.json();
_showOcrResult("server-spec-result", d, d.message||"납품서 처리 완료");
if (d.server_id) showToast(`CMDB 등록 완료 (서버 ID: ${d.server_id})`, "success");
} catch(e) { showToast(e.message, "error"); }
}
async function processInvoice() {
const file = document.getElementById("invoice-file")?.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file); form.append("auto_register", "true");
try {
const r = await fetch("/api/docflow/invoice", {method:"POST", headers:_ocrHeaders(), body:form});
const d = await r.json();
_showOcrResult("invoice-result", d, `청구서 처리 완료. 금액: ${(d.total_amount||0).toLocaleString()}`);
} catch(e) { showToast(e.message, "error"); }
}
async function processIncident() {
const file = document.getElementById("incident-file")?.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file); form.append("auto_create_sr", "true");
try {
const r = await fetch("/api/docflow/incident-report", {method:"POST", headers:_ocrHeaders(), body:form});
const d = await r.json();
_showOcrResult("incident-result", d, d.message||"장애보고서 처리 완료");
if (d.sr_id) showToast(`SR-${d.sr_id} 자동 생성됨`, "success");
} catch(e) { showToast(e.message, "error"); }
}
async function processMeeting() {
const file = document.getElementById("meeting-file")?.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file); form.append("auto_create_sr", "true");
try {
const r = await fetch("/api/docflow/meeting-minutes", {method:"POST", headers:_ocrHeaders(), body:form});
const d = await r.json();
_showOcrResult("meeting-result", d, d.message||"회의록 처리 완료");
if (d.sr_ids?.length) showToast(`SR ${d.sr_ids.join(',')} 생성됨`, "success");
} catch(e) { showToast(e.message, "error"); }
}
async function applyAllBuiltinTemplates() {
const token = localStorage.getItem("token")||"";
const keys = ["narasajang_contract","server_delivery","brand_contract","invoice","incident_report","csap_report","meeting_minutes"];
const r = await fetch("/api/doctemplate/apply-builtin", {
method:"POST", headers:{..._ocrHeaders(),"Content-Type":"application/json"},
body:JSON.stringify({template_keys: keys})
});
const d = await r.json();
showToast(`템플릿 ${d.count}개 적용됨`, "success");
showPage("doc_templates");
}
function showOcrConfig() { showPage("ocr_parse"); showToast("상단 설정 메뉴 → POST /api/ocr/config 에서 API Key를 등록하세요", "info"); }
// ══════════════════════════════════════════════════════════════════════════════
// ── GUARDiA 기능 개선 v4 — 앱배포QR / 배치SSH / 자산QR / 스마트알림
// ══════════════════════════════════════════════════════════════════════════════
// ── 앱 배포 QR ────────────────────────────────────────────────────────────────
const APP_VIEWS = {
app_deploy: `
<h2>📱 모바일 앱 배포 · QR 생성</h2>
<div class="card" style="margin-bottom:16px">
<div id="app-latest"></div>
</div>
<div class="card">
<h3>새 버전 배포</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div><label class="form-label">버전 *</label>
<input id="app-version" class="form-control" placeholder="예: 1.2.3"></div>
<div><label class="form-label">iOS URL (선택)</label>
<input id="app-ios-url" class="form-control" placeholder="TestFlight URL"></div>
</div>
<div style="margin-bottom:12px"><label class="form-label">APK 파일</label>
<input type="file" id="app-file" accept=".apk" class="form-control"></div>
<div style="margin-bottom:12px"><label class="form-label">업데이트 내용</label>
<textarea id="app-notes" class="form-control" rows="2" placeholder="변경사항..."></textarea></div>
<button class="btn btn-primary" onclick="uploadApk()">🚀 배포 + QR 생성</button>
</div>
<div class="card" style="margin-top:16px">
<h3>버전 이력</h3>
<div id="app-versions-list">로딩 중...</div>
</div>`,
app_versions: `<h2>📋 버전 이력</h2><div id="app-versions-list2">로딩 중...</div>`,
app_stats: `<h2>📊 다운로드 통계</h2><div id="app-stats-div">로딩 중...</div>`,
};
function renderAppDeploy() {
document.getElementById("content").innerHTML = APP_VIEWS.app_deploy;
loadAppLatest(); loadAppVersions();
}
async function loadAppLatest() {
const t = localStorage.getItem("token")||"";
try {
const r = await fetch("/api/app/latest", {headers:{Authorization:`Bearer ${t}`}});
const d = await r.json();
if (!d.has_version) { document.getElementById("app-latest").innerHTML = "<p style='color:#64748b'>배포된 버전이 없습니다.</p>"; return; }
document.getElementById("app-latest").innerHTML = `
<div style="display:flex;gap:20px;align-items:flex-start">
<div style="flex:1">
<div style="font-size:11px;color:#64748b">현재 최신 버전</div>
<div style="font-size:28px;font-weight:800;color:#003366">v${d.version}</div>
<div style="font-size:13px;color:#64748b;margin-top:4px">${d.platform} · 총 ${d.download_count}회 다운로드</div>
<div style="margin-top:10px;display:flex;gap:8px">
<a href="${d.qr_url}" target="_blank" class="btn btn-primary btn-sm">🖼️ QR 이미지</a>
<a href="${d.landing_url}" target="_blank" class="btn btn-sm">📄 랜딩 페이지</a>
</div>
</div>
<div style="text-align:center">
<img src="${d.qr_url}" width="100" height="100" style="border:2px solid #e2e8f0;border-radius:8px" onerror="this.style.display='none'">
<div style="font-size:11px;color:#64748b;margin-top:4px">스캔하여 설치</div>
</div>
</div>`;
} catch(e) {}
}
async function loadAppVersions() {
const t = localStorage.getItem("token")||"";
try {
const r = await fetch("/api/app/versions", {headers:{Authorization:`Bearer ${t}`}});
const versions = await r.json();
const el = document.getElementById("app-versions-list");
if (!el) return;
if (!versions.length) { el.innerHTML = "<p style='color:#94a3b8;text-align:center;padding:20px'>버전 없음</p>"; return; }
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${['버전','플랫폼','다운로드','QR','배포일',''].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
<tbody>${versions.map(v=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 8px;font-weight:${v.is_latest?700:400}">v${v.version}${v.is_latest?' <span style="background:#003366;color:#fff;font-size:10px;padding:1px 6px;border-radius:8px;margin-left:4px">최신</span>':''}</td>
<td style="padding:10px 8px">${v.platform}</td>
<td style="padding:10px 8px">${v.download_count}회</td>
<td style="padding:10px 8px"><a href="${v.qr_url}" target="_blank" style="color:#003366;font-size:12px">QR↗</a></td>
<td style="padding:10px 8px;color:#64748b;font-size:11px">${v.created_at?new Date(v.created_at).toLocaleDateString('ko-KR'):'-'}</td>
<td style="padding:10px 8px">${!v.is_latest?`<button onclick="deleteAppVersion(${v.id})" style="padding:3px 8px;border:1px solid #fca5a5;color:#dc2626;border-radius:4px;background:none;cursor:pointer;font-size:11px">삭제</button>`:''}</td>
</tr>`).join('')}</tbody></table>`;
} catch(e) {}
}
async function uploadApk() {
const file = document.getElementById("app-file").files[0];
const version = document.getElementById("app-version").value;
const notes = document.getElementById("app-notes").value;
const iosUrl = document.getElementById("app-ios-url").value;
if (!file || !version) return showToast("APK 파일과 버전을 입력하세요", "error");
const form = new FormData();
form.append("file", file); form.append("version", version);
form.append("release_notes", notes); form.append("ios_url", iosUrl);
const t = localStorage.getItem("token")||"";
try {
showToast("업로드 중...", "info");
await fetch("/api/app/upload", {method:"POST", headers:{Authorization:`Bearer ${t}`}, body:form});
showToast(`✅ v${version} 배포 완료! QR 코드가 생성됐습니다.`, "success");
renderAppDeploy();
} catch(e) { showToast(e.message, "error"); }
}
async function deleteAppVersion(id) {
if (!confirm("이 버전을 삭제하시겠습니까?")) return;
const t = localStorage.getItem("token")||"";
await fetch(`/api/app/versions/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}});
loadAppVersions();
}
// ── 배치 SSH ─────────────────────────────────────────────────────────────────
function renderBatchSsh() {
document.getElementById("content").innerHTML = `
<h2>⚡ 배치 SSH 실행</h2>
<p style="color:#64748b;margin-bottom:16px">여러 서버에 동시에 SSH 명령을 실행하고 결과를 수집합니다.</p>
<div class="card" style="margin-bottom:16px">
<h3>명령 실행</h3>
<div style="margin-bottom:12px"><label class="form-label">명령어</label>
<input id="batch-cmd" class="form-control" placeholder="예: df -h /"></div>
<div style="margin-bottom:12px"><label class="form-label">서버 목록 (콤마 구분 서버 ID 또는 태그)</label>
<input id="batch-servers" class="form-control" placeholder="예: 1,2,3 또는 web,db"></div>
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
<label class="form-label" style="margin:0;white-space:nowrap">타임아웃(초)</label>
<input id="batch-timeout" class="form-control" type="number" value="30" style="width:80px">
</div>
<button class="btn btn-primary" onclick="runBatchSsh()">⚡ 실행</button>
</div>
<div id="batch-results"></div>
<div class="card" style="margin-top:16px">
<h3>실행 이력</h3>
<div id="batch-history">로딩 중...</div>
</div>`;
loadBatchHistory();
}
async function runBatchSsh() {
const cmd = document.getElementById("batch-cmd").value;
const servers = document.getElementById("batch-servers").value;
const timeout = parseInt(document.getElementById("batch-timeout").value) || 30;
if (!cmd || !servers) return showToast("명령어와 서버를 입력하세요", "error");
const serverIds = servers.split(",").map(s=>s.trim()).filter(Boolean);
const t = localStorage.getItem("token")||"";
showToast("실행 중...", "info");
try {
const r = await fetch("/api/batch-ssh/run", {
method:"POST", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({command:cmd, server_ids:serverIds, timeout_sec:timeout})
});
const d = await r.json();
const results = d.results || {};
let html = `<div class="card"><h3>실행 결과 — ${d.success_count}/${d.total_count} 성공</h3>`;
for (const [sid, res] of Object.entries(results)) {
html += `<div style="margin-bottom:12px;padding:12px;border:1px solid ${res.success?'#bbf7d0':'#fca5a5'};border-radius:8px;background:${res.success?'#f0fdf4':'#fff5f5'}">
<div style="font-weight:700;margin-bottom:4px">${res.server_name||'서버 '+sid} <span style="color:${res.success?'#166534':'#dc2626'};font-size:12px">${res.success?'✅ 성공':'❌ 실패'}</span></div>
${res.stdout?`<pre style="font-size:12px;margin:0;white-space:pre-wrap;color:#374151">${res.stdout.substring(0,300)}</pre>`:''}
${res.stderr?`<pre style="font-size:12px;margin:0;color:#dc2626">${res.stderr.substring(0,200)}</pre>`:''}
</div>`;
}
document.getElementById("batch-results").innerHTML = html + "</div>";
loadBatchHistory();
} catch(e) { showToast(e.message, "error"); }
}
async function loadBatchHistory() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/batch-ssh/jobs?limit=10", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
const jobs = await r.json();
const el = document.getElementById("batch-history");
if (!el) return;
if (!jobs.length) { el.innerHTML = "<p style='color:#94a3b8;text-align:center;padding:12px'>실행 이력 없음</p>"; return; }
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${['이름','명령어','결과','실행일'].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
<tbody>${jobs.map(j=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px">${j.name||'-'}</td>
<td style="padding:8px;font-family:monospace;font-size:12px">${(j.command||'').substring(0,40)}</td>
<td style="padding:8px;color:${j.fail_count>0?'#dc2626':'#166534'}">${j.success_count}/${j.total_count}</td>
<td style="padding:8px;color:#64748b;font-size:11px">${j.created_at?new Date(j.created_at).toLocaleDateString('ko-KR'):'-'}</td>
</tr>`).join('')}</tbody></table>`;
}
// ── 자산 QR ───────────────────────────────────────────────────────────────────
function renderAssetQr() {
document.getElementById("content").innerHTML = `
<h2>🏷️ 자산 QR 태그 관리</h2>
<p style="color:#64748b;margin-bottom:16px">서버 장비에 QR 라벨을 부착하여 모바일 스캔으로 CMDB 정보를 즉시 확인합니다.</p>
<div class="card" style="margin-bottom:16px">
<h3>QR 토큰 생성</h3>
<div style="display:flex;gap:12px;align-items:flex-end">
<div style="flex:1"><label class="form-label">서버 ID</label>
<input id="qr-server-id" class="form-control" type="number" placeholder="서버 ID"></div>
<button class="btn btn-primary" onclick="generateQr()">🏷️ QR 생성</button>
</div>
<div id="qr-result" style="margin-top:12px"></div>
</div>
<div class="card">
<h3>등록된 QR 목록</h3>
<div id="qr-list">로딩 중...</div>
</div>`;
loadQrList();
}
async function generateQr() {
const serverId = document.getElementById("qr-server-id").value;
if (!serverId) return showToast("서버 ID를 입력하세요", "error");
const t = localStorage.getItem("token")||"";
const r = await fetch(`/api/asset-qr/generate/${serverId}`, {method:"POST", headers:{Authorization:`Bearer ${t}`}});
const d = await r.json();
if (d.qr_url) {
document.getElementById("qr-result").innerHTML = `
<div style="display:flex;gap:16px;align-items:center;padding:12px;border:1px solid #e2e8f0;border-radius:8px">
<img src="${d.qr_url}" width="80" height="80" style="border:1px solid #e2e8f0;border-radius:4px">
<div>
<div style="font-weight:700">${d.server_name}</div>
<div style="font-size:12px;color:#64748b;font-family:monospace;margin-top:4px">${d.token}</div>
<a href="/api/asset-qr/label/${d.token}" target="_blank" class="btn btn-sm" style="margin-top:8px">🖨️ 라벨 인쇄</a>
</div>
</div>`;
showToast("QR 생성 완료", "success");
loadQrList();
}
}
async function loadQrList() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/asset-qr/list?limit=20", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
const tokens = await r.json();
const el = document.getElementById("qr-list");
if (!el) return;
if (!tokens.length) { el.innerHTML = "<p style='color:#94a3b8;text-align:center;padding:12px'>등록된 QR 없음</p>"; return; }
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${['서버','토큰','스캔 수','최종 스캔','라벨'].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
<tbody>${tokens.map(tk=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px;font-weight:600">${tk.server_name||'서버 '+tk.server_id}</td>
<td style="padding:8px;font-family:monospace;font-size:11px;color:#64748b">${tk.token.substring(0,12)}...</td>
<td style="padding:8px">${tk.scan_count}회</td>
<td style="padding:8px;color:#64748b;font-size:11px">${tk.last_scanned_at?new Date(tk.last_scanned_at).toLocaleDateString('ko-KR'):'없음'}</td>
<td style="padding:8px"><a href="/api/asset-qr/label/${tk.token}" target="_blank" style="font-size:12px;color:#003366">라벨↗</a></td>
</tr>`).join('')}</tbody></table>`;
}
// ── 스마트 알림 규칙 ──────────────────────────────────────────────────────────
function renderNotificationRules() {
document.getElementById("content").innerHTML = `
<h2>🔔 스마트 알림 규칙</h2>
<p style="color:#64748b;margin-bottom:16px">조건 기반 알림 규칙을 설정합니다. AND 조건으로 모두 충족 시 알림이 발송됩니다.</p>
<div style="margin-bottom:12px;text-align:right">
<button class="btn btn-primary" onclick="showAddNotifyRule()">+ 규칙 추가</button>
</div>
<div id="notify-rules-list">로딩 중...</div>
<div id="notify-rule-form" style="display:none;margin-top:16px"></div>`;
loadNotifyRules();
}
async function loadNotifyRules() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/notify/rules", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
const rules = await r.json();
const el = document.getElementById("notify-rules-list");
if (!el) return;
if (!rules.length) { el.innerHTML = `<div style="text-align:center;padding:30px;border:2px dashed #e2e8f0;border-radius:10px;color:#94a3b8">등록된 알림 규칙이 없습니다</div>`; return; }
el.innerHTML = rules.map(r=>`
<div style="border:1px solid #e2e8f0;border-radius:10px;padding:16px;margin-bottom:10px;opacity:${r.enabled?1:0.6}">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<span style="font-weight:700;font-size:15px">${r.name}</span>
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${r.enabled?'#dcfce7':'#f1f5f9'};color:${r.enabled?'#166534':'#64748b'}">${r.enabled?'활성':'비활성'}</span>
${r.digest_mode?'<span style="margin-left:4px;padding:2px 8px;border-radius:8px;font-size:11px;background:#fef3c7;color:#92400e">다이제스트</span>':''}
<div style="font-size:12px;color:#64748b;margin-top:4px">조건: ${(r.conditions||[]).map(c=>`${c.field} ${c.op} "${c.value}"`).join(' AND ')||'없음'}</div>
<div style="margin-top:4px">${(r.channels||[]).map(ch=>`<span style="padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:11px;margin-right:4px">${ch}</span>`).join('')}</div>
</div>
<div style="display:flex;gap:6px">
<button onclick="testNotifyRule(${r.id})" style="padding:5px 10px;border:1px solid #e2e8f0;border-radius:6px;background:none;cursor:pointer;font-size:12px">테스트</button>
<button onclick="toggleNotifyRule(${r.id},${r.enabled})" style="padding:5px 10px;border:1px solid #e2e8f0;border-radius:6px;background:none;cursor:pointer;font-size:12px">${r.enabled?'비활성화':'활성화'}</button>
<button onclick="deleteNotifyRule(${r.id})" style="padding:5px 10px;border:1px solid #fca5a5;color:#dc2626;border-radius:6px;background:none;cursor:pointer;font-size:12px">삭제</button>
</div>
</div>
</div>`).join('');
}
function showAddNotifyRule() {
const form = document.getElementById("notify-rule-form");
form.style.display = "block";
form.innerHTML = `
<div class="card">
<h3>새 알림 규칙</h3>
<label class="form-label">규칙 이름</label>
<input id="rule-name" class="form-control" placeholder="예: CRITICAL SR 즉시 알림" style="margin-bottom:10px">
<label class="form-label">조건 (field==value 형식, 쉼표 구분)</label>
<input id="rule-cond" class="form-control" placeholder="sr_priority==CRITICAL" style="margin-bottom:10px">
<label class="form-label">알림 채널 (쉼표 구분)</label>
<input id="rule-channels" class="form-control" placeholder="messenger,email" style="margin-bottom:10px">
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-primary" onclick="saveNotifyRule()">저장</button>
<button class="btn" onclick="document.getElementById('notify-rule-form').style.display='none'">취소</button>
</div>
</div>`;
}
async function saveNotifyRule() {
const name = document.getElementById("rule-name").value;
const condStr = document.getElementById("rule-cond").value;
const channels = document.getElementById("rule-channels").value.split(",").map(s=>s.trim()).filter(Boolean);
if (!name) return showToast("이름을 입력하세요", "error");
const conditions = condStr.split(",").map(s=>{
const m = s.match(/(\w+)(==|!=|>=|<=|>|<|contains)(.+)/);
return m ? {field:m[1].trim(),op:m[2],value:m[3].trim()} : null;
}).filter(Boolean);
const t = localStorage.getItem("token")||"";
await fetch("/api/notify/rules", {
method:"POST", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({name, enabled:true, conditions, channels, digest_mode:false})
});
showToast("규칙 저장됨", "success");
document.getElementById("notify-rule-form").style.display = "none";
loadNotifyRules();
}
async function testNotifyRule(id) {
const t = localStorage.getItem("token")||"";
const r = await fetch(`/api/notify/rules/${id}/test`, {method:"POST", headers:{Authorization:`Bearer ${t}`}});
const d = await r.json();
showToast(d.ok ? "✅ 테스트 발송 성공" : `${d.message||'실패'}`, d.ok?"success":"error");
}
async function toggleNotifyRule(id, enabled) {
const t = localStorage.getItem("token")||"";
await fetch(`/api/notify/rules/${id}/toggle`, {method:"PATCH", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, body:JSON.stringify({enabled:!enabled})});
loadNotifyRules();
}
async function deleteNotifyRule(id) {
if (!confirm("삭제하시겠습니까?")) return;
const t = localStorage.getItem("token")||"";
await fetch(`/api/notify/rules/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}});
loadNotifyRules();
}
// ══════════════════════════════════════════════════════════════════════════════
// ── GUARDiA 차세대 확장 뷰 — AIOps 2.0 / Zero Trust / IDP / GreenOps+Edge
// ══════════════════════════════════════════════════════════════════════════════
function _nextCard(title, icon, content) {
return `<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;margin-bottom:14px">
<h3 style="margin:0 0 12px;font-size:15px;font-weight:700">${icon} ${title}</h3>${content}</div>`;
}
// ── AIOps 2.0 ─────────────────────────────────────────────────────────────────
function renderAgenticAiops() {
document.getElementById("content").innerHTML = `
<h2>🤖 에이전트 태스크 실행 (AIOps 2.0)</h2>
<p style="color:#64748b;margin-bottom:16px">Ollama 기반 tool-calling 멀티에이전트 — 태스크를 입력하면 에이전트가 도구를 선택·실행합니다.</p>
${_nextCard("태스크 실행","⚡",`
<textarea id="agent-task" class="form-control" rows="2" placeholder="예: server-1 CPU 90% 원인 분석 후 해결해줘"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="runAgentTask()">🤖 실행</button>
`)}
${_nextCard("실행 이력","📋",`<div id="agent-runs-list">로딩 중...</div>`)}`;
loadAgentRuns();
}
async function runAgentTask() {
const task = document.getElementById("agent-task").value;
if (!task) return showToast("태스크를 입력하세요","error");
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/agent/run", {method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({task})});
const d = await r.json();
showToast(`에이전트 실행 시작 (ID: ${d.run_id})`,"success");
loadAgentRuns();
}
async function loadAgentRuns() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/agent/runs",{headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
const runs = await r.json();
const el = document.getElementById("agent-runs-list");
if(!el) return;
if(!runs.length){el.innerHTML="<p style='color:#94a3b8;text-align:center;padding:12px'>실행 이력 없음</p>";return;}
el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${["ID","태스크","상태","실행일"].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join("")}</tr></thead>
<tbody>${runs.map(r=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px;font-weight:600">${r.id}</td>
<td style="padding:8px">${(r.task||"").substring(0,60)}</td>
<td style="padding:8px"><span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${r.status==="DONE"?"#dcfce7":"#fef3c7"};color:${r.status==="DONE"?"#166534":"#92400e"}">${r.status}</span></td>
<td style="padding:8px;color:#64748b;font-size:11px">${r.created_at?new Date(r.created_at).toLocaleString("ko-KR"):"-"}</td>
</tr>`).join("")}</tbody></table>`;
}
function renderAutoRemediation() {
document.getElementById("content").innerHTML = `
<h2>🔧 자율 교정 루프</h2>
<p style="color:#64748b;margin-bottom:16px">이상 감지 → Ollama 진단 → 자동 교정(안전) 또는 승인 요청(위험) 자동화 루프</p>
${_nextCard("수동 트리거","⚡",`
<input id="remediation-data" class="form-control" placeholder='{"cpu":95,"server":"server-1"}' style="margin-bottom:8px">
<button class="btn btn-primary" onclick="triggerRemediation()">교정 트리거</button>
`)}
${_nextCard("승인 대기","⏳",`<div id="remediation-pending">로딩 중...</div>`)}
${_nextCard("교정 이력","📋",`<div id="remediation-history">로딩 중...</div>`)}`;
loadRemediationData();
}
async function triggerRemediation() {
const data = document.getElementById("remediation-data").value;
let trigger_data={};
try{trigger_data=JSON.parse(data);}catch(e){}
const t=localStorage.getItem("token")||"";
await fetch("/api/remediation/trigger",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({trigger_data})});
showToast("교정 트리거됨","success"); loadRemediationData();
}
async function loadRemediationData() {
const t=localStorage.getItem("token")||"";
const [p,h]=await Promise.all([
fetch("/api/remediation/pending",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/remediation/history?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const pe=document.getElementById("remediation-pending"), he=document.getElementById("remediation-history");
if(pe) pe.innerHTML=p.length?p.map(i=>`<div style="border:1px solid #fef3c7;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600">${i.diagnosis||"진단 중..."}</div>
<div style="font-size:12px;color:#64748b;margin-top:4px">조치: ${i.action_taken||"결정 중"}</div>
<button onclick="approveRemediation(${i.id})" class="btn btn-primary btn-sm" style="margin-top:8px">승인</button>
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'>승인 대기 없음</p>";
if(he) he.innerHTML=h.map(i=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
<span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${i.status==="AUTO_FIXED"?"#dcfce7":"#fef3c7"};color:${i.status==="AUTO_FIXED"?"#166534":"#92400e"}">${i.status}</span>
<span style="margin-left:8px">${i.diagnosis||"-"}</span>
</div>`).join("") || "<p style='color:#94a3b8;padding:12px;text-align:center'>이력 없음</p>";
}
async function approveRemediation(id) {
const t=localStorage.getItem("token")||"";
await fetch(`/api/remediation/approve/${id}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
showToast("승인됨","success"); loadRemediationData();
}
function renderOtelTracing() {
document.getElementById("content").innerHTML = `
<h2>📡 분산 트레이싱 (OpenTelemetry)</h2>
<p style="color:#64748b;margin-bottom:16px">OTLP HTTP로 스팬을 수집하고 서비스 간 호출 흐름을 시각화합니다.</p>
${_nextCard("최근 트레이스","🔍",`<div id="otel-traces">로딩 중...</div>`)}
${_nextCard("서비스 목록","🗂️",`<div id="otel-services">로딩 중...</div>`)}`;
loadOtelData();
}
async function loadOtelData() {
const t=localStorage.getItem("token")||"";
const [traces,svcs]=await Promise.all([
fetch("/api/tracing/traces?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/tracing/services",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const te=document.getElementById("otel-traces"), se=document.getElementById("otel-services");
if(te) te.innerHTML=traces.length?traces.map(tr=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px;font-family:monospace">${tr.trace_id.substring(0,16)}... <span style="color:#64748b">${tr.service}</span></div>`).join(""):"<p style='color:#94a3b8;padding:12px'>트레이스 없음 — 앱에서 /api/tracing/ingest 로 스팬을 전송하세요</p>";
if(se) se.innerHTML=svcs.length?svcs.map(s=>`<span style="padding:4px 10px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:12px;margin:2px;display:inline-block">${s}</span>`).join(""):"<p style='color:#94a3b8;padding:12px'>서비스 없음</p>";
}
function renderMlsecops() {
document.getElementById("content").innerHTML = `
<h2>🔒 AI 모델 보안 (MLSecOps)</h2>
<p style="color:#64748b;margin-bottom:16px">Ollama 모델 무결성·취약점·편향 관리</p>
${_nextCard("설치된 모델","🤖",`<div id="ml-models">로딩 중...</div>`)}`;
loadMlModels();
}
async function loadMlModels() {
const t=localStorage.getItem("token")||"";
const models=await fetch("/api/mlsec/models",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("ml-models");
if(!el) return;
if(!models.length){el.innerHTML="<p style='color:#94a3b8;padding:12px'>모델 없음</p>";return;}
el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid #e2e8f0">${["모델명","크기","위험도","상태","액션"].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join("")}</tr></thead>
<tbody>${models.map(m=>`<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px;font-family:monospace">${m.name}</td>
<td style="padding:8px">${m.size_gb}GB</td>
<td style="padding:8px"><span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${m.risk==="HIGH"?"#fef2f2":"#dcfce7"};color:${m.risk==="HIGH"?"#dc2626":"#166534"}">${m.risk}</span></td>
<td style="padding:8px;font-size:11px;color:#64748b">${m.vulnerability||"정상"}</td>
<td style="padding:8px"><button onclick="scanMlModel('${m.name}')" style="padding:3px 8px;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;cursor:pointer;background:none">스캔</button></td>
</tr>`).join("")}</tbody></table>`;
}
async function scanMlModel(name) {
const t=localStorage.getItem("token")||"";
const r=await fetch(`/api/mlsec/models/scan?model_name=${encodeURIComponent(name)}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d=await r.json();
showToast(`${name}: ${d.recommendation}`,"info"); loadMlModels();
}
// ── Zero Trust ────────────────────────────────────────────────────────────────
function renderZtna() {
document.getElementById("content").innerHTML = `
<h2>🔐 ZTNA 정책 관리</h2>
<p style="color:#64748b;margin-bottom:16px">Zero Trust Network Access — 리소스별 접근 정책 및 디바이스 신뢰 점수 관리</p>
${_nextCard("정책 목록","📋",`<div id="ztna-policies">로딩 중...</div>`)}
${_nextCard("최근 위반","⚠️",`<div id="ztna-violations">로딩 중...</div>`)}`;
loadZtnaData();
}
async function loadZtnaData() {
const t=localStorage.getItem("token")||"";
const [policies,violations]=await Promise.all([
fetch("/api/ztna/policies",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/ztna/violations?limit=5",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const pe=document.getElementById("ztna-policies"), ve=document.getElementById("ztna-violations");
if(pe) pe.innerHTML=policies.length?policies.map(p=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9">
<span style="font-weight:600">${p.name}</span> → <span style="color:#64748b">${p.resource}</span>
<span style="margin-left:8px;font-size:11px">신뢰점수 ≥ ${p.min_trust_score}</span>
${p.require_mfa?'<span style="margin-left:6px;background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 6px;border-radius:8px">MFA</span>':''}
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'>정책 없음</p>";
if(ve) ve.innerHTML=violations.length?violations.map(v=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
<span style="color:#dc2626">⚠️</span> ${v.resource}${v.reason} <span style="color:#94a3b8;font-size:11px">(점수:${v.trust_score})</span>
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'>위반 없음</p>";
}
function renderSbom() {
document.getElementById("content").innerHTML = `
<h2>📦 SBOM 관리 (CycloneDX)</h2>
<p style="color:#64748b;margin-bottom:16px">서버별 소프트웨어 구성 요소 목록 — EU CRA/공공 조달 준수</p>
${_nextCard("SBOM 생성","⚙️",`
<input id="sbom-server-id" class="form-control" type="number" placeholder="서버 ID" style="width:150px;display:inline-block">
<button class="btn btn-primary" style="margin-left:8px" onclick="generateSbom()">SBOM 생성</button>
`)}
${_nextCard("SBOM 목록","📋",`<div id="sbom-list">로딩 중...</div>`)}`;
loadSbomList();
}
async function generateSbom() {
const sid=document.getElementById("sbom-server-id").value;
if(!sid) return showToast("서버 ID 입력","error");
const t=localStorage.getItem("token")||"";
showToast("SBOM 생성 중...","info");
const r=await fetch(`/api/sbom/generate?server_id=${sid}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d=await r.json();
showToast(`SBOM 생성 완료 (ID:${d.sbom_id}, 컴포넌트:${d.component_count}개)`,"success");
loadSbomList();
}
async function loadSbomList() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/sbom/list",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("sbom-list");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>SBOM 없음</p>";return;}
el.innerHTML=list.map(s=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px;display:flex;justify-content:space-between">
<div>서버 #${s.server_id} <span style="color:#64748b">${s.format}</span> — ${s.component_count}개 컴포넌트</div>
<a href="/api/sbom/${s.id}/export" style="color:#003366;font-size:12px">내보내기 ↗</a>
</div>`).join("");
}
function renderN2sf() {
document.getElementById("content").innerHTML = `
<h2>🛡️ N²SF 국가 망 보안체계</h2>
<p style="color:#64748b;margin-bottom:16px">국정원 N²SF 준수 — 데이터 민감도별 3단계 보안 구역 (2026 공공기관 의무)</p>
${_nextCard("시스템 분류","🗂️",`
<input id="n2sf-system" class="form-control" placeholder="시스템명" style="width:200px;display:inline-block">
<label style="margin-left:12px"><input type="checkbox" id="n2sf-pi"> 개인정보</label>
<label style="margin-left:8px"><input type="checkbox" id="n2sf-internet"> 인터넷 연결</label>
<button class="btn btn-primary" style="margin-left:8px" onclick="classifyN2sf()">구역 분류</button>
<div id="n2sf-classify-result" style="margin-top:10px"></div>
`)}
${_nextCard("평가 이력","📋",`<div id="n2sf-report">로딩 중...</div>`)}`;
loadN2sfReport();
}
async function classifyN2sf() {
const t=localStorage.getItem("token")||"";
const system=document.getElementById("n2sf-system").value||"미지정";
const pi=document.getElementById("n2sf-pi").checked;
const internet=document.getElementById("n2sf-internet").checked;
const r=await fetch("/api/n2sf/classify",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({system_name:system,handles_personal_info:pi,internet_facing:internet})});
const d=await r.json();
const colors={"A":"#dc2626","B":"#f59e0b","C":"#10b981"};
document.getElementById("n2sf-classify-result").innerHTML=`<div style="padding:10px;border-left:4px solid ${colors[d.recommended_zone]||"#64748b"};background:#f8fafc;border-radius:4px">
<strong>권고 구역: Zone ${d.recommended_zone}</strong> — ${d.zone_label}<br>
<span style="font-size:12px;color:#64748b">${d.requirements?.join(" / ")}</span>
</div>`;
}
async function loadN2sfReport() {
const t=localStorage.getItem("token")||"";
const report=await fetch("/api/n2sf/report",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("n2sf-report");
if(!el) return;
if(!report.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>평가 이력 없음</p>";return;}
el.innerHTML=report.map(a=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${a.system_name}</strong> Zone ${a.zone}
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${a.score>=80?"#dcfce7":a.score>=60?"#fef3c7":"#fef2f2"};color:${a.score>=80?"#166534":a.score>=60?"#92400e":"#dc2626"}">${a.grade}</span>
<span style="margin-left:8px;color:#64748b">${a.passed}/${a.total} (${a.score}점)</span>
</div>`).join("");
}
// ── IDP ───────────────────────────────────────────────────────────────────────
function renderIdpCatalog() {
document.getElementById("content").innerHTML = `
<h2>🗂️ 서비스 카탈로그 (IDP)</h2>
<p style="color:#64748b;margin-bottom:16px">Backstage-style 소프트웨어 카탈로그 — 서비스·컴포넌트·인프라 등록·검색</p>
${_nextCard("등록된 서비스","📋",`<div id="idp-catalog-list">로딩 중...</div>`)}
${_nextCard("서비스 등록","",`
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
<input id="idp-name" class="form-control" placeholder="서비스명">
<input id="idp-lang" class="form-control" placeholder="언어 (python/java/...)">
</div>
<input id="idp-desc" class="form-control" placeholder="설명" style="margin-bottom:8px">
<button class="btn btn-primary" onclick="registerIdpComponent()">등록</button>
`)}`;
loadIdpCatalog();
}
async function loadIdpCatalog() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/idp/catalog?limit=20",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("idp-catalog-list");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>등록된 서비스 없음</p>";return;}
el.innerHTML=list.map(c=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${c.display_name||c.name}</strong>
<span style="margin-left:8px;padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px">${c.component_type}</span>
${c.language?`<span style="margin-left:4px;color:#64748b;font-size:12px">${c.language}</span>`:""}
${c.lifecycle==="production"?'<span style="margin-left:8px;background:#dcfce7;color:#166534;font-size:10px;padding:1px 6px;border-radius:8px">운영</span>':""}
</div>`).join("");
}
async function registerIdpComponent() {
const t=localStorage.getItem("token")||"";
const name=document.getElementById("idp-name").value;
const language=document.getElementById("idp-lang").value;
const description=document.getElementById("idp-desc").value;
if(!name) return showToast("서비스명 입력","error");
await fetch("/api/idp/catalog",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({name,language,description})});
showToast("등록됨","success"); loadIdpCatalog();
}
function renderIdpTemplate() {
document.getElementById("content").innerHTML = `
<h2>📐 Golden Path 템플릿 (IDP)</h2>
<p style="color:#64748b;margin-bottom:16px">표준화된 서비스 스캐폴딩 — 내장 4종 + 커스텀 템플릿</p>
${_nextCard("템플릿 목록","📋",`<div id="idp-templates">로딩 중...</div>`)}`;
loadIdpTemplates();
}
async function loadIdpTemplates() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/idp/template",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("idp-templates");
if(!el) return;
el.innerHTML=list.map(tp=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:8px">
<div style="font-weight:700;margin-bottom:4px">${tp.name} ${tp.is_builtin?'<span style="background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 6px;border-radius:8px">내장</span>':""}</div>
<div style="font-size:12px;color:#64748b;margin-bottom:8px">${tp.description}</div>
<div style="font-size:12px">언어: ${tp.language||"-"} &nbsp;|&nbsp; 변수: ${(tp.variables||[]).join(", ")||"없음"}</div>
<button onclick="previewTemplate('${tp.id}')" style="margin-top:8px;padding:4px 10px;border:1px solid #e2e8f0;border-radius:4px;font-size:12px;cursor:pointer;background:none">미리보기</button>
</div>`).join("");
}
async function previewTemplate(id) {
const t=localStorage.getItem("token")||"";
const d=await fetch(`/api/idp/template/${id}/preview`,{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({}));
showToast(`파일: ${(d.files||[]).join(", ")||"없음"}`, "info");
}
function renderIdpPortal() {
document.getElementById("content").innerHTML = `
<h2>🚀 셀프서비스 포털 (IDP)</h2>
<p style="color:#64748b;margin-bottom:16px">인프라 셀프서비스 — SSH 키·DB·Jenkins·Gitea 자동 프로비저닝</p>
${_nextCard("리소스 요청","⚡",`
<select id="portal-type" class="form-control" style="width:200px;display:inline-block;margin-right:8px">
<option value="ssh_key">SSH 키 쌍 발급</option>
<option value="db_schema">DB 스키마 생성</option>
<option value="jenkins_job">Jenkins Job 생성</option>
<option value="gitea_repo">Gitea 저장소 생성</option>
</select>
<input id="portal-reason" class="form-control" placeholder="요청 사유" style="width:200px;display:inline-block;margin-right:8px">
<button class="btn btn-primary" onclick="requestResource()">요청</button>
`)}
${_nextCard("요청 이력","📋",`<div id="portal-requests">로딩 중...</div>`)}`;
loadPortalRequests();
}
async function requestResource() {
const t=localStorage.getItem("token")||"";
const type=document.getElementById("portal-type").value;
const reason=document.getElementById("portal-reason").value;
const r=await fetch("/api/idp/portal/provision",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({resource_type:type,justification:reason,params:{}})});
const d=await r.json();
showToast(`요청 #${d.request_id}${d.auto_approved?"자동 승인됨":"승인 대기"}`, d.auto_approved?"success":"info");
loadPortalRequests();
}
async function loadPortalRequests() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/idp/portal/requests",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("portal-requests");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>요청 이력 없음</p>";return;}
el.innerHTML=list.map(r=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${r.resource_type}</strong>
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${r.status==="COMPLETED"?"#dcfce7":"#fef3c7"};color:${r.status==="COMPLETED"?"#166534":"#92400e"}">${r.status}</span>
</div>`).join("");
}
// ── GreenOps + Edge ────────────────────────────────────────────────────────────
function renderGreenops() {
document.getElementById("content").innerHTML = `
<h2>🌱 탄소 배출 대시보드 (GreenOps)</h2>
<p style="color:#64748b;margin-bottom:16px">Scope 2 탄소 배출 추적 — EU CSRD / GHG Protocol 준수. 한국 전력망 기준 0.4593 kgCO₂e/kWh</p>
<div id="greenops-summary" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px">로딩 중...</div>
${_nextCard("탄소 기록 추가","📊",`
<div style="display:flex;gap:8px;align-items:flex-end">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:3px">서버 ID</label>
<input id="carbon-server" type="number" class="form-control" style="width:80px"></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:3px">전력(W)</label>
<input id="carbon-watt" type="number" class="form-control" style="width:80px" placeholder="200"></div>
<button class="btn btn-primary" onclick="recordCarbon()">기록</button>
</div>
`)}
${_nextCard("절감 분석","💡",`<div id="greenops-savings">로딩 중...</div>`)}`;
loadGreenopsData();
}
async function loadGreenopsData() {
const t=localStorage.getItem("token")||"";
const [dash,savings]=await Promise.all([
fetch("/api/greenops/dashboard",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/greenops/savings",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
]);
const se=document.getElementById("greenops-summary");
if(se) se.innerHTML=[
{val:`${dash.total_carbon_kg||0}kg`,lab:"총 탄소 배출",icon:"🌍"},
{val:`${dash.total_carbon_ton||0}ton`,lab:"CO₂e (Scope 2)",icon:"♻️"},
{val:`${dash.grid_factor||0.4593}`,lab:"한국 탄소 계수",icon:"⚡"},
].map(s=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:16px;text-align:center">
<div style="font-size:20px">${s.icon}</div>
<div style="font-size:22px;font-weight:700;color:#003366">${s.val}</div>
<div style="font-size:12px;color:#64748b">${s.lab}</div>
</div>`).join("");
const sve=document.getElementById("greenops-savings");
if(sve) sve.innerHTML=`<div style="font-size:13px">
<div>베이스라인 월 배출: <strong>${savings.baseline_monthly_kg||0} kg</strong></div>
<div>실제 월 배출: <strong>${savings.actual_monthly_kg||0} kg</strong></div>
<div style="color:#166534;font-weight:700">절감: ${savings.saving_kg||0} kg (${savings.saving_pct||0}%)</div>
</div>`;
}
async function recordCarbon() {
const t=localStorage.getItem("token")||"";
const server_id=+document.getElementById("carbon-server").value;
const watt=+document.getElementById("carbon-watt").value;
if(!server_id||!watt) return showToast("서버 ID와 전력을 입력하세요","error");
const r=await fetch("/api/greenops/emissions/record",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({server_id,watt})});
const d=await r.json();
showToast(`탄소 기록 완료: ${d.carbon_kg} kgCO₂e`,"success"); loadGreenopsData();
}
function renderEdgeMonitor() {
document.getElementById("content").innerHTML = `
<h2>📡 Edge/IoT 디바이스 모니터링</h2>
<p style="color:#64748b;margin-bottom:16px">엣지 서버·IoT 센서·CCTV·키오스크 모니터링. 텔레메트리 Push 방식 (POST /api/edge/telemetry)</p>
${_nextCard("디바이스 등록","",`
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:8px">
<input id="edge-name" class="form-control" placeholder="디바이스명">
<select id="edge-type" class="form-control">
<option value="SERVER_EDGE">엣지 서버</option>
<option value="IOT_SENSOR">IoT 센서</option>
<option value="CCTV">CCTV</option>
<option value="KIOSK">키오스크</option>
<option value="NETWORK_EDGE">네트워크 장비</option>
</select>
<input id="edge-location" class="form-control" placeholder="위치">
</div>
<button class="btn btn-primary" onclick="registerEdgeDevice()">등록</button>
`)}
${_nextCard("디바이스 목록","🗺️",`<div id="edge-devices">로딩 중...</div>`)}`;
loadEdgeDevices();
}
async function registerEdgeDevice() {
const t=localStorage.getItem("token")||"";
const name=document.getElementById("edge-name").value;
const device_type=document.getElementById("edge-type").value;
const location=document.getElementById("edge-location").value;
if(!name) return showToast("디바이스명 입력","error");
const r=await fetch("/api/edge/devices",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({name,device_type,location})});
const d=await r.json();
showToast(`등록 완료 (토큰: ${d.device_token.substring(0,8)}...)`,"success"); loadEdgeDevices();
}
async function loadEdgeDevices() {
const t=localStorage.getItem("token")||"";
const list=await fetch("/api/edge/devices",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("edge-devices");
if(!el) return;
if(!list.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>디바이스 없음</p>";return;}
el.innerHTML=list.map(d=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${d.name}</strong>
<span style="margin-left:8px;padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px">${d.device_type}</span>
${d.location?`<span style="margin-left:6px;color:#64748b;font-size:12px">${d.location}</span>`:""}
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:10px;background:${d.status==="ONLINE"?"#dcfce7":"#f1f5f9"};color:${d.status==="ONLINE"?"#166534":"#64748b"}">${d.status}</span>
${d.last_seen?`<span style="margin-left:6px;font-size:11px;color:#94a3b8">${new Date(d.last_seen).toLocaleString("ko-KR")}</span>`:""}
</div>`).join("");
}
function renderEnergyOptimizer() {
document.getElementById("content").innerHTML = `
<h2>⚡ 에너지 최적화 (Carbon-aware)</h2>
<p style="color:#64748b;margin-bottom:16px">Ollama 기반 에너지 효율 권고 + 탄소 낮은 시간대 배치 스케줄링</p>
${_nextCard("최적화 권고","💡",`
<button class="btn btn-primary" onclick="generateEnergyRecs()" style="margin-bottom:12px">🤖 AI 권고 생성</button>
<div id="energy-recs">로딩 중...</div>
`)}
${_nextCard("Carbon-aware 스케줄","🌱",`
<div style="display:flex;gap:8px;margin-bottom:8px">
<input id="sched-job" class="form-control" placeholder="작업명">
<input id="sched-cmd" class="form-control" placeholder="명령어">
<input id="sched-server" type="number" class="form-control" style="width:80px" placeholder="서버 ID">
<button class="btn btn-primary" onclick="scheduleJob()">예약</button>
</div>
<p style="font-size:12px;color:#64748b">탄소 계수 ≤ 0.40 시간대 자동 배정 (한국 경부하: 23~08시)</p>
`)}`;
loadEnergyRecs();
}
async function generateEnergyRecs() {
const t=localStorage.getItem("token")||"";
await fetch("/api/energy/recommendations/generate",{method:"POST",headers:{Authorization:`Bearer ${t}`}});
showToast("권고 생성 중...","info");
setTimeout(loadEnergyRecs, 2000);
}
async function loadEnergyRecs() {
const t=localStorage.getItem("token")||"";
const recs=await fetch("/api/energy/recommendations",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el=document.getElementById("energy-recs");
if(!el) return;
if(!recs.length){el.innerHTML="<p style='color:#94a3b8;padding:12px;text-align:center'>권고 없음 — AI 권고 생성 버튼 클릭</p>";return;}
el.innerHTML=recs.map(r=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600;margin-bottom:4px">${r.rec_type}${r.description}</div>
<div style="font-size:12px;color:#64748b">절감 예상: ${r.saving_kwh} kWh/월</div>
${r.status==="PENDING"?`<button onclick="applyEnergyRec(${r.id})" style="margin-top:8px;padding:4px 10px;background:#003366;color:#fff;border:none;border-radius:4px;font-size:11px;cursor:pointer">적용</button>`:`<span style="color:#166534;font-size:11px">✅ ${r.status}</span>`}
</div>`).join("");
}
async function applyEnergyRec(id) {
const t=localStorage.getItem("token")||"";
await fetch(`/api/energy/apply/${id}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
showToast("권고 적용됨","success"); loadEnergyRecs();
}
async function scheduleJob() {
const t=localStorage.getItem("token")||"";
const job_name=document.getElementById("sched-job").value;
const job_command=document.getElementById("sched-cmd").value;
const server_id=+document.getElementById("sched-server").value;
if(!job_name||!server_id) return showToast("작업명과 서버 ID 입력","error");
const r=await fetch("/api/energy/schedule",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({job_name,job_command,server_id})});
const d=await r.json();
showToast(`스케줄 등록: ${d.preferred_hour}시 (탄소 ${d.carbon_factor} kgCO₂e/kWh)`,"success");
}
// ══════════════════════════════════════════════════════════════════════════════
// ── GUARDiA Brain — AI 지능화 엔진 뷰
// ══════════════════════════════════════════════════════════════════════════════
function renderBrainDashboard() {
document.getElementById("content").innerHTML = `
<h2>🧠 GUARDiA Brain — AI 지능화 엔진</h2>
<p style="color:#64748b;margin-bottom:16px">운영 경험에서 스스로 배우고 진화하는 AI 엔진. 영구 메모리·자동 스킬·LoRA 파인튜닝.</p>
<div id="brain-stats" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px">로딩 중...</div>
${_nextCard("AI 엔진 헬스","💚","<div id=\"brain-health\">로딩 중...</div>")}
${_nextCard("Ollama 모델","🤖","<div id=\"brain-models\">로딩 중...</div>")}
${_nextCard("AI 개선 제안","💡","<div id=\"brain-suggestions\">로딩 중...</div>")}`;
loadBrainDashboard();
}
async function loadBrainDashboard() {
const t = localStorage.getItem("token")||"";
const H = {Authorization:`Bearer ${t}`};
const [dash, health, models, sugg] = await Promise.all([
fetch("/api/brain/dashboard",{headers:H}).then(r=>r.json()).catch(()=>({})),
fetch("/api/brain/health",{headers:H}).then(r=>r.json()).catch(()=>({})),
fetch("/api/brain/models",{headers:H}).then(r=>r.json()).catch(()=>[]),
fetch("/api/brain/observe/suggestions",{headers:H}).then(r=>r.json()).catch(()=>[]),
]);
const se = document.getElementById("brain-stats");
if(se) se.innerHTML = [
{icon:"🧠",label:"총 기억",val:dash.memory?.total||0},
{icon:"⚡",label:"스킬 수",val:dash.skills?.total||0},
{icon:"📊",label:"학습 샘플",val:dash.learning?.feedback_samples||0},
{icon:"🕸️",label:"KG 노드",val:dash.knowledge_graph?.nodes||0},
{icon:"🎯",label:"뇌 점수",val:dash.brain_score||0},
{icon:"🤖",label:"모델 수",val:(dash.ollama_models||[]).length},
].map(s=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:14px;text-align:center">
<div style="font-size:22px">${s.icon}</div>
<div style="font-size:22px;font-weight:700;color:#003366">${s.val}</div>
<div style="font-size:12px;color:#64748b">${s.label}</div>
</div>`).join("");
const he = document.getElementById("brain-health");
if(he) he.innerHTML = Object.entries(health.checks||{}).map(([k,v])=>
`<div>${v==="OK"?"✅":"❌"} ${k}: ${v}</div>`).join("");
const me = document.getElementById("brain-models");
if(me) me.innerHTML = (models||[]).map(m=>`<div style="padding:6px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${m.name}</strong> <span style="color:#64748b">${m.size_gb}GB</span>
</div>`).join("") || "<p style=\"color:#94a3b8\">모델 없음</p>";
const sge = document.getElementById("brain-suggestions");
if(sge) sge.innerHTML = (sugg||[]).map(s=>`<div style="padding:8px;border-left:3px solid #003366;margin-bottom:6px;font-size:12px">
<strong>[${s.type}]</strong> ${s.action}<br><span style="color:#64748b">${s.benefit}</span>
</div>`).join("") || "<p style=\"color:#94a3b8\">제안 없음</p>";
}
function renderAiMemory() {
document.getElementById("content").innerHTML = `
<h2>🧠 영구 메모리</h2>
<p style="color:#64748b;margin-bottom:16px">세션을 넘어 지속되는 AI 운영 경험 저장소.</p>
${_nextCard("기억 저장","💾",`
<select id="mem-type" class="form-control" style="margin-bottom:8px">
<option value="EPISODIC">에피소딕 (특정 사건)</option>
<option value="SEMANTIC">시맨틱 (일반 지식)</option>
<option value="PROCEDURAL">절차적 (해결 방법)</option>
</select>
<textarea id="mem-content" class="form-control" rows="3" placeholder="기억할 내용 (예: 서버1 OOM 발생 시 JVM 힙 증가로 해결)"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="saveMemory()">💾 저장</button>
`)}
${_nextCard("기억 검색","🔍",`
<div style="display:flex;gap:8px">
<input id="mem-query" class="form-control" placeholder="검색어 (예: 서버 OOM)">
<button class="btn btn-primary" onclick="searchMemory()">검색</button>
</div>
<div id="mem-results" style="margin-top:10px"></div>
`)}
${_nextCard("현황","📊","<div id=\"mem-stats\">로딩 중...</div>")}`;
loadMemStats();
}
async function saveMemory() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/memory/remember",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({content:document.getElementById("mem-content").value,
memory_type:document.getElementById("mem-type").value})});
const d = await r.json();
showToast(`기억 저장 완료 (ID: ${d.memory_id})`,"success"); loadMemStats();
}
async function searchMemory() {
const t = localStorage.getItem("token")||"";
const q = document.getElementById("mem-query").value;
if(!q) return;
const results = await fetch(`/api/memory/recall?q=${encodeURIComponent(q)}&limit=5`,
{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
document.getElementById("mem-results").innerHTML = results.length
? results.map(m=>`<div style="padding:10px;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:6px">
<div style="font-size:12px;color:#64748b">[${m.type}] 유사도: ${(m.similarity*100).toFixed(1)}%</div>
<div style="font-size:13px">${m.content}</div>
</div>`).join("") : "<p style=\"color:#94a3b8\">관련 기억 없음</p>";
}
async function loadMemStats() {
const t = localStorage.getItem("token")||"";
const s = await fetch("/api/memory/stats",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({}));
const el = document.getElementById("mem-stats");
if(el) el.innerHTML = `${s.total_memories||0}개 | 에피소딕: ${s.by_type?.EPISODIC||0} | 시맨틱: ${s.by_type?.SEMANTIC||0} | 절차적: ${s.by_type?.PROCEDURAL||0}`;
}
function renderKnowledgeGraph() {
document.getElementById("content").innerHTML = `
<h2>🕸️ 운영 지식 그래프</h2>
<p style="color:#64748b;margin-bottom:16px">서버-장애-해결책의 시간적 관계 그래프.</p>
${_nextCard("SR 자동 기록","📝",`
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
<input id="kg-sr-id" type="number" class="form-control" placeholder="SR ID">
<input id="kg-srv-id" type="number" class="form-control" placeholder="서버 ID">
</div>
<input id="kg-problem" class="form-control" placeholder="문제" style="margin-bottom:6px">
<input id="kg-solution" class="form-control" placeholder="해결책">
<button class="btn btn-primary" style="margin-top:8px" onclick="recordKG()">📝 기록</button>
`)}
${_nextCard("패턴","📊","<div id=\"kg-patterns\">로딩 중...</div>")}
${_nextCard("시각화","🗺️","<div id=\"kg-viz\">로딩 중...</div>")}`;
loadKGData();
}
async function recordKG() {
const t = localStorage.getItem("token")||"";
const r = await fetch(`/api/kg/auto-record?sr_id=${document.getElementById("kg-sr-id").value}&server_id=${document.getElementById("kg-srv-id").value}&problem=${encodeURIComponent(document.getElementById("kg-problem").value)}&solution=${encodeURIComponent(document.getElementById("kg-solution").value)}`,
{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d = await r.json();
showToast(`KG 기록 완료`,"success"); loadKGData();
}
async function loadKGData() {
const t = localStorage.getItem("token")||"";
const [pat, viz] = await Promise.all([
fetch("/api/kg/pattern",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/kg/visualization",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
]);
const pe = document.getElementById("kg-patterns");
if(pe) pe.innerHTML = (pat.patterns||[]).map(p=>`<div style="padding:6px;border-bottom:1px solid #f1f5f9;font-size:13px">${p.edge_type}: ${p.count}건</div>`).join("") || "<p style=\"color:#94a3b8\">패턴 없음</p>";
const ve = document.getElementById("kg-viz");
if(ve) ve.innerHTML = `노드 ${(viz.nodes||[]).length}개 | 엣지 ${(viz.links||[]).length}`;
}
function renderSkillRegistry() {
document.getElementById("content").innerHTML = `
<h2>⚡ 스킬 레지스트리</h2>
<p style="color:#64748b;margin-bottom:16px">운영 자동화 스킬 목록. 1클릭 실행.</p>
${_nextCard("스킬 목록","📋","<div id=\"skills-list\">로딩 중...</div>")}
${_nextCard("스킬 실행","▶️",`
<input id="skill-exec-id" class="form-control" placeholder="스킬 ID (예: builtin-0)" style="margin-bottom:8px">
<input id="skill-server-id" type="number" class="form-control" placeholder="서버 ID" style="margin-bottom:8px">
<button class="btn btn-primary" onclick="executeSkill()">▶️ 실행</button>
<div id="skill-exec-result" style="margin-top:10px"></div>
`)}`;
loadSkillsList();
}
async function loadSkillsList() {
const t = localStorage.getItem("token")||"";
const skills = await fetch("/api/skills",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el = document.getElementById("skills-list");
if(!el) return;
el.innerHTML = skills.map(s=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px;display:flex;justify-content:space-between">
<div><strong>${s.name}</strong> <span style="color:#64748b">${s.description||""}</span>
${s.is_builtin?"<span style=\"margin-left:6px;background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 5px;border-radius:6px\">내장</span>":""}
</div>
<div style="font-size:11px;color:#64748b">✅${s.success_count||0}${s.fail_count||0}</div>
</div>`).join("");
}
async function executeSkill() {
const t = localStorage.getItem("token")||"";
const id = document.getElementById("skill-exec-id").value;
const server_id = +document.getElementById("skill-server-id").value;
if(!id||!server_id) return showToast("스킬 ID와 서버 ID 입력","error");
showToast("스킬 실행 중...","info");
const r = await fetch(`/api/skills/${id}/execute`,{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({server_id})});
const d = await r.json();
document.getElementById("skill-exec-result").innerHTML = `<div style="padding:10px;border:1px solid ${d.success?"#bbf7d0":"#fca5a5"};border-radius:8px;font-size:12px">
${d.success?"✅ 성공":"❌ 실패"} (${d.duration_ms}ms)<br>
<pre style="font-size:11px;white-space:pre-wrap">${d.result||""}</pre>
</div>`;
}
function renderSkillMiner() {
document.getElementById("content").innerHTML = `
<h2>🔍 자동 스킬 발굴</h2>
<p style="color:#64748b;margin-bottom:16px">반복 명령 패턴 감지 → 자동 스킬화.</p>
${_nextCard("패턴 분석","📊",`
<textarea id="miner-cmds" class="form-control" rows="3" placeholder="반복 명령 (줄바꿈 구분)&#10;df -h /&#10;du -sh /var/log/*"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="analyzePattern()">📊 분석</button>
<div id="miner-result" style="margin-top:8px"></div>
`)}
${_nextCard("스킬화 대기","⏳","<div id=\"miner-queue\">로딩 중...</div>")}`;
loadMinerQueue();
}
async function analyzePattern() {
const t = localStorage.getItem("token")||"";
const cmds = document.getElementById("miner-cmds").value.split("\n").filter(c=>c.trim());
if(cmds.length < 2) return showToast("최소 2개 명령 필요","error");
const r = await fetch("/api/skill-miner/analyze",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({commands:cmds})});
const d = await r.json();
document.getElementById("miner-result").innerHTML = `<div style="padding:10px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px">
패턴 ID: ${d.pattern_id} | 발생: ${d.occurrence_count}
${d.auto_skill?"<span style=\"color:#166534;margin-left:8px\">🎉 스킬 자동 생성!</span>":"<span style=\"color:#64748b;margin-left:8px\">3회 시 자동 생성</span>"}
</div>`;
loadMinerQueue();
}
async function loadMinerQueue() {
const t = localStorage.getItem("token")||"";
const q = await fetch("/api/skill-miner/queue",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el = document.getElementById("miner-queue");
if(!el) return;
el.innerHTML = q.length ? q.map(p=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
[${p.count}회] ${p.trigger}${p.status==="SKILL_PROPOSED"?"✅ 스킬 제안":"⏳ 분석 중"}
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px;text-align:center\">대기 없음</p>";
}
function renderFinetune() {
document.getElementById("content").innerHTML = `
<h2>🎓 LoRA 파인튜닝</h2>
<p style="color:#64748b;margin-bottom:16px">운영 데이터로 Ollama 모델 특화 학습.</p>
${_nextCard("피드백 수집","📥",`
<input id="ft-q" class="form-control" placeholder="질문" style="margin-bottom:6px">
<textarea id="ft-approved" class="form-control" rows="2" placeholder="전문가 승인 답변" style="margin-bottom:6px"></textarea>
<select id="ft-domain" class="form-control" style="margin-bottom:8px">
<option value="general">일반</option><option value="incident">장애</option>
<option value="deploy">배포</option><option value="security">보안</option>
</select>
<button class="btn btn-primary" onclick="addFeedback()">📥 추가</button>
`)}
${_nextCard("데이터 품질","📊","<div id=\"ft-quality\">로딩 중...</div>")}
${_nextCard("파인튜닝 작업","🎓",`<div id="ft-jobs">로딩 중...</div>
<button class="btn btn-primary" style="margin-top:8px" onclick="startFinetune()">🎓 파인튜닝 시작</button>`)}`;
loadFtData();
}
async function addFeedback() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/finetune/feedback",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({question:document.getElementById("ft-q").value,
ollama_response:"",approved_answer:document.getElementById("ft-approved").value,
domain:document.getElementById("ft-domain").value})});
const d = await r.json();
showToast(d.ok===false ? d.message : `학습 데이터 추가 (총 ${d.total_samples}개)`,"success");
loadFtData();
}
async function startFinetune() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/finetune/start",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({base_model:"llama3",epochs:3,dataset_min:50})});
const d = await r.json();
if(d.ok===false) return showToast(d.message,"error");
showToast(`파인튜닝 시작 (Job ID: ${d.job_id})`,"success"); loadFtData();
}
async function loadFtData() {
const t = localStorage.getItem("token")||"";
const [q, jobs] = await Promise.all([
fetch("/api/finetune/quality",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/finetune/jobs",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const qe = document.getElementById("ft-quality");
if(qe) qe.innerHTML = `${q.total_samples||0}개 | 고품질: ${q.high_quality||0}개 (${q.quality_rate||0}%) | ${q.ready_for_training?"✅ 파인튜닝 가능":"⚠️ 50개 이상 필요"}`;
const je = document.getElementById("ft-jobs");
if(je) je.innerHTML = (jobs||[]).slice(0,5).map(j=>`<div style="padding:6px;border-bottom:1px solid #f1f5f9;font-size:12px">
#${j.id} ${j.base_model} [${j.status}] ${j.dataset_size}
</div>`).join("") || "<p style=\"color:#94a3b8\">작업 없음</p>";
}
function renderBrainPlugins() {
document.getElementById("content").innerHTML = `
<h2>🔌 플러그인 관리</h2>
<p style="color:#64748b;margin-bottom:16px">Claude Marketplace AI 역량 강화 플러그인.</p>
${_nextCard("사용 가능","📦","<div id=\"plugins-available\">로딩 중...</div>")}
${_nextCard("설치됨","✅","<div id=\"plugins-installed\">로딩 중...</div>")}`;
loadPlugins();
}
async function loadPlugins() {
const t = localStorage.getItem("token")||"";
const [avail, installed] = await Promise.all([
fetch("/api/brain/plugins/available",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/brain/plugins/installed",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const ae = document.getElementById("plugins-available");
if(ae) ae.innerHTML = avail.map(p=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:700;margin-bottom:4px">${p.name} <span style="color:#64748b;font-size:11px">[${p.category}]</span></div>
<div style="font-size:12px;color:#64748b;margin-bottom:6px">${p.description}</div>
<button onclick="installPlugin('${p.name}')" style="padding:4px 10px;background:#003366;color:#fff;border:none;border-radius:4px;font-size:11px;cursor:pointer">설치</button>
</div>`).join("");
const ie = document.getElementById("plugins-installed");
if(ie) ie.innerHTML = installed.length ? installed.map(p=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
${p.name}
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px\">설치된 플러그인 없음</p>";
}
async function installPlugin(name) {
const t = localStorage.getItem("token")||"";
const r = await fetch(`/api/brain/plugins/install?plugin_name=${name}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d = await r.json();
showToast(d.ok ? `${name} 설치됨` : (d.message||"이미 설치됨"), d.ok?"success":"info");
if(d.ok) loadPlugins();
}