feat(cloud): incident detail view renders snapshot images
This commit is contained in:
parent
d67513fc0e
commit
55d99602ce
5 changed files with 144 additions and 2 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
119
cloud/public/assets/detail.js
Normal file
119
cloud/public/assets/detail.js
Normal file
|
|
@ -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 <img src="/snap/{file_uuid}">.
|
||||
*
|
||||
* 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');
|
||||
});
|
||||
})();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ require __DIR__ . '/layout.php';
|
|||
render_header('이벤트 상세');
|
||||
?>
|
||||
<h1>이벤트 상세</h1>
|
||||
<div id="event-detail-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
||||
<div id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
||||
<div id="event-detail-snapshots"></div>
|
||||
<p><a href="/">← 목록으로</a></p>
|
||||
<script src="/assets/detail.js"></script>
|
||||
<?php
|
||||
render_footer();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ final class WiringSmokeTest extends TestCase
|
|||
$paths = [
|
||||
['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
|
||||
['POST', '/login'], ['POST', '/logout'], ['GET', '/api/events'],
|
||||
['GET', '/events/{id}'], ['POST', '/events/{id}/ack'],
|
||||
['GET', '/events/{id}'], ['POST', '/events/{id}/ack'], ['GET', '/events/{id}/view'],
|
||||
['GET', '/api/zones/{camera}'], ['POST', '/api/zones'],
|
||||
['POST', '/api/zones/{id}/delete'], ['GET', '/snap/{uuid}'],
|
||||
];
|
||||
|
|
@ -19,5 +19,13 @@ final class WiringSmokeTest extends TestCase
|
|||
$this->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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue