zioinfo-mail/itsm/static/ssl.html
DESKTOP-TKLFCPR\ython e228faabf5 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

580 lines
26 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 — SSL 관리</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:130px; background:var(--bg-card); border:1px solid var(--border);
border-radius:10px; padding:16px 20px; text-align:center; cursor:pointer;
transition:border-color .2s, transform .1s;
}
.stat-box:hover { transform:translateY(-2px); }
.stat-box.active-filter { border-color:var(--accent); }
.stat-box-val { font-size:30px; font-weight:700; line-height:1.1; }
.stat-box-lbl { font-size:12px; color:var(--text-muted); margin-top:6px; }
.val-ok { color:#4ade80; }
.val-warn { color:#fcd34d; }
.val-urgent { color:#fb923c; }
.val-expired{ color:#f87171; }
/* 슬라이더 컨트롤 */
.slider-row {
display:flex; align-items:center; gap:14px;
background:var(--bg-card); border:1px solid var(--border);
border-radius:8px; padding:12px 18px; margin-bottom:16px;
}
.slider-row label { font-size:13px; color:var(--text-muted); white-space:nowrap; }
.slider-row input[type=range] {
flex:1; accent-color:var(--accent);
}
.slider-val { font-size:14px; font-weight:700; color:var(--accent); min-width:60px; }
/* 테이블 */
.table-wrap { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
.ssl-table { width:100%; border-collapse:collapse; }
.ssl-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); white-space:nowrap; }
.ssl-table td { font-size:13px; padding:11px 14px; border-bottom:1px solid var(--border); vertical-align:middle; }
.ssl-table tr:last-child td { border-bottom:none; }
.ssl-table tr:hover td { background:var(--bg-hover); }
/* 경고 레벨 배지 */
.ssl-badge {
display:inline-block; font-size:11px; font-weight:700; padding:3px 9px;
border-radius:5px; letter-spacing:.3px;
}
.ssl-OK { background:#16a34a22; color:#4ade80; border:1px solid #16a34a44; }
.ssl-WARN { background:#ca8a0422; color:#fcd34d; border:1px solid #ca8a0444; }
.ssl-URGENT { background:#ea580c22; color:#fb923c; border:1px solid #ea580c44; }
.ssl-EXPIRED{ background:#dc262622; color:#f87171; border:1px solid #dc262644; }
/* 게이지 바 */
.gauge-wrap { width:100px; height:6px; background:var(--border); border-radius:3px; display:inline-block; vertical-align:middle; }
.gauge-fill { height:6px; border-radius:3px; transition:width .4s; }
.gauge-ok { background:#4ade80; }
.gauge-warn { background:#fcd34d; }
.gauge-urgent { background:#fb923c; }
.gauge-expired{ background:#f87171; }
/* 버튼 */
.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); }
.btn-sm.checking { opacity:.6; cursor:wait; }
.btn-sm-renew { border-color:#10b981; color:#10b981; }
.btn-sm-renew:hover { background:#10b981; color:#fff; border-color:#10b981; }
/* 모달 */
.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:520px; max-width:100%; position:relative;
}
.modal-box.modal-wide { width:700px; }
.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-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
/* 이력 테이블 */
.hist-table { width:100%; border-collapse:collapse; margin-top:4px; }
.hist-table th { font-size:11px; color:var(--text-muted); padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); background:var(--bg); }
.hist-table td { font-size:12px; padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
.hist-table tr:last-child td { border-bottom:none; }
.empty-state { text-align:center; color:var(--text-muted); padding:48px; font-size:14px; }
.server-name-link { color:var(--accent); cursor:pointer; text-decoration:underline; text-underline-offset:2px; }
.server-name-link:hover { opacity:.8; }
/* 필터 바 */
.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:10px 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;
}
/* 점검 결과 뱃지 */
.check-result { font-size:11px; padding:2px 7px; border-radius:4px; font-weight:600; }
.check-ok { background:#16a34a22; color:#4ade80; }
.check-fail { background:#dc262622; color:#f87171; }
</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" href="/incidents">장애관리</a>
<a class="topnav-link active" 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">SSL 인증서 관리</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>
</div>
</div>
<!-- 현황 카드 -->
<div class="stat-row">
<div class="stat-box" id="card-ok" onclick="setLevelFilter('OK')">
<div class="stat-box-val val-ok" id="cnt-ok"></div>
<div class="stat-box-lbl">OK (정상)</div>
</div>
<div class="stat-box" id="card-warn" onclick="setLevelFilter('WARN')">
<div class="stat-box-val val-warn" id="cnt-warn"></div>
<div class="stat-box-lbl">WARN (30일 이내)</div>
</div>
<div class="stat-box" id="card-urgent" onclick="setLevelFilter('URGENT')">
<div class="stat-box-val val-urgent" id="cnt-urgent"></div>
<div class="stat-box-lbl">URGENT (7일 이내)</div>
</div>
<div class="stat-box" id="card-expired" onclick="setLevelFilter('EXPIRED')">
<div class="stat-box-val val-expired" id="cnt-expired"></div>
<div class="stat-box-lbl">EXPIRED (만료)</div>
</div>
</div>
<!-- 조회 범위 슬라이더 -->
<div class="slider-row">
<label>만료 임박 조회 범위</label>
<input type="range" id="days-slider" min="0" max="90" value="30" oninput="onSliderChange(this.value)">
<span class="slider-val" id="slider-label">30일 이내</span>
<button class="btn btn-secondary" style="font-size:12px;padding:4px 12px" onclick="loadExpiring()">적용</button>
</div>
<!-- 레벨 필터 -->
<div class="filter-bar">
<label>경고 레벨</label>
<select class="filter-select" id="f-level" onchange="renderTable()">
<option value="">전체</option>
<option value="EXPIRED">EXPIRED</option>
<option value="URGENT">URGENT</option>
<option value="WARN">WARN</option>
<option value="OK">OK</option>
</select>
<label style="margin-left:8px;">서버명 검색</label>
<input type="text" id="f-server" class="filter-select" placeholder="서버명..." oninput="renderTable()" style="min-width:160px;">
</div>
<!-- 만료 현황 테이블 -->
<div class="table-wrap">
<table class="ssl-table">
<thead>
<tr>
<th>서버명</th>
<th>역할/인스티튜션</th>
<th>만료일</th>
<th>남은 일수</th>
<th>게이지</th>
<th>경고 레벨</th>
<th>SSH 점검</th>
<th>갱신</th>
</tr>
</thead>
<tbody id="ssl-tbody">
<tr><td colspan="8" class="empty-state">데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ══ 갱신 기록 모달 ══ -->
<div id="renew-modal" class="modal-overlay">
<div class="modal-box">
<button class="modal-close" onclick="closeRenewModal()"></button>
<div class="modal-title" id="renew-modal-title">SSL 갱신 기록</div>
<div class="form-row">
<label>새 만료일 *</label>
<input id="renew-expire" type="date" class="form-input">
</div>
<div class="form-row">
<label>처리자</label>
<input id="renew-by" class="form-input" placeholder="처리자 이름">
</div>
<div class="form-row">
<label>인증서 도메인</label>
<input id="renew-domain" class="form-input" placeholder="예: *.example.com">
</div>
<div class="form-row">
<label>인증서 경로</label>
<input id="renew-path" class="form-input" placeholder="/etc/ssl/certs/cert.pem">
</div>
<div class="form-row">
<label>발급기관</label>
<input id="renew-issuer" class="form-input" placeholder="Let's Encrypt, DigiCert 등">
</div>
<div class="form-row">
<label>메모</label>
<textarea id="renew-note" class="form-input" rows="3" placeholder="갱신 관련 메모..."></textarea>
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="closeRenewModal()">취소</button>
<button class="btn btn-primary" onclick="submitRenew()">갱신 기록 저장</button>
</div>
</div>
</div>
<!-- ══ 갱신 이력 모달 ══ -->
<div id="history-modal" class="modal-overlay">
<div class="modal-box modal-wide">
<button class="modal-close" onclick="closeHistoryModal()"></button>
<div class="modal-title" id="history-modal-title">갱신 이력</div>
<div id="history-content">
<div class="empty-state">로딩 중...</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 apiFetch(path, opt = {}) {
const r = await fetch(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 allItems = []; // 전체 만료 데이터
let daysVal = 30;
let renewServerId = 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);
}
// ── 슬라이더 ──────────────────────────────────────────────────────────────────
function onSliderChange(v) {
daysVal = parseInt(v);
document.getElementById('slider-label').textContent = v === '0' ? '만료됨만' : `${v}일 이내`;
}
// ── 전체 로드 ────────────────────────────────────────────────────────────────
async function loadAll() {
await Promise.all([loadSummary(), loadExpiring()]);
startCountdown();
}
// ── 요약 통계 ────────────────────────────────────────────────────────────────
async function loadSummary() {
try {
const d = await apiFetch('/api/ssl/summary');
if (!d) return;
const lv = d.by_level || {};
document.getElementById('cnt-ok').textContent = lv.OK || 0;
document.getElementById('cnt-warn').textContent = lv.WARN || 0;
document.getElementById('cnt-urgent').textContent = lv.URGENT || 0;
document.getElementById('cnt-expired').textContent = lv.EXPIRED || 0;
} catch(e) { console.warn('SSL 요약 로드 실패:', e); }
}
// ── 만료 현황 목록 ────────────────────────────────────────────────────────────
async function loadExpiring() {
try {
const days = daysVal;
const include = document.getElementById('f-level').value === 'OK' ? 'true' : 'false';
const d = await apiFetch(`/api/ssl/expiring?days=${days}&include_ok=true`);
allItems = d || [];
renderTable();
} catch(e) {
document.getElementById('ssl-tbody').innerHTML =
`<tr><td colspan="8" class="empty-state">로드 실패: ${e.message}</td></tr>`;
}
}
// ── 레벨 필터 카드 클릭 ──────────────────────────────────────────────────────
function setLevelFilter(level) {
const sel = document.getElementById('f-level');
sel.value = (sel.value === level) ? '' : level;
// 카드 active 표시
['OK','WARN','URGENT','EXPIRED'].forEach(l => {
document.getElementById('card-' + l.toLowerCase()).classList.toggle('active-filter', sel.value === l);
});
renderTable();
}
// ── 테이블 렌더링 ─────────────────────────────────────────────────────────────
function renderTable() {
const levelFilter = document.getElementById('f-level').value;
const serverFilter = document.getElementById('f-server').value.trim().toLowerCase();
let items = allItems;
if (levelFilter) items = items.filter(i => i.alert_level === levelFilter);
if (serverFilter) items = items.filter(i => (i.server_name||'').toLowerCase().includes(serverFilter));
const tbody = document.getElementById('ssl-tbody');
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">해당 조건의 서버가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = items.map(item => {
const level = item.alert_level || 'OK';
const daysLeft = item.days_left;
const gaugeW = daysLeft == null ? 0 : daysLeft < 0 ? 0 : Math.min(100, Math.round((daysLeft / 30) * 100));
const gaugeClass = { OK:'gauge-ok', WARN:'gauge-warn', URGENT:'gauge-urgent', EXPIRED:'gauge-expired' }[level] || 'gauge-ok';
const daysStr = daysLeft == null ? '-' : daysLeft < 0 ? `만료 ${Math.abs(daysLeft)}일 경과` : `${daysLeft}`;
const daysColor= { EXPIRED:'#f87171', URGENT:'#fb923c', WARN:'#fcd34d', OK:'#4ade80' }[level] || '';
return `
<tr>
<td>
<span class="server-name-link" onclick="openHistoryModal(${item.server_id}, '${esc(item.server_name)}')">${esc(item.server_name)}</span>
</td>
<td style="font-size:12px;color:var(--text-muted);">${esc(item.inst_name || item.server_role || '-')}</td>
<td style="font-size:12px;white-space:nowrap;">${item.ssl_expire_date || '-'}</td>
<td style="font-weight:600;color:${daysColor};">${daysStr}</td>
<td>
<div class="gauge-wrap">
<div class="gauge-fill ${gaugeClass}" style="width:${gaugeW}%"></div>
</div>
</td>
<td><span class="ssl-badge ssl-${level}">${level}</span></td>
<td>
<button class="btn-sm" id="check-btn-${item.server_id}" onclick="doSSHCheck(${item.server_id}, this)">
SSH 점검
</button>
</td>
<td>
<button class="btn-sm btn-sm-renew" onclick="openRenewModal(${item.server_id}, '${esc(item.server_name)}')">
갱신 기록
</button>
</td>
</tr>
`;
}).join('');
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ── SSH 점검 실행 ────────────────────────────────────────────────────────────
async function doSSHCheck(serverId, btn) {
btn.classList.add('checking');
btn.textContent = '점검 중...';
try {
const d = await apiFetch(`/api/ssl/check/${serverId}`, { method: 'POST' });
if (!d) return;
const label = d.days_left != null
? (d.days_left < 0 ? `만료 ${Math.abs(d.days_left)}일 경과` : `D-${d.days_left}`)
: '';
showToast(`점검 완료: ${d.server_name}${d.alert_level} ${label}`, false);
if (d.updated) loadAll();
else {
btn.textContent = 'SSH 점검';
btn.classList.remove('checking');
}
} catch(e) {
showToast('점검 실패: ' + e.message, true);
btn.textContent = 'SSH 점검';
btn.classList.remove('checking');
}
}
// ── 갱신 기록 모달 ───────────────────────────────────────────────────────────
function openRenewModal(serverId, serverName) {
renewServerId = serverId;
document.getElementById('renew-modal-title').textContent = `SSL 갱신 기록 — ${serverName}`;
// 기본 만료일: 1년 후
const future = new Date();
future.setFullYear(future.getFullYear() + 1);
document.getElementById('renew-expire').value = future.toISOString().split('T')[0];
document.getElementById('renew-by').value = '';
document.getElementById('renew-domain').value = '';
document.getElementById('renew-path').value = '';
document.getElementById('renew-issuer').value = '';
document.getElementById('renew-note').value = '';
document.getElementById('renew-modal').classList.add('open');
}
function closeRenewModal() {
document.getElementById('renew-modal').classList.remove('open');
renewServerId = null;
}
async function submitRenew() {
if (!renewServerId) return;
const newExpire = document.getElementById('renew-expire').value;
if (!newExpire) { showToast('새 만료일을 입력하세요.', true); return; }
const body = {
server_id: renewServerId,
new_expire: newExpire,
renewed_by: document.getElementById('renew-by').value.trim() || null,
cert_domain: document.getElementById('renew-domain').value.trim() || null,
cert_path: document.getElementById('renew-path').value.trim() || null,
issuer: document.getElementById('renew-issuer').value.trim() || null,
note: document.getElementById('renew-note').value.trim() || null,
};
try {
await apiFetch(`/api/ssl/renew/${renewServerId}`, {
method: 'POST', body: JSON.stringify(body)
});
showToast('갱신 기록이 저장되었습니다.');
closeRenewModal();
loadAll();
} catch(e) { showToast('저장 실패: ' + e.message, true); }
}
// ── 갱신 이력 모달 ───────────────────────────────────────────────────────────
async function openHistoryModal(serverId, serverName) {
document.getElementById('history-modal-title').textContent = `갱신 이력 — ${serverName}`;
document.getElementById('history-content').innerHTML = '<div class="empty-state">로딩 중...</div>';
document.getElementById('history-modal').classList.add('open');
try {
const items = await apiFetch(`/api/ssl/history/${serverId}`) || [];
if (!items.length) {
document.getElementById('history-content').innerHTML =
'<div class="empty-state">갱신 이력이 없습니다.</div>';
return;
}
document.getElementById('history-content').innerHTML = `
<table class="hist-table">
<thead>
<tr>
<th>갱신일</th>
<th>이전 만료일</th>
<th>신규 만료일</th>
<th>처리자</th>
<th>도메인</th>
<th>발급기관</th>
<th>메모</th>
</tr>
</thead>
<tbody>
${items.map(h => `
<tr>
<td style="white-space:nowrap;">${fmtDate(h.created_at)}</td>
<td style="white-space:nowrap;">${h.old_expire || '-'}</td>
<td style="white-space:nowrap;color:#4ade80;font-weight:600;">${h.new_expire || '-'}</td>
<td>${esc(h.renewed_by || '-')}</td>
<td style="font-size:11px;">${esc(h.cert_domain || '-')}</td>
<td style="font-size:11px;">${esc(h.issuer || '-')}</td>
<td style="font-size:11px;max-width:160px;word-break:break-all;">${esc(h.note || '-')}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch(e) {
document.getElementById('history-content').innerHTML =
`<div style="color:#ef4444;text-align:center;padding:24px;">로드 실패: ${e.message}</div>`;
}
}
function closeHistoryModal() {
document.getElementById('history-modal').classList.remove('open');
}
function fmtDate(iso) {
if (!iso) return '-';
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
return d.toLocaleString('ko-KR', { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
// ── 토스트 ────────────────────────────────────────────────────────────────────
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:340px;`;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
// ── 초기화 ────────────────────────────────────────────────────────────────────
loadAll();
</script>
</body>
</html>