/* ══════════════════════════════════════════════════ 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;