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('이벤트 상세');
?>