move_detect/server/public/assets/package.js
유창욱 e00ad69aa3 feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
  뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
  밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
  '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
  (전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
  무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
  플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
  통째로 꺼지는 경로다.

전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
  KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
  (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
  시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
  넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.

시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
  납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
  캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.

━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━

통보 — 이 제품의 정의에 해당하는 구멍
  지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
  넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
  어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
  없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
  아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
  '아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
  인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.

조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
  전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
  초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.

끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
  /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
  장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
  없었다. 지표는 있는데 그것을 켜는 사건이 없었다.

관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
  엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
  목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
  통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
  의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).

증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
  없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
  exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
  매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
  조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).

운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
  모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
  백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.

미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
  감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
  (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
  명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
2026-08-18 07:51:23 +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, 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, '카메라', 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 = '패키지를 불러오지 못했습니다.'; });
})();