move_detect/server/public/assets/package.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

240 lines
11 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.

/**
* 사고조사 패키지.
*
* 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다:
* 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지,
* 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면
* 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다.
*
* 인쇄 CSS 가 앱 크롬을 걷어내므로 이 화면 그대로 PDF 로 저장해 제출할 수 있다.
*
* XSS-safe: 모든 동적 값은 textContent.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var root = document.getElementById('package-root');
if (!root) return;
var m = location.pathname.match(/\/events\/(\d+)\/package/);
if (!m) { root.textContent = '잘못된 주소입니다.'; return; }
var id = m[1];
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: '화재·연기'
};
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
var ALERT_STATUS = {
sent: '발송됨', pending: '발송 대기', failed: '발송 실패', suppressed: '억제됨 (정비 중)'
};
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = text;
return n;
}
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function stamp(ts) {
var d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '-';
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function clockOnly(ts) {
var d = new Date(Number(ts) * 1000);
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function section(title) {
var s = el('section', 'pkg-section');
s.appendChild(el('h2', 'pkg-h2', title));
return s;
}
function kv(parent, label, value) {
var row = el('div', 'pkg-kv');
row.appendChild(el('dt', null, label));
row.appendChild(el('dd', null, value === null || value === undefined || value === '' ? '-' : String(value)));
parent.appendChild(row);
}
function render(d) {
var ev = d.event || {};
root.textContent = '';
// ---- 표지 -----------------------------------------------------------------
var head = el('header', 'pkg-head');
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
var sub = el('p', 'pkg-sub');
sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, DeviceScope.label(ev.camera_id), stamp(ev.occurred_at)]
.filter(Boolean).join(' · ');
head.appendChild(sub);
root.appendChild(head);
// ---- 사건 개요 -------------------------------------------------------------
var facts = section('사건 개요');
var dl = el('dl', 'pkg-facts');
kv(dl, '사건 번호', ev.event_id);
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
kv(dl, '장치', ev.device_id);
kv(dl, '카메라', DeviceScope.label(ev.camera_id));
kv(dl, '현장', ev.site);
kv(dl, '발생 시각', stamp(ev.occurred_at));
kv(dl, '지속', (ev.duration_s || 0) + '초');
kv(dl, '처리 상태', ev.false_positive_by ? ('오탐 판정 · ' + ev.false_positive_by)
: (ev.acknowledged_by ? ('확인 · ' + ev.acknowledged_by + ' · ' + stamp(ev.acknowledged_at))
: '미확인'));
// 대응 시간은 조사에서 가장 먼저 묻는 숫자다.
if (ev.acknowledged_at) {
var tta = Math.max(0, Math.round(ev.acknowledged_at - ev.occurred_at));
kv(dl, '대응 소요', tta < 60 ? tta + '초' : Math.floor(tta / 60) + '분 ' + (tta % 60) + '초');
}
facts.appendChild(dl);
root.appendChild(facts);
// ---- 증거 프레임 -----------------------------------------------------------
var evidence = section('증거 프레임');
var snaps = (d.snapshots || []).filter(function (s) { return s && s.file_uuid; });
if (!snaps.length) {
evidence.appendChild(el('p', 'pkg-empty',
'저장된 스냅샷이 없습니다 (미촬영이거나 개인정보 보존기간이 지나 삭제됨).'));
} else {
var strip = el('div', 'pkg-frames');
var CAPTIONS = ['이전', '직전', '발생 시점'];
snaps.forEach(function (s, i) {
var fig = el('figure', 'pkg-frame');
var img = document.createElement('img');
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
img.alt = '사고 스냅샷';
img.loading = 'lazy';
fig.appendChild(img);
fig.appendChild(el('figcaption', null,
snaps.length > 1 ? CAPTIONS[Math.max(0, CAPTIONS.length - snaps.length + i)] : '발생 시점'));
strip.appendChild(fig);
});
evidence.appendChild(strip);
evidence.appendChild(el('p', 'pkg-note',
'얼굴은 개인정보 보호를 위해 촬영 시점에 블러 처리되어 저장됩니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.'));
}
root.appendChild(evidence);
// ---- 전후 타임라인 ---------------------------------------------------------
var win = Math.round((d.context_window_s || 1800) / 60);
var tl = section('같은 카메라의 전후 ' + win + '분');
var rows = d.timeline || [];
if (!rows.length) {
tl.appendChild(el('p', 'pkg-empty', '해당 구간에 다른 감지가 없습니다.'));
} else {
var list = el('ol', 'pkg-timeline');
rows.forEach(function (r) {
var li = el('li', 'pkg-tl-row' + (r.event_id === ev.event_id ? ' is-subject' : ''));
li.appendChild(el('span', 'pkg-tl-time', clockOnly(r.occurred_at)));
li.appendChild(el('span', 'pkg-tl-sev sev-' + r.severity, SEV_LABELS[r.severity] || r.severity));
li.appendChild(el('span', 'pkg-tl-type', TYPE_LABELS[r.event_type] || r.event_type));
li.appendChild(el('span', 'pkg-tl-state',
r.false_positive_by ? '오탐' : (r.acknowledged_by ? '확인됨' : '미확인')));
list.appendChild(li);
});
tl.appendChild(list);
}
root.appendChild(tl);
// ---- 통보 이력 -------------------------------------------------------------
// 시스템이 "봤다"는 것만 증명하고 사람에게 "닿았는가"를 비워두면 조사의 절반이 빈다.
var notif = section('통보 이력');
var alerts = d.notifications || [];
if (!alerts.length) {
notif.appendChild(el('p', 'pkg-empty',
'이 사건에 대한 통보 기록이 없습니다. 통보 경로가 설정되지 않았거나, 심각도가 발송 기준에 못 미쳤습니다.'));
} else {
var nt = el('ul', 'pkg-alerts');
alerts.forEach(function (a) {
var li = el('li', 'pkg-alert is-' + a.status);
li.appendChild(el('span', 'pkg-alert-ch', (a.channel_name || '삭제된 채널')));
li.appendChild(el('span', 'pkg-alert-status', ALERT_STATUS[a.status] || a.status));
li.appendChild(el('span', 'pkg-alert-when',
a.sent_at ? stamp(a.sent_at) : ('접수 ' + stamp(a.queued_at))));
if (a.last_error) li.appendChild(el('span', 'pkg-alert-err', a.last_error));
nt.appendChild(li);
});
notif.appendChild(nt);
}
root.appendChild(notif);
// ---- 위험성평가 -------------------------------------------------------------
var risk = section('해당 위험요인 · 통제 대책');
if (!d.risk_control) {
risk.appendChild(el('p', 'pkg-empty',
'이 감지 유형에 등록된 위험요인이 없습니다. [안전 관리] 화면에서 등록하면 다음 조사부터 함께 실립니다.'));
} else {
var rc = d.risk_control;
var rdl = el('dl', 'pkg-facts');
kv(rdl, '위험요인', rc.hazard);
kv(rdl, '현재 대책', rc.control);
kv(rdl, '위험도', rc.risk_score + ' (강도 ' + rc.severity_rating + ' × 빈도 ' + rc.likelihood_rating + ')');
kv(rdl, '최종 검토', rc.reviewed_by ? (rc.reviewed_by + ' · ' + stamp(rc.reviewed_at)) : '-');
risk.appendChild(rdl);
}
root.appendChild(risk);
// ---- 조치 이력 -------------------------------------------------------------
var acts = section('이 카메라의 개선 조치 이력');
var actions = d.actions || [];
if (!actions.length) {
acts.appendChild(el('p', 'pkg-empty', '기록된 조치가 없습니다.'));
} else {
var al = el('ul', 'pkg-actions');
actions.forEach(function (a) {
var li = el('li', null);
li.appendChild(el('span', 'pkg-act-date', stamp(a.taken_at).slice(0, 10)));
li.appendChild(el('span', 'pkg-act-note', a.note));
li.appendChild(el('span', 'pkg-act-by', a.created_by));
al.appendChild(li);
});
acts.appendChild(al);
}
root.appendChild(acts);
// ---- 서명 -----------------------------------------------------------------
var sign = section('확인');
var grid = el('div', 'pkg-sign');
['조사자', '안전관리자', '사업주 · 대리인'].forEach(function (role) {
var box = el('div', 'pkg-sign-box');
box.appendChild(el('span', 'pkg-sign-role', role));
box.appendChild(el('span', 'pkg-sign-line', ''));
grid.appendChild(box);
});
sign.appendChild(grid);
root.appendChild(sign);
var foot = el('footer', 'pkg-foot');
foot.textContent = '생성 ' + stamp(d.generated_at) + ' · 작성 ' + (d.generated_by || '-')
+ ' · 본 문서는 AI 감지 기록에 근거한 보조 자료이며 공식 재해조사 결과를 대체하지 않습니다.';
root.appendChild(foot);
document.title = '사고조사 #' + ev.event_id + ' · AI SAFETY CCTV';
}
var printBtn = document.getElementById('package-print');
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
fetch(B + '/api/events/' + encodeURIComponent(id) + '/package', { headers: { Accept: 'application/json' } })
.then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
if (r.status === 404) { root.textContent = '해당 사건을 찾을 수 없습니다.'; return null; }
if (!r.ok) throw new Error('package_failed');
return r.json();
})
.then(function (d) { if (d) render(d); })
.catch(function () { root.textContent = '패키지를 불러오지 못했습니다.'; });
})();