/** * 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 . * * This is where an incident is JUDGED, so it is also where it is RESOLVED: 확인/오탐 post to * the same routes the stream uses. Previously the operator had to read the evidence here, * navigate back, and find the row again — the deepest screen had no way to act on what it * showed, which is exactly backwards. * * 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 — which is // why `confidence` had been rendering as a bare uppercase "CONFIDENCE" in an otherwise // Korean column. Anything the edge can attach belongs in this table. var METRIC_LABELS = { confidence: '검출 신뢰도', score: '검출 점수', 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: '소실 시 상태' }; var STATE_LABELS = { FALLING: '넘어지는 중', FALLEN: '넘어짐', NO_MOTION: '미동 없음', UPRIGHT: '기립' }; 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 + '초'; } // A 0..1 confidence reads as a percentage everywhere else a human meets one. if ((key === 'confidence' || key === 'score') && typeof v === 'number' && v <= 1) { return Math.round(v * 100) + '%'; } if (key === 'last_state') return STATE_LABELS[v] || String(v); return String(v); } var meta = document.getElementById('event-detail-meta'); var snapBox = document.getElementById('event-detail-snapshots'); var actionsBox = document.getElementById('detail-actions'); var titleEl = document.getElementById('detail-title'); var whenEl = document.getElementById('detail-when'); if (!meta) return; var m = location.pathname.match(/\/events\/(\d+)\/view/); if (!m) { meta.textContent = '잘못된 주소입니다.'; meta.removeAttribute('aria-busy'); return; } var id = m[1]; var currentEvent = null; var csrfToken = null; var deviceNames = {}; function severityInfo(sev) { return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' }; } function pad2(n) { return n < 10 ? '0' + n : String(n); } // One stamp format across the product. This page used to print toLocaleString('ko-KR') // ("2026. 8. 8. 오후 4:01:50") while the stream printed "08-08 16:01:50" for the same // instant — the same event looked like two different records depending on the screen. var DOW = ['일', '월', '화', '수', '목', '금', '토']; function formatTime(occurredAt) { if (!occurredAt && occurredAt !== 0) return '-'; var d = new Date(Number(occurredAt) * 1000); if (isNaN(d.getTime())) return '-'; return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' (' + DOW[d.getDay()] + ') ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); } function fmtDuration(s) { var v = Number(s); if (!isFinite(v)) return '-'; if (v < 60) return (Math.round(v * 10) / 10) + '초'; return Math.floor(v / 60) + '분 ' + Math.round(v % 60) + '초'; } 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, cls) { var span = document.createElement('span'); if (cls) span.className = cls; 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('dt'); k.textContent = label; var v = document.createElement('dd'); v.appendChild(valueNode); row.appendChild(k); row.appendChild(v); meta.appendChild(row); } // --- resolution actions -------------------------------------------------------------- function loadCsrf() { return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } }) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (d) { if (d && d.token) csrfToken = d.token; }) .catch(function () { /* the POST surfaces it as a 403 */ }); } function post(path) { return fetch(B + '/events/' + encodeURIComponent(id) + path, { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } }).then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; return null; } if (!res.ok) throw new Error('action_failed:' + res.status); return res; }); } function setNote(text, isError) { var note = document.createElement('p'); note.className = 'detail-action-note' + (isError ? ' is-error' : ''); note.textContent = text; actionsBox.appendChild(note); } // 오탐 is two-step here for the same reason as in the stream: it asserts the detector was // wrong, it moves a per-camera statistic, and there is no undo. var fpArmed = false; function renderActions() { if (!actionsBox || !currentEvent) return; actionsBox.hidden = false; actionsBox.textContent = ''; fpArmed = false; if (currentEvent.false_positive_by) { setNote('오탐으로 판정됨 · ' + currentEvent.false_positive_by); return; } if (currentEvent.acknowledged_by) { setNote('확인 완료 · ' + currentEvent.acknowledged_by); return; } var ack = document.createElement('button'); ack.type = 'button'; ack.className = 'btn-primary'; ack.textContent = '확인 처리'; ack.addEventListener('click', function () { ack.disabled = true; post('/ack') .then(function (res) { if (!res) return; currentEvent.acknowledged_by = window.APP_USER || '확인'; renderActions(); renderMeta(); }) .catch(function () { ack.disabled = false; setNote('확인 처리에 실패했습니다.', true); }); }); actionsBox.appendChild(ack); var fp = document.createElement('button'); fp.type = 'button'; fp.className = 'fp-btn'; fp.textContent = '오탐'; fp.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)'); fp.addEventListener('click', function () { if (!fpArmed) { fpArmed = true; fp.classList.add('is-armed'); fp.textContent = '오탐 확정?'; setTimeout(function () { if (!fpArmed) return; fpArmed = false; fp.classList.remove('is-armed'); fp.textContent = '오탐'; }, 4000); return; } fp.disabled = true; ack.disabled = true; post('/false-positive') .then(function (res) { if (!res) return; currentEvent.acknowledged_by = window.APP_USER || '확인'; currentEvent.false_positive_by = window.APP_USER || '오탐'; renderActions(); renderMeta(); }) .catch(function () { fp.disabled = false; ack.disabled = false; setNote('오탐 처리에 실패했습니다.', true); }); }); actionsBox.appendChild(fp); } function renderMeta() { var ev = currentEvent || {}; meta.textContent = ''; meta.removeAttribute('aria-busy'); addRow('심각도', buildBadge(ev.severity)); addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type)); addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id)); addRow('카메라', textNode(ev.camera_id, 'mono')); addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono')); addRow('지속', textNode(fmtDuration(ev.duration_s))); if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono')); if (ev.zone_id) addRow('구역', textNode(ev.zone_id, 'mono')); var status = ev.false_positive_by ? '오탐 (' + ev.false_positive_by + ')' : (ev.acknowledged_by ? '확인됨 (' + ev.acknowledged_by + ')' : '미확인'); addRow('처리 상태', textNode(status)); // 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다. var metrics = ev.metrics || {}; Object.keys(metrics).forEach(function (key) { addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[key]))); }); } function renderHead() { var ev = currentEvent || {}; if (titleEl) titleEl.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '이벤트 상세'); if (whenEl) { whenEl.textContent = ''; whenEl.appendChild(textNode(deviceNames[ev.device_id] || ev.device_id, 'ev-src')); whenEl.appendChild(textNode(ev.camera_id, 'ev-cam')); whenEl.appendChild(textNode(formatTime(ev.occurred_at), 'ev-at')); } } function renderSnapshots(snaps) { if (!snapBox) return; snapBox.textContent = ''; var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; }); if (kept.length === 0) { var none = document.createElement('p'); none.className = 'detail-empty'; none.textContent = '이 이벤트에는 저장된 스냅샷이 없습니다 (미촬영이거나 PIPA 보존기간이 지나 삭제됨).'; 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, and // inventing seconds here would be a fabricated measurement on an accident record. var CAPTIONS = ['이전', '직전', '발생 시점']; 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); }); } // --- outbound links + improvement action --------------------------------------------- function renderLinks() { var ev = currentEvent || {}; var pkg = document.getElementById('detail-package'); if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view'; var zones = document.getElementById('detail-zones'); if (zones) { // Deep-link the zone editor straight at this camera. Reading an incident and then having // to re-select the camera by hand on the next screen is the same "the system knows this // and asks you anyway" problem the editor's own picker exists to remove. zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '') + '&camera=' + encodeURIComponent(ev.camera_id || ''); zones.hidden = !ev.camera_id; } } function wireActionForm() { var form = document.getElementById('detail-action-form'); var note = document.getElementById('detail-action-note'); var status = document.getElementById('detail-action-status'); if (!form) return; form.addEventListener('submit', function (e) { e.preventDefault(); var text = (note.value || '').trim(); if (!text || !currentEvent) return; status.textContent = '기록 중…'; fetch(B + '/api/actions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' }, body: JSON.stringify({ camera_id: currentEvent.camera_id, note: text, event_id: currentEvent.event_id }) }) .then(function (r) { if (r.status === 401) { window.location.href = B + '/login'; return; } if (!r.ok) throw new Error('action_failed'); note.value = ''; status.textContent = '기록되었습니다. 안전 리포트의 [개선 조치 효과]에서 전후 비교를 볼 수 있습니다.'; }) .catch(function () { status.textContent = '기록에 실패했습니다. 권한을 확인하세요.'; }); }); } function render(data) { currentEvent = data.event || {}; renderHead(); renderMeta(); renderActions(); renderLinks(); renderSnapshots(data.snapshots); } // Device NAMES, so the incident record says "1공장 프레스라인" and not "pi-a1" — the same // provenance the stream row shows. A failure here is non-fatal: the raw id stands in. function loadDevices() { return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } }) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (d) { if (!d || !Array.isArray(d.devices)) return; d.devices.forEach(function (dev) { if (dev.name) deviceNames[dev.device_id] = dev.name; }); }) .catch(function () { /* raw id stands in */ }); } loadCsrf(); wireActionForm(); // Names first so the record never paints an id and then swap it for a name a beat later. loadDevices().then(function () { return 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'); }); }); })();