guardia-itsm/static/incidents.html
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

795 lines
36 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA — 장애관리</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.page-wrap { display:flex; flex-direction:column; height:100vh; }
.topnav { display:flex; align-items:center; gap:16px; padding:0 24px; height:52px; background:var(--bg-card); border-bottom:1px solid var(--border); flex-shrink:0; }
.topnav-logo { font-weight:700; font-size:16px; color:var(--accent); text-decoration:none; }
.topnav-links { display:flex; gap:4px; }
.topnav-link { padding:6px 12px; font-size:13px; color:var(--text-muted); text-decoration:none; border-radius:6px; transition:background .15s,color .15s; }
.topnav-link:hover, .topnav-link.active { background:var(--bg-hover); color:var(--text); }
.topnav-right { margin-left:auto; display:flex; align-items:center; gap:12px; }
.page-content { flex:1; overflow-y:auto; padding:24px; }
.page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
.page-title { font-size:22px; font-weight:700; color:var(--text); }
/* 통계 카드 */
.stat-row { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:20px; }
.stat-box {
flex:1; min-width:140px; background:var(--bg-card); border:1px solid var(--border);
border-radius:10px; padding:16px 20px; text-align:center; cursor:default;
}
.stat-box-val { font-size:30px; font-weight:700; color:var(--accent); line-height:1.1; }
.stat-box-lbl { font-size:12px; color:var(--text-muted); margin-top:6px; }
/* 필터 툴바 */
.filter-bar {
display:flex; gap:10px; align-items:center; flex-wrap:wrap;
background:var(--bg-card); border:1px solid var(--border);
border-radius:8px; padding:12px 16px; margin-bottom:16px;
}
.filter-bar label { font-size:13px; color:var(--text-muted); }
.filter-select {
background:var(--bg); border:1px solid var(--border); color:var(--text);
border-radius:6px; padding:5px 10px; font-size:13px; cursor:pointer;
}
.filter-select:focus { outline:none; border-color:var(--accent); }
.filter-input {
background:var(--bg); border:1px solid var(--border); color:var(--text);
border-radius:6px; padding:5px 12px; font-size:13px; flex:1; min-width:160px;
}
.filter-input:focus { outline:none; border-color:var(--accent); }
.filter-divider { width:1px; height:24px; background:var(--border); }
/* 테이블 */
.table-wrap { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
.inc-table { width:100%; border-collapse:collapse; }
.inc-table th { font-size:12px; color:var(--text-muted); font-weight:600; padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); background:var(--bg); }
.inc-table td { font-size:13px; padding:11px 14px; border-bottom:1px solid var(--border); vertical-align:middle; }
.inc-table tr:last-child td { border-bottom:none; }
.inc-table tr:hover td { background:var(--bg-hover); cursor:pointer; }
/* 등급 배지 */
.grade-badge {
display:inline-block; font-size:11px; font-weight:700; padding:3px 9px;
border-radius:5px; letter-spacing:.3px;
}
.grade-P1 { background:#dc262622; color:#f87171; border:1px solid #dc262644; }
.grade-P2 { background:#ea580c22; color:#fb923c; border:1px solid #ea580c44; }
.grade-P3 { background:#ca8a0422; color:#fcd34d; border:1px solid #ca8a0444; }
.grade-P4 { background:#16a34a22; color:#4ade80; border:1px solid #16a34a44; }
/* 상태 텍스트 */
.status-OPEN { color:#f87171; font-weight:600; }
.status-INVESTIGATING{ color:#fb923c; font-weight:600; }
.status-MITIGATED { color:#fcd34d; font-weight:600; }
.status-RESOLVED { color:#4ade80; font-weight:600; }
.status-CLOSED { color:#6b7280; }
/* 버튼 */
.btn-sm {
padding:4px 10px; font-size:11px; border-radius:5px; cursor:pointer;
border:1px solid var(--border); background:var(--bg); color:var(--text);
transition:background .15s; white-space:nowrap;
}
.btn-sm:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
/* 모달 오버레이 */
.modal-overlay {
display:none; position:fixed; inset:0; background:rgba(0,0,0,.65);
z-index:1000; align-items:flex-start; justify-content:center;
overflow-y:auto; padding:40px 16px;
}
.modal-overlay.open { display:flex; }
.modal-box {
background:var(--bg-card); border:1px solid var(--border); border-radius:12px;
padding:28px; width:600px; max-width:100%; position:relative;
}
.modal-box.modal-wide { width:800px; }
.modal-title { font-size:17px; font-weight:700; margin-bottom:20px; color:var(--text); }
.modal-close {
position:absolute; top:16px; right:16px; background:none; border:none;
color:var(--text-muted); font-size:20px; cursor:pointer; line-height:1;
}
.modal-close:hover { color:var(--text); }
/* 폼 */
.form-row { margin-bottom:14px; }
.form-row label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:5px; font-weight:500; }
.form-input {
width:100%; background:var(--bg); border:1px solid var(--border); color:var(--text);
border-radius:6px; padding:8px 12px; font-size:13px; box-sizing:border-box;
}
.form-input:focus { outline:none; border-color:var(--accent); }
.form-row-2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.form-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
/* 상세 모달 전용 */
.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px 20px; margin-bottom:16px; }
.detail-item label { font-size:11px; color:var(--text-muted); display:block; margin-bottom:2px; }
.detail-item span { font-size:13px; color:var(--text); }
.detail-section { border-top:1px solid var(--border); margin-top:16px; padding-top:16px; }
.detail-section-title { font-size:13px; font-weight:600; margin-bottom:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.5px; }
/* 상태 전환 버튼들 */
.transition-btns { display:flex; gap:8px; flex-wrap:wrap; }
.btn-transition {
padding:6px 14px; font-size:12px; border-radius:6px; cursor:pointer;
font-weight:600; transition:opacity .15s; border:none;
}
.btn-transition:hover { opacity:.85; }
.btn-INVESTIGATING { background:#ea580c33; color:#fb923c; border:1px solid #ea580c55; }
.btn-MITIGATED { background:#ca8a0433; color:#fcd34d; border:1px solid #ca8a0455; }
.btn-RESOLVED { background:#16a34a33; color:#4ade80; border:1px solid #16a34a55; }
.btn-CLOSED { background:#37415133; color:#9ca3af; border:1px solid #37415155; }
/* SR 목록 */
.sr-list { display:flex; flex-direction:column; gap:6px; }
.sr-item { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px 12px; display:flex; justify-content:space-between; align-items:center; }
.sr-item-info { font-size:12px; }
.sr-item-id { font-weight:600; color:var(--accent); }
.sr-item-title { color:var(--text); margin-left:8px; }
.sr-item-status { font-size:11px; color:var(--text-muted); }
/* 페이지네이션 */
.pagination { display:flex; gap:8px; align-items:center; justify-content:center; margin-top:16px; }
.pagination button {
padding:5px 12px; font-size:12px; border-radius:5px; cursor:pointer;
border:1px solid var(--border); background:var(--bg); color:var(--text);
}
.pagination button:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
.pagination button:disabled { opacity:.4; cursor:not-allowed; }
.pagination span { font-size:12px; color:var(--text-muted); }
.empty-state { text-align:center; color:var(--text-muted); padding:48px; font-size:14px; }
.inc-id { font-family:monospace; font-size:12px; color:var(--accent); }
.mttr-val { font-size:12px; color:var(--text-muted); }
/* 텍스트에어리어 */
textarea.form-input { resize:vertical; min-height:70px; }
/* 탭 */
.tab-row { display:flex; gap:4px; border-bottom:1px solid var(--border); margin-bottom:16px; }
.tab-btn { padding:8px 14px; font-size:13px; cursor:pointer; border:none; background:none; color:var(--text-muted); border-bottom:2px solid transparent; }
.tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.tab-pane { display:none; }
.tab-pane.active { display:block; }
/* RCA 섹션 */
.rca-box {
background:var(--bg); border:1px solid var(--border); border-radius:6px;
padding:12px; font-size:12px; color:var(--text-muted); white-space:pre-wrap; line-height:1.5;
}
</style>
</head>
<body>
<script>document.body.dataset.theme = localStorage.getItem("guardia_theme") || "dark";</script>
<div class="page-wrap">
<!-- 네비게이션 바 -->
<nav class="topnav">
<a class="topnav-logo" href="/">GUARDiA ITSM</a>
<div class="topnav-links">
<a class="topnav-link" href="/">대시보드</a>
<a class="topnav-link active" href="/incidents">장애관리</a>
<a class="topnav-link" href="/ssl">SSL</a>
<a class="topnav-link" href="/pm">PM점검</a>
<a class="topnav-link" href="/oncall">온콜</a>
<a class="topnav-link" href="/batch">배치</a>
<a class="topnav-link" href="/vibe">바이브</a>
<a class="topnav-link" href="/si">SI</a>
<a class="topnav-link" href="/agents">AI에이전트</a>
</div>
<div class="topnav-right">
<span id="nav-user" style="font-size:12px;color:var(--text-muted)"></span>
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px" onclick="logout()">로그아웃</button>
</div>
</nav>
<!-- 메인 콘텐츠 -->
<div class="page-content">
<div class="page-header">
<div class="page-title">장애 관리</div>
<div style="display:flex;gap:8px;align-items:center;">
<span id="refresh-countdown" style="font-size:12px;color:var(--text-muted)"></span>
<button class="btn btn-secondary" style="font-size:13px" onclick="loadAll()">새로고침</button>
<button class="btn btn-primary" style="font-size:13px" onclick="openCreateModal()">+ 장애 등록</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="stat-row" id="stat-row">
<div class="stat-box">
<div class="stat-box-val" id="st-total"></div>
<div class="stat-box-lbl">전체 건수</div>
</div>
<div class="stat-box">
<div class="stat-box-val" id="st-open" style="color:#f87171"></div>
<div class="stat-box-lbl">OPEN 건수</div>
</div>
<div class="stat-box">
<div class="stat-box-val" id="st-p1p2" style="color:#fb923c"></div>
<div class="stat-box-lbl">P1/P2 활성</div>
</div>
<div class="stat-box">
<div class="stat-box-val" id="st-mttr" style="color:#22d3ee"></div>
<div class="stat-box-lbl">평균 MTTR</div>
</div>
</div>
<!-- 필터 툴바 -->
<div class="filter-bar">
<label>상태</label>
<select class="filter-select" id="f-status" onchange="applyFilter()">
<option value="">전체</option>
<option value="OPEN">OPEN</option>
<option value="INVESTIGATING">INVESTIGATING</option>
<option value="MITIGATED">MITIGATED</option>
<option value="RESOLVED">RESOLVED</option>
<option value="CLOSED">CLOSED</option>
</select>
<div class="filter-divider"></div>
<label>등급</label>
<select class="filter-select" id="f-grade" onchange="applyFilter()">
<option value="">전체</option>
<option value="P1">P1</option>
<option value="P2">P2</option>
<option value="P3">P3</option>
<option value="P4">P4</option>
</select>
<div class="filter-divider"></div>
<input class="filter-input" id="f-keyword" type="text" placeholder="제목 또는 내용 검색..." oninput="debounceFilter()">
<button class="btn-sm" onclick="resetFilter()">초기화</button>
</div>
<!-- 장애 테이블 -->
<div class="table-wrap">
<table class="inc-table">
<thead>
<tr>
<th>INC 번호</th>
<th>제목</th>
<th>등급</th>
<th>상태</th>
<th>발생시각</th>
<th>담당자</th>
<th>MTTR</th>
<th></th>
</tr>
</thead>
<tbody id="inc-tbody">
<tr><td colspan="8" class="empty-state">데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
<div class="pagination" id="pagination"></div>
</div>
</div>
<!-- ══ 장애 등록 모달 ══ -->
<div id="create-modal" class="modal-overlay">
<div class="modal-box">
<button class="modal-close" onclick="closeCreateModal()"></button>
<div class="modal-title">장애 등록</div>
<div class="form-row">
<label>제목 *</label>
<input id="c-title" class="form-input" placeholder="장애 제목을 입력하세요">
</div>
<div class="form-row">
<label>설명</label>
<textarea id="c-desc" class="form-input" placeholder="장애 상세 설명..."></textarea>
</div>
<div class="form-row-2">
<div class="form-row">
<label>등급 *</label>
<select id="c-grade" class="form-input">
<option value="P1">P1 — 치명적</option>
<option value="P2">P2 — 높음</option>
<option value="P3" selected>P3 — 보통</option>
<option value="P4">P4 — 낮음</option>
</select>
</div>
<div class="form-row">
<label>담당자</label>
<input id="c-assigned" class="form-input" placeholder="담당자 이름">
</div>
</div>
<div class="form-row">
<label>영향 서비스</label>
<input id="c-services" class="form-input" placeholder="쉼표로 구분: web, api, db">
</div>
<div class="form-row">
<label>발생 일시</label>
<input id="c-occurred" type="datetime-local" class="form-input">
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="closeCreateModal()">취소</button>
<button class="btn btn-primary" onclick="submitCreate()">등록</button>
</div>
</div>
</div>
<!-- ══ 장애 상세 모달 ══ -->
<div id="detail-modal" class="modal-overlay">
<div class="modal-box modal-wide">
<button class="modal-close" onclick="closeDetailModal()"></button>
<div class="modal-title" id="detail-title"></div>
<div class="tab-row">
<button class="tab-btn active" onclick="switchDetailTab('info',this)">기본 정보</button>
<button class="tab-btn" onclick="switchDetailTab('status',this)">상태 전환</button>
<button class="tab-btn" onclick="switchDetailTab('sr',this)">SR 연결</button>
<button class="tab-btn" onclick="switchDetailTab('rca',this)">RCA / 종료</button>
</div>
<!-- 탭: 기본 정보 -->
<div class="tab-pane active" id="tab-info">
<div class="detail-grid" id="detail-grid"></div>
<div style="margin-top:10px;">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px;">설명</div>
<div class="rca-box" id="detail-desc" style="min-height:60px;"></div>
</div>
</div>
<!-- 탭: 상태 전환 -->
<div class="tab-pane" id="tab-status">
<div style="margin-bottom:12px;">
<div style="font-size:13px;color:var(--text-muted);margin-bottom:10px;">현재 상태: <strong id="cur-status-lbl"></strong></div>
<div style="font-size:13px;margin-bottom:14px;color:var(--text);">다음 상태로 전환하려면 버튼을 클릭하세요.</div>
<div class="transition-btns" id="transition-btns"></div>
</div>
<div class="form-row" id="status-note-row">
<label>전환 메모 (선택)</label>
<input id="status-note" class="form-input" placeholder="상태 전환 사유 입력...">
</div>
</div>
<!-- 탭: SR 연결 -->
<div class="tab-pane" id="tab-sr">
<div class="form-row" style="display:flex;gap:10px;align-items:flex-end;">
<div style="flex:1;">
<label style="font-size:12px;color:var(--text-muted);display:block;margin-bottom:5px;">SR 번호</label>
<input id="sr-link-input" class="form-input" placeholder="SR-YYYYMMDD-XXXXXX">
</div>
<button class="btn btn-primary" style="white-space:nowrap" onclick="submitLinkSR()">SR 연결</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">연결된 SR 목록</div>
<div class="sr-list" id="sr-list">
<div style="font-size:13px;color:var(--text-muted);text-align:center;padding:20px;">로딩 중...</div>
</div>
</div>
<!-- 탭: RCA / 종료 -->
<div class="tab-pane" id="tab-rca">
<div id="rca-existing" style="display:none;margin-bottom:16px;">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px;">기존 RCA</div>
<div class="rca-box" id="rca-existing-text"></div>
</div>
<div id="rca-form">
<div class="form-row">
<label>근본 원인 분석 (RCA) *</label>
<textarea id="rca-text" class="form-input" rows="4" placeholder="장애의 근본 원인을 입력하세요..."></textarea>
</div>
<div class="form-row">
<label>재발 방지 조치</label>
<textarea id="prevention-text" class="form-input" rows="3" placeholder="재발 방지 대책..."></textarea>
</div>
<div class="form-row">
<label>연관 KB 문서 ID (선택)</label>
<input id="kb-doc-id" class="form-input" placeholder="KB-XXXXXX">
</div>
<div class="form-actions">
<button class="btn btn-danger" style="background:#dc2626;color:#fff;border-color:#dc2626;padding:7px 18px;border-radius:6px;cursor:pointer;font-size:13px;" onclick="submitClose()">장애 종료</button>
</div>
</div>
<div id="rca-closed-msg" style="display:none;text-align:center;padding:24px;color:var(--text-muted);font-size:14px;">
이 장애는 이미 종료되었습니다.
</div>
</div>
</div>
</div>
<script>
// ── 인증 ──────────────────────────────────────────────────────────────────────
(function() {
const tok = localStorage.getItem('guardia_token');
if (!tok) { location.href = '/login'; }
try {
const payload = JSON.parse(atob(tok.split('.')[1]));
document.getElementById('nav-user').textContent = payload.sub || '';
} catch(e) {}
})();
function logout() {
localStorage.removeItem('guardia_token');
location.href = '/login';
}
const token = () => localStorage.getItem('guardia_token') || '';
const headers = () => ({ 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' });
async function api(path, opt = {}) {
const r = await fetch('/api/incidents' + path, { ...opt, headers: headers() });
if (r.status === 401) { location.href = '/login'; return null; }
if (!r.ok) {
const e = await r.json().catch(() => ({ detail: '요청 실패' }));
throw new Error(e.detail || r.statusText);
}
if (r.status === 204) return null;
return r.json();
}
// ── 상태 ──────────────────────────────────────────────────────────────────────
let currentPage = 0;
const PAGE_SIZE = 30;
let currentIncidentId = null;
let filterTimer = null;
// ── 자동 새로고침 카운트다운 ──────────────────────────────────────────────────
let countdown = 30;
function startCountdown() {
const el = document.getElementById('refresh-countdown');
clearInterval(window._cdi);
countdown = 30;
window._cdi = setInterval(() => {
countdown--;
el.textContent = `자동갱신 ${countdown}s`;
if (countdown <= 0) {
loadAll();
countdown = 30;
}
}, 1000);
}
// ── 전체 로드 ────────────────────────────────────────────────────────────────
async function loadAll() {
await Promise.all([loadStats(), loadIncidents()]);
startCountdown();
}
// ── 통계 ────────────────────────────────────────────────────────────────────
async function loadStats() {
try {
const d = await api('/stats');
if (!d) return;
document.getElementById('st-total').textContent = d.total || 0;
document.getElementById('st-open').textContent = (d.by_status && d.by_status.OPEN) || 0;
document.getElementById('st-p1p2').textContent = d.open_p1_p2 || 0;
const mttr = d.mttr_min || 0;
document.getElementById('st-mttr').textContent = mttr >= 60
? Math.floor(mttr / 60) + 'h ' + (mttr % 60) + 'm'
: mttr + '분';
} catch(e) { console.warn('통계 로드 실패:', e); }
}
// ── 장애 목록 로드 ────────────────────────────────────────────────────────────
async function loadIncidents() {
const status = document.getElementById('f-status').value;
const grade = document.getElementById('f-grade').value;
const keyword = document.getElementById('f-keyword').value.trim();
let qs = `?skip=${currentPage * PAGE_SIZE}&limit=${PAGE_SIZE}`;
if (status) qs += `&status=${status}`;
if (grade) qs += `&grade=${grade}`;
if (keyword) qs += `&keyword=${encodeURIComponent(keyword)}`;
try {
const items = await api(qs) || [];
renderTable(items);
renderPagination(items.length);
} catch(e) {
document.getElementById('inc-tbody').innerHTML =
`<tr><td colspan="8" class="empty-state">로드 실패: ${e.message}</td></tr>`;
}
}
// ── 테이블 렌더링 ────────────────────────────────────────────────────────────
function renderTable(items) {
const tbody = document.getElementById('inc-tbody');
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">장애 내역이 없습니다.</td></tr>';
return;
}
tbody.innerHTML = items.map(inc => `
<tr onclick="openDetailModal('${inc.incident_id}')">
<td><span class="inc-id">${inc.incident_id}</span></td>
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(inc.title)}">${esc(inc.title)}</td>
<td><span class="grade-badge grade-${inc.grade}">${inc.grade}</span></td>
<td><span class="status-${inc.status}">${statusLabel(inc.status)}</span></td>
<td style="white-space:nowrap;font-size:12px;">${fmtDt(inc.occurred_at)}</td>
<td style="font-size:12px;">${inc.assigned_to || '<span style="color:var(--text-muted)">미지정</span>'}</td>
<td class="mttr-val">${calcMttr(inc)}</td>
<td onclick="event.stopPropagation()">
<button class="btn-sm" onclick="openDetailModal('${inc.incident_id}')">상세</button>
</td>
</tr>
`).join('');
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function statusLabel(s) {
return { OPEN:'OPEN', INVESTIGATING:'조사중', MITIGATED:'완화됨', RESOLVED:'해결됨', CLOSED:'종료' }[s] || s;
}
function fmtDt(iso) {
if (!iso) return '-';
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
return d.toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function calcMttr(inc) {
if (!inc.resolved_at || !inc.occurred_at) return '—';
const min = Math.round((new Date(inc.resolved_at) - new Date(inc.occurred_at)) / 60000);
if (min < 60) return `${min}`;
return `${Math.floor(min/60)}h ${min%60}m`;
}
// ── 페이지네이션 ──────────────────────────────────────────────────────────────
function renderPagination(count) {
const pg = document.getElementById('pagination');
pg.innerHTML = `
<button onclick="prevPage()" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<span>${currentPage + 1} 페이지</span>
<button onclick="nextPage()" ${count < PAGE_SIZE ? 'disabled' : ''}>다음</button>
`;
}
function prevPage() { if (currentPage > 0) { currentPage--; loadIncidents(); } }
function nextPage() { currentPage++; loadIncidents(); }
// ── 필터 ──────────────────────────────────────────────────────────────────────
function applyFilter() { currentPage = 0; loadIncidents(); }
function debounceFilter() {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => applyFilter(), 400);
}
function resetFilter() {
document.getElementById('f-status').value = '';
document.getElementById('f-grade').value = '';
document.getElementById('f-keyword').value = '';
applyFilter();
}
// ── 장애 등록 모달 ────────────────────────────────────────────────────────────
function openCreateModal() {
// 기본 발생일시 = 현재
const now = new Date();
const local = new Date(now - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById('c-occurred').value = local;
document.getElementById('create-modal').classList.add('open');
}
function closeCreateModal() {
document.getElementById('create-modal').classList.remove('open');
['c-title','c-desc','c-assigned','c-services'].forEach(id => {
document.getElementById(id).value = '';
});
}
async function submitCreate() {
const title = document.getElementById('c-title').value.trim();
if (!title) { showToast('제목을 입력하세요.', true); return; }
const occurred = document.getElementById('c-occurred').value;
const body = {
title,
description: document.getElementById('c-desc').value.trim() || null,
grade: document.getElementById('c-grade').value,
assigned_to: document.getElementById('c-assigned').value.trim() || null,
affected_services: document.getElementById('c-services').value.trim() || null,
occurred_at: occurred ? new Date(occurred).toISOString() : null,
};
try {
await api('', { method:'POST', body: JSON.stringify(body) });
showToast('장애가 등록되었습니다.');
closeCreateModal();
loadAll();
} catch(e) { showToast('등록 실패: ' + e.message, true); }
}
// ── 장애 상세 모달 ────────────────────────────────────────────────────────────
async function openDetailModal(incidentId) {
currentIncidentId = incidentId;
document.getElementById('detail-modal').classList.add('open');
// 기본 탭으로 초기화
switchDetailTab('info', document.querySelector('.tab-btn.active'));
try {
const inc = await api('/' + encodeURIComponent(incidentId));
if (!inc) return;
renderDetail(inc);
// SR 탭 로드 준비
document.getElementById('sr-link-input').value = '';
loadLinkedSRs(incidentId);
} catch(e) {
showToast('상세 조회 실패: ' + e.message, true);
}
}
function closeDetailModal() {
document.getElementById('detail-modal').classList.remove('open');
currentIncidentId = null;
}
function renderDetail(inc) {
document.getElementById('detail-title').textContent = inc.incident_id + ' — ' + inc.title;
document.getElementById('detail-desc').textContent = inc.description || '(설명 없음)';
// 기본정보 그리드
const grid = document.getElementById('detail-grid');
grid.innerHTML = [
['INC 번호', `<span class="inc-id">${inc.incident_id}</span>`],
['등급', `<span class="grade-badge grade-${inc.grade}">${inc.grade}</span>`],
['상태', `<span class="status-${inc.status}">${statusLabel(inc.status)}</span>`],
['담당자', inc.assigned_to || '미지정'],
['발생일시', fmtDt(inc.occurred_at)],
['감지일시', fmtDt(inc.detected_at)],
['완화일시', fmtDt(inc.mitigated_at)],
['해결일시', fmtDt(inc.resolved_at)],
['종료일시', fmtDt(inc.closed_at)],
['영향 서비스', inc.affected_services || '-'],
].map(([lbl, val]) => `
<div class="detail-item">
<label>${lbl}</label>
<span>${val}</span>
</div>
`).join('');
// 상태 전환
document.getElementById('cur-status-lbl').innerHTML = `<span class="status-${inc.status}">${statusLabel(inc.status)}</span>`;
renderTransitionBtns(inc.status);
// RCA 탭
if (inc.rca) {
document.getElementById('rca-existing').style.display = '';
document.getElementById('rca-existing-text').textContent = inc.rca;
} else {
document.getElementById('rca-existing').style.display = 'none';
}
if (inc.status === 'CLOSED') {
document.getElementById('rca-form').style.display = 'none';
document.getElementById('rca-closed-msg').style.display = '';
} else {
document.getElementById('rca-form').style.display = '';
document.getElementById('rca-closed-msg').style.display = 'none';
}
}
const TRANSITIONS = {
OPEN: ['INVESTIGATING', 'CLOSED'],
INVESTIGATING: ['MITIGATED', 'RESOLVED'],
MITIGATED: ['INVESTIGATING', 'RESOLVED'],
RESOLVED: ['CLOSED'],
CLOSED: [],
};
const TRANSITION_LABELS = {
INVESTIGATING: '조사 시작',
MITIGATED: '완화됨',
RESOLVED: '해결됨',
CLOSED: '종료',
};
function renderTransitionBtns(status) {
const btns = document.getElementById('transition-btns');
const nexts = TRANSITIONS[status] || [];
if (!nexts.length) {
btns.innerHTML = '<span style="color:var(--text-muted);font-size:13px;">더 이상 전환 가능한 상태가 없습니다.</span>';
return;
}
btns.innerHTML = nexts.map(s => `
<button class="btn-transition btn-${s}" onclick="doStatusTransition('${s}')">
${statusLabel(s)}
</button>
`).join('');
}
async function doStatusTransition(newStatus) {
if (!currentIncidentId) return;
const note = document.getElementById('status-note').value.trim();
let qs = `?new_status=${newStatus}`;
if (note) qs += `&note=${encodeURIComponent(note)}`;
try {
const r = await fetch(`/api/incidents/${encodeURIComponent(currentIncidentId)}/status${qs}`, {
method: 'PATCH', headers: headers()
});
if (r.status === 401) { location.href = '/login'; return; }
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || '실패'); }
const inc = await r.json();
showToast(`상태가 "${statusLabel(newStatus)}"로 전환되었습니다.`);
document.getElementById('status-note').value = '';
renderDetail(inc);
loadIncidents();
loadStats();
} catch(e) { showToast('상태 전환 실패: ' + e.message, true); }
}
// ── SR 연결 ───────────────────────────────────────────────────────────────────
async function loadLinkedSRs(incidentId) {
try {
const srs = await api(`/${encodeURIComponent(incidentId)}/srs`) || [];
const list = document.getElementById('sr-list');
if (!srs.length) {
list.innerHTML = '<div style="font-size:13px;color:var(--text-muted);text-align:center;padding:16px;">연결된 SR 없음</div>';
return;
}
list.innerHTML = srs.map(sr => `
<div class="sr-item">
<div class="sr-item-info">
<span class="sr-item-id">${sr.sr_id}</span>
<span class="sr-item-title">${esc(sr.title || '')}</span>
</div>
<span class="sr-item-status">${sr.status || ''}</span>
</div>
`).join('');
} catch(e) {
document.getElementById('sr-list').innerHTML = `<div style="color:#ef4444;font-size:13px;text-align:center;padding:12px;">로드 실패: ${e.message}</div>`;
}
}
async function submitLinkSR() {
if (!currentIncidentId) return;
const srId = document.getElementById('sr-link-input').value.trim();
if (!srId) { showToast('SR 번호를 입력하세요.', true); return; }
try {
await fetch(`/api/incidents/${encodeURIComponent(currentIncidentId)}/link-sr?sr_id=${encodeURIComponent(srId)}`, {
method: 'POST', headers: headers()
}).then(async r => {
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || '실패'); }
});
showToast('SR이 연결되었습니다.');
document.getElementById('sr-link-input').value = '';
loadLinkedSRs(currentIncidentId);
} catch(e) { showToast('연결 실패: ' + e.message, true); }
}
// ── RCA 종료 ──────────────────────────────────────────────────────────────────
async function submitClose() {
if (!currentIncidentId) return;
const rca = document.getElementById('rca-text').value.trim();
if (!rca) { showToast('RCA 내용을 입력하세요.', true); return; }
const prevention = document.getElementById('prevention-text').value.trim();
const kbDocId = document.getElementById('kb-doc-id').value.trim();
let qs = `?rca=${encodeURIComponent(rca)}`;
if (prevention) qs += `&prevention=${encodeURIComponent(prevention)}`;
if (kbDocId) qs += `&kb_doc_id=${encodeURIComponent(kbDocId)}`;
try {
const r = await fetch(`/api/incidents/${encodeURIComponent(currentIncidentId)}/close${qs}`, {
method: 'POST', headers: headers()
});
if (r.status === 401) { location.href = '/login'; return; }
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || '실패'); }
const inc = await r.json();
showToast('장애가 종료되었습니다.');
renderDetail(inc);
loadIncidents();
loadStats();
} catch(e) { showToast('종료 실패: ' + e.message, true); }
}
// ── 상세 탭 전환 ──────────────────────────────────────────────────────────────
function switchDetailTab(name, btn) {
document.querySelectorAll('#detail-modal .tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#detail-modal .tab-pane').forEach(p => p.classList.remove('active'));
if (btn) btn.classList.add('active');
const pane = document.getElementById('tab-' + name);
if (pane) pane.classList.add('active');
}
// ── 토스트 ────────────────────────────────────────────────────────────────────
function showToast(msg, error = false) {
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText = `position:fixed;bottom:24px;right:24px;background:${error ? '#dc2626' : '#059669'};
color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;z-index:9999;
box-shadow:0 4px 12px rgba(0,0,0,.4);max-width:320px;`;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
// ── 초기화 ────────────────────────────────────────────────────────────────────
loadAll();
</script>
</body>
</html>