/* ══════════════════════════════════════════════════
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: "작업 타임테이블",
// ── 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 확장 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 `
${v}`;
}).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 `
${d.label}`;
}).join("");
el.innerHTML = `
`;
}
/* ─── 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 `
${sr.sr_id}
${esc(sr.title)}
${STATUS_LABEL[sr.status] || sr.status}
${extra}
`;
}
function _statCard(value, label, color = "accent", sub = "", icon = "") {
return `
${icon ? `
${icon}
` : ""}
${value}
${label}
${sub ? `
${sub}
` : ""}
`;
}
function _statusBarChart(byStatus) {
const total = Object.values(byStatus).reduce((a, b) => a + b, 0) || 1;
return `` +
Object.entries(byStatus).sort((a,b) => b[1]-a[1]).map(([k,v]) => `
${STATUS_LABEL[k]||k}${v}
`).join("") + `
`;
}
/* ── 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,
`${fmtDate(sr.created_at)}`
)).join("") || 'SR이 없습니다.
';
// 상태별 차트 (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 => `
${sr.sr_id}
${esc(sr.title)}
${PRIORITY_LABEL[sr.priority]||sr.priority}
${esc(sr.requested_by||"")}
`).join("")
: '승인 대기 SR 없음
';
// workload 카드 헤더 변경
const wlCard = document.getElementById("workload-card");
if (wlCard) {
wlCard.querySelector(".card-header").innerHTML = `
🟡 승인 대기 (${pendList.length}건)
클릭하여 상세 확인`;
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 = `
${wl.map(e => {
const pct = e.utilization || 0;
const color = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950";
return `
`;
}).join("")}
`;
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 = `
${(_userInfo.display_name||"E").charAt(0)}
${esc(d.greeting||"")}
${skills.map(s=>`${s}`).join("")}
${_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", `
현재 워크로드${wl.active||0}/${wl.max||5}건 (${pct}%)
`);
// 처리 대기 (APPROVED — 바로 시작 가능)
const ready = d.action_required || [];
document.getElementById("recent-list").innerHTML = `
⚡ 즉시 실행 가능 (APPROVED, ${ready.length}건)
` +
(ready.map(sr => `
${sr.sr_id}
${esc(sr.title)}
${PRIORITY_LABEL[sr.priority]||sr.priority}
`).join("") ||
'처리 대기 SR 없음
');
// 진행 중
const inprog = d.in_progress || [];
document.getElementById("status-chart").innerHTML = `
🔄 진행 중 (${inprog.length}건)
` +
(inprog.map(sr => _srRow(sr, `${fmtDate(sr.updated_at)}`)).join("") ||
'진행 중 SR 없음
');
// 워크로드 카드: 최근 완료
const done = d.recent_completed || [];
const wlEl = document.getElementById("workload-card");
if (wlEl) {
wlEl.querySelector(".card-header").innerHTML = `✅ 최근 완료 (${done.length}건)`;
const body = document.getElementById("workload-body");
if (body) body.innerHTML = done.map(sr =>
_srRow(sr, `${fmtDate(sr.updated_at)}`)
).join("") || '완료 이력 없음
';
}
}
/* ── PM 대시보드 ────────────────────────────────── */
function renderDashboardPM(d) {
const pendCnt = d.pending_count || 0;
document.getElementById("stats-row").innerHTML = `
${pendCnt}
승인 대기
${pendCnt > 0 ? '
즉시 처리 필요
' : ""}
${(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 = `
🔔 승인 대기 큐 — 결재 필요
` +
pend.map(sr => `
${sr.sr_id}
${esc(sr.title)}
${PRIORITY_LABEL[sr.priority]||sr.priority}
${esc(sr.requested_by||"")}
`).join("") ||
'승인 대기 SR 없음 ✨
';
// 엔지니어 워크로드
const wlEl = document.getElementById("workload-card");
if (wlEl) {
wlEl.querySelector(".card-header").innerHTML = `👷 엔지니어 워크로드`;
const body = document.getElementById("workload-body");
if (body) body.innerHTML = `` +
(d.workload||[]).map(e => {
const pct = e.utilization||0;
const color = pct>=80?"#f85149":pct>=50?"#e3b341":"#3fb950";
return `
`;
}).join("") + `
`;
}
// 상태 차트
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 = `
소속 기관
${esc(d.inst_name||d.inst_code||"—")}
${_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 = `
🔄 진행 중인 요청 (${activeSRs.length}건)
` +
activeSRs.map(sr => `
${sr.sr_id}
${esc(sr.title)}
${STATUS_LABEL[sr.status]||sr.status}
${esc(sr.assigned_to||"처리 중")}
`).join("") ||
'진행 중인 요청이 없습니다.
';
// 상태 현황
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 = `✅ 최근 완료된 요청`;
const body = document.getElementById("workload-body");
if (body) body.innerHTML = done.map(sr => `
${sr.sr_id}
${esc(sr.title)}
완료
${fmtDate(sr.updated_at)}
`).join("") ||
'완료된 요청 없음
';
}
}
/* ── 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 = '등록된 엔지니어 프로필 없음
';
return;
}
const skillLabel = { DEPLOY:"배포", RESTART:"재기동", LOG:"로그", INQUIRY:"문의", OTHER:"기타" };
const canAssign = ["ADMIN","PM"].includes(_userInfo.role || "");
el.innerHTML = `
${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 => `
${skillLabel[s]||s}`).join("");
const aff = (eng.inst_affinity || "").split(",").filter(Boolean)
.map(a => `
${a}`).join("");
const assignBtn = canAssign
? `
`
: "";
return `
${skills}${aff}
완료 ${eng.completed}건
${assignBtn}
`;
}).join("")}
`;
}
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 => `
${sr.sr_id}
${esc(sr.title)}
${STATUS_LABEL[sr.status]||sr.status}
`).join("")
: '현재 담당 중인 SR 없음
';
document.getElementById("modal-body").innerHTML = `
👷 ${esc(eng.display_name)} 담당 SR
활성 ${eng.active}건 | 완료 ${eng.completed}건 | 이용률 ${eng.utilization}%
${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 = `
`;
board.appendChild(colEl);
const cardsEl = colEl.querySelector(`#col-${col.key}`);
cards.forEach(sr => {
const card = document.createElement("div");
card.className = "kanban-card";
card.innerHTML = `
${sr.sr_id}
${esc(sr.title)}
${TYPE_LABEL[sr.sr_type] || sr.sr_type}
${PRIORITY_LABEL[sr.priority] || sr.priority}
`;
card.addEventListener("click", () => openDetail(sr.sr_id));
cardsEl.appendChild(card);
});
});
}
/* ─── SR List ───────────────────────────────────── */
function setupListFilters() {
document.getElementById("search-input").addEventListener("input", renderList);
document.getElementById("filter-status").addEventListener("change", renderList);
document.getElementById("filter-type").addEventListener("change", renderList);
}
function renderList() {
const keyword = document.getElementById("search-input").value.toLowerCase();
const fStatus = document.getElementById("filter-status").value;
const fType = document.getElementById("filter-type").value;
let rows = srCache;
if (keyword) rows = rows.filter(r => r.title.toLowerCase().includes(keyword) || r.sr_id.toLowerCase().includes(keyword));
if (fStatus) rows = rows.filter(r => r.status === fStatus);
if (fType) rows = rows.filter(r => r.sr_type === fType);
document.getElementById("sr-tbody").innerHTML = rows.map(sr => {
const engInfo = workloadCache.find(e => e.username === sr.assigned_to);
const engChip = sr.assigned_to
? `${esc(engInfo?.display_name || sr.assigned_to)}`
: `미배정`;
return `
${sr.sr_id} |
${TYPE_LABEL[sr.sr_type] || sr.sr_type} |
${esc(sr.title)} |
${STATUS_LABEL[sr.status] || sr.status} |
${PRIORITY_LABEL[sr.priority] || sr.priority} |
${engChip} |
${esc(sr.requested_by || "")} |
${fmtDate(sr.created_at)} |
`;
}).join("") || `| 결과 없음 |
`;
}
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 `첨부파일 없음
`;
return `` + atts.map(a => `
${_fileIcon(a.original_name)}
${esc(a.original_name)}
${_fmtSize(a.file_size)}
${esc(a.uploaded_by)}
${fmtDate(a.created_at)}
`).join("") + `
`;
}
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 => `
${esc(a.approver)}
${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"}
${a.comment ? `${esc(a.comment)}` : ""}
`).join("")
: '승인 기록 없음
';
const auditHTML = auditRes.slice(0, 10).map(log => `
${esc(log.action)} by ${esc(log.actor || "system")}
${esc(log.detail || "")} #${(log.log_hash || "").slice(0, 12)}
`).join("") || '기록 없음
';
const canApprove = sr.status === "PENDING_APPROVAL";
const canSimulate = !["COMPLETED","FAILED_ROLLBACK","REJECTED"].includes(sr.status);
const canAssign = ["ADMIN","PM"].includes(_userInfo.role || "");
/* 작업 로그 HTML */
const workHTML = workRes.length
? `` + workRes.map(w => `
${WORK_ACTION_LABEL[w.action_type] || w.action_type}
by ${esc(w.engineer||"AI")}
${esc(w.content||"")}
${w.result ? `
${esc(w.result.slice(0,120))}` : ""}
`).join("") + `
`
: `작업 이력 없음
`;
/* 별점 HTML */
const ratingHTML = ratingRes
? `${"★".repeat(ratingRes.stars)}${"☆".repeat(5-ratingRes.stars)}
${esc(ratingRes.customer||"")}
${ratingRes.comment ? `— ${esc(ratingRes.comment)}` : ""}
`
: sr.status === "COMPLETED"
? ``
: "";
document.getElementById("modal-body").innerHTML = `
${esc(sr.title)}
${STATUS_LABEL[sr.status] || sr.status}
${TYPE_LABEL[sr.sr_type] || sr.sr_type}
${PRIORITY_LABEL[sr.priority] || sr.priority}
${sr.sr_id}
요약 정보
요청자: ${esc(sr.requested_by || "")}
담당 엔지니어:
${sr.assigned_to
? `${esc(workloadCache.find(e=>e.username===sr.assigned_to)?.display_name || sr.assigned_to)}`
: `미배정`}
${canAssign ? `` : ""}
대상 서버: ${esc(sr.target_server || "-")}
생성일: ${fmtDate(sr.created_at)}
${sr.description ? `
설명
${esc(sr.description)}
` : ""}
🛠️ 작업 실행 이력
${workHTML}
${canSimulate ? `
` : ""}
${ratingHTML ? `
` : ""}
${canApprove ? `
` : ""}
`;
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 = `${"★".repeat(stars)}${"☆".repeat(5-stars)} — 평가 감사합니다!
`;
}
}
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 = `
담당 엔지니어 변경
`;
// 기존 패널 있으면 제거
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 => `
${_fileIcon(f.name)}
${esc(f.name)}
${_fmtSize(f.size)}
`).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) => `
| ${i + 1} |
${esc(log.sr_id || "—")} |
${esc(log.actor || "system")} |
${esc(log.action)} |
${esc(log.detail || "")} |
${(log.log_hash || "").slice(0, 12)} |
${fmtDate(log.created_at)} |
`).join("") || `| 기록 없음 |
`;
document.getElementById("btn-verify").addEventListener("click", async () => {
const res = await 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 = `
${servers.map(s => `
${s.server_role}
${esc(s.server_name)}
${esc(s.os_type || "")}
${s.is_active ? "● 정상" : "● 비활성"}
`).join("") || '
서버 없음
'}
${inst.contact_pm ? `PM: ${esc(inst.contact_pm)}
` : ""}
`;
grid.appendChild(card);
}));
}
/* ─── 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 = '로드 실패
'; }
// 검색 이벤트 연결 (한 번만)
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 = '검색 중…
';
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 = '검색 실패
'; }
}
function renderKBDocs(hits) {
const el = document.getElementById("kb-results");
if (!hits.length) {
el.innerHTML = '관련 문서 없음
';
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
? `관련도 ${Math.round(h.score * 100)}%`
: "";
const kwHTML = h.matched_keywords?.length
? `${h.matched_keywords.map(k => `${esc(k)}`).join(" ")}
`
: "";
if (compact) {
// 모달 내 축약형
return `
${kwHTML}
`;
}
// 전체 카드
return `
${kwHTML ? `
${kwHTML}
` : ""}
🔍 증상
${esc(d.symptoms || "-")}
💡 원인
${esc(d.cause || "-")}
🛠️ 해결 절차
${esc(d.solution || "-")}
${d.commands ? `
⚡ 점검 명령어
${esc(d.commands)}
` : ""}
${d.doc_id}
`;
}
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 = '분석 중…
';
try {
const hits = await authFetch(`/api/kb/suggest/${srId}`).then(r => r.json());
if (!hits.length) {
el.innerHTML = '관련 문서 없음
';
return;
}
el.innerHTML = hits.map(h => renderKBCard(h, true)).join("");
} catch {
el.innerHTML = '추천 로드 실패
';
}
}
/* ─── 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, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/^• /gm, '▪ ')
.replace(/\n/g, '
');
div.innerHTML = rendered;
// 데이터 링크 (SR 클릭 가능)
if (data?.length) {
const links = data.filter(d => d.sr_id).map(d =>
`${d.sr_id}`
);
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 =>
``
).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",
`⏳ 처리 중…
`
);
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 = '| 등록된 기관이 없습니다. |
';
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 ? `${inst.contract_end}` : "-";
if (daysLeft !== null && daysLeft <= 30 && daysLeft > 7) expiryBadge = `D-${daysLeft} ⚠`;
if (daysLeft !== null && daysLeft <= 7) expiryBadge = `D-${daysLeft} 🔴`;
return `
| ${esc(inst.inst_code)} |
${esc(inst.inst_name)} |
${esc(inst.region || "-")} |
${expiryBadge} |
${inst.sla_hours}h |
${inst.server_count ?? "-"}대 |
${inst.contact_count ?? "-"}명 |
${inst.is_active ? "활성" : "비활성"} |
${canEdit ? `` : ""}
|
`;
}).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 = `
🏢 ${esc(inst.inst_name)} (${esc(inst.inst_code)})
지역${esc(inst.region||"-")}
SLA${inst.sla_hours}시간
전화${esc(inst.phone||"-")}
계약 기간${inst.contract_start||"?"} ~ ${inst.contract_end||"?"}
주소${esc(inst.address||"-")}
비고${esc(inst.note||"-")}
👤 담당자 (${contacts.length}명)
${canEdit ? `` : ""}
${contacts.length ? `
| 이름 | 역할 | 부서 | 이메일 | 전화 | 주담 |
${contacts.map(c => `
| ${esc(c.contact_name)} |
${roleLabel[c.role]||c.role} |
${esc(c.dept||"-")} |
${esc(c.email||"-")} |
${esc(c.phone||c.mobile||"-")} |
${c.is_primary ? "★" : ""} |
`).join("")}
` : `등록된 담당자가 없습니다.
`}
`;
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 = '등록된 스크립트가 없습니다.
';
return;
}
body.innerHTML = list.map(s => {
const catColor = SCRIPT_CATEGORY_COLOR[s.category] || "#818cf8";
const dangerBadge = s.is_dangerous
? `⚠ 위험` : "";
const approvalBadge = s.requires_approval
? `승인필요` : "";
return `
${esc(s.description)}
버전 ${esc(s.version)}
사용 ${s.use_count}회
${s.tags ? `${esc(s.tags).split(",").map(t=>`#${t.trim()}`).join(" ")}` : ""}
`;
}).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 = `
📜 ${esc(s.script_name)}
${SCRIPT_CATEGORY_KO[s.category]||s.category}
${esc(s.target_layer)}
${esc(s.os_type)}
${s.is_dangerous ? `⚠ 위험 명령 포함` : ""}
${s.requires_approval ? `실행 전 승인 필요` : ""}
설명${esc(s.description)}
버전${esc(s.version)}
작성자${esc(s.author||"-")}
사용 횟수${s.use_count}회
스크립트 내용
${esc(s.script_body)}
${s.sample_output ? `예상 출력
${esc(s.sample_output)}` : ""}
`;
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 = '| 등록된 작업이 없습니다. |
';
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 `
| ${WORK_TYPE_KO[t.work_type]||t.work_type} |
${esc(t.title)} |
${esc(instName)} |
${fmtDate(t.scheduled_at)} |
${t.completed_at ? fmtDate(t.completed_at) : "-"} |
${RESULT_STATUS_KO[t.result_status]||t.result_status} |
${esc(t.assignee||"-")} |
${t.sr_id ? `${esc(t.sr_id)}` : "-"} |
${canEdit ? `` : ""}
|
`;
}).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 = `
📅 ${esc(t.title)}
${WORK_TYPE_KO[t.work_type]||t.work_type}
${RESULT_STATUS_KO[t.result_status]||t.result_status}
기관${esc(instName)}
처리예정${fmtDate(t.scheduled_at)}
시작${t.started_at ? fmtDate(t.started_at) : "-"}
완료${t.completed_at ? fmtDate(t.completed_at) : "-"}
소요${duration}
담당자${esc(t.assignee||"-")}
검토자${esc(t.reviewer||"-")}
${t.sr_id ? `
SR${esc(t.sr_id)}
` : ""}
처리내용
${esc(t.content)}
${t.command_or_shell ? `명령어/쉘
${esc(t.command_or_shell)}` : ""}
${t.result ? `처리결과
${esc(t.result)}
` : ""}
${t.note ? `비고
${esc(t.note)}
` : ""}
`;
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, """);
}
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 = `
🔍 RAG 하이브리드 검색
KB + SR 이력을 BM25 + pgvector로 검색합니다
`;
break;
// ── AI 인사이트 ───────────────────────────────────
case "ai_insights": {
const r = await fetch("/api/insights/weekly", {headers: H});
const d = await r.json();
container.innerHTML = `
${[
{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=>`
`).join("")}
🤖 AI 주간 인사이트
${esc(d.ai_insight || "데이터 수집 중...")}
📊 상위 SR 카테고리
${(d.top_categories||[]).map(c=>`
${esc(c.category)}
${c.count}건
`).join("")}
`;
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 = `
${{"GREEN":"✅","YELLOW":"⚠️","RED":"❌","NO_DATA":"❔"}[d.overall_status]||"❔"}
전체 KPI 상태: ${d.overall_status||"N/A"}
GREEN:${d.summary?.GREEN||0} · YELLOW:${d.summary?.YELLOW||0} · RED:${d.summary?.RED||0}
${(d.kpis||[]).map(k=>`
${esc(k.display_name)}
${k.status}
${k.current_value !== null ? k.current_value : "—"}${esc(k.unit)}
목표: ${k.target}${k.unit} · 달성: ${k.achievement_pct !== null ? k.achievement_pct+"%" : "—"}
`).join("")}
${(d.kpis||[]).length === 0 ? `
KPI가 없습니다. 템플릿을 적용하세요.
` : ""}
`;
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 = `
${(ovr.cards||[]).map(c=>`
${c.value}
${esc(c.label)} ${esc(c.unit)}
${c.change !== undefined ? `
${c.change>=0?"+":""}${c.change} ${esc(c.change_label||"")}
` : ""}
`).join("")}
📊 SR 카테고리 분포 (30일)
${(pie.data||[]).slice(0,6).map(d=>`
${esc(d.category)}
${d.pct}%
`).join("")}
🔗 빠른 분석
${[
["SR 트렌드", "bi_sr_trend"], ["SLA 히트맵", "bi_sla"],
["엔지니어 워크로드", "bi_eng"], ["MTTR 트렌드", "bi_mttr"],
].map(([label, v])=>`
`).join("")}
`;
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 = `
📉 SLA 위반 예측 (7일)
${Math.round((sla.breach_probability_7d||0)*100)}%
현재 SLA: ${sla.current_rate||0}% · 목표: ${sla.target||95}%
${sla.insight ? `
${esc(sla.insight)}
` : ""}
📈 SR 급증 감지
${surge.surge_ratio||1}x
오늘 ${surge.today_count||0}건 · 7일 평균 ${surge.avg_7d||0}건
${surge.insight ? `
${esc(surge.insight)}
` : ""}
${(d.alerts||[]).length ? `
⚠️ 알림 (${d.alerts.length}건)
${d.alerts.map(a=>`
${esc(a.type)}: ${esc(a.message)}
`).join("")}
` : ""}`;
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 = `
⚙️ Jira 연동 설정
${cfg ? `
URL: ${esc(cfg.base_url||"")}
프로젝트: ${esc(cfg.project_key||"")}
자동 동기화: ${cfg.auto_sync?"켜짐":"꺼짐"}
` :
`
설정 없음
`}
🔄 SR-Issue 매핑 현황 (${mappings.length}건)
| SR ID | Jira Key | 프로젝트 | 동기화 시간 |
${mappings.slice(0,10).map(m=>`
| SR-${m.sr_id} |
${esc(m.jira_key)} |
${esc(m.project)} |
${fmtDate(m.synced_at)} |
`).join("")}
${mappings.length===0?`| 매핑 없음 |
`:""}
`;
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 = `
🏢 기관 현황
플랜: ${esc(me.plan||"")}
역할: ${esc(me.my_role||"")}
SR (이번 달): ${me.stats?.sr_this_month||0}건
미처리 SR: ${me.stats?.open_sr||0}건
📊 쿼터 사용량
${[
["서버", quota.servers_used, quota.servers_limit],
["사용자", quota.users_used, quota.users_limit],
].map(([name, used, limit])=>`
${name}${used} / ${limit < 0 ? "∞" : limit}
`).join("")}
👥 사용자 관리 (${users.length}명)
| 이름 | 이메일 | 역할 | 상태 |
${users.slice(0,10).map(u=>`
| ${esc(u.name)} | ${esc(u.email)} |
${esc(u.role)} |
${u.is_active?'활성':'비활성'} |
`).join("")}
`;
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 = `
📋 현재 구독
${esc(sub.plan||"COMMUNITY")}
${sub.price ? `월 ${sub.price.toLocaleString()}원` : "무료"} · ${esc(sub.billing_cycle||"MONTHLY")}
📊 이번 달 사용량
서버: ${usage.servers?.used||0} / ${usage.servers?.limit < 0 ? "∞" : (usage.servers?.limit||"-")}
사용자: ${usage.users?.used||0} / ${usage.users?.limit < 0 ? "∞" : (usage.users?.limit||"-")}
SR (이번 달): ${usage.sr_this_month||0}건
🧾 청구서 이력
| 기간 | 플랜 | 금액 | 상태 |
${invoices.slice(0,10).map(i=>`
| ${esc(i.period)} | ${esc(i.plan||"-")} |
${i.amount ? i.amount.toLocaleString()+"원" : "무료"} |
${esc(i.status)} |
`).join("")}
${!invoices.length ? `| 청구서 없음 |
` : ""}
`;
break;
}
// ── Kubernetes ────────────────────────────────────
case "kubernetes": {
const r = await fetch("/api/k8s/clusters", {headers: H});
const clusters = await r.json();
container.innerHTML = `
☸️ Kubernetes 클러스터 관리
${clusters.length ? clusters.map(cl=>`
☸️
${esc(cl.name)}
${esc(cl.description||"")} · 네임스페이스: ${esc(cl.namespace)}
`).join("") :
``}`;
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 = `
📋 알림 규칙 (${rules.length})
${rules.map(r=>`
${esc(r.name)}
서버ID: ${r.server_id} · ${r.auto_sr?"SR자동":"수동"}
`).join("") || `
규칙 없음
`}
🔔 최근 알림 로그
| 유형 | 컨테이너 | 심각도 | 시간 |
${logs.slice(0,15).map(l=>`
| ${esc(l.type)} |
${esc(l.container)} |
${esc(l.severity)} |
${fmtDate(l.detected_at)} |
`).join("") || `| 알림 없음 |
`}
`;
break;
}
// ── NCloud ────────────────────────────────────────
case "ncloud": {
const r = await fetch("/api/ncloud/summary", {headers: H});
if (r.status === 404) {
container.innerHTML = `
☁️ NCloud 연동 설정
NCloud API Key를 등록하세요.
`; break;
}
const d = await r.json();
const servers = await fetch("/api/ncloud/servers", {headers: H}).then(r=>r.json());
container.innerHTML = `
${[
{label:"전체 서버", val: d.server_count||0, icon:"🖥️"},
{label:"실행 중", val: d.running_servers||0, icon:"▶️"},
{label:"LB", val: d.lb_count||0, icon:"⚖️"},
].map(s=>`
${s.icon} ${s.val}
${s.label}
`).join("")}
서버 목록
| 이름 | 상태 | 공인 IP | 사설 IP |
${(servers.servers||[]).map(s=>`
| ${esc(s.name)} |
${esc(s.status)} |
${esc(s.public_ip||"-")} |
${esc(s.private_ip||"-")} |
`).join("") || `| 서버 없음 또는 API 응답 대기 |
`}
`;
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 = `
⚙️ 자율 워크플로우 엔진
${rules.map(r=>`
⚙️
${esc(r.name)}
트리거: ${esc(r.trigger_type)} · 오늘: ${r.run_count_today}회
${r.is_active?"활성":"비활성"}
`).join("") || `
`}
최근 실행 이력
${history.slice(0,8).map(h=>`
${esc(h.rule_name||"")}
${h.status} · ${fmtDate(h.started_at)}
`).join("") || `
이력 없음
`}
`;
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 = `
🧠 학습 데이터 현황
${status.available_samples||0}
수집 가능 샘플
RAG 피드백: ${status.high_quality_rag||0} · SR 이력: ${status.sr_samples||0}
${status.ready_to_train?"준비됨":"샘플 부족"}
📊 모델 품질
${quality.quality_grade||"N/A"}
품질 등급
평균 평점: ${quality.avg_rating||0} · 긍정 비율: ${quality.positive_rate||0}%
📋 학습 이력
| 모델 | 상태 | 샘플 | 시작 |
${history.slice(0,8).map(h=>`
| ${esc(h.model_name||"-")} |
${h.status} |
${h.samples_used||0} |
${fmtDate(h.started_at)} |
`).join("") || `| 이력 없음 |
`}
`;
break;
}
// ── 멀티모달 AI ───────────────────────────────────
case "multimodal":
container.innerHTML = `
🖼️ 멀티모달 AI 분석
스크린샷·에러 화면을 업로드하면 AI가 자동 분석합니다
`;
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 = `
📊 업계 평균 대비 비교
${(comp.comparison||[]).map(m=>`
${esc(m.metric)}
${m.mine}${esc(m.unit)}
업계 평균: ${m.industry}${esc(m.unit)}
${m.status==="ABOVE"?"▲ 평균 이상":"▼ 평균 이하"}
`).join("")}
🏆 업계 백분위 순위
${[
{label:"SR 완료율", val: rank.completion_rate_percentile||0},
{label:"MTTR", val: rank.mttr_percentile||0},
{label:"SLA 준수율", val: rank.sla_percentile||0},
].map(r=>`
`).join("")}
기여 시 더 정확한 벤치마킹 가능
`;
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 = `
📄 보고서 템플릿
${templates.map(t=>`
${esc(t.name)}
${esc(t.period)} · ${(t.format||[]).join("/")}
`).join("")}
📋 생성된 보고서 (${reports.length}개)
| 템플릿 | 기간 | 포맷 | 상태 | |
${reports.slice(0,15).map(r=>`
| ${esc(r.template)} | ${esc(r.period)} |
${esc(r.format)} |
${esc(r.status)} |
⬇ |
`).join("") || `| 보고서 없음 |
`}
`;
break;
}
// ── 브랜딩 설정 ───────────────────────────────────
case "white_label": {
const brand = await fetch("/api/brand/", {headers: H}).then(r=>r.json());
container.innerHTML = `
🎨 화이트라벨 브랜딩 설정
미리보기
${esc(brand.company_name||"GUARDiA ITSM")}
`;
break;
}
// ── SSO 설정 ──────────────────────────────────────
case "sso_config": {
const configs = await fetch("/api/sso/config", {headers: H}).then(r=>r.json());
container.innerHTML = `
🔐 SSO 통합 인증
${configs.length ? configs.map(c=>`
${c.provider_type==="SAML"?"🏛️":c.provider_type==="OIDC"?"🔑":"🔗"}
${esc(c.name)}
${esc(c.provider_type)} · ${c.is_active?"활성":"비활성"}
테스트 로그인
`).join("") :
`
SSO IdP 미등록
행안부 GPKI, Google, Microsoft 등 SSO를 설정하세요.
`}
`;
break;
}
// ── Slack 설정 ────────────────────────────────────
case "slack_config": {
const cfg = await fetch("/api/slack/config", {headers: H}).then(r=>r.json());
container.innerHTML = `
💬 Slack 연동 설정
${cfg ? `
발신번호: ${esc(cfg.sender||"")}
기본 채널: ${esc(cfg.default_channel||"")}
상태: 연동됨
` :
`
설정되지 않음
`}
`;
break;
}
// ── ERP 연동 ──────────────────────────────────────
case "erp_config": {
const cfgs = await fetch("/api/erp/config", {headers: H}).then(r=>r.json());
container.innerHTML = `
🏢 ERP / 그룹웨어 연동
${cfgs.map(c=>`
🏢
${esc(c.name)}
${esc(c.erp_type)} · ${esc(c.base_url)}
`).join("") || ``}`;
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 = `
📋 발송 이력
| 템플릿 | 수신자 | 결과 | 시간 |
${history.map(h=>`
| ${esc(h.template)} | ${h.receivers}명 |
${h.success?"성공":"실패"} |
${fmtDate(h.sent_at)} |
`).join("") || `| 이력 없음 |
`}
`;
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 = `
📈 SR 처리 속도 코호트
| 코호트 | SR 수 | 평균 처리 시간 | 평가 |
${(resolution.data||[]).map(r=>`
| ${esc(r.cohort)} | ${r.sr_count} |
${r.avg_resolution_hours}시간 |
${esc(r.benchmark)} |
`).join("") || `| 데이터 없음 |
`}
`;
break;
}
// ── ServiceNow ────────────────────────────────────
case "servicenow": {
const cfg = await fetch("/api/servicenow/config", {headers: H}).then(r=>r.json()).catch(()=>null);
container.innerHTML = `
🔗 ServiceNow 연동
${cfg ? `
인스턴스: ${esc(cfg.instance_url||"")}
연동됨
` :
`
설정 없음
`}
`;
break;
}
default:
container.innerHTML = `
🚧 준비 중
${esc(view)} 화면을 구현 중입니다.
`;
}
} catch(e) {
container.innerHTML = `
오류 발생
${esc(e.message||String(e))}
`;
}
}
/* ── 확장 뷰 헬퍼 함수들 ──────────────────────────── */
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=>`
${esc(item.title)}
${esc(item.excerpt)}
관련도: ${item.score} · 출처: ${item.source}
`).join("") || '결과 없음
';
}
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=>`
${f.adopted?"✅":"❌"}
${esc(f.feature)}
${f.usage_count}회
`).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 = `
| 노드 | 상태 | 역할 | 버전 |
${(d.nodes||[]).map(n=>`
| ${esc(n.name)} |
${n.status} |
${esc(n.roles)} |
${esc(n.version)} |
`).join("")}
`;
}
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 = '분석 중...
';
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 = `
분석 결과
${esc(JSON.stringify(d.analysis||d, null, 2))}
`;
} catch(e) {
if (el) el.innerHTML = `분석 실패: ${esc(e.message)}
`;
}
}
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 = `
| 번호 | 제목 | 우선순위 |
${(Array.isArray(incidents)?incidents:[]).slice(0,5).map(i=>`
| ${esc(i.number)} | ${esc(i.title)} | ${esc(i.priority)} |
`).join("")||`| 없음 |
`}
`;
}
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");
}