/** * Incident detail view: parses the event id from /events/{id}/view, fetches * GET /events/{id} (JSON {event, snapshots}), and renders the event metadata * plus each snapshot as an . * * XSS-safe: every dynamic value is written via textContent or a validated URL * attribute, never innerHTML. Severity is shown as icon + text (never color * alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js. */ (function () { 'use strict'; // Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another // site. Injected by src/View/layout.php from the server's SCRIPT_NAME. var B = window.APP_BASE || ''; var SEVERITY_LABELS = { critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, high: { icon: '⚠', text: '높음', cls: 'sev-high' }, warning: { icon: '△', text: '경고', cls: 'sev-warning' }, info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } }; var meta = document.getElementById('event-detail-meta'); var snapBox = document.getElementById('event-detail-snapshots'); if (!meta) return; var m = location.pathname.match(/\/events\/(\d+)\/view/); if (!m) { meta.textContent = '잘못된 주소입니다.'; meta.removeAttribute('aria-busy'); return; } var id = m[1]; function severityInfo(sev) { return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' }; } function formatTime(occurredAt) { if (!occurredAt && occurredAt !== 0) return '-'; var d = new Date(Number(occurredAt) * 1000); if (isNaN(d.getTime())) return '-'; return d.toLocaleString('ko-KR'); } function buildBadge(sev) { var info = severityInfo(sev); var badge = document.createElement('span'); badge.className = 'severity-badge ' + info.cls; var icon = document.createElement('span'); icon.className = 'icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = info.icon; var label = document.createElement('span'); label.className = 'label'; label.textContent = info.text; badge.appendChild(icon); badge.appendChild(label); return badge; } function textNode(value) { var span = document.createElement('span'); span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value); return span; } function addRow(label, valueNode) { var row = document.createElement('div'); row.className = 'detail-row'; var k = document.createElement('span'); k.className = 'detail-label'; k.textContent = label + ': '; row.appendChild(k); row.appendChild(valueNode); meta.appendChild(row); } function render(data) { var ev = data.event || {}; meta.textContent = ''; meta.removeAttribute('aria-busy'); addRow('심각도', buildBadge(ev.severity)); addRow('유형', textNode(ev.event_type)); addRow('카메라', textNode(ev.camera_id)); addRow('발생 시각', textNode(formatTime(ev.occurred_at))); addRow('지속(초)', textNode(ev.duration_s)); addRow('확인', textNode(ev.acknowledged_by || '미확인')); if (!snapBox) return; snapBox.textContent = ''; var snaps = data.snapshots || []; if (snaps.length === 0) { var none = document.createElement('p'); none.textContent = '스냅샷이 없습니다.'; snapBox.appendChild(none); return; } snaps.forEach(function (s) { if (!s || !s.file_uuid) return; var img = document.createElement('img'); img.src = B + '/snap/' + encodeURIComponent(s.file_uuid); img.alt = '사고 스냅샷'; img.className = 'detail-snapshot'; img.loading = 'lazy'; snapBox.appendChild(img); }); } fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } }) .then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; return null; } if (!res.ok) throw new Error('detail_failed:' + res.status); return res.json(); }) .then(function (data) { if (data) render(data); }) .catch(function () { meta.textContent = '불러오기 실패'; meta.removeAttribute('aria-busy'); }); })();