move_detect/server/public/assets/detail.js
유창욱 43a3d069a3 fix(audit): 1.1.0 3차 재감사 — 확정 결함 14건 전량 해소
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증된 4건은 감사 문서에
REFUTED 로 남겼다(히트맵 왜곡·에스컬레이션 전파·스트림 필터 고정·설정 미배선).

CRITICAL 2건 — 둘 다 이번 기능이 만든 회귀다:

R3-1 dispatch_alerts.php 의 장치 건전성 단계가 try 밖에 있었다. 이 단계만
move_devices·move_device_metrics 를 읽는데 통보 경로는 그 두 표를 건드리지
않는다 — 즉 마이그레이션 미적용·커넥션 한도·lock timeout 이 원래대로면 정상
발송됐을 실행을 통째로 죽인다. 원인이 지속형이라 매분 반복되고 콘솔은 멀쩡해
보인다. 1·2단계를 각각 격리했고, 3단계의 exit(1) 도 없앴다 — 거기 도달하는
예외는 저장소 장애이고 4단계 재시도가 바로 그것에서 회복하려고 존재한다.

R3-2 간헐 고장에 감지기가 양방향으로 무너졌다. edge/daemon.py:105 는 성공
프레임 하나에 streak 를 지워 cameras_erroring 를 0 으로 되돌린다. 정상 표본
하나로 저하 구간을 끊으니 (a) 두 시간 고장에도 sustain 을 못 넘어 아무것도
못 올리고 (b) 끊긴 구간마다 새 앵커가 생겨 6시간에 54건을 쏟아냈다. 엣지
낙상 감지기의 recover_grace_s 와 같은 규율로 짧은 정상을 흡수하고, uuid 에
재고지 버킷을 더해 주기당 1건으로 묶었다(같은 입력 ≤7건). 기존 16개 테스트가
전부 단조 시퀀스라 이 결함이 CI를 통과했다 — 그래서 플래핑·폭주·회복·미래시각
테스트를 추가했다.

HIGH 5건: 센티넬이 메일·웹훅으로 회사 밖에 나갔고(라벨을 app.js 에만 넣은 것이
원인), 사건 상세 링크로 존재하지 않는 카메라에 구역을 저장할 수 있었고, 인제스트가
센티넬을 예약값으로 안 막아 엣지가 그 camera_id 로 올린 진짜 낙상이 화면에서
더 안 보이게 됐고, BUILD-INFO 가 릴리스 이전 커밋을 가리켰고, 실제 자격증명
파일이 납품 폴더 안에 있었다.

값이 흩어진 것이 센티넬 결함의 근본 원인이라 단일 출처를 만들었다:
App\DeviceScope(서버) + assets/device-scope.js(프런트) + PdoEventRepository 의
NOT_DEVICE SQL 조각. 셋이 어긋나면 DeviceScopeTest 가 잡는다.

MEDIUM 7건: 카메라별 집계 3곳 제외, 설정 점검 오판정, 개선조치 대상 거부,
라벨 CSV 제외, 낡은 지표로 판정 금지, 미래 시각 표본 제거, 죽은 노드 재고지.

검증: phpunit 250 passed (신규 10) · pytest 는 dist 재생성 후 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 00:06:35 +09:00

409 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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}">.
*
* 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: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
};
// 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(DeviceScope.label(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 actionCard = document.getElementById('detail-action-card')
|| document.getElementById('detail-action-form');
if (actionCard) actionCard.hidden = !isCameraEvent(ev);
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 || '');
// 노드 단위 사건(장치 이상·감시 성능 저하)에는 카메라가 없다. 링크를 남겨 두면
// ?camera=__device__ 로 편집기가 열리고, 그 값이 그대로 저장되면 어떤 카메라와도
// 매칭되지 않는 구역이 '활성'으로 남는다. 서버도 막지만 여기서도 내보내지 않는다.
zones.hidden = !ev.camera_id || DeviceScope.is(ev.camera_id);
}
}
/** 이 사건이 카메라에 대한 것인가. 개선 조치는 카메라 단위 개념이라 노드 사건에는 없다. */
function isCameraEvent(ev) {
return !!(ev && ev.camera_id) && !DeviceScope.is(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');
});
});
})();