Two operator-value features: evidence that shows HOW it happened, and the one-line answer to "what happened overnight". Evidence timeline (closes the P1-B "前中後 스냅샷 배선" follow-up): - The ring buffer now stores per-frame head boxes, and an event captures the frames nearest T-2.5s / T-1.0s / T-0 (server cap: 3 snapshots). A fall's evidentiary value is in the seconds BEFORE the person hit the floor - one end-state frame never showed how it happened. - Each past frame is blurred with the head positions of ITS OWN moment - blurring history with the current tick's boxes would leave a face exactly where it actually was (PIPA). - Right after startup the ring is short: fewer frames go out, never zero and never duplicated. Detail view captions the sequence 이전/직전/발생 시점 by position (offsets are edge config the server doesn't know). 24h catch-up brief: - The events console renders one line above the stream on load - "지난 24시간 - 감지 N건 (중대 n) · 니어미스 n · 평균 대응 t · 미확인 n건 남음" - reusing GET /api/report?days=1, with a link into /report. High-tinted when the window held critical/high events; hidden entirely until data arrives. Verified: pytest 235 pass (3 new: timeline selection, short-buffer dedup, 3-frame capture through run_once) / PHPUnit 138 pass + Chrome real-render (brief line with severe tint on the console; detail page showing the 3-frame timeline with captions; no console errors).
171 lines
6.9 KiB
JavaScript
171 lines
6.9 KiB
JavaScript
/**
|
||
* 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';
|
||
|
||
// 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' }
|
||
};
|
||
|
||
// Same wire-code -> Korean map as app.js (the two pages share no module system);
|
||
// an unknown code falls through as the raw code — never blank.
|
||
var TYPE_LABELS = {
|
||
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
|
||
};
|
||
|
||
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo
|
||
// report's whole value IS its metrics). Unknown keys fall through as the raw key.
|
||
var METRIC_LABELS = {
|
||
observed_s: '관측 시간', trunk_high_s: '허리 고위험(60°+) 노출',
|
||
trunk_mod_s: '허리 중위험(20~60°) 노출', arm_high_s: '팔 거상(90°+) 노출',
|
||
squat_s: '웅크림 노출', prone_s: '엎드림 지속', immobility_s: '부동 지속',
|
||
waited_s: '재출현 대기', dwell_s: '체류 시간', limit_s: '체류 상한',
|
||
alone_s: '단독 작업 지속', workers: '감지 인원', required: '최소 인원', limit: '정원',
|
||
torso_angle: '몸통 각도(°)', aspect_ratio: '박스 종횡비', height_remaining: '잔여 높이 비율',
|
||
last_state: '소실 시 상태'
|
||
};
|
||
|
||
function metricValue(key, v) {
|
||
if (/_s$/.test(key) && typeof v === 'number') {
|
||
if (v >= 90) return Math.round(v / 60) + '분 ' + Math.round(v % 60) + '초';
|
||
return v + '초';
|
||
}
|
||
return String(v);
|
||
}
|
||
|
||
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(TYPE_LABELS[ev.event_type] || 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 (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그'));
|
||
|
||
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
|
||
var metrics = ev.metrics || {};
|
||
Object.keys(metrics).forEach(function (key) {
|
||
addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[key])));
|
||
});
|
||
|
||
if (!snapBox) return;
|
||
snapBox.textContent = '';
|
||
var snaps = data.snapshots || [];
|
||
if (snaps.length === 0) {
|
||
var none = document.createElement('p');
|
||
none.textContent = '스냅샷이 없습니다.';
|
||
snapBox.appendChild(none);
|
||
return;
|
||
}
|
||
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by
|
||
// position from the end — exact offsets are edge config the server doesn't know.
|
||
var CAPTIONS = ['이전', '직전', '발생 시점'];
|
||
var kept = snaps.filter(function (s) { return s && s.file_uuid; });
|
||
kept.forEach(function (s, i) {
|
||
var fig = document.createElement('figure');
|
||
fig.className = 'detail-snap';
|
||
var img = document.createElement('img');
|
||
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||
var cap = kept.length > 1
|
||
? CAPTIONS[Math.max(0, CAPTIONS.length - kept.length + i)] : '발생 시점';
|
||
img.alt = '사고 스냅샷 — ' + cap;
|
||
img.className = 'detail-snapshot';
|
||
img.loading = 'lazy';
|
||
fig.appendChild(img);
|
||
var fc = document.createElement('figcaption');
|
||
fc.textContent = cap;
|
||
fig.appendChild(fc);
|
||
snapBox.appendChild(fig);
|
||
});
|
||
}
|
||
|
||
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');
|
||
});
|
||
})();
|