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
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
/**
|
||
* 사고조사 패키지.
|
||
*
|
||
* 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다:
|
||
* 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지,
|
||
* 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면
|
||
* 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다.
|
||
*
|
||
* 인쇄 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 = '패키지를 불러오지 못했습니다.'; });
|
||
})();
|