diff --git a/cloud/public/assets/app.css b/cloud/public/assets/app.css index 733ec6d..1ed9ca5 100644 --- a/cloud/public/assets/app.css +++ b/cloud/public/assets/app.css @@ -84,3 +84,14 @@ button:hover { background: #f3f4f6; } .ack-btn { margin-left: auto; } .ack-btn:disabled { opacity: 0.6; cursor: default; } + +/* Dashboard row -> incident detail link (keyboard-focusable affordance). */ +.event-detail-link { color: inherit; text-decoration: none; } +.event-detail-link:hover, +.event-detail-link:focus-visible { text-decoration: underline; } + +/* Incident detail view (event_detail.php + detail.js). */ +.detail-row { margin: 0.25rem 0; } +.detail-row .detail-label { color: #4b5563; font-weight: 600; } +#event-detail-snapshots { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; } +.detail-snapshot { max-width: 100%; height: auto; border: 1px solid #d1d5db; border-radius: 4px; } diff --git a/cloud/public/assets/detail.js b/cloud/public/assets/detail.js new file mode 100644 index 0000000..58f5145 --- /dev/null +++ b/cloud/public/assets/detail.js @@ -0,0 +1,119 @@ +/** + * 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'; + + 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 = '/snap/' + encodeURIComponent(s.file_uuid); + img.alt = '사고 스냅샷'; + img.className = 'detail-snapshot'; + img.loading = 'lazy'; + snapBox.appendChild(img); + }); + } + + fetch('/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { window.location.href = '/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'); + }); +})(); diff --git a/cloud/public/index.php b/cloud/public/index.php index bf653ac..6e4157e 100644 --- a/cloud/public/index.php +++ b/cloud/public/index.php @@ -61,5 +61,6 @@ $r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p) $r->add('GET', '/', fn() => $render('events')); $r->add('GET', '/login', fn() => $render('login')); $r->add('GET', '/zones', fn() => $render('zones')); +$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail')); $r->dispatch(Request::fromGlobals())->send(); diff --git a/cloud/src/View/event_detail.php b/cloud/src/View/event_detail.php index 952d4aa..864f5bb 100644 --- a/cloud/src/View/event_detail.php +++ b/cloud/src/View/event_detail.php @@ -3,6 +3,9 @@ require __DIR__ . '/layout.php'; render_header('이벤트 상세'); ?>

이벤트 상세

-
불러오는 중...
+
불러오는 중...
+
+

← 목록으로

+ assertSame(200, $r->dispatch(new Request('GET', '/events/9', [], '', [], []))->status()); $this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status()); $this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->status()); + + // The 3-segment view route must resolve to itself, NOT be swallowed by the + // 2-segment /events/{id} JSON route (they are registered in that order). + $twoSeg = $r->dispatch(new Request('GET', '/events/9', [], '', [], [])); + $this->assertSame('/events/{id}', json_decode($twoSeg->body(), true)['route']); + $viewSeg = $r->dispatch(new Request('GET', '/events/9/view', [], '', [], [])); + $this->assertSame(200, $viewSeg->status()); + $this->assertSame('/events/{id}/view', json_decode($viewSeg->body(), true)['route']); } }