4992 lines
261 KiB
JavaScript
4992 lines
261 KiB
JavaScript
/* ══════════════════════════════════════════════════
|
||
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();
|
||
// ── 디자인 AI + 스마트 UX 뷰 ──
|
||
else if (currentView === "design_dashboard") renderDesignDashboard();
|
||
else if (currentView === "design_icon") renderDesignIcon();
|
||
else if (currentView === "design_css") renderDesignCSS();
|
||
else if (currentView === "design_review") renderDesignReview();
|
||
// ── 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 || "")} <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, "&").replace(/</g, "<")
|
||
.replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
if (!iso) return "";
|
||
try {
|
||
return new Date(iso).toLocaleString("ko-KR", {
|
||
month: "2-digit", day: "2-digit",
|
||
hour: "2-digit", minute: "2-digit",
|
||
});
|
||
} catch { return iso; }
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════
|
||
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||"-"} | 변수: ${(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="반복 명령 (줄바꿈 구분) df -h / 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();
|
||
}
|
||
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// ── GUARDiA 디자인 AI + 스마트 UX 뷰
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function renderDesignDashboard() {
|
||
document.getElementById("content").innerHTML = `
|
||
<h2>🎨 디자인 AI — SR 자동화</h2>
|
||
<p style="color:#64748b;margin-bottom:16px">디자인 수정 SR을 AI가 자동 분류·처리. Ollama llava 비전 분석 + SVG 아이콘 생성 + CSS 자동 생성.</p>
|
||
<div id="design-stats" style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px">로딩 중...</div>
|
||
${_nextCard("빠른 디자인 SR 처리","⚡",`
|
||
<textarea id="design-req" class="form-control" rows="3" placeholder="예: 로그인 버튼 색상을 #003366으로 변경, 또는 서버 아이콘 픽토그램 스타일 24px 생성"></textarea>
|
||
<button class="btn btn-primary" style="margin-top:8px" onclick="autoResolveDesignSR()">🤖 AI 자동 처리</button>
|
||
<div id="design-result" style="margin-top:12px"></div>
|
||
`)}
|
||
${_nextCard("디자인 SR 이력","📋","<div id=\"design-queue\">로딩 중...</div>")}`;
|
||
loadDesignData();
|
||
}
|
||
async function autoResolveDesignSR() {
|
||
const req = document.getElementById("design-req").value;
|
||
if(!req) return showToast("요구사항 입력 필요","error");
|
||
const t = localStorage.getItem("token")||"";
|
||
showToast("AI 처리 중...","info");
|
||
const r = await fetch("/api/design/auto-resolve",{method:"POST",
|
||
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
|
||
body:JSON.stringify({requirement:req})});
|
||
const d = await r.json();
|
||
const el = document.getElementById("design-result");
|
||
el.innerHTML = `<div style="border:1px solid #e2e8f0;border-radius:10px;padding:14px">
|
||
<div style="font-size:11px;color:#64748b;margin-bottom:6px">분류: ${d.design_type} | AI 처리: ${d.resolved_by_ai?"✅":"❌"}</div>
|
||
${d.generated_code?`<pre style="font-size:11px;background:#f8fafc;padding:10px;border-radius:6px;overflow:auto;max-height:200px;white-space:pre-wrap">${d.generated_code}</pre>`:""}
|
||
<p style="font-size:12px;color:#64748b;margin-top:8px">${d.next_step||""}</p>
|
||
</div>`;
|
||
loadDesignData();
|
||
}
|
||
async function loadDesignData() {
|
||
const t = localStorage.getItem("token")||"";
|
||
const [stats, queue] = await Promise.all([
|
||
fetch("/api/design/stats",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
|
||
fetch("/api/design/sr-queue?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
|
||
]);
|
||
const se = document.getElementById("design-stats");
|
||
if(se) se.innerHTML=[
|
||
{icon:"📋",label:"전체 디자인 SR",val:stats.total||0},
|
||
{icon:"🤖",label:"AI 처리됨",val:stats.ai_resolved||0},
|
||
{icon:"📈",label:"자동화율",val:`${stats.ai_resolution_rate||0}%`},
|
||
{icon:"🔍",label:"비전 모델",val:"llava:7b"},
|
||
].map(s=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:14px;text-align:center">
|
||
<div style="font-size:20px">${s.icon}</div>
|
||
<div style="font-size:20px;font-weight:700;color:#003366">${s.val}</div>
|
||
<div style="font-size:11px;color:#64748b">${s.label}</div>
|
||
</div>`).join("");
|
||
const qe = document.getElementById("design-queue");
|
||
if(qe) qe.innerHTML = queue.length ? queue.map(s=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px;display:flex;justify-content:space-between">
|
||
<div><span style="padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px;margin-right:6px">${s.design_type}</span>${s.requirement}</div>
|
||
<div style="font-size:11px"><span style="color:${s.resolved_by_ai?"#166534":"#92400e"}">${s.status}</span></div>
|
||
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px;text-align:center\">처리된 디자인 SR 없음</p>";
|
||
}
|
||
|
||
function renderDesignIcon() {
|
||
document.getElementById("content").innerHTML = `
|
||
<h2>🎨 SVG 아이콘 생성</h2>
|
||
<p style="color:#64748b;margin-bottom:16px">텍스트로 설명하면 SVG 아이콘을 자동 생성합니다. GUARDiA 브랜드 색상(#003366) 기본 적용.</p>
|
||
${_nextCard("아이콘 생성","✨",`
|
||
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||
<input id="icon-desc" class="form-control" placeholder="아이콘 설명 (예: 서버 픽토그램 24px, 데이터베이스 심볼)">
|
||
<input id="icon-color" class="form-control" type="color" value="#003366" style="width:50px;padding:2px">
|
||
<button class="btn btn-primary" onclick="generateIcon()">생성</button>
|
||
</div>
|
||
<div id="icon-result" style="margin-top:10px"></div>
|
||
`)}
|
||
${_nextCard("내장 아이콘 라이브러리","📚","<div id=\"icon-library\">로딩 중...</div>")}`;
|
||
loadIconLibrary();
|
||
}
|
||
async function generateIcon() {
|
||
const desc = document.getElementById("icon-desc").value;
|
||
const color = document.getElementById("icon-color").value;
|
||
if(!desc) return showToast("아이콘 설명 입력","error");
|
||
const t = localStorage.getItem("token")||"";
|
||
showToast("아이콘 생성 중...","info");
|
||
const r = await fetch("/api/icon/generate",{method:"POST",
|
||
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
|
||
body:JSON.stringify({description:desc,color,save:true})});
|
||
const d = await r.json();
|
||
const el = document.getElementById("icon-result");
|
||
if(d.svg_code) {
|
||
el.innerHTML = `<div style="display:flex;gap:16px;align-items:center;padding:12px;border:1px solid #e2e8f0;border-radius:8px">
|
||
<div style="width:60px;height:60px;border:1px solid #e2e8f0;border-radius:8px;padding:8px;background:#f8fafc">${d.svg_code}</div>
|
||
<div>
|
||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">출처: ${d.source||"AI 생성"}</div>
|
||
<a href="${d.download_url||"#"}" style="color:#003366;font-size:12px">SVG 다운로드 ↗</a>
|
||
</div>
|
||
<pre style="flex:1;font-size:10px;background:#f8fafc;padding:8px;border-radius:4px;overflow:auto;max-height:80px">${d.svg_code.substring(0,200)}</pre>
|
||
</div>`;
|
||
showToast("아이콘 생성 완료","success");
|
||
loadIconLibrary();
|
||
}
|
||
}
|
||
async function loadIconLibrary() {
|
||
const t = localStorage.getItem("token")||"";
|
||
const lib = await fetch("/api/icon/library",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({}));
|
||
const el = document.getElementById("icon-library");
|
||
if(!el) return;
|
||
const builtins = lib.builtin||[];
|
||
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||
${builtins.map(ic=>`<div style="text-align:center;padding:8px;border:1px solid #e2e8f0;border-radius:8px;width:70px">
|
||
<div style="font-size:10px;color:#64748b">${ic.name}</div>
|
||
<a href="${ic.download_url}" style="font-size:11px;color:#003366">SVG↗</a>
|
||
</div>`).join("")}
|
||
</div>
|
||
<p style="font-size:12px;color:#64748b;margin-top:8px">내장 ${builtins.length}개 | 생성 ${(lib.custom||[]).length}개</p>`;
|
||
}
|
||
|
||
function renderDesignCSS() {
|
||
document.getElementById("content").innerHTML = `
|
||
<h2>✍️ CSS 자동 생성</h2>
|
||
<p style="color:#64748b;margin-bottom:16px">자연어로 디자인 요구사항을 설명하면 CSS 코드를 자동 생성합니다. GUARDiA 브랜드 변수 자동 적용.</p>
|
||
${_nextCard("CSS 생성","⚙️",`
|
||
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||
<select id="css-mode" class="form-control" style="width:130px">
|
||
<option value="css">CSS</option>
|
||
<option value="tailwind">Tailwind</option>
|
||
<option value="component">컴포넌트</option>
|
||
</select>
|
||
<input id="css-req" class="form-control" placeholder="예: 기본 버튼 스타일, 호버·포커스 포함, 브랜드 컬러">
|
||
</div>
|
||
<button class="btn btn-primary" onclick="generateCSS()">✍️ CSS 생성</button>
|
||
<div id="css-result" style="margin-top:12px"></div>
|
||
`)}
|
||
${_nextCard("GUARDiA 브랜드 변수","🎨",`
|
||
<div id="brand-vars">로딩 중...</div>
|
||
`)}
|
||
${_nextCard("생성 이력","📋","<div id=\"css-history\">로딩 중...</div>")}`;
|
||
loadCSSData();
|
||
}
|
||
async function generateCSS() {
|
||
const req = document.getElementById("css-req").value;
|
||
const mode = document.getElementById("css-mode").value;
|
||
if(!req) return showToast("요구사항 입력","error");
|
||
const t = localStorage.getItem("token")||"";
|
||
showToast("CSS 생성 중...","info");
|
||
const r = await fetch("/api/css/generate",{method:"POST",
|
||
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
|
||
body:JSON.stringify({requirement:req,mode})});
|
||
const d = await r.json();
|
||
document.getElementById("css-result").innerHTML = `<div>
|
||
<div style="font-size:11px;color:#64748b;margin-bottom:6px">CSS ID: ${d.css_id}</div>
|
||
<pre style="font-size:12px;background:#1e293b;color:#e2e8f0;padding:14px;border-radius:8px;overflow:auto;max-height:300px;white-space:pre-wrap">${d.generated_css||""}</pre>
|
||
<button onclick="copyToClipboard(document.querySelector('pre').textContent)" style="margin-top:6px;padding:4px 10px;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;cursor:pointer;background:#fff">📋 복사</button>
|
||
<p style="font-size:12px;color:#64748b;margin-top:6px">${d.preview_tip||""}</p>
|
||
</div>`;
|
||
loadCSSData();
|
||
}
|
||
function copyToClipboard(text) {
|
||
navigator.clipboard?.writeText(text).then(()=>showToast("클립보드 복사됨","success")).catch(()=>showToast("복사 실패","error"));
|
||
}
|
||
async function loadCSSData() {
|
||
const t = localStorage.getItem("token")||"";
|
||
const [vars, hist] = await Promise.all([
|
||
fetch("/api/css/brand-variables",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
|
||
fetch("/api/css/history",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
|
||
]);
|
||
const ve = document.getElementById("brand-vars");
|
||
if(ve) ve.innerHTML = Object.entries(vars.variables||{}).map(([k,v])=>
|
||
`<div style="padding:4px 0;font-size:12px;font-family:monospace"><span style="color:#003366">${k}</span>: ${v}</div>`).join("");
|
||
const he = document.getElementById("css-history");
|
||
if(he) he.innerHTML = hist.length ? hist.map(h=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
|
||
<div>${h.requirement}</div>
|
||
<span style="font-size:11px;color:${h.applied?"#166534":"#64748b"}">${h.applied?"✅ 적용됨":"대기"}</span>
|
||
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px;text-align:center\">생성 이력 없음</p>";
|
||
}
|
||
|
||
function renderDesignReview() {
|
||
document.getElementById("content").innerHTML = `
|
||
<h2>🔍 스크린샷 UI 분석</h2>
|
||
<p style="color:#64748b;margin-bottom:16px">스크린샷을 업로드하면 Ollama llava가 UI/UX를 분석하고 개선 제안을 제공합니다.</p>
|
||
${_nextCard("스크린샷 분석","📸",`
|
||
<div style="margin-bottom:8px">
|
||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">스크린샷 파일</label>
|
||
<input type="file" id="review-file" accept="image/*" class="form-control" style="margin-bottom:8px">
|
||
<input id="review-q" class="form-control" placeholder="분석 질문 (기본: 이 UI의 개선점 3가지 제안)" style="margin-bottom:8px">
|
||
<button class="btn btn-primary" onclick="analyzeScreenshot()">🔍 AI 분석</button>
|
||
</div>
|
||
<div id="review-result" style="margin-top:12px"></div>
|
||
`)}
|
||
${_nextCard("텍스트 디자인 리뷰","✍️",`
|
||
<textarea id="review-text" class="form-control" rows="3" placeholder="텍스트로 디자인 요구사항 설명 (스크린샷 없이 텍스트만으로 분석)"></textarea>
|
||
<button class="btn btn-primary" style="margin-top:8px" onclick="textReview()">분석</button>
|
||
<div id="text-review-result" style="margin-top:10px"></div>
|
||
`)}`;
|
||
}
|
||
async function analyzeScreenshot() {
|
||
const file = document.getElementById("review-file").files[0];
|
||
const q = document.getElementById("review-q").value || "이 UI의 개선점을 한국어로 3가지 제안해줘";
|
||
if(!file) return showToast("이미지 파일 선택 필요","error");
|
||
const t = localStorage.getItem("token")||"";
|
||
showToast("llava 비전 분석 중... (30초 내외 소요)","info");
|
||
const form = new FormData();
|
||
form.append("file",file); form.append("question",q);
|
||
const r = await fetch("/api/design/analyze",{method:"POST",headers:{Authorization:`Bearer ${t}`},body:form});
|
||
const d = await r.json();
|
||
document.getElementById("review-result").innerHTML = `<div style="border:1px solid #e2e8f0;border-radius:10px;padding:16px">
|
||
<div style="font-size:11px;color:#64748b;margin-bottom:8px">분석 ID: ${d.analysis_id} | 모델: ${d.model||"llava:7b"}</div>
|
||
<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${d.analysis||""}</div>
|
||
</div>`;
|
||
showToast("분석 완료","success");
|
||
}
|
||
async function textReview() {
|
||
const req = document.getElementById("review-text").value;
|
||
if(!req) return showToast("요구사항 입력","error");
|
||
const t = localStorage.getItem("token")||"";
|
||
showToast("AI 리뷰 생성 중...","info");
|
||
const r = await fetch("/api/design/review",{method:"POST",
|
||
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
|
||
body:JSON.stringify({requirement:req})});
|
||
const d = await r.json();
|
||
document.getElementById("text-review-result").innerHTML = `<div style="border:1px solid #e2e8f0;border-radius:10px;padding:16px">
|
||
<div style="font-size:11px;color:#64748b;margin-bottom:8px">분류: ${d.design_type}</div>
|
||
<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${d.analysis||""}</div>
|
||
</div>`;
|
||
showToast("리뷰 완료","success");
|
||
}
|