move_detect/server/public/assets/detail.js
유창욱 7daee0b372 fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.

CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
  edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
  끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
  검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
  허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
  (tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
  프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
  지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
  한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
  식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
  (/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
  전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
  경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.

HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
  그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
  공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
  04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
  Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
  DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
  이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.

MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
  크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
  claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
  정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
  발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
  공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.

LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
  읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
  PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.

수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.

부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.

검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
2026-08-18 10:33:02 +09:00

397 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(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');
});
});
})();