[DOCX 3종 생성 (UTF-8, 편집 가능)] - 01_소프트웨어_저작권_등록_신청서.docx (37KB) 한국저작권위원회 제출용 / 맑은 고딕 / 색상 섹션 - 02_소프트웨어사업자_신고서.docx (37KB) 과학기술정보통신부/KOSA 제출용 - 03_조달청_나라장터_물품_등록_신청서.docx (38KB) 공공기관 납품용 나라장터 등록 [generate_docx.py 특징] - python-docx 기반 (한글 UTF-8 완전 지원) - 검정 박스 없음 (맑은 고딕 직접 적용) - 편집 가능: Word / 한글(HWP) / LibreOffice - 섹션별 색상 배너 (파란/빨간/주황 테마) - 서명란, 첨부서류, 수수료 안내 포함 [certification/source/ 저작권 등록용 소스코드] - 01_core_ssh_agentless.py (450줄) - 에이전트리스 SSH 핵심 - 02_core_license_engine.py (455줄) - AES-256-GCM 라이선스 - 03_router_sr_management.py(501줄) - SR 관리 API - 04_core_ai_classifier.py (90줄) - AI 티켓 분류 - 05_frontend_dashboard.js (200줄) - 대시보드 프론트 - README.md - 제출 안내 및 독창성 설명 - 모든 파일: 영업비밀(암호화키) 마스킹 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
7.9 KiB
JavaScript
200 lines
7.9 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; |