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>
795 lines
36 KiB
HTML
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
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 += `¬e=${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>
|