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 { margin-left: auto; }
|
||||||
.ack-btn:disabled { opacity: 0.6; cursor: default; }
|
.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', '/', fn() => $render('events'));
|
||||||
$r->add('GET', '/login', fn() => $render('login'));
|
$r->add('GET', '/login', fn() => $render('login'));
|
||||||
$r->add('GET', '/zones', fn() => $render('zones'));
|
$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();
|
$r->dispatch(Request::fromGlobals())->send();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ require __DIR__ . '/layout.php';
|
||||||
render_header('이벤트 상세');
|
render_header('이벤트 상세');
|
||||||
?>
|
?>
|
||||||
<h1>이벤트 상세</h1>
|
<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
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ final class WiringSmokeTest extends TestCase
|
||||||
$paths = [
|
$paths = [
|
||||||
['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
|
['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
|
||||||
['POST', '/login'], ['POST', '/logout'], ['GET', '/api/events'],
|
['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'],
|
['GET', '/api/zones/{camera}'], ['POST', '/api/zones'],
|
||||||
['POST', '/api/zones/{id}/delete'], ['GET', '/snap/{uuid}'],
|
['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', '/events/9', [], '', [], []))->status());
|
||||||
$this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status());
|
$this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status());
|
||||||
$this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->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