From e00ad69aa3b1fb48f7533002000ca985336d966e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Tue, 18 Aug 2026 07:51:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(safety,ux):=20=ED=86=B5=EB=B3=B4=C2=B7?= =?UTF-8?q?=EC=96=B5=EC=A0=9C=C2=B7=EC=A6=9D=EA=B1=B0=20=EC=B2=B4=EA=B3=84?= =?UTF-8?q?=20=EC=8B=A0=EC=84=A4=20+=20UI/UX=20=EA=B0=90=EC=82=AC=2021?= =?UTF-8?q?=EA=B1=B4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/INDEX.md | 50 ++ docs/PROJECT_MAP.md | 37 +- edge/app.py | 16 +- edge/beacon.py | 176 ++++++ edge/daemon.py | 58 +- edge/detectors/integrity.py | 151 +++++ edge/detectors/pending.py | 103 ++++ edge/events.py | 22 + edge/runtime.py | 18 +- edge/tuning.py | 101 ++++ server/config.example.php | 13 + server/deploy/build_cafe24_package.sh | 6 + server/deploy/config.template.php | 15 + server/docs/server-deploy.md | 42 ++ .../migrations/2026-08-09-safety-platform.sql | 191 ++++++ server/public/assets/admin.js | 322 ++++++++++ server/public/assets/app.css | 539 ++++++++++++++++- server/public/assets/app.js | 568 ++++++++++++++++-- server/public/assets/compliance.js | 420 +++++++++++++ server/public/assets/detail.js | 279 ++++++++- server/public/assets/package.js | 240 ++++++++ server/public/assets/report.js | 122 +++- server/public/assets/setup.js | 168 ++++++ server/public/assets/zone-draw.js | 258 +++++++- server/public/index.php | 44 +- server/schema.sql | 167 ++++- server/src/Alert/AlertDispatcher.php | 130 ++++ server/src/Alert/Channels.php | 185 ++++++ server/src/Alert/EscalationWatch.php | 91 +++ server/src/Api/ConfigController.php | 31 +- server/src/Api/HeartbeatController.php | 24 +- server/src/Api/ThresholdCatalog.php | 101 ++++ server/src/Config.php | 30 + server/src/Dashboard/ActionsController.php | 17 +- .../src/Dashboard/AdminConfigController.php | 247 ++++++++ server/src/Dashboard/ComplianceController.php | 225 +++++++ .../src/Dashboard/DeviceStatusController.php | 72 ++- server/src/Dashboard/ReportController.php | 3 + .../src/Dashboard/SuppressionController.php | 136 +++++ server/src/Dashboard/ZoneConfigController.php | 6 + server/src/Repository/ActionRepository.php | 2 +- server/src/Repository/AlertRepository.php | 34 ++ .../src/Repository/DeviceMetricRepository.php | 15 + server/src/Repository/DeviceRepository.php | 8 + server/src/Repository/EventRepository.php | 21 + server/src/Repository/HandoverRepository.php | 13 + .../Repository/Pdo/PdoActionRepository.php | 10 +- .../src/Repository/Pdo/PdoAlertRepository.php | 184 ++++++ .../src/Repository/Pdo/PdoAuditRepository.php | 80 ++- .../Pdo/PdoDeviceMetricRepository.php | 65 ++ .../Repository/Pdo/PdoDeviceRepository.php | 12 + .../src/Repository/Pdo/PdoEventRepository.php | 62 ++ .../Repository/Pdo/PdoHandoverRepository.php | 66 ++ .../Pdo/PdoRiskControlRepository.php | 92 +++ .../Pdo/PdoSuppressionRepository.php | 109 ++++ .../Repository/Pdo/PdoThresholdRepository.php | 108 +++- .../src/Repository/RiskControlRepository.php | 13 + .../src/Repository/SuppressionRepository.php | 23 + server/src/Repository/ThresholdRepository.php | 12 + server/src/View/admin.php | 110 ++++ server/src/View/compliance.php | 107 ++++ server/src/View/event_detail.php | 50 +- server/src/View/events.php | 34 +- server/src/View/incident_package.php | 19 + server/src/View/layout.php | 20 +- server/src/View/report.php | 7 +- server/src/View/setup.php | 25 + server/src/View/zones.php | 117 +++- server/tests/AlertDispatchTest.php | 242 ++++++++ server/tests/AuditChainTest.php | 140 +++++ server/tests/PdoIntegrationTest.php | 290 ++++++++- server/tests/RouteTableTest.php | 11 + server/tests/SafetyConfigTest.php | 328 ++++++++++ server/tests/Support/InMemoryRepositories.php | 209 ++++++- server/tools/backup.php | 157 +++++ server/tools/bootstrap.php | 69 +++ server/tools/dispatch_alerts.php | 125 ++++ server/tools/maintain.php | 122 ++++ server/tools/seed_demo.php | 373 ++++++++++++ server/tools/verify_audit_chain.php | 102 ++++ tests/test_beacon.py | 134 +++++ tests/test_daemon.py | 110 ++++ tests/test_integrity.py | 142 +++++ tests/test_pending_detectors.py | 94 +++ tests/test_tuning.py | 112 ++++ 85 files changed, 9368 insertions(+), 234 deletions(-) create mode 100644 edge/beacon.py create mode 100644 edge/detectors/integrity.py create mode 100644 edge/detectors/pending.py create mode 100644 edge/tuning.py create mode 100644 server/migrations/2026-08-09-safety-platform.sql create mode 100644 server/public/assets/admin.js create mode 100644 server/public/assets/compliance.js create mode 100644 server/public/assets/package.js create mode 100644 server/public/assets/setup.js create mode 100644 server/src/Alert/AlertDispatcher.php create mode 100644 server/src/Alert/Channels.php create mode 100644 server/src/Alert/EscalationWatch.php create mode 100644 server/src/Api/ThresholdCatalog.php create mode 100644 server/src/Dashboard/AdminConfigController.php create mode 100644 server/src/Dashboard/ComplianceController.php create mode 100644 server/src/Dashboard/SuppressionController.php create mode 100644 server/src/Repository/AlertRepository.php create mode 100644 server/src/Repository/DeviceMetricRepository.php create mode 100644 server/src/Repository/HandoverRepository.php create mode 100644 server/src/Repository/Pdo/PdoAlertRepository.php create mode 100644 server/src/Repository/Pdo/PdoDeviceMetricRepository.php create mode 100644 server/src/Repository/Pdo/PdoHandoverRepository.php create mode 100644 server/src/Repository/Pdo/PdoRiskControlRepository.php create mode 100644 server/src/Repository/Pdo/PdoSuppressionRepository.php create mode 100644 server/src/Repository/RiskControlRepository.php create mode 100644 server/src/Repository/SuppressionRepository.php create mode 100644 server/src/View/admin.php create mode 100644 server/src/View/compliance.php create mode 100644 server/src/View/incident_package.php create mode 100644 server/src/View/setup.php create mode 100644 server/tests/AlertDispatchTest.php create mode 100644 server/tests/AuditChainTest.php create mode 100644 server/tests/SafetyConfigTest.php create mode 100644 server/tools/backup.php create mode 100644 server/tools/bootstrap.php create mode 100644 server/tools/dispatch_alerts.php create mode 100644 server/tools/maintain.php create mode 100644 server/tools/seed_demo.php create mode 100644 server/tools/verify_audit_chain.php create mode 100644 tests/test_beacon.py create mode 100644 tests/test_integrity.py create mode 100644 tests/test_pending_detectors.py create mode 100644 tests/test_tuning.py diff --git a/docs/INDEX.md b/docs/INDEX.md index eca2a5b..2be2f8d 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,55 @@ # INDEX — 작업 기록 (완료 결과만) +- **2026-08-09** 안전 플랫폼 확장 — 로드맵 23건 중 소프트웨어로 완결 가능한 19건 반영. + 마이그레이션 `2026-08-09-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장). + **통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다. + ① 엣지 GPIO 경광등(`edge/beacon.py`) — 네트워크 없이 동작하는 유일한 경로, /api/config 로 설정 수신 + ② 메일·웹훅 채널 + 크론 디스패처(`tools/dispatch_alerts.php`, `--dry-run` 지원) + ③ 미확인 위급 → `unacked_escalation` 이벤트 발행(멱등) → 상위 채널로 에스컬레이션. + 중복 발송은 (event,channel) UNIQUE 로 막는다(크론 중첩 실행 대비). + **조용한 실패 제거**: 카메라 화각 이동·가림·조도 저하 감지(`detectors/integrity.py`) — + 존 판정이 무의미해진 것을 처음으로 알 수 있게 됨. 스토리지 여유 감시. + **엣지에 닿지 않던 배선 2건 수리**: `thresholds_from_config()` 가 파싱만 하고 버려서 임계값이 + 장비에 적용되지 않던 것(`edge/tuning.py`), 존/임계값 저장 시 `config_version` 을 올리지 않아 + 설정 드리프트 표시가 영원히 꺼져 있던 것. + **관리 화면 신설**: 임계값 편집(카탈로그 기반 범위 강제 — 엣지가 기동 시 거부하는 값을 키보드 + 앞에서 막는다) · 통보 채널 · 정비 억제(사유 필수·최대 8시간·감지는 계속되고 통보만 보류) · + 위험성평가 · 교대 인수인계 · 설비 성능 추이 · 설치 점검(`/setup`, 실제 상태 조회 기반). + **증거·규정**: 감사로그 해시체인(수정·삭제·우회삽입 전부 탐지, `tools/verify_audit_chain.php`) · + 사고조사 패키지 1클릭(전후 타임라인+통보 이력+위험요인+조치 이력+서명란, 인쇄 CSS) · + 이벤트→조치 직접 기록 · 현장별 롤업(전역 계정만). + **운영**: 일괄 확인(위급 제외·2단계) · 키보드 트리아지(J/K/A/F/Enter) · 모바일 레이아웃 + (44px 타깃) · 보존기간 정리(`tools/maintain.php`) · 백업/복구(`tools/backup.php`, www 안 출력 거부) · + 데모 시드(`tools/seed_demo.php`). + 검증: pytest 통과(3 skip = 하드웨어·환경) · PHPUnit 221 (skip 0, +59) · Chrome 실렌더로 + 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→변조 탐지 전 경로 확인 · + cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개) 재생성, 소스 패리티 통과. + **미착수 4건(모델·하드웨어 선행)**: 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보와 + HEF 컴파일이 관문이라 감지기 배선·이벤트 계약·테스트만 완성하고 기본 비활성으로 둠 + (`edge/detectors/pending.py`). PLC 접점 협착 게이팅도 같은 이유로 인터페이스만. + +- **2026-08-09** UI/UX 전면 점검 후 21건 일괄 반영 (동선 7 · 전달 9 · 시각 5). + **동선**: ① 스트림 정렬을 '미확인 우선'에서 **엄격 시간순**으로 되돌림 — 확인 상태가 연대기를 + 뒤집어 사고 재구성이 불가능했고, 확인 시 행이 목록 밖으로 사라져 아래 행이 밀려 올라오며 + 오클릭으로 다른 이벤트가 확인 처리될 수 있었다. 확인은 제자리 전환 + 확정 토스트로 변경. + ② 처리 큐는 정렬이 아니라 뷰모드(전체/미확인만) 필터로 분리. ③ 이벤트 상세에 확인/오탐 액션 + 신설(판정 화면에 판정 도구가 없던 문제). ④ 오탐·구역삭제 2단계 확정(되돌릴 수 없는 동작). + ⑤ 구역 설정에 장치·카메라 선택기(자유 텍스트 ID 암기 제거, 가짜 플레이스홀더 제거). + ⑥ 날짜 조회(키셋 커서 시딩, 최대 6페이지) — 사고 조사 진입로. ⑦ 경보음 세션 음소거 + (새 위급 도착 시 자동 해제). + **전달**: KPI 스트립에 실제 집계 범위 표기(24h 브리프와 숫자 충돌 해소), 중대=위급+높음 + 인라인 정의, 페이지 명칭 3종·날짜 포맷 3종 통일, '확인 필요' 칩을 전 심각도로 확장, + 상세 `CONFIDENCE`→`검출 신뢰도 %`, 오탐율·시간패턴 표본 게이트, 개선조치 효과에 푸아송 + 잡음 범위(√before) 판정 보류, 카메라 위험 순위 강조 기준을 상대값으로, 날짜 구분선. + **시각**: 구역 설정·이벤트 상세를 카드 셸로 재구성(둘 다 스타일 없는 기본 폼이었음), + 히트맵 토글 aria-pressed 시각 상태 신설(CSS 규칙 자체가 없었음), 존 색을 팔레트 정합 + (팔레트 외 보라 제거, 채움+라벨칩+협착 점선), 주 동작 accent 승격, 장치명 플리커 제거. + 검증: pytest 통과 · PHPUnit 162 (skip 0) · Chrome 실렌더로 4개 화면 + 확인/오탐/날짜조회/ + 삭제 2단계/히트맵 토글 실동작 확인 · cafe24 번들 재생성(소스 패리티 통과). + **미해결(설계 판단)**: 확인 취소(undo)는 넣지 않음 — `acknowledged_by`는 감사 로그가 남는 + 추가 전용 기록이라, 무음 되돌리기는 안전 시스템에서 더 나쁜 속성. 서버에 unack 라우트를 + 두는 안은 별도 결정 필요. + - **2026-08-08** 감사 잔여 확정 항목 일괄 처리 (VERSION 0.9.0 신설). **보안**: CSP nonce 도입(인라인 2블록, unsafe-inline 없음)+보안헤더를 PHP로 이관(개발/납품 동일), /logout CSRF, 계정 잠금 423→401(사용자명 열거 제거). **엣지**: 큐 flush 배치 상한(정전 복구 시 감지 정지 diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 8613fbf..506e405 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -25,21 +25,48 @@ | `daemon.py` / `runtime.py` / `health.py` | 상주 루프 · 하트비트 텔레메트리 | | `adapters/` | hailo_pose · movenet_pose(CPU 폴백) · picamera_source(CSI) · uvc_source(USB) | | `probe.py` / `tools/` | 카메라 사전 점검 · HEF/모델 fetch (빌드머신 전용) | +| `beacon.py` | 현장 경광등·부저 (GPIO). **네트워크 없이 동작하는 유일한 통보 경로** — /api/config 의 `beacons` 로 설정 수신, 하드웨어 없으면 NullBeacon 으로 폴백 | +| `tuning.py` | 서버 임계값 → 살아 있는 감지기에 적용. 이전에는 `thresholds_from_config()` 가 파싱만 하고 버려서 관리자 변경이 장비에 닿지 않았다 | +| `detectors/integrity.py` | 카메라 화각 이동·가림·조도 저하 (`camera_moved`/`camera_obstructed`/`low_light`). 존 판정이 조용히 무의미해지는 실패 모드를 드러낸다 | +| `detectors/pending.py` | 보호구·중장비 근접·화재 — 배선/계약 완료, **모델 미확보로 기본 비활성** (AGPL 아닌 가중치 + HEF 컴파일 필요) | ## server/ (PHP 8 + PDO — Cafe24) -- 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php` +- 진입: `public/index.php` (라우트 전부 여기), + 뷰: `src/View/{layout,events,login,zones,report,event_detail,admin,compliance,setup,incident_package}.php` +- `src/Alert/`: `AlertDispatcher`(어떤 통보를 낼지 결정 — 순수 로직, I/O 없음) · + `Channels`(email/webhook 발송; `local` 은 엣지가 자체 GPIO 로) · `EscalationWatch`(미확인 위급 → + `unacked_escalation` 이벤트 발행, event_uuid 로 멱등) +- `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명. + 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게) +- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · + `backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA). + 전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치 - 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` · `GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) · `GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` · `GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그, ack 겸함) · `GET /api/report`(운영 지표+시간패턴+전조경보+추이) · `GET /api/export/labels`(라벨 JSONL, 관리자) · `GET/POST /api/actions`(개선 조치·전후 효과) · - 페이지 `/`, `/login`, `/zones`, `/report`(안전 리포트), `/events/{id}/view` + `GET /api/devices/{id}/metrics`(성능 시계열) · `GET/POST /api/admin/thresholds*`(임계값 편집, + admin) · `GET/POST /api/admin/channels*`(통보 채널, admin) · `GET/POST /api/suppressions*`(정비 + 억제) · `GET/POST /api/risks*`(위험성평가) · `GET/POST /api/handovers`(교대 인수인계) · + `GET /api/events/{id}/package`(사고조사 패키지) · + 페이지 `/`, `/login`, `/zones`, `/report`(안전 리포트), `/compliance`(안전 관리), + `/admin`(시스템 설정), `/setup`(설치 점검), `/events/{id}/view`, `/events/{id}/package/view` - `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF · - `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO) -- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) · - `zone-draw.js` · `detail.js` · `app.css` + `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시·시스템설정·억제·규정 · + `src/Alert/` 통보 결정·발송·에스컬레이션 · `src/Repository/` 14종 (인터페이스+PDO) +- 감사로그는 **해시체인**(prev_hash/entry_hash). 수정·삭제·우회삽입이 전부 탐지된다 — + `tools/verify_audit_chain.php`, 손상 시 종료 코드 1 +- 프런트: `public/assets/app.js`(이벤트 콘솔 — 엄격 시간순 정렬 · 뷰모드(전체/미확인만) · + 날짜 조회(커서 시딩) · 확인 제자리 전환+토스트 · 오탐 2단계 · 경보음 음소거 · 날짜 구분선) · + `device-tree.js`(장치→카메라 트리 레일) · `zone-draw.js`(장치·카메라 선택기 · 존 채움/라벨칩 · + 삭제 2단계) · `detail.js`(상세 + 확인/오탐 판정) · `report.js`(표본·유의성 게이트) · `app.css` +- UI 불변식: 스트림 정렬은 **항상 occurred_at 내림차순** — 확인 상태가 순서를 바꾸면 안 됨 + (연대기 = 사고 재구성 근거). 처리 큐는 정렬이 아니라 필터(`filters.unackedOnly`)로만 만든다. +- 페이지 명칭은 nav 라벨 · `

` · `` 세 곳이 동일 문자열 (`이벤트`/`구역 설정`/`안전 리포트`). + 날짜 표기는 `YYYY-MM-DD` 로 3개 화면 통일 (각 파일의 로컬 `formatTime`/`fmtDate`). - DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/`. 존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시 ZoneConfigController가 검증, /api/config로 엣지 전달 diff --git a/edge/app.py b/edge/app.py index 66bb3b9..1519778 100644 --- a/edge/app.py +++ b/edge/app.py @@ -8,7 +8,7 @@ from edge.detectors.zone import ZoneIntrusionDetector from edge.engine import EventEngine from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter from edge.models import BBox -from edge.events import EventType +from edge.events import Event, EventType, severity_for # Evidence timeline offsets (seconds before the event) — 3 frames tell how it developed. # The server accepts at most 3 snapshots per event (EventValidator), so this is the cap. @@ -34,7 +34,8 @@ def _head_boxes(persons: list) -> list: return boxes -def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None): +def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None, + integrity=None): ok, frame = source.read() if not ok: # No frame this tick. Return None (not []) so the daemon can tell "camera produced no @@ -70,6 +71,17 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f # daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before. wall_now = wall_fn() if wall_fn is not None else None events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now) + # Camera-integrity findings are about the FRAME, not about any track, so they are produced + # here (where the frame is) rather than inside the engine (which only sees tracks). They + # matter because a camera that has been knocked askew keeps producing perfectly healthy + # frames while every zone drawn on it now points somewhere else — the failure that used to + # be invisible until after an incident. + if integrity is not None: + stamp = wall_now if wall_now is not None else now + for etype, metrics in integrity.observe(frame, now): + et = EventType(etype) + events.append(Event(et, severity_for(et), engine.cfg.camera_id, None, None, + stamp, 0.0, metrics, None)) for tr in lost: pool.forget(tr.track_id) if events: diff --git a/edge/beacon.py b/edge/beacon.py new file mode 100644 index 0000000..f895fec --- /dev/null +++ b/edge/beacon.py @@ -0,0 +1,176 @@ +"""현장 경광등·부저 — 엣지 노드가 스스로 울리는 유일한 통보 경로. + +WHY LOCAL + 폐쇄망 현장에서는 FCM 도 Slack 도 쓸 수 없고, 사내 SMTP·웹훅조차 없는 곳이 있다. 게다가 + 서버 경유 통보는 업링크가 살아 있을 때만 동작한다 — 정전이나 스위치 장애로 회선이 끊긴 + 순간은 사고 위험이 오히려 높아지는 때다. GPIO 출력은 그 모든 것과 무관하게 동작한다: + 감지기가 이벤트를 만든 그 자리에서, 네트워크 없이, 수십 밀리초 안에. + + 그래서 이것이 어떤 사이트든 **가장 먼저 설정해야 할 통보 경로**다. 서버측 채널 + (App\\Alert\\Channels)은 이 위에 얹는 보조 수단이다. + +설정 전달 + 서버 /api/config 의 `beacons` 배열로 내려온다 (핀 번호, latch/pulse, 유지 시간, + active_high). 관리 화면에서 켜고 끄므로 현장에서 edge.env 를 고칠 필요가 없다. + +하드웨어 부재 + gpiozero / RPi.GPIO 가 없거나(개발 PC, CI) 핀 열기에 실패하면 NullBeacon 으로 조용히 + 떨어진다. 경광등이 없다고 감지가 멈추면 안 된다 — 통보는 감지의 부가 기능이지 전제가 + 아니다. 다만 그 사실은 로그로 남긴다: 아무도 모르게 경광등이 죽어 있는 것이 최악이다. + +실장비 검증 + 핀이 실제로 떨어지는지는 이 파일의 단위 테스트로 증명할 수 없다. tests/hardware/ 의 + OFF-HW 항목으로 남긴다 (docs/hardware-bringup.md). +""" + +import time + +_SEVERITY_RANK = {"info": 0, "warning": 1, "high": 2, "critical": 3} + + +class NullBeacon: + """하드웨어가 없을 때의 무동작 구현. 인터페이스는 동일하다.""" + + available = False + + def fire(self, severity: str, now: float) -> bool: + return False + + def tick(self, now: float) -> None: + pass + + def release(self) -> None: + pass + + +class GpioBeacon: + """핀 하나를 latch(고정) 또는 pulse(일정 시간 후 해제) 로 구동한다. + + `latch` 는 사람이 확인할 때까지 계속 울리는 대신 hold_s 뒤 자동 해제한다. 무한 latch 는 + 현장에 사람이 갈 수 없는 시간대(야간·주말)에 아무도 끌 수 없는 소음이 되고, 그러면 다음 + 주에 배선이 뽑혀 있다. + """ + + available = True + + def __init__(self, pin: int, *, mode: str = "latch", hold_s: float = 30.0, + active_high: bool = False, min_severity: str = "high", + backend=None, log=None): + self.pin = int(pin) + self.mode = mode if mode in ("latch", "pulse") else "latch" + self.hold_s = max(0.5, float(hold_s)) + self.active_high = bool(active_high) + self.min_rank = _SEVERITY_RANK.get(min_severity, 2) + self._on_until: float | None = None + self._log = log or (lambda m: None) + self._backend = backend if backend is not None else _open_pin(self.pin, self.active_high) + + def _write(self, on: bool) -> None: + # active_high=False 는 릴레이 모듈의 통상 배선(LOW 에서 도통)이다. 반대로 물린 + # 현장에서 경광등이 상시 켜져 있는 것을 설정으로 뒤집을 수 있어야 한다. + self._backend.set(on if self.active_high else not on) + + def fire(self, severity: str, now: float) -> bool: + if _SEVERITY_RANK.get(severity, 0) < self.min_rank: + return False + self._write(True) + # 이미 울리는 중에 새 이벤트가 오면 유지 시간을 연장한다 — 사고가 이어지는 동안 + # 경광등이 중간에 꺼졌다 켜지면 사람이 "끝났다"로 읽는다. + self._on_until = now + self.hold_s + return True + + def tick(self, now: float) -> None: + """루프가 매 틱 호출한다. 유지 시간이 지나면 해제.""" + if self._on_until is not None and now >= self._on_until: + self._write(False) + self._on_until = None + + def release(self) -> None: + try: + self._write(False) + finally: + close = getattr(self._backend, "close", None) + if close: + close() + + +class _PinBackend: + """gpiozero / RPi.GPIO 중 있는 쪽에 얇게 붙는다.""" + + def __init__(self, device=None, rpi=None, pin: int = 0): + self._device = device + self._rpi = rpi + self._pin = pin + + def set(self, high: bool) -> None: + if self._device is not None: + if high: + self._device.on() + else: + self._device.off() + elif self._rpi is not None: + self._rpi.output(self._pin, self._rpi.HIGH if high else self._rpi.LOW) + + def close(self) -> None: + if self._device is not None: + self._device.close() + elif self._rpi is not None: + self._rpi.cleanup(self._pin) + + +def _open_pin(pin: int, active_high: bool): # pragma: no cover (hardware) + try: + from gpiozero import DigitalOutputDevice + # initial_value=False 는 "비활성" 을 뜻한다. active_high 해석은 _write 가 담당하므로 + # 여기서는 항상 raw 출력으로 열고, 부팅 순간 경광등이 번쩍이지 않도록 초기값을 준다. + dev = DigitalOutputDevice(pin, active_high=True, initial_value=not active_high) + return _PinBackend(device=dev, pin=pin) + except Exception: + pass + try: + import RPi.GPIO as GPIO + GPIO.setmode(GPIO.BCM) + GPIO.setup(pin, GPIO.OUT, initial=GPIO.LOW if active_high else GPIO.HIGH) + return _PinBackend(rpi=GPIO, pin=pin) + except Exception: + raise RuntimeError("no usable GPIO backend") + + +def build_beacons(config: dict, log=None) -> list: + """/api/config 의 `beacons` 배열 -> 구동 가능한 비콘 목록. + + 핀 하나가 열리지 않아도 나머지는 살린다. 전부 실패하면 빈 목록이고, 그때도 감지·전송은 + 그대로 돈다. + """ + log = log or (lambda m: None) + out = [] + for b in (config or {}).get("beacons") or []: + if not isinstance(b, dict): + continue + pin = int(b.get("gpio_pin") or 0) + if pin <= 0: + continue # 0 = 미배선 (관리 화면에서 핀을 아직 안 정한 상태) + try: + out.append(GpioBeacon( + pin, + mode=str(b.get("mode") or "latch"), + hold_s=float(b.get("hold_s") or 30.0), + active_high=bool(b.get("active_high")), + min_severity=str(b.get("min_severity") or "high"), + log=log, + )) + except Exception as e: + # 조용히 넘어가면 아무도 경광등이 죽은 줄 모른다. + log(f"beacon pin {pin} unavailable ({type(e).__name__}: {e}); alerts on this node " + f"will not sound locally") + return out + + +def fire_all(beacons, events, now: float | None = None) -> int: + """이벤트 목록 중 가장 높은 심각도로 비콘을 울린다. @return 실제로 울린 비콘 수.""" + if not beacons or not events: + return 0 + now = time.monotonic() if now is None else now + worst = max((getattr(e.severity, "value", e.severity) for e in events), + key=lambda s: _SEVERITY_RANK.get(s, 0)) + return sum(1 for b in beacons if b.fire(worst, now)) diff --git a/edge/daemon.py b/edge/daemon.py index fbcdae9..ac9cfa4 100644 --- a/edge/daemon.py +++ b/edge/daemon.py @@ -7,6 +7,14 @@ import os _LOG_EVERY = 300 +def _beacon_signature(beacons): + """Identity of a beacon set, so a sync only re-opens GPIO when the wiring actually changed. + Rebuilding unconditionally would blink every strobe on every sync (every ~150 ticks).""" + return tuple(sorted((getattr(b, "pin", 0), getattr(b, "mode", ""), getattr(b, "hold_s", 0.0), + getattr(b, "active_high", False), getattr(b, "min_rank", 0)) + for b in (beacons or []))) + + def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper) import sys import traceback @@ -19,7 +27,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0, zones_for=None, log=None, heartbeat_interval_s=None, health_fn=None, configured_cameras=None, - notify=None): + notify=None, beacons=None, build_beacons_fn=None): # `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All # cameras share this loop, one shared clock read per tick, and the periodic # sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged, @@ -39,7 +47,17 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, # The ping happens at the TOP of each tick, before any camera work. That is the point: it # proves the loop is turning. Pinging after the work would still ping while a camera step # blocks forever, which is the exact failure the watchdog exists to catch. - counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0} + # Local beacons (siren / strobe on the node's own GPIO). This is the ONE notification path + # that survives a dead uplink, so it fires here — the instant this loop sees the event — + # rather than waiting for the server-side dispatcher. Configured from /api/config, so the + # list is rebuilt on sync; None means "not built yet", [] means "built, nothing wired". + if beacons is None: + beacons = [] + if build_beacons_fn is None: + from edge.beacon import build_beacons as build_beacons_fn + from edge.beacon import fire_all as _fire_beacons + counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0, + "beacon_fires": 0, "tuned": 0} streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging def note(key, label, exc=None): @@ -85,12 +103,48 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, else: counters["frames"] += 1 streak.pop(id(rt), None) # recovered — reset the streak + # Sound the local beacon on this tick's events. Wrapped: a wiring fault must + # never take down the detection loop — a node that stopped watching because + # its strobe failed would be the worst possible trade. + if produced and beacons: + try: + counters["beacon_fires"] += _fire_beacons(beacons, produced, now) + except Exception as e: + note("beacon", "beacon fire failed", e) + # Release latched beacons whose hold time has elapsed. + for b in beacons: + try: + b.tick(now) + except Exception as e: + note("beacon", "beacon tick failed", e) try: if sync_every and i % sync_every == 0: cfg = client.fetch_config() if cfg is not None: for rt in runtimes: rt.apply_zones(zones_for(cfg, rt)) + # Thresholds too. The server has always SENT these; nothing ever applied + # them, so an administrator's retuning never reached the device. + thresholds = cfg.get("thresholds") if isinstance(cfg, dict) else None + if isinstance(thresholds, dict) and thresholds: + for rt in runtimes: + applier = getattr(rt, "apply_thresholds", None) + if applier is None: + continue + applied = applier(thresholds, log=lambda m: log(m, None)) + counters["tuned"] += len(applied) + # Rebuild the local beacons from the published wiring. Doing it on sync + # (not only at boot) is what lets an administrator turn a site's strobe on + # without anyone walking to the node. + try: + rebuilt = build_beacons_fn(cfg if isinstance(cfg, dict) else {}, + log=lambda m: log(m, None)) + if _beacon_signature(rebuilt) != _beacon_signature(beacons): + for b in beacons: + b.release() + beacons = rebuilt + except Exception as e: + note("beacon", "beacon rebuild failed", e) # Remember the config version so heartbeats can echo it back — the server's # config-drift indicator is meaningless until the device reports what it runs. if isinstance(cfg, dict) and cfg.get("version") is not None: diff --git a/edge/detectors/integrity.py b/edge/detectors/integrity.py new file mode 100644 index 0000000..66ff455 --- /dev/null +++ b/edge/detectors/integrity.py @@ -0,0 +1,151 @@ +"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하. + +WHY THIS EXISTS + 존(위험구역) 판정은 "카메라가 어제와 같은 곳을 보고 있다"는 전제 위에 통째로 서 있다. + 청소하다 삼각대를 스치거나, 지게차가 브래킷을 치거나, 누가 각도를 조금 돌리면 제한구역 + 폴리곤은 엉뚱한 바닥을 가리키게 된다. 그런데 시스템은 아무 오류도 내지 않는다 — 프레임은 + 계속 들어오고, 사람은 계속 검출되고, 대시보드는 초록불이다. 침입 감지가 죽었다는 사실을 + 누군가 사고가 난 뒤에야 알게 되는 것이 이 제품의 가장 조용한 실패 모드였다. + +접근 + 무거운 특징점 매칭 대신 저해상도 그레이스케일 요약본을 기준으로 삼는다. Pi 에서 매 프레임 + 돌아야 하고, 우리가 감지하려는 것은 미세한 흔들림이 아니라 "다른 곳을 보고 있다" 수준의 + 변화이기 때문에 이 해상도로 충분하다. 세 신호를 각각 다른 물리량으로 잡는다: + + moved — 기준 대비 평균 절대 차이(MAD)가 임계 초과. 각도가 바뀌면 화면 전체가 바뀐다. + obstructed — 프레임 자체의 분산 급락. 렌즈를 천으로 덮으면 평탄한 회색이 된다. + low_light — 평균 밝기 하강. 야간·조명 고장 시 검출력이 떨어졌음을 스스로 신고한다. + + 사람이 화면을 지나가도 MAD 는 오른다. 그래서 **지속 시간**을 요구한다: 순간 변화가 아니라 + sustain_s 이상 유지될 때만 확정한다. 사람은 지나가고 화각 변화는 남는다는 성질을 쓴다. + +기준 프레임 + 부팅 후 warmup_s 동안 조용히 관찰하다가 그 시점의 요약본을 기준으로 잡는다. 부팅 직후 + 한 장을 그냥 쓰면 마침 그때 사람이 서 있었을 경우 그 사람이 영원히 배경이 된다. + 확정 이벤트를 낸 뒤에는 기준을 현재 화면으로 재설정한다 — 재설정하지 않으면 옮겨진 + 카메라가 cooldown 마다 같은 이벤트를 영원히 재발행한다. + +의존성 없음: numpy 만 쓴다 (OpenCV 도 불필요). 폐쇄망 번들에 새 휠을 추가하지 않는다. +""" + +from dataclasses import dataclass + +import numpy as np + + +@dataclass +class IntegrityConfig: + enabled: bool = True + # 기준 프레임을 잡기까지의 관찰 시간. 이 사이 화면에 사람이 있어도 평균이 희석된다. + warmup_s: float = 20.0 + # 요약본 해상도. 32x24 면 사람 하나가 화면의 몇 퍼센트인지 구분하기엔 충분하고, + # 조명 깜빡임 같은 화소 단위 잡음은 자연히 뭉개진다. + grid_w: int = 32 + grid_h: int = 24 + # 0..255 스케일의 평균 절대 차이. 30 은 "화면 절반 이상이 다른 밝기" 수준으로, + # 사람 한 명이 지나가는 것(보통 5~15)과 명확히 갈린다. + moved_mad: float = 30.0 + # 화각 변화로 확정하기까지 필요한 지속 시간. 지나가는 사람은 이 시간을 못 채운다. + sustain_s: float = 8.0 + # 렌즈 가림: 프레임 분산이 이 밑이면 사실상 단색 화면이다. + obstructed_var: float = 25.0 + obstructed_sustain_s: float = 5.0 + # 조도: 평균 밝기가 이 밑이면 포즈 추정 신뢰도가 급격히 떨어진다. + low_light_mean: float = 40.0 + low_light_sustain_s: float = 60.0 + # 확정 후 같은 유형을 다시 낼 때까지의 최소 간격 (엔진 cooldown 과 별개의 2차 방어). + reemit_s: float = 900.0 + + +class CameraIntegrityWatch: + """프레임을 받아 (event_type, metrics) 목록을 돌려준다. 이벤트 생성은 엔진이 한다.""" + + def __init__(self, cfg: IntegrityConfig | None = None): + self.cfg = cfg or IntegrityConfig() + self._baseline: np.ndarray | None = None + self._first_seen: float | None = None + # 각 상태가 "연속으로 참이기 시작한 시각". None 이면 지금은 정상. + self._since: dict[str, float | None] = {"moved": None, "obstructed": None, "low_light": None} + self._last_emit: dict[str, float] = {} + + # ---------------------------------------------------------------- helpers + def _summarize(self, frame) -> np.ndarray | None: + """프레임 -> (grid_h, grid_w) float32 그레이스케일 요약본.""" + if frame is None: + return None + a = np.asarray(frame) + if a.ndim == 3: + # BGR/RGB 어느 쪽이든 평균 그레이면 충분하다 (우리가 재는 것은 구조 변화이지 색이 아니다). + a = a.mean(axis=2) + elif a.ndim != 2: + return None + if a.size == 0: + return None + h, w = a.shape[:2] + gh, gw = self.cfg.grid_h, self.cfg.grid_w + if h < gh or w < gw: + return None + # 정수 인덱싱으로 다운샘플: 보간 라이브러리 없이 균등 격자를 뽑는다. + ys = (np.linspace(0, h - 1, gh)).astype(np.int32) + xs = (np.linspace(0, w - 1, gw)).astype(np.int32) + return a[np.ix_(ys, xs)].astype(np.float32) + + def _hold(self, key: str, active: bool, now: float, need_s: float) -> bool: + """상태가 need_s 이상 연속 유지되면 True 를 한 번 돌려준다.""" + if not active: + self._since[key] = None + return False + if self._since[key] is None: + self._since[key] = now + return False + if now - self._since[key] < need_s: + return False + last = self._last_emit.get(key) + if last is not None and now - last < self.cfg.reemit_s: + return False + self._last_emit[key] = now + return True + + # ---------------------------------------------------------------- main + def observe(self, frame, now: float) -> list[tuple[str, dict]]: + """@return [(event_type_value, metrics), ...] — 보통 빈 리스트.""" + if not self.cfg.enabled: + return [] + summary = self._summarize(frame) + if summary is None: + return [] + + if self._first_seen is None: + self._first_seen = now + mean = float(summary.mean()) + var = float(summary.var()) + + out: list[tuple[str, dict]] = [] + + # 조도와 가림은 기준 프레임이 없어도 판정할 수 있다 — 프레임 자체의 성질이다. + if self._hold("obstructed", var < self.cfg.obstructed_var, now, self.cfg.obstructed_sustain_s): + out.append(("camera_obstructed", {"variance": round(var, 2), "mean_luma": round(mean, 1)})) + if self._hold("low_light", mean < self.cfg.low_light_mean, now, self.cfg.low_light_sustain_s): + out.append(("low_light", {"mean_luma": round(mean, 1)})) + + # 화각 이동은 기준이 있어야 한다. + if self._baseline is None: + if now - self._first_seen >= self.cfg.warmup_s: + self._baseline = summary + return out + + mad = float(np.abs(summary - self._baseline).mean()) + if self._hold("moved", mad > self.cfg.moved_mad, now, self.cfg.sustain_s): + out.append(("camera_moved", {"mad": round(mad, 1), "threshold": self.cfg.moved_mad})) + # 새 화각을 기준으로 승격한다. 하지 않으면 옮겨진 카메라가 영원히 같은 경보를 낸다. + # (존은 여전히 무효 상태다 — 그래서 이 이벤트가 HIGH 이고, 사람이 존을 다시 그려야 한다.) + self._baseline = summary + self._since["moved"] = None + + return out + + def reset_baseline(self) -> None: + """존을 다시 그린 뒤 등, 현재 화각을 정상으로 재선언할 때.""" + self._baseline = None + self._first_seen = None + self._since = {k: None for k in self._since} diff --git a/edge/detectors/pending.py b/edge/detectors/pending.py new file mode 100644 index 0000000..7de43a2 --- /dev/null +++ b/edge/detectors/pending.py @@ -0,0 +1,103 @@ +"""모델 대기 감지기 — 보호구 미착용 · 중장비 근접 · 화재/연기. + +STATUS: 배선 완료, 모델 없음. 기본 비활성이며 이 상태에서는 어떤 이벤트도 발화하지 않는다. + +왜 모델 없이 이 파일이 있는가 + 이 세 가지는 현장에서 가장 자주 요구되는 항목이고(특히 보호구), 나중에 급하게 끼워 + 넣으면 이벤트 계약·심각도·쿨다운·스냅샷 정책을 그때그때 즉흥적으로 정하게 된다. 계약을 + 지금 고정해 두면 모델이 확보되는 날 바꿀 것은 `Model` 구현 하나뿐이다. + +무엇이 막고 있는가 (정직하게) + ultralytics(YOLO) 계열은 AGPL 이라 이 프로젝트에서 사용 금지다(CLAUDE.md). 따라서 + - 보호구/중장비: AGPL 이 아닌 라이선스의 가중치 + Hailo HEF 컴파일이 선행되어야 한다. + - 화재/연기: 위와 같거나, 열화상 센서 같은 다른 감지 방식을 택해야 한다. + 확보 전까지 `enabled=False` 이며, 켜더라도 `model=None` 이면 아무 일도 하지 않는다. + "곧 됩니다" 상태로 켜 두는 것이 가장 위험하다 — 현장은 감지되고 있다고 믿는다. + +모델 인터페이스 (구현체가 만족해야 하는 계약) + detect(frame, persons) -> list[dict] + 각 dict: {"kind": "ppe_missing"|"proximity"|"fire_smoke", + "confidence": float 0..1, + "track_id": int | None, + "spatial": (x, y) | None, # 0..1 정규화 + "metrics": dict} # 유형별 부가 수치 + 프레임당 한 번 호출되며, 예외를 던지면 그 틱만 건너뛴다(파이프라인은 계속 돈다). +""" + +from dataclasses import dataclass, field + + +_KINDS = ("ppe_missing", "proximity", "fire_smoke") + + +@dataclass +class PendingConfig: + # 기본 False. 모델이 없는 채로 켜면 "감지되고 있다"는 잘못된 믿음만 만든다. + enabled: bool = False + # 유형별 최소 신뢰도. 보호구는 오탐이 곧 현장 반발이라 높게 잡는다. + min_confidence: dict = field(default_factory=lambda: { + "ppe_missing": 0.80, "proximity": 0.70, "fire_smoke": 0.85, + }) + # 유형별 재발행 억제(초). 엔진 쿨다운과 별개의 2차 방어 — 보호구 미착용은 한 사람이 + # 한 시간을 그러고 있어도 사건 하나다. + reemit_s: dict = field(default_factory=lambda: { + "ppe_missing": 600.0, "proximity": 30.0, "fire_smoke": 60.0, + }) + + +class PendingDetectors: + """모델이 주어지면 그 출력을 이벤트 후보로 정규화한다. 없으면 아무것도 하지 않는다.""" + + def __init__(self, cfg: PendingConfig | None = None, model=None, log=None): + self.cfg = cfg or PendingConfig() + self.model = model + self._log = log or (lambda m: None) + self._last: dict[tuple, float] = {} + + @property + def active(self) -> bool: + """켜져 있고 실제로 판정할 수단이 있는가. 대시보드/기동 로그가 이것을 보고한다.""" + return bool(self.cfg.enabled and self.model is not None) + + def _allow(self, kind: str, track_id, now: float) -> bool: + key = (kind, track_id) + last = self._last.get(key) + if last is not None and now - last < float(self.cfg.reemit_s.get(kind, 60.0)): + return False + self._last[key] = now + return True + + def forget(self, track_id: int) -> None: + self._last = {k: v for k, v in self._last.items() if k[1] != track_id} + + def observe(self, frame, persons, now: float) -> list[tuple[str, dict, object, object]]: + """@return [(event_type, metrics, track_id, spatial), ...] — 보통 빈 리스트. + + 모델이 없거나 비활성이면 항상 빈 리스트다. 그것이 정상 상태다. + """ + if not self.active: + return [] + try: + raw = self.model.detect(frame, persons) or [] + except Exception as e: + # 한 틱의 추론 실패로 넘어짐 감지까지 멈추면 안 된다. + self._log(f"pending detector model failed: {type(e).__name__}: {e}") + return [] + + out = [] + for d in raw: + if not isinstance(d, dict): + continue + kind = d.get("kind") + if kind not in _KINDS: + continue + conf = float(d.get("confidence") or 0.0) + if conf < float(self.cfg.min_confidence.get(kind, 1.0)): + continue + track_id = d.get("track_id") + if not self._allow(kind, track_id, now): + continue + metrics = dict(d.get("metrics") or {}) + metrics["confidence"] = round(conf, 3) + out.append((kind, metrics, track_id, d.get("spatial"))) + return out diff --git a/edge/events.py b/edge/events.py index d40c735..37b24e3 100644 --- a/edge/events.py +++ b/edge/events.py @@ -18,6 +18,19 @@ class EventType(str, Enum): OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별) NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복 ERGO_REPORT = "ergo_report" # 자세 부담 노출 주기 리포트 (카메라 단위, 익명) + # --- 감시 자체의 건전성 --------------------------------------------------------- + # 존 판정은 카메라 화각이 고정이라는 전제 위에 서 있다. 청소하다 삼각대를 건드리면 + # 제한구역이 엉뚱한 곳을 가리키는데, 시스템은 계속 "정상"이라고 보고한다. 이 조용한 + # 실패를 드러내는 것이 아래 세 유형이다. + CAMERA_MOVED = "camera_moved" # 배경 기준 프레임 대비 화각 이동 + CAMERA_OBSTRUCTED = "camera_obstructed" # 렌즈 가림 (분산 급락) + LOW_LIGHT = "low_light" # 조도 저하로 검출력 하락 + # --- 모델 확보 후 활성화되는 자리 ----------------------------------------------- + # 감지기 배선과 이벤트 계약은 준비되어 있고, 추론 모델만 비어 있다. AGPL 아닌 + # 가중치를 확보하기 전까지 엣지는 이 유형을 발화하지 않는다 (edge/detectors/pending.py). + PPE_MISSING = "ppe_missing" # 보호구(안전모·안전대) 미착용 + PROXIMITY = "proximity" # 중장비-사람 근접 + FIRE_SMOKE = "fire_smoke" # 화재·연기 class Severity(str, Enum): @@ -44,6 +57,15 @@ _SEVERITY = { # 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO. EventType.NEAR_FALL: Severity.INFO, EventType.ERGO_REPORT: Severity.INFO, + # 화각이 틀어지면 그 카메라의 존 판정 전체가 무효다. 사람이 다친 사건은 아니지만 + # "지금부터 못 보고 있다"는 뜻이라 HIGH — 정비 대기열이 아니라 당일 처리 대상이다. + EventType.CAMERA_MOVED: Severity.HIGH, + EventType.CAMERA_OBSTRUCTED: Severity.HIGH, + # 조도 저하는 검출력이 떨어졌다는 신호이지 실패는 아니다 — monitoring_degraded 와 같은 급. + EventType.LOW_LIGHT: Severity.WARNING, + EventType.PPE_MISSING: Severity.WARNING, + EventType.PROXIMITY: Severity.HIGH, + EventType.FIRE_SMOKE: Severity.CRITICAL, } diff --git a/edge/runtime.py b/edge/runtime.py index b0e9dab..de4962c 100644 --- a/edge/runtime.py +++ b/edge/runtime.py @@ -5,8 +5,10 @@ from edge.detectors.fall import FallDetector from edge.detectors.immobility import ImmobilityWatchdog from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.zone import ZoneIntrusionDetector +from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig from edge.engine import EventEngine from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter +from edge.tuning import apply_thresholds class PipelineRuntime: @@ -37,6 +39,10 @@ class PipelineRuntime: ) self.ring = RingBuffer() self.snapshotter = Snapshotter(FaceBlurrer()) + # Watches the frame itself rather than the people in it: a knocked-askew or covered + # camera keeps delivering healthy frames while every zone drawn on it is now wrong. + self.integrity = CameraIntegrityWatch( + getattr(config, "integrity", None) or IntegrityConfig()) def step(self, now: float) -> list: self._tick += 1 @@ -51,7 +57,17 @@ class PipelineRuntime: return [] if ok else None return run_once(self.source, self.pose, self.tracker, self.pool, self.engine, self.ring, self.snapshotter, self.sink, - now_fn=lambda: now, wall_fn=self._wall) + now_fn=lambda: now, wall_fn=self._wall, + integrity=self.integrity) def apply_zones(self, zones: list) -> None: self.zone_detector.zones = list(zones) + + def apply_thresholds(self, thresholds: dict, log=None) -> dict: + """Push server-published tunables into the live detectors. + + Until this existed the server stored thresholds, /api/config delivered them and the edge + parsed them into a dict it then threw away — the two ends of the wire were built and the + middle was missing, so nothing an administrator changed ever reached a device. + """ + return apply_thresholds(self, thresholds, log=log) diff --git a/edge/tuning.py b/edge/tuning.py new file mode 100644 index 0000000..09f1d18 --- /dev/null +++ b/edge/tuning.py @@ -0,0 +1,101 @@ +"""서버에서 내려온 임계값을 살아 있는 감지기에 적용한다. + +WHAT WAS BROKEN + `edge/zones.py:thresholds_from_config()` 는 정의만 되어 있고 어디에서도 호출되지 않았다. + 서버에는 `move_thresholds` 표가 있고, `GET /api/config` 가 그것을 성실히 실어 보내고, + 엣지는 그 값을 파싱한 뒤 그대로 버렸다. 즉 배선의 양 끝이 다 있는데 가운데가 비어 있어서, + 관리자가 무엇을 바꾸든 장비는 부팅 시 edge.env 값으로 영원히 돌았다. 이 파일이 그 가운데다. + +WHY VALIDATE HERE TOO + 서버의 ThresholdCatalog 가 이미 범위를 강제하지만, 그것은 *편집 화면* 의 방어선이다. + DB 를 직접 고친 값, 구버전 서버, 손상된 응답은 여전히 도달할 수 있고, `now - t >= limit` + 비교에 NaN 이 들어가면 감지기가 조용히 꺼지고 0 이 들어가면 영구 알람이 된다 + (edge/config.py `_positive` 의 주석과 같은 이유). 기동 시점의 검증은 여기서 반복된다. + +WHY NOT CRASH + 기동 검증은 잘못된 값에 예외를 던진다 — 그때는 운영자가 화면 앞에 있다. 여기는 24/7 로 + 도는 동기화 경로다. 서버가 보낸 한 값이 이상하다고 공장 안 장비를 재시작 루프에 빠뜨리면 + 그 카메라는 그 순간부터 아무것도 못 본다. 나쁜 값은 무시하고 로그로 남기고, 나머지는 + 적용한다. +""" + +import math + +# 서버 키 -> (대상 객체 경로, 속성명). 하나의 키가 두 곳에 적용되는 경우도 있다 +# (motion_thr 은 fall 과 immobility 가 공유한다 — edge/config.py 가 같은 값을 둘에 넣는다). +_TARGETS: dict[str, tuple[tuple[str, str], ...]] = { + "fall_prone_s": (("fall", "prone_confirm_s"),), + "fall_no_motion_s": (("fall", "no_motion_s"),), + "immobile_s": (("immobility", "immobile_s"),), + "ghost_reappear_s": (("ghost", "reappear_s"),), + "zone_debounce_s": (("zone", "debounce_s"),), + "cooldown_s": (("engine", "cooldown_s"),), + "motion_thr": (("fall", "motion_thr"), ("immobility", "motion_thr")), +} + +# 정수 노브는 별도 취급: 감지기 설정이 아니라 런타임 자체의 샘플링 주기다. +_INT_KNOBS = {"infer_every"} + + +def _clean(raw) -> float | None: + """유한 양수만 통과. 그 외에는 None (호출부가 로그하고 무시).""" + try: + v = float(raw) + except (TypeError, ValueError): + return None + if not math.isfinite(v) or v <= 0: + return None + return v + + +def apply_thresholds(runtime, thresholds: dict, log=None) -> dict: + """`runtime` 의 감지기 설정에 서버 임계값을 적용한다. + + @return 실제로 적용된 {key: value} — 호출부가 로그/테스트에 쓴다. + """ + log = log or (lambda m: None) + if not isinstance(thresholds, dict) or not thresholds: + return {} + + engine = getattr(runtime, "engine", None) + if engine is None: + return {} + + # 감지기별 설정 객체. 이름이 다른 구현이 오더라도 없는 것은 조용히 건너뛴다. + holders = { + "fall": getattr(getattr(engine, "fall", None), "cfg", None), + "immobility": getattr(getattr(engine, "immobility", None), "cfg", None), + "ghost": getattr(getattr(engine, "ghost", None), "cfg", None), + "zone": getattr(getattr(engine, "zone", None), "cfg", None), + "engine": getattr(engine, "cfg", None), + } + + applied: dict[str, float] = {} + for key, raw in thresholds.items(): + if key in _INT_KNOBS: + v = _clean(raw) + if v is None or v != int(v): + log(f"threshold {key}={raw!r} ignored (must be a positive integer)") + continue + # infer_every 는 런타임의 샘플링 주기. 상한을 두는 이유: 서버가 실수로 1000 을 + # 보내면 그 카메라는 사실상 감시를 멈춘다. + runtime.infer_every = max(1, min(10, int(v))) + applied[key] = runtime.infer_every + continue + + targets = _TARGETS.get(key) + if targets is None: + # 알 수 없는 키는 무시한다. 서버가 신버전이고 엣지가 구버전인 정상 상황이다. + continue + v = _clean(raw) + if v is None: + log(f"threshold {key}={raw!r} ignored (must be a finite positive number)") + continue + for holder_name, attr in targets: + holder = holders.get(holder_name) + if holder is None or not hasattr(holder, attr): + continue + setattr(holder, attr, v) + applied[key] = v + + return applied diff --git a/server/config.example.php b/server/config.example.php index f049413..58840c5 100644 --- a/server/config.example.php +++ b/server/config.example.php @@ -14,4 +14,17 @@ return [ // PIPA retention: worker snapshots are purged after this many days (0 disables). 'snapshot_retention_days' => 7, 'event_retention_days' => 0, // 0 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료) + + // --- 통보 (tools/dispatch_alerts.php) -------------------------------------------- + // 대시보드의 외부 접속 주소. 알림 메시지의 사건 링크에 쓰인다. 비어 있으면 디스패처가 + // 실행을 거부한다 — 열리지 않는 링크가 담긴 알림을 보내는 것보다 낫다. + 'public_base_url' => '', // 예: 'https://example.com/move_detect' + // 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다. + 'escalate_after_seconds' => 300, + + // --- 보존 / 용량 ------------------------------------------------------------------ + // 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리). + 'metric_retention_days' => 30, + // 스냅샷 볼륨 여유 공간 하한(MB). 밑돌면 device_fault 로 알린다. 0 = 검사 안 함. + 'storage_min_free_mb' => 200, ]; diff --git a/server/deploy/build_cafe24_package.sh b/server/deploy/build_cafe24_package.sh index efd9494..3949708 100755 --- a/server/deploy/build_cafe24_package.sh +++ b/server/deploy/build_cafe24_package.sh @@ -148,6 +148,12 @@ cp "$SERVER/schema.sql" "$PRIV/schema.sql" # running these (phpMyAdmin, date order) instead of re-importing — schema.sql is for a # FRESH database only. Like schema.sql, delete from the host after applying. cp -r "$SERVER/migrations" "$PRIV/migrations" +# tools/ ships INSIDE the private folder, never under www/. These are CLI entry points the host +# runs from cron (notification dispatch, retention) or by hand (backup, audit verification), and +# bootstrap.php refuses to run under any SAPI but CLI — but the real guarantee is that they are +# not web-reachable at all, because dispatch_alerts.php sends mail and backup.php reads every +# incident record in the database. +cp -r "$SERVER/tools" "$PRIV/tools" # The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one. cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess" cp "$SERVER/deploy/config.template.php" "$PRIV/config.php" diff --git a/server/deploy/config.template.php b/server/deploy/config.template.php index 73bd439..47890d2 100644 --- a/server/deploy/config.template.php +++ b/server/deploy/config.template.php @@ -32,4 +32,19 @@ return [ // 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다. // DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다. 'event_retention_days' => 0, + + // ---- 4. 통보 (알림을 쓰려면 채우세요) ------------------------------- + // 대시보드 주소입니다. 알림 메일/웹훅에 들어가는 사건 링크가 이 주소로 만들어집니다. + // 비워두면 통보 디스패처(tools/dispatch_alerts.php)가 실행을 거부합니다 — + // 열리지 않는 링크가 담긴 알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다. + 'public_base_url' => '', // 예: 'https://example.com/move_detect' + // 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를 + // 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분. + 'escalate_after_seconds' => 300, + + // ---- 5. 용량 관리 (그대로 두세요) ----------------------------------- + // 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다. + 'metric_retention_days' => 30, + // 스냅샷 저장 공간 여유 하한(MB). 밑돌면 대시보드에 경고가 뜹니다. 0 = 검사 안 함. + 'storage_min_free_mb' => 200, ]; diff --git a/server/docs/server-deploy.md b/server/docs/server-deploy.md index 02773d7..70b3be5 100644 --- a/server/docs/server-deploy.md +++ b/server/docs/server-deploy.md @@ -280,3 +280,45 @@ client as a bare 500 with no body/trace, which is the intended behavior — turn - [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per §4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php` and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing. + +## 11. Scheduled jobs (cron) + +Two jobs turn the installation from "records incidents" into "responds to incidents" and keep +the database inside a shared-hosting quota. Both live in `move_detect_app/tools/` — **inside the +private folder, never under `www/`** — and refuse to run under any SAPI but CLI. + +```cron +# 통보 디스패처 — 통보 발송 + 미확인 에스컬레이션 + 실패 재시도. +# 이것이 돌지 않으면 감지는 되지만 아무에게도 전달되지 않는다 (콘솔을 보고 있는 사람 제외). +*/1 * * * * /usr/bin/php /home/<account>/move_detect_app/tools/dispatch_alerts.php >> /home/<account>/logs/alerts.log 2>&1 + +# 보존기간 정리 — 스냅샷(PIPA) · 성능 시계열 · nonce. +# 이벤트 기록은 event_retention_days 를 명시적으로 설정한 현장에서만 지워진다. +17 3 * * * /usr/bin/php /home/<account>/move_detect_app/tools/maintain.php >> /home/<account>/logs/maintain.log 2>&1 +``` + +**처음 켤 때는 반드시 dry-run 부터.** 잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는 +두 번 다시 이 기능을 켜지 않습니다. + +```bash +php move_detect_app/tools/dispatch_alerts.php --dry-run +php move_detect_app/tools/maintain.php --dry-run +``` + +`public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지 않는 링크가 담긴 +알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다 (`config.php` §4). + +### 그 외 도구 (수동 실행) + +| 도구 | 용도 | +|------|------| +| `tools/backup.php --out=<디렉터리>` | DB 덤프 + 스냅샷 아카이브 + 복구 안내. 출력 경로가 `www/` 안이면 거부합니다. | +| `tools/verify_audit_chain.php` | 감사로그 해시체인 검증. 손상 시 종료 코드 1 과 함께 끊긴 지점을 출력합니다. | +| `tools/seed_demo.php --days=45` | 시연·QA용 데모 데이터. **운영 DB 에서는 절대 실행하지 마십시오** (`--purge` 로 데모 행만 제거). | + +## 12. Post-deploy: 설치 점검 화면 + +로그인 후 `/setup` 을 엽니다. 각 단계는 실제 상태를 조회해 판정하므로, 체크가 켜졌다면 그 +단계는 정말로 동작하는 것입니다. 특히 **⑤ 통보 경로** 가 비어 있으면 이 시스템은 감지 기록 +장치일 뿐입니다 — 현장 경광등(엣지 GPIO)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한 +경로입니다. diff --git a/server/migrations/2026-08-09-safety-platform.sql b/server/migrations/2026-08-09-safety-platform.sql new file mode 100644 index 0000000..6876839 --- /dev/null +++ b/server/migrations/2026-08-09-safety-platform.sql @@ -0,0 +1,191 @@ +-- 안전 플랫폼 확장 — 통보·억제·증거무결성·운영지표. +-- +-- 이 마이그레이션 하나로 다음 기능의 저장소를 연다. 기능별로 파일을 쪼개지 않은 이유는 +-- 서로를 참조하기 때문이다(억제 규칙은 통보를 막고, 통보 실패는 감사로그에 남고, 감사로그는 +-- 해시체인으로 봉인된다). 부분 적용된 중간 상태가 더 위험하다. +-- +-- 1. move_audit_log 해시체인 — 증거 무결성 (schema.sql 이 "P3 로 연기"라 적어둔 항목) +-- 2. move_suppressions — 정비/작업허가 중 알람 억제 (사유·기간·감사 필수) +-- 3. move_alert_channels/_log — 통보 경로와 전달 이력 (지금까지 통보 수단이 전무했다) +-- 4. move_shift_handovers — 교대 인수인계 기록 +-- 5. move_risk_controls — 감지 유형 ↔ 위험요인·대책 (위험성평가 연계) +-- 6. move_device_metrics — 엣지 성능 시계열 (지금은 순간값만 있어 열화를 못 본다) +-- 7. move_thresholds 감사 컬럼 — 편집 UI 가 생기므로 누가 언제 바꿨는지 남긴다 +-- 8. move_actions.event_id — 어떤 사건이 그 조치의 계기였는지 +-- 9. move_events CHECK 확장 — 신규 감지 유형 +-- +-- 적용 조건: 2026-08-08-actions-site.sql 이후. + +-- --------------------------------------------------------------------------- +-- 1. 감사로그 해시체인 +-- 각 행은 직전 행의 entry_hash 를 prev_hash 로 물고, 자신의 내용+prev_hash 를 sha256 한 +-- 값을 entry_hash 로 갖는다. 중간 행을 지우거나 고치면 그 이후 전 구간의 사슬이 끊긴다. +-- (삭제·수정을 "막는" 것이 아니라 "숨길 수 없게" 만드는 것이 목적이다. append-only 권한을 +-- DB 레벨에서 강제할 수 없는 공유호스팅에서는 이것이 실질적인 방어선이다.) +-- 기존 행은 NULL 로 남는다 — 체인 이전 구간임을 검증 도구가 그대로 보고한다. +ALTER TABLE move_audit_log + ADD COLUMN prev_hash CHAR(64) NULL AFTER ts, + ADD COLUMN entry_hash CHAR(64) NULL AFTER prev_hash, + ADD KEY ix_audit_log_entry_hash (entry_hash); + +-- --------------------------------------------------------------------------- +-- 2. 알람 억제 (정비 모드 / 작업허가) +-- 오탐의 최대 원인은 "계획된 작업"이다. 정비 중 제한구역 진입이 전부 알람이면 현장은 +-- 시스템을 끄고, 그 다음 진짜 사고를 놓친다. 억제는 감지를 멈추지 않는다 — 이벤트는 그대로 +-- 적재되고 통보만 막힌다. 사후 조사에서 "그때 무슨 일이 있었나"가 사라지면 안 되기 때문이다. +CREATE TABLE move_suppressions ( + suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + scope_type VARCHAR(16) NOT NULL, -- device | camera | zone + scope_id VARCHAR(64) NOT NULL, -- 해당 스코프의 식별자 + site VARCHAR(64) NULL, -- 접근제어 스코프 + reason VARCHAR(500) NOT NULL, -- 필수: 사유 없는 억제는 허용하지 않는다 + starts_at BIGINT UNSIGNED NOT NULL, + ends_at BIGINT UNSIGNED NOT NULL, -- 무기한 억제 없음. 반드시 만료된다. + created_by VARCHAR(190) NOT NULL, + created_at BIGINT UNSIGNED NOT NULL, + revoked_by VARCHAR(190) NULL, -- 조기 해제 + revoked_at BIGINT UNSIGNED NULL, + PRIMARY KEY (suppression_id), + KEY ix_suppressions_window (ends_at, starts_at), + KEY ix_suppressions_scope (scope_type, scope_id), + KEY ix_suppressions_site (site), + CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- 3. 통보 경로와 전달 이력 +-- 폐쇄망이라 FCM/Slack 을 쓸 수 없다. 사내에서 실제로 닿는 경로만 둔다: +-- local — 엣지 노드의 경광등/부저 (GPIO). 인터넷이 필요 없는 유일한 경로이자 1순위. +-- email — 사내 SMTP 릴레이 +-- webhook— 사내 시스템(그룹웨어·문자게이트웨이) HTTP 엔드포인트 +-- config_json 은 경로별 설정(수신자, URL, GPIO 핀 등). 비밀번호류는 넣지 않는다 — +-- SMTP 인증정보는 config.php 에 두고 여기서는 참조만 한다. +CREATE TABLE move_alert_channels ( + channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + site VARCHAR(64) NULL, + kind VARCHAR(16) NOT NULL, -- local | email | webhook + name VARCHAR(190) NOT NULL, + config_json JSON NOT NULL, + min_severity VARCHAR(16) NOT NULL DEFAULT 'high', + enabled TINYINT(1) NOT NULL DEFAULT 1, + -- 에스컬레이션 단계. 0 = 즉시. 1 이상이면 escalate_after_s 만큼 미확인일 때만 발송한다. + step INT UNSIGNED NOT NULL DEFAULT 0, + escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0, + created_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (channel_id), + KEY ix_alert_channels_site (site, enabled), + CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')), + CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 전달 이력. (event_id, channel_id) UNIQUE 가 중복 발송을 막는 유일한 방어선이다 — +-- 디스패처는 크론으로 돌고 겹쳐 실행될 수 있다. +CREATE TABLE move_alert_log ( + alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id INT UNSIGNED NOT NULL, + channel_id INT UNSIGNED NOT NULL, + status VARCHAR(16) NOT NULL, -- pending | sent | failed | suppressed + attempts INT UNSIGNED NOT NULL DEFAULT 0, + last_error VARCHAR(500) NULL, + queued_at BIGINT UNSIGNED NOT NULL, + sent_at BIGINT UNSIGNED NULL, + PRIMARY KEY (alert_id), + UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id), + KEY ix_alert_log_status (status, queued_at), + CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')), + CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- 4. 교대 인수인계 +-- 24시간 브리프는 있으나 교대 경계와 책임 이관이 없었다. 인수자가 "무엇을 넘겨받았는지"를 +-- 명시적으로 확인한 기록이 남아야 미처리 이벤트가 교대 사이에서 증발하지 않는다. +CREATE TABLE move_shift_handovers ( + handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + site VARCHAR(64) NULL, + from_user VARCHAR(190) NULL, -- 최초 교대는 인계자가 없다 + to_user VARCHAR(190) NOT NULL, + period_start BIGINT UNSIGNED NOT NULL, -- 인계 대상 구간 + period_end BIGINT UNSIGNED NOT NULL, + unacked_count INT UNSIGNED NOT NULL, -- 인계 시점 스냅샷 (사후 재계산과 대조 가능) + severe_count INT UNSIGNED NOT NULL, + note VARCHAR(1000) NULL, + created_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (handover_id), + KEY ix_handovers_site_created (site, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- 5. 위험성평가 연계 +-- 감지 유형 하나하나가 위험요인 하나에 대응한다. 이 표가 있어야 "이 시스템이 무엇을 +-- 통제하고 있는가"를 서식으로 뽑을 수 있고, 개선조치가 어느 위험요인을 낮췄는지 이어진다. +CREATE TABLE move_risk_controls ( + control_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + site VARCHAR(64) NULL, + event_type VARCHAR(32) NOT NULL, -- move_events.event_type 와 같은 코드 + hazard VARCHAR(300) NOT NULL, -- 위험요인 + control VARCHAR(500) NOT NULL, -- 현재 통제 대책 + severity_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (강도) + likelihood_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (빈도) + reviewed_by VARCHAR(190) NULL, + reviewed_at BIGINT UNSIGNED NULL, + updated_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (control_id), + UNIQUE KEY uq_risk_controls_site_type (site, event_type), + CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5), + CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- 6. 엣지 성능 시계열 +-- move_devices 는 마지막 값만 덮어쓴다. "어제부터 추론 지연이 두 배가 됐다"를 볼 수 없어 +-- 서서히 죽는 노드를 놓친다. 하트비트마다 한 행씩 append 하고 보존기간으로 잘라낸다. +CREATE TABLE move_device_metrics ( + metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + device_id VARCHAR(64) NOT NULL, + ts BIGINT UNSIGNED NOT NULL, + fps DOUBLE NULL, + temp DOUBLE NULL, + throttle TINYINT(1) NULL, + queue_pending INT UNSIGNED NULL, + cameras_total INT UNSIGNED NULL, + cameras_erroring INT UNSIGNED NULL, + errors INT UNSIGNED NULL, + PRIMARY KEY (metric_id), + KEY ix_device_metrics_device_ts (device_id, ts) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- 7. 임계값 감사 컬럼 (편집 UI 도입) +-- 지금까지 이 표는 phpMyAdmin 으로 직접 고치는 것이 유일한 방법이었다(스키마 주석 참조). +-- 화면이 생기면 누가 언제 바꿨는지가 사고 조사에서 반드시 필요해진다. +ALTER TABLE move_thresholds + ADD COLUMN updated_by VARCHAR(190) NULL, + ADD COLUMN updated_at BIGINT UNSIGNED NULL; + +-- --------------------------------------------------------------------------- +-- 8. 조치 ↔ 계기 사건 +ALTER TABLE move_actions + ADD COLUMN event_id INT UNSIGNED NULL AFTER site, + ADD KEY ix_actions_event (event_id); + +-- --------------------------------------------------------------------------- +-- 9. 신규 감지 유형 +-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고, +-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다. +-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음). +ALTER TABLE move_events + DROP CHECK chk_events_type; +ALTER TABLE move_events + ADD CONSTRAINT chk_events_type CHECK (event_type IN ( + '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' + )); + +-- 검증: +-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간 +-- php server/tools/verify_audit_chain.php -- 사슬 무결성 +-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%'; diff --git a/server/public/assets/admin.js b/server/public/assets/admin.js new file mode 100644 index 0000000..4756ecd --- /dev/null +++ b/server/public/assets/admin.js @@ -0,0 +1,322 @@ +/** + * 시스템 설정: 통보 채널 + 감지 임계값. + * + * 이 두 화면이 없던 동안 납품된 현장은 (1) 감지되어도 아무에게도 알릴 수 없었고 + * (2) 오탐이 나도 임계값을 고칠 방법이 phpMyAdmin 뿐이었다. 콘솔의 오탐율 패널이 + * "이 카메라를 재튜닝하라"고 알려주는데 재튜닝할 화면이 없는 상태였다. + * + * 임계값 입력은 자유 텍스트가 아니라 서버가 내려준 카탈로그(범위·단위·설명)로 렌더한다. + * 범위 밖 값은 엣지가 기동 시 거부하는데, 그때는 이미 공장 안 장비가 재시작 루프에 빠진 + * 뒤다 — 키보드 앞에서 막는 편이 낫다. + * + * XSS-safe: 모든 동적 값은 textContent. innerHTML 금지 (프로젝트 규약). + */ +(function () { + 'use strict'; + + var B = window.APP_BASE || ''; + var root = document.querySelector('.admin-page'); + if (!root) return; + + var csrf = null; + var catalog = {}; + var devices = []; + + 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 loadCsrf() { + return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d && d.token) csrf = d.token; }) + .catch(function () { /* 저장 시 403 으로 드러난다 */ }); + } + + function post(path, body) { + return fetch(B + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' }, + body: JSON.stringify(body || {}) + }).then(function (r) { + if (r.status === 401) { window.location.href = B + '/login'; return null; } + return r.json().then(function (d) { return { ok: r.ok, data: d }; }); + }); + } + + // 서버 오류 코드 -> 사람이 고칠 수 있는 문장. 코드를 그대로 보여주면 운영자는 + // 무엇을 고쳐야 할지 알 수 없다. + var ERRORS = { + out_of_range: '허용 범위를 벗어났습니다.', + not_numeric: '숫자를 입력하세요.', + not_finite: '유효한 숫자가 아닙니다.', + not_integer: '정수만 입력할 수 있습니다.', + unknown_key: '알 수 없는 항목입니다.', + unknown_device: '등록되지 않은 장치입니다.', + invalid_kind: '채널 종류가 올바르지 않습니다.', + invalid_name: '이름을 입력하세요.', + invalid_config: '연결 설정을 확인하세요 (수신자 주소 / URL).', + invalid_escalate_after: '에스컬레이션 지연은 30초 이상이어야 합니다.', + invalid_step: '단계는 0~5 사이여야 합니다.', + csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.', + forbidden: '권한이 없습니다.', + not_found: '대상을 찾을 수 없습니다.' + }; + function errText(code) { return ERRORS[code] || ('저장에 실패했습니다 (' + String(code) + ')'); } + + // ============================================================ 통보 채널 + var chList = document.getElementById('channel-list'); + var chForm = document.getElementById('channel-form'); + var chStatus = document.getElementById('ch-status'); + var KIND_LABELS = { local: '현장 경광등', email: '메일', webhook: '웹훅' }; + var SEV_LABELS = { critical: '위급만', high: '높음 이상', warning: '경고 이상', info: '전부' }; + + function field(id) { return document.getElementById(id); } + + function showKind(kind) { + ['local', 'email', 'webhook'].forEach(function (k) { + var box = root.querySelector('.kind-' + k); + if (box) box.hidden = (k !== kind); + }); + } + + function renderChannels(list) { + chList.textContent = ''; + if (!list.length) { + var empty = el('p', 'admin-empty', + '설정된 통보 경로가 없습니다. 지금은 감지가 되어도 화면을 보고 있는 사람에게만 전달됩니다.'); + chList.appendChild(empty); + return; + } + list.forEach(function (c) { + var row = el('div', 'channel-row' + (c.enabled ? '' : ' is-off')); + var main = el('div', 'channel-main'); + main.appendChild(el('span', 'channel-kind', KIND_LABELS[c.kind] || c.kind)); + main.appendChild(el('span', 'channel-name', c.name)); + var meta = el('span', 'channel-meta'); + var bits = [SEV_LABELS[c.min_severity] || c.min_severity]; + if (c.step > 0) bits.push('에스컬레이션 ' + Math.round(c.escalate_after_s / 60) + '분 후'); + else bits.push('즉시'); + if (c.kind === 'local' && c.config && c.config.gpio_pin) bits.push('GPIO ' + c.config.gpio_pin); + if (c.kind === 'email' && c.config && c.config.to) bits.push([].concat(c.config.to).join(', ')); + if (c.kind === 'webhook' && c.config && c.config.url) bits.push(c.config.url); + if (!c.enabled) bits.push('사용 안 함'); + meta.textContent = bits.join(' · '); + main.appendChild(meta); + row.appendChild(main); + + var edit = el('button', null, '수정'); + edit.type = 'button'; + edit.addEventListener('click', function () { openForm(c); }); + row.appendChild(edit); + + var del = el('button', 'zone-del', '삭제'); + del.type = 'button'; + var armed = false, timer = null; + del.addEventListener('click', function () { + if (!armed) { + armed = true; del.classList.add('is-armed'); del.textContent = '정말 삭제?'; + timer = setTimeout(function () { armed = false; del.classList.remove('is-armed'); del.textContent = '삭제'; }, 4000); + return; + } + clearTimeout(timer); + post('/api/admin/channels/' + c.channel_id + '/delete').then(function (r) { + if (r && r.ok) loadChannels(); + }); + }); + row.appendChild(del); + chList.appendChild(row); + }); + } + + function openForm(c) { + chForm.hidden = false; + chStatus.textContent = ''; + c = c || {}; + var cfg = c.config || {}; + field('ch-id').value = c.channel_id || ''; + field('ch-kind').value = c.kind || 'local'; + field('ch-name').value = c.name || ''; + field('ch-severity').value = c.min_severity || 'high'; + field('ch-step').value = c.step || 0; + field('ch-after').value = c.escalate_after_s || 300; + field('ch-enabled').checked = c.channel_id ? !!c.enabled : true; + field('ch-pin').value = cfg.gpio_pin || 0; + field('ch-mode').value = cfg.mode || 'latch'; + field('ch-hold').value = cfg.hold_s || 30; + field('ch-active-high').checked = !!cfg.active_high; + field('ch-to').value = cfg.to ? [].concat(cfg.to).join(', ') : ''; + field('ch-from').value = cfg.from || ''; + field('ch-url').value = cfg.url || ''; + // 토큰은 서버가 마스킹해서 준다. 그대로 두면 "변경 안 함"이 된다. + field('ch-token').value = cfg.token || ''; + showKind(field('ch-kind').value); + field('ch-after-wrap').hidden = Number(field('ch-step').value) === 0; + field('ch-name').focus(); + } + + function collectConfig(kind) { + if (kind === 'email') { + return { to: field('ch-to').value, from: field('ch-from').value.trim() }; + } + if (kind === 'webhook') { + var out = { url: field('ch-url').value.trim() }; + var t = field('ch-token').value.trim(); + if (t) out.token = t; + return out; + } + return { + gpio_pin: Number(field('ch-pin').value) || 0, + mode: field('ch-mode').value, + hold_s: Number(field('ch-hold').value) || 30, + active_high: field('ch-active-high').checked + }; + } + + if (chForm) { + field('ch-kind').addEventListener('change', function () { showKind(this.value); }); + field('ch-step').addEventListener('input', function () { + field('ch-after-wrap').hidden = Number(this.value) === 0; + }); + document.getElementById('ch-cancel').addEventListener('click', function () { chForm.hidden = true; }); + document.getElementById('channel-new').addEventListener('click', function () { openForm(null); }); + + chForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + var kind = field('ch-kind').value; + var body = { + channel_id: Number(field('ch-id').value) || 0, + kind: kind, + name: field('ch-name').value.trim(), + min_severity: field('ch-severity').value, + step: Number(field('ch-step').value) || 0, + escalate_after_s: Number(field('ch-after').value) || 0, + enabled: field('ch-enabled').checked ? 1 : 0, + config: collectConfig(kind) + }; + chStatus.textContent = '저장 중…'; + post('/api/admin/channels', body).then(function (r) { + if (!r) return; + if (!r.ok) { chStatus.textContent = errText(r.data && r.data.error); return; } + chStatus.textContent = '저장되었습니다.'; + chForm.hidden = true; + loadChannels(); + }).catch(function () { chStatus.textContent = '저장에 실패했습니다.'; }); + }); + } + + function loadChannels() { + fetch(B + '/api/admin/channels', { headers: { Accept: 'application/json' } }) + .then(function (r) { + if (r.status === 401) { window.location.href = B + '/login'; return null; } + return r.ok ? r.json() : null; + }) + .then(function (d) { if (d) renderChannels(d.channels || []); }) + .catch(function () { chList.textContent = '통보 경로를 불러오지 못했습니다.'; }); + } + + // ============================================================ 임계값 + var thrDevice = document.getElementById('thr-device'); + var thrList = document.getElementById('thr-list'); + var thrStatus = document.getElementById('thr-status'); + var values = {}; // device_id -> {key: value} + + function renderThresholds() { + var dev = thrDevice.value; + thrList.textContent = ''; + if (!dev) { thrList.textContent = '장치를 선택하세요.'; return; } + var current = values[dev] || {}; + + Object.keys(catalog).forEach(function (key) { + var spec = catalog[key]; + var row = el('div', 'thr-row'); + + var head = el('div', 'thr-head'); + head.appendChild(el('span', 'thr-label', spec.label)); + var val = el('span', 'thr-value'); + row.appendChild(head); + + var input = document.createElement('input'); + input.type = 'number'; + input.min = spec.min; + input.max = spec.max; + input.step = spec.step; + // 값이 없으면 장비가 쓰고 있는 기본값을 보여준다 — 빈칸이면 "설정 안 됨"과 + // "기본값과 같음"을 구분할 수 없다. + var isSet = Object.prototype.hasOwnProperty.call(current, key); + input.value = isSet ? current[key] : spec['default']; + head.appendChild(val); + val.textContent = (spec.unit ? spec.unit : '') + (isSet ? '' : ' · 기본값'); + + var controls = el('div', 'thr-controls'); + controls.appendChild(input); + var save = el('button', 'btn-primary', '적용'); + save.type = 'button'; + save.addEventListener('click', function () { + thrStatus.textContent = '적용 중…'; + post('/api/admin/thresholds', { device_id: dev, key: key, value: input.value }) + .then(function (r) { + if (!r) return; + if (!r.ok) { + thrStatus.textContent = spec.label + ': ' + errText(r.data && r.data.error) + + ' (허용 ' + spec.min + '~' + spec.max + ')'; + return; + } + thrStatus.textContent = spec.label + ' 적용됨 — 다음 동기화 때 장치에 반영됩니다.'; + loadThresholds(); + }) + .catch(function () { thrStatus.textContent = '적용에 실패했습니다.'; }); + }); + controls.appendChild(save); + row.appendChild(controls); + row.appendChild(el('p', 'thr-help', spec.help)); + thrList.appendChild(row); + }); + } + + function loadThresholds() { + fetch(B + '/api/admin/thresholds', { headers: { Accept: 'application/json' } }) + .then(function (r) { + if (r.status === 401) { window.location.href = B + '/login'; return null; } + if (r.status === 403) { thrList.textContent = '임계값 편집 권한이 없습니다.'; return null; } + return r.ok ? r.json() : null; + }) + .then(function (d) { + if (!d) return; + catalog = d.catalog || {}; + devices = d.devices || []; + values = {}; + (d.thresholds || []).forEach(function (t) { + if (t.camera_id) return; // 카메라별 재정의는 이 화면의 범위 밖 + if (!values[t.device_id]) values[t.device_id] = {}; + values[t.device_id][t.key] = t.value; + }); + + var keep = thrDevice.value; + thrDevice.textContent = ''; + var first = document.createElement('option'); + first.value = ''; + first.textContent = devices.length ? '장치를 선택하세요' : '등록된 장치가 없습니다'; + thrDevice.appendChild(first); + devices.forEach(function (dev) { + var o = document.createElement('option'); + o.value = dev.device_id; + o.textContent = (dev.name || dev.device_id) + ' · ' + dev.device_id; + thrDevice.appendChild(o); + }); + if (keep) thrDevice.value = keep; + renderThresholds(); + }) + .catch(function () { thrList.textContent = '임계값을 불러오지 못했습니다.'; }); + } + + if (thrDevice) thrDevice.addEventListener('change', renderThresholds); + + loadCsrf().then(function () { + loadChannels(); + loadThresholds(); + }); +})(); diff --git a/server/public/assets/app.css b/server/public/assets/app.css index 6cd3576..9e2e641 100644 --- a/server/public/assets/app.css +++ b/server/public/assets/app.css @@ -173,8 +173,11 @@ label { color:var(--ink-2); font-size:.84rem; } .rail-filters { display:flex; flex-direction:column; gap:.55rem; } .rail-filters .field { display:flex; flex-direction:column; gap:.28rem; font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; } .rail-filters select { text-transform:none; font-size:.84rem; font-weight:400; letter-spacing:0; color:var(--ink); } -.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; } -.filter-check input { width:15px; height:15px; accent-color:var(--accent); } +/* Rail: 날짜 조회 (사고 조사 진입로). */ +.rail-date { display:flex; align-items:flex-end; gap:.4rem; } +.rail-date .field { flex:1; min-width:0; display:flex; flex-direction:column; gap:.28rem; font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; } +.rail-date input { width:100%; font-size:.8rem; } +.rail-date button { flex:none; } /* ---- Device → camera tree (rail) --------------------------------- */ /* Devices carry the heartbeat telemetry (online/offline, fps, temp, throttle, drift); the @@ -262,6 +265,57 @@ h1 { font-size:1.3rem; font-weight:760; letter-spacing:-.02em; margin:0; } .watch.is-stale::before { animation:none; } @keyframes watch-pulse { 0%{ box-shadow:0 0 0 0 rgba(21,128,61,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,128,61,0);} } +/* Alarm mute — only present while something is actually alarming, and it says so while on. + A silenced console that LOOKS identical to a live one is the failure this must not create. */ +.mute-btn { font-size:.72rem; padding:.22rem .5rem; color:var(--ink-2); } +.mute-btn[hidden] { display:none; } +.mute-btn[aria-pressed="true"] { color:var(--crit-ink); background:var(--crit-bg); border-color:var(--crit-line); font-weight:650; } +.mute-state { font-size:.72rem; font-weight:600; color:var(--crit-ink); } +.mute-state[hidden] { display:none; } + +/* View mode — a segmented pair, deliberately NOT a checkbox: the two modes are exclusive + readings of the same list, and a checkbox implied "and also filter by this". */ +/* align-self, because .stream is a stretching flex column and an inline-flex child would + otherwise span the full stream width — a segmented control the width of the page reads as + a toolbar, not as a two-way switch. */ +.view-modes { display:inline-flex; align-self:flex-start; gap:2px; padding:2px; margin:0 0 .7rem; + background:var(--panel-2); border:1px solid var(--line); border-radius:9px; } +.mode-btn { font-size:.78rem; font-weight:600; padding:.3rem .7rem; border:0; border-radius:7px; + background:transparent; color:var(--ink-3); box-shadow:none; } +.mode-btn:hover { background:#fff; color:var(--ink-2); } +.mode-btn.is-on { background:var(--card); color:var(--accent); box-shadow:var(--shadow-sm); } + +/* Day-query banner: the stream is showing a past day, not the live feed. Says so, and the + way back is the same control every time. */ +.query-banner { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem; + font-size:.79rem; color:var(--accent-deep); background:var(--accent-wash); + border:1px solid var(--accent-line); border-radius:8px; } +.query-banner[hidden] { display:none; } +.query-banner .query-text { font-weight:600; } +.query-banner button { margin-left:auto; flex:none; } +.query-banner.is-truncated { color:var(--warn-ink); background:var(--warn-bg); border-color:var(--warn-line); } + +/* Day divider — a strictly chronological list still has to make midnight visible. */ +.day-sep { display:flex; align-items:center; gap:.7rem; margin:.75rem 0 .3rem; } +.day-sep:first-child { margin-top:0; } +.day-sep span { + flex:none; font-size:.7rem; font-weight:700; letter-spacing:.05em; color:var(--ink-3); + font-variant-numeric:tabular-nums; +} +.day-sep::after { content:""; flex:1; height:1px; background:var(--line); } + +/* Resolution feedback. 확인/오탐 are audit-backed and not reversible here, so this is a + confirmation of what was recorded — never an "undo" that the server could not honour. */ +.action-toast { + position:fixed; left:50%; bottom:1.5rem; transform:translate(-50%, .5rem); + z-index:var(--z-toast); max-width:min(90vw, 30rem); + padding:.55rem .9rem; font-size:.82rem; font-weight:600; + color:#fff; background:#1b2330; border-radius:9px; + box-shadow:0 10px 30px -12px rgba(19,24,36,.6); + opacity:0; pointer-events:none; transition:opacity .18s, transform .18s; +} +.action-toast.is-on { opacity:1; transform:translate(-50%, 0); } + /* ---- KPI strip: compact unacknowledged counts -------------------- */ /* Computed client-side from the /api/events window (no extra endpoint). A tight inline strip of pills, not four hero cards — dense, glanceable. Zero dims, never hides. */ @@ -332,7 +386,15 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .event-time .ev-at { font-family:var(--mono); color:var(--ink-3); } .event-detail-link { color:inherit; text-decoration:none; } .event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; } -.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; } +/* "확인 필요" chip — on EVERY unacknowledged row, toned by severity. A critical still shouts + (solid fill); lower tiers state the same fact quietly (tinted, outlined). What must never + happen again is a row whose handled/unhandled state is only inferable from whether a button + exists in a distant column. */ +.chip { font-size:.68rem; font-weight:700; padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; flex:none; } +.chip-critical { color:#fff; background:var(--crit-ink); } +.chip-high { color:var(--high-ink); background:var(--high-bg); box-shadow:inset 0 0 0 1px var(--high-line); } +.chip-warning { color:var(--warn-ink); background:var(--warn-bg); box-shadow:inset 0 0 0 1px var(--warn-line); } +.chip-info { color:var(--ink-2); background:#eef1f5; box-shadow:inset 0 0 0 1px #dde3ea; } /* Actions column: [확인][오탐] while open; a single mono verdict line once resolved. */ .row-actions { margin-left:auto; flex:none; display:flex; align-items:center; gap:.4rem; } @@ -342,6 +404,9 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .fp-btn { flex:none; padding:.32rem .55rem; font-size:.75rem; color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; } .fp-btn:hover { color:var(--ink-2); border-color:var(--line); background:#fff; } +/* Armed state: the first click asks, the second commits. 오탐 asserts the DETECTOR was wrong, + moves a per-camera statistic a threshold is later retuned from, and has no undo. */ +.fp-btn.is-armed { color:var(--high-ink); background:var(--high-bg); border-color:var(--high-line); font-weight:650; } .ack-done, .fp-done { color:var(--ink-3); font-family:var(--mono); font-size:.75rem; white-space:nowrap; } .fp-done { text-decoration:line-through; text-decoration-color:#c9d1da; } @@ -363,6 +428,9 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .fp-what .fp-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); margin-right:.15rem; } .fp-rate { flex:none; font-family:var(--mono); color:var(--ink-3); } .fp-rate.is-hot { color:var(--warn-ink); font-weight:700; } +/* Under the minimum sample there is no rate to print — only counts, and they must not + compete visually with a real one ("100% (1/1)" used to outrank "33% (5/15)"). */ +.fp-rate.is-thin { color:#8b93a1; font-size:.7rem; } /* "new events arrived while paging" reload affordance — honest about a possible gap. */ .page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem; @@ -389,33 +457,144 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .login-form button:hover { background:var(--accent-deep); border-color:var(--accent-deep); } .form-error { color:var(--crit-ink); font-size:.85rem; } +/* ---- Primary action ---------------------------------------------- */ +/* The one filled button in the app. It exists so a page's committing action cannot sit at the + same weight as the destructive one beside it — which is exactly how 저장 and 폴리곤 지우기 + used to read on the zone editor. */ +.btn-primary { + color:var(--accent-ink); background:var(--accent); border-color:var(--accent); font-weight:650; +} +.btn-primary:hover { background:var(--accent-deep); border-color:var(--accent-deep); } + /* ---- Zone editor ------------------------------------------------- */ -.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; } -.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; } +/* Same console grammar as the events page (panel rail + stage), so the two screens read as one + product. The panel walks the actual task order: ① pick a camera ② draw ③ rules + save. + Before this the page was a bare <h1> over three unlabelled rows of loose controls. */ +.zone-page { display:flex; flex-direction:column; } +.zone-console { display:grid; grid-template-columns:300px minmax(0,1fr); gap:1rem; align-items:start; } + +.zone-panel { + position:sticky; top:calc(45px + 1.15rem); + display:flex; flex-direction:column; gap:.85rem; + background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); + padding:.85rem .8rem; box-shadow:var(--shadow-sm); +} +.zone-step { display:flex; flex-direction:column; gap:.5rem; } +.zone-step + .zone-step { padding-top:.85rem; border-top:1px solid var(--line-2); } +.zone-step .rail-title { display:flex; align-items:center; gap:.45rem; } +.step-no { + display:grid; place-items:center; width:16px; height:16px; flex:none; + border-radius:50%; font-size:.62rem; font-weight:700; letter-spacing:0; + color:var(--accent); background:var(--accent-wash); border:1px solid var(--accent-line); +} +.step-hint { margin:0; font-size:.73rem; color:var(--ink-3); } +.zone-step .field { + display:flex; flex-direction:column; gap:.28rem; + font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; +} +.zone-step .field input, .zone-step .field select { + text-transform:none; font-size:.83rem; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; +} +/* Label and its unit/why note share ONE line — as separate flex children of the column they + stacked, and a hint on its own row reads as a second label. */ +.field-label { display:inline-flex; align-items:baseline; gap:.35rem; flex-wrap:wrap; } +.field-hint { font-weight:600; font-size:.64rem; color:#8b93a1; letter-spacing:0; text-transform:none; } +.step-actions { display:flex; flex-wrap:wrap; gap:.4rem; } +.step-actions button { flex:1 1 auto; } +.zone-overlay-tools { display:flex; align-items:flex-end; gap:.4rem; } +.zone-overlay-tools .field { flex:0 0 5.5rem; } +/* aria-pressed had no visual pair: the heatmap could be on with nothing on screen saying so — + the one place this stylesheet's own "state is never colour alone" rule was broken by there + being no state indication at all. */ +#heatmap-btn { flex:1; } +#heatmap-btn[aria-pressed="true"] { + color:var(--accent); background:var(--accent-wash); border-color:var(--accent-line); font-weight:650; +} +.zone-manual { font-size:.75rem; color:var(--ink-3); } +.zone-manual > summary { cursor:pointer; padding:.15rem 0; color:var(--accent); font-weight:600; } +.zone-manual[open] { display:flex; flex-direction:column; gap:.45rem; padding-bottom:.2rem; } +.zone-manual-why { margin:.25rem 0 0; font-size:.72rem; color:var(--ink-3); } + +.zone-stage { display:flex; flex-direction:column; gap:.6rem; min-width:0; } #zone-canvas { + width:100%; height:auto; display:block; border:1px solid var(--line); border-radius:10px; - background:#0e1116 center / contain no-repeat; cursor:crosshair; max-width:100%; + background:#0e1116 center / contain no-repeat; cursor:crosshair; box-shadow:var(--shadow); } -#zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; } -#zone-list li { - display:flex; align-items:center; justify-content:space-between; gap:.75rem; - max-width:380px; padding:.5rem .75rem; - background:var(--card); border:1px solid var(--line); border-radius:9px; - font-size:.85rem; color:var(--ink-2); +.zone-status { + margin:0; padding:.42rem .65rem; font-size:.79rem; color:var(--ink-2); + background:var(--panel-2); border:1px solid var(--line); border-radius:8px; +} +.zone-saved { display:flex; flex-direction:column; gap:.4rem; } +.zone-list { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:.35rem; } +.zone-item { + display:flex; align-items:center; justify-content:space-between; gap:.75rem; + padding:.45rem .6rem; + background:var(--card); border:1px solid var(--line); border-radius:9px; + font-size:.83rem; color:var(--ink-2); +} +.zone-item-label { display:inline-flex; align-items:center; gap:.5rem; min-width:0; } +.zone-swatch { width:13px; height:13px; flex:none; border-radius:3px; border:2px solid; } +.zone-empty { font-size:.79rem; color:var(--ink-3); padding:.3rem 0; } +.zone-del { flex:none; font-size:.75rem; padding:.26rem .55rem; color:var(--ink-3); } +.zone-del:hover { color:var(--crit-ink); border-color:var(--crit-line); background:var(--crit-bg); } +/* Armed: the second click deletes, and there is no restore on the server. */ +.zone-del.is-armed { color:#fff; background:var(--crit-ink); border-color:var(--crit-ink); font-weight:650; } + +@media (max-width:860px) { + .zone-console { grid-template-columns:1fr; } + .zone-panel { position:static; } } -#zone-status { font-family:var(--mono); font-size:.82rem; color:var(--ink-3); } /* ---- Incident detail -------------------------------------------- */ -.detail-row { margin:.35rem 0; } -.detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; } -#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; } +/* The screen an accident is judged on. It was a bare label:value list with a default blue link, + in a 200px column inside a 1240px shell — the least finished surface in the product and the + one an investigator spends the most time on. */ +.detail { display:flex; flex-direction:column; gap:.9rem; } +.detail-crumb { display:flex; align-items:center; gap:.4rem; font-size:.76rem; color:var(--ink-3); } +.detail-crumb a { color:var(--accent); text-decoration:none; font-weight:600; } +.detail-crumb a:hover { text-decoration:underline; text-underline-offset:3px; } +.detail-head { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; flex-wrap:wrap; } +.detail-headline h1 { margin:0; } +.detail-when { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.7rem; margin:.3rem 0 0; font-size:.79rem; } +.detail-when .ev-src { font-weight:550; color:var(--ink-2); } +.detail-when .ev-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); } +.detail-when .ev-at { font-family:var(--mono); color:var(--ink-3); } +.detail-actions { display:flex; align-items:center; gap:.5rem; flex:none; } +.detail-actions[hidden] { display:none; } +.detail-action-note { margin:0; font-size:.82rem; font-weight:600; color:var(--ink-2); } +.detail-action-note.is-error { color:var(--crit-ink); } + +.detail-grid { display:grid; grid-template-columns:minmax(0,1fr) 336px; gap:.9rem; align-items:start; } +.detail-card { + padding:.9rem 1rem 1rem; background:var(--card); + border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm); +} +.detail-card-title { margin:0 0 .75rem; font-size:.72rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); } +.detail-empty { margin:0; font-size:.82rem; color:var(--ink-3); } +.detail-foot { margin:.8rem 0 0; font-size:.72rem; color:var(--ink-3); } + +/* Meta as a definition list: the label column is a real column, so values line up. */ +#event-detail-meta { margin:0; display:flex; flex-direction:column; } +.detail-row { display:grid; grid-template-columns:4.9rem minmax(0,1fr); gap:.55rem; align-items:baseline; padding:.32rem 0; } +.detail-row + .detail-row { border-top:1px solid var(--line-2); } +.detail-row dt { font-size:.73rem; font-weight:600; color:var(--ink-3); } +.detail-row dd { margin:0; font-size:.85rem; color:var(--ink); min-width:0; overflow-wrap:anywhere; } +.detail-row dd .mono { font-family:var(--mono); font-size:.8rem; } + +#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; } /* Evidence timeline: each frame carries its moment label — the sequence reads left→right - into the event ("이전 → 직전 → 발생 시점"). */ + into the event ("이전 → 직전 → 발생 시점"). No fabricated second offsets: the frame + interval is edge configuration this server does not know. */ .detail-snap { margin:0; display:flex; flex-direction:column; gap:.3rem; flex:1 1 240px; max-width:360px; } .detail-snap figcaption { font-size:.72rem; font-weight:700; color:var(--ink-3); font-family:var(--mono); } .detail-snap:last-child figcaption { color:var(--ink); } -.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:10px; background:#fff; padding:4px; box-shadow:var(--shadow); } +.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:10px; background:#fff; padding:4px; box-shadow:var(--shadow-sm); } + +@media (max-width:860px) { + .detail-grid { grid-template-columns:1fr; } +} /* ---- Responsive: rail collapses above the stream ----------------- */ @media (max-width:860px) { @@ -480,9 +659,13 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .bar-fill { height:100%; border-radius:999px; background:var(--accent); } .bar-fill.is-hot { background:var(--crit-ink); } -.rule-stats { display:grid; grid-template-columns:repeat(2, 1fr); gap:.6rem; } +/* Three zone-rule counters. "미확인 잔여" used to sit here as a fourth tile of equal weight, + which read as a fourth rule; it is a processing state, so it moved to a line below. */ +/* minmax(0,1fr), not 1fr: a grid item's automatic minimum is its CONTENT, so "체류 상한 초과" + refused to shrink, blew the card out and gave the whole report a horizontal scrollbar. */ +.rule-stats { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:.6rem; } .rule-stat { - display:flex; flex-direction:column; gap:.1rem; padding:.6rem .7rem; + display:flex; flex-direction:column; gap:.1rem; padding:.6rem .7rem; min-width:0; border:1px solid var(--line); border-radius:8px; background:var(--panel-2); } .rule-stat strong { font-size:1.25rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-3); } @@ -504,6 +687,20 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .kpi-trend.is-better { color:var(--accent); } .report-card-wide { grid-column:1 / -1; } +.report-card[hidden] { display:none; } + +/* 현장별 롤업 (본사 관점). */ +.site-rows { display:flex; flex-direction:column; gap:.4rem; } +.site-row { display:flex; align-items:baseline; justify-content:space-between; gap:.8rem; + padding:.5rem .65rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; } +.site-row.is-worst { background:var(--crit-bg); border-color:var(--crit-line); } +.site-name { font-weight:650; font-size:.9rem; color:var(--ink); } +.site-nums { display:flex; align-items:baseline; gap:.35rem; font-size:.73rem; color:var(--ink-3); } +.site-nums strong { font-size:1rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-2); margin-left:.55rem; } +.site-nums strong:first-child { margin-left:0; } +.site-severe.is-hot { color:var(--crit-ink); } +.site-unacked.is-open { color:var(--high-ink); } +.site-cams { margin-left:.8rem; font-family:var(--mono); } /* 개선 조치: 기록 폼 + 전후 비교 행. */ .action-form { display:flex; gap:.5rem; margin-bottom:.8rem; } @@ -521,6 +718,10 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .action-effect.is-better { color:var(--accent); font-weight:700; } .action-effect.is-worse { color:var(--crit-ink); font-weight:700; } .action-effect.is-pending { font-style:normal; } +/* Below the minimum sample the row states the counts and withholds the verdict — a coloured + ▲33% off six events is the kind of number a safety meeting rejects, and takes the rest of + the card's credibility with it. */ +.action-effect.is-thin { color:#8b93a1; font-weight:400; } .pattern { display:flex; flex-direction:column; gap:2px; overflow-x:auto; } .pattern-row { display:flex; align-items:center; gap:2px; } .pattern-day { flex:none; width:1.4rem; font-size:.68rem; color:var(--ink-3); font-family:var(--mono); } @@ -543,6 +744,304 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .report-grid, .report-kpis { break-inside:avoid; } } +/* ================================================================== + 확장 화면: 시스템 설정 · 안전 관리 · 사고조사 패키지, 그리고 콘솔의 + 일괄 처리 · 키보드 트리아지 · 저장공간 경고. + 기존 콘솔과 같은 문법(카드 + 소문자 캡션 + 단일 accent)을 유지한다 — + 새 화면이 다른 제품처럼 보이는 것이 이 프로젝트의 원래 문제였다. + ================================================================== */ + +/* ---- Stream tools: 뷰모드 + 일괄 확인 + 단축키 안내 --------------- */ +.stream-tools { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:.7rem; } +.stream-tools .view-modes { margin:0; } +.bulk-btn { font-size:.78rem; font-weight:600; color:var(--ink-2); } +.bulk-btn[hidden] { display:none; } +/* 무장 상태는 붉게 — 되돌릴 수 없는 일괄 처리라는 사실을 색으로도 말한다. */ +.bulk-btn.is-armed { color:#fff; background:var(--crit-ink); border-color:var(--crit-ink); } +.kbd-hint { margin-left:auto; font-size:.7rem; color:var(--ink-3); display:inline-flex; align-items:center; gap:.25rem; } +.kbd-hint kbd { + font-family:var(--mono); font-size:.65rem; padding:.05rem .3rem; border-radius:4px; + background:var(--panel-2); border:1px solid var(--line); color:var(--ink-2); +} +/* 키보드 커서: 포커스 링과 구분되는 은은한 레일. 마우스 사용자에게는 보이지 않는다. */ +.event-row.is-cursor { box-shadow:inset 3px 0 0 0 var(--accent), var(--shadow-sm); } + +.storage-warn { + margin:0 0 .6rem; padding:.5rem .75rem; font-size:.8rem; color:var(--crit-ink); + background:var(--crit-bg); border:1px solid var(--crit-line); border-radius:8px; +} +.storage-warn[hidden] { display:none; } +.storage-warn .warn-lead { font-weight:700; margin-right:.15rem; } + +/* ---- 공용 관리 화면 셸 -------------------------------------------- */ +.admin-page, .compliance-page { display:flex; flex-direction:column; gap:1rem; } +.comp-grid { display:grid; grid-template-columns:1fr 1fr; gap:1rem; align-items:start; } +.comp-wide { grid-column:1 / -1; } +.admin-card { + padding:1rem 1.1rem 1.1rem; background:var(--card); + border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm); +} +.admin-card-head { display:flex; align-items:center; justify-content:space-between; gap:.8rem; flex-wrap:wrap; margin-bottom:.6rem; } +.admin-card-head h2 { margin:0; font-size:.95rem; font-weight:750; letter-spacing:-.01em; } +.admin-device-pick { min-width:16rem; } +.admin-note { + margin:0 0 .8rem; padding:.5rem .7rem; font-size:.78rem; line-height:1.5; color:var(--ink-2); + background:var(--panel-2); border:1px solid var(--line-2); border-radius:8px; +} +.admin-note code { font-family:var(--mono); font-size:.74rem; } +.admin-empty { margin:.4rem 0; font-size:.8rem; color:var(--ink-3); } +.admin-status { margin:.55rem 0 0; font-size:.78rem; color:var(--ink-2); min-height:1em; } +.comp-subhead { margin:1rem 0 .5rem; font-size:.7rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); } + +.form-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(11rem, 1fr)); gap:.6rem; align-items:end; } +/* `display:grid` beats the UA's [hidden]{display:none}, so a collapsed form would render + open — say it explicitly for every layout class that sets display. */ +.form-grid[hidden], .detail-links[hidden] { display:none; } +.form-grid .field { display:flex; flex-direction:column; gap:.28rem; + font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; } +.form-grid .field input, .form-grid .field select { + text-transform:none; font-size:.83rem; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; +} +.form-grid .filter-check { align-self:center; } +.form-grid > button { align-self:end; } +.comp-span, .sup-reason { grid-column:1 / -1; } +.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.8rem; color:var(--ink-2); cursor:pointer; user-select:none; text-transform:none; letter-spacing:0; font-weight:400; } +.filter-check input { width:15px; height:15px; accent-color:var(--accent); } + +/* ---- 통보 채널 ---------------------------------------------------- */ +.channel-list { display:flex; flex-direction:column; gap:.4rem; margin-bottom:.8rem; } +.channel-row { + display:flex; align-items:center; gap:.7rem; padding:.5rem .7rem; + background:var(--panel-2); border:1px solid var(--line); border-radius:9px; +} +.channel-row.is-off { opacity:.6; } +.channel-main { display:flex; flex-direction:column; gap:.1rem; min-width:0; flex:1; } +.channel-kind { font-size:.66rem; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:var(--accent); } +.channel-name { font-size:.86rem; font-weight:650; color:var(--ink); } +.channel-meta { font-size:.73rem; color:var(--ink-3); overflow-wrap:anywhere; } +.channel-form { display:flex; flex-direction:column; gap:.7rem; padding-top:.8rem; border-top:1px solid var(--line-2); } +.channel-form[hidden] { display:none; } + +/* ---- 임계값 ------------------------------------------------------- */ +.thr-list { display:grid; grid-template-columns:repeat(auto-fit, minmax(19rem, 1fr)); gap:.7rem; } +.thr-row { padding:.65rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; } +.thr-head { display:flex; align-items:baseline; justify-content:space-between; gap:.5rem; } +.thr-label { font-size:.85rem; font-weight:650; color:var(--ink); } +.thr-value { font-size:.7rem; color:var(--ink-3); font-family:var(--mono); } +.thr-controls { display:flex; gap:.4rem; margin-top:.4rem; } +.thr-controls input { flex:1; min-width:0; } +.thr-help { margin:.45rem 0 0; font-size:.72rem; line-height:1.5; color:var(--ink-3); } + +/* ---- 교대 인수인계 ------------------------------------------------ */ +.handover-pending { padding:.7rem .8rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; } +.handover-head { display:flex; align-items:baseline; justify-content:space-between; gap:.6rem; flex-wrap:wrap; } +.handover-range { font-family:var(--mono); font-size:.73rem; color:var(--ink-3); } +.handover-stats { display:flex; gap:.6rem; margin-top:.6rem; } +.handover-stat { flex:1; display:flex; flex-direction:column; gap:.1rem; padding:.5rem .6rem; + background:var(--card); border:1px solid var(--line); border-radius:8px; } +.handover-stat strong { font-size:1.3rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-3); } +.handover-stat span { font-size:.72rem; color:var(--ink-3); } +.handover-stat.is-open strong { color:var(--ink); } +.handover-stat.is-severe { background:var(--crit-bg); border-color:var(--crit-line); } +.handover-stat.is-severe strong { color:var(--crit-ink); } +.handover-from { margin:.5rem 0 0; font-size:.75rem; color:var(--ink-3); } +.handover-form { display:flex; align-items:flex-end; gap:.5rem; margin-top:.7rem; } +.handover-form .field { flex:1; display:flex; flex-direction:column; gap:.28rem; + font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; } +.handover-form input { text-transform:none; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; } +.handover-list { display:flex; flex-direction:column; gap:.3rem; } +.handover-row { display:flex; flex-wrap:wrap; gap:.5rem; align-items:baseline; padding:.4rem 0; + border-top:1px solid var(--line-2); font-size:.78rem; } +.handover-when { font-family:var(--mono); font-size:.72rem; color:var(--ink-3); } +.handover-who { font-weight:650; color:var(--ink); } +.handover-count { font-family:var(--mono); font-size:.73rem; color:var(--ink-3); } +.handover-note { color:var(--ink-2); flex-basis:100%; } + +/* ---- 억제 --------------------------------------------------------- */ +.suppress-list { display:flex; flex-direction:column; gap:.4rem; margin-top:.7rem; } +.suppress-row { display:flex; align-items:center; gap:.7rem; padding:.5rem .7rem; + background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:9px; } +.suppress-main { display:flex; flex-direction:column; gap:.1rem; flex:1; min-width:0; } +.suppress-scope { font-family:var(--mono); font-size:.76rem; font-weight:650; color:var(--warn-ink); } +.suppress-reason { font-size:.82rem; color:var(--ink); } +.suppress-meta { font-size:.72rem; color:var(--ink-3); } + +/* ---- 위험성평가 --------------------------------------------------- */ +.risk-list { display:flex; flex-direction:column; gap:.4rem; } +.risk-row { display:flex; align-items:flex-start; gap:.8rem; padding:.6rem .7rem; + background:var(--panel-2); border:1px solid var(--line); border-radius:9px; } +.risk-score { flex:none; width:3.2rem; display:flex; flex-direction:column; align-items:center; + gap:.05rem; padding:.35rem 0; border-radius:8px; background:var(--card); border:1px solid var(--line); } +.risk-score strong { font-size:1.2rem; font-weight:750; font-variant-numeric:tabular-nums; } +.risk-score span { font-size:.63rem; font-weight:700; letter-spacing:.04em; } +.risk-score.is-high { background:var(--crit-bg); border-color:var(--crit-line); color:var(--crit-ink); } +.risk-score.is-mid { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn-ink); } +.risk-score.is-low { color:var(--ink-3); } +.risk-body { display:flex; flex-direction:column; gap:.12rem; flex:1; min-width:0; } +.risk-type { font-size:.66rem; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:var(--accent); } +.risk-hazard { font-size:.86rem; font-weight:600; color:var(--ink); } +.risk-control { font-size:.78rem; color:var(--ink-2); } +.risk-meta { font-size:.72rem; color:var(--ink-3); font-family:var(--mono); } + +/* ---- 성능 추이 ---------------------------------------------------- */ +.metric-chart { min-height:6rem; } +.metric-svg { width:100%; height:auto; display:block; } +.metric-svg .axis { stroke:var(--line); stroke-width:1; } +.metric-svg .series-fps { fill:none; stroke:var(--accent); stroke-width:2; stroke-linejoin:round; } +.metric-svg .series-throttle { fill:var(--high); opacity:.35; } +.metric-legend { display:flex; gap:1rem; flex-wrap:wrap; margin-top:.4rem; font-size:.72rem; color:var(--ink-3); } +.legend-fps::before { content:""; display:inline-block; width:14px; height:2px; background:var(--accent); margin-right:.3rem; vertical-align:middle; } +.legend-throttle::before { content:""; display:inline-block; width:8px; height:10px; background:var(--high); opacity:.35; margin-right:.3rem; vertical-align:middle; } +.legend-range { margin-left:auto; font-family:var(--mono); } +.metric-summary { margin:.35rem 0 0; font-size:.78rem; color:var(--ink-2); font-family:var(--mono); } + +/* ---- 이벤트 상세: 링크 · 조치 카드 --------------------------------- */ +.detail-side { display:flex; flex-direction:column; gap:.9rem; min-width:0; } +.detail-links { display:flex; gap:.5rem; flex-wrap:wrap; } +.detail-link-btn { + font-size:.78rem; font-weight:600; text-decoration:none; padding:.32rem .65rem; border-radius:8px; + color:var(--accent); background:var(--accent-wash); border:1px solid var(--accent-line); +} +.detail-link-btn:hover { background:#d5e9ed; } +.detail-card-hint { margin:0 0 .6rem; font-size:.74rem; line-height:1.5; color:var(--ink-3); } +.detail-action-card .field { display:flex; flex-direction:column; gap:.28rem; margin-bottom:.5rem; + font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; } +.detail-action-card input { text-transform:none; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; } + +/* ---- 설치 점검 (온보딩) -------------------------------------------- */ +.setup-page { max-width:820px; margin:0 auto; } +.setup-steps { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.6rem; } +.setup-step { + display:flex; gap:.85rem; align-items:flex-start; padding:.85rem 1rem; + background:var(--card); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm); +} +.setup-mark { + display:grid; place-items:center; width:26px; height:26px; flex:none; border-radius:50%; + font-size:.8rem; font-weight:750; + color:var(--ink-3); background:var(--panel-2); border:1px solid var(--line); +} +.setup-step.is-ok { border-color:var(--accent-line); } +.setup-step.is-ok .setup-mark { color:#fff; background:var(--ok); border-color:var(--ok); } +.setup-step.is-todo { background:var(--warn-bg); border-color:var(--warn-line); } +.setup-step.is-todo .setup-mark { color:var(--warn-ink); background:#fff; border-color:var(--warn-line); } +/* blocked = 앞 단계가 끝나야 판정할 수 있는 단계. 실패가 아니므로 경고색을 쓰지 않는다. */ +.setup-step.is-blocked { opacity:.62; } +.setup-step.is-unknown .setup-mark { color:var(--ink-3); } +.setup-body { display:flex; flex-direction:column; gap:.2rem; min-width:0; } +.setup-title { margin:0; font-size:.92rem; font-weight:700; } +.setup-status { margin:0; font-size:.82rem; color:var(--ink-2); } +.setup-why { margin:.25rem 0 0; font-size:.78rem; line-height:1.55; color:var(--ink-3); } +.setup-link { align-self:flex-start; margin-top:.4rem; font-size:.78rem; font-weight:600; + color:var(--accent); text-decoration:none; } +.setup-link:hover { text-decoration:underline; text-underline-offset:3px; } +.setup-foot { margin:1rem 0 0; font-size:.76rem; color:var(--ink-3); } +.setup-foot a { color:var(--accent); } + +/* ---- 사고조사 패키지 ---------------------------------------------- */ +/* 인쇄물로 제출되는 문서다. 화면과 종이에서 같은 위계로 읽혀야 한다. */ +.package-page { display:flex; flex-direction:column; gap:.9rem; } +.package-toolbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; } +.package { + max-width:860px; margin:0 auto; padding:2rem 2.2rem 2.4rem; background:var(--card); + border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow); +} +.pkg-head { padding-bottom:1rem; margin-bottom:1.2rem; border-bottom:2px solid var(--ink); } +.pkg-doc-kind { margin:0; font-size:.7rem; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--ink-3); } +.pkg-title { margin:.3rem 0 .2rem; font-size:1.6rem; } +.pkg-sub { margin:0; font-size:.85rem; color:var(--ink-2); font-family:var(--mono); } +.pkg-section { margin-bottom:1.5rem; break-inside:avoid; } +.pkg-h2 { margin:0 0 .6rem; font-size:.72rem; font-weight:700; letter-spacing:.08em; text-transform:uppercase; + color:var(--ink-3); padding-bottom:.3rem; border-bottom:1px solid var(--line); } +.pkg-facts { margin:0; display:grid; grid-template-columns:auto 1fr; gap:.3rem 1rem; } +.pkg-kv { display:contents; } +.pkg-kv dt { font-size:.76rem; color:var(--ink-3); } +.pkg-kv dd { margin:0; font-size:.84rem; color:var(--ink); overflow-wrap:anywhere; } +.pkg-empty { margin:.3rem 0; font-size:.8rem; color:var(--ink-3); } +.pkg-note { margin:.6rem 0 0; font-size:.72rem; color:var(--ink-3); } +.pkg-frames { display:flex; gap:.7rem; flex-wrap:wrap; } +.pkg-frame { margin:0; flex:1 1 220px; max-width:280px; } +.pkg-frame img { width:100%; height:auto; display:block; border:1px solid var(--line); border-radius:8px; } +.pkg-frame figcaption { margin-top:.25rem; font-size:.7rem; font-family:var(--mono); color:var(--ink-3); } +.pkg-timeline { list-style:none; margin:0; padding:0; } +.pkg-tl-row { display:flex; align-items:baseline; gap:.7rem; padding:.3rem 0; border-bottom:1px solid var(--line-2); font-size:.8rem; } +/* 조사 대상 사건은 목록 안에서 즉시 찾을 수 있어야 한다. */ +.pkg-tl-row.is-subject { font-weight:700; background:var(--accent-wash); margin:0 -.5rem; padding:.3rem .5rem; border-radius:6px; } +.pkg-tl-time { font-family:var(--mono); font-size:.75rem; color:var(--ink-3); flex:none; } +.pkg-tl-sev { flex:none; font-size:.72rem; font-weight:700; min-width:2.4rem; } +.pkg-tl-sev.sev-critical { color:var(--crit-ink); } +.pkg-tl-sev.sev-high { color:var(--high-ink); } +.pkg-tl-sev.sev-warning { color:var(--warn-ink); } +.pkg-tl-sev.sev-info { color:var(--info); } +.pkg-tl-type { flex:1; } +.pkg-tl-state { flex:none; font-size:.73rem; color:var(--ink-3); } +.pkg-alerts, .pkg-actions { list-style:none; margin:0; padding:0; } +.pkg-alert, .pkg-actions li { display:flex; gap:.7rem; align-items:baseline; flex-wrap:wrap; + padding:.3rem 0; border-bottom:1px solid var(--line-2); font-size:.8rem; } +.pkg-alert-ch { font-weight:650; } +.pkg-alert-status { font-size:.75rem; } +.pkg-alert.is-sent .pkg-alert-status { color:var(--ok); } +.pkg-alert.is-failed .pkg-alert-status { color:var(--crit-ink); font-weight:700; } +.pkg-alert.is-suppressed .pkg-alert-status { color:var(--warn-ink); } +.pkg-alert-when { margin-left:auto; font-family:var(--mono); font-size:.73rem; color:var(--ink-3); } +.pkg-alert-err { flex-basis:100%; font-size:.72rem; color:var(--crit-ink); font-family:var(--mono); } +.pkg-act-date { font-family:var(--mono); font-size:.75rem; color:var(--ink-3); } +.pkg-act-note { flex:1; } +.pkg-act-by { font-size:.73rem; color:var(--ink-3); } +.pkg-sign { display:grid; grid-template-columns:repeat(3, 1fr); gap:1.2rem; margin-top:.5rem; } +.pkg-sign-box { display:flex; flex-direction:column; gap:1.8rem; } +.pkg-sign-role { font-size:.74rem; color:var(--ink-3); } +.pkg-sign-line { border-bottom:1px solid var(--ink-3); } +.pkg-foot { margin-top:1.5rem; padding-top:.7rem; border-top:1px solid var(--line); + font-size:.7rem; color:var(--ink-3); line-height:1.6; } +.package-loading { color:var(--ink-3); font-size:.85rem; } + +@media (max-width:860px) { + .comp-grid { grid-template-columns:1fr; } + .package { padding:1.2rem 1rem 1.6rem; } + .pkg-sign { grid-template-columns:1fr; } +} + +/* 패키지 인쇄: 앱 크롬과 조작 버튼을 걷어내고 문서만 남긴다. */ +@media print { + .package-toolbar, .detail-links, .detail-action-card { display:none !important; } + .package { max-width:none; margin:0; padding:0; border:0; box-shadow:none; } + .pkg-section { page-break-inside:avoid; } +} + +/* ---- 모바일: 현장 관리자는 폰으로 본다 ---------------------------- */ +/* 데스크 콘솔의 축소판이 아니라, 한 손으로 확인만 하는 화면으로 재구성한다. + 터치 타깃 44px, 썸네일 우선, 부수 정보는 접는다. */ +@media (max-width:560px) { + .stream-tools { gap:.4rem; } + .kbd-hint { display:none; } /* 폰에는 키보드가 없다 */ + .view-modes { flex:1; } + .mode-btn { flex:1; min-height:38px; } + .bulk-btn { width:100%; min-height:40px; } + + .event-row { flex-wrap:wrap; padding:.55rem .6rem; gap:.55rem; } + .event-thumb-slot { width:88px; height:52px; } + .event-meta { flex:1 1 0; } + .event-type { font-size:.92rem; } + /* 액션은 행 아래 전폭으로 내린다 — 44px 타깃을 확보하고, 좁은 화면에서 + 썸네일과 버튼이 서로를 밀어내지 않게 한다. */ + .row-actions { margin-left:0; flex-basis:100%; gap:.5rem; } + .ack-btn { flex:1; min-height:44px; font-size:.88rem; } + .fp-btn { flex:0 0 5.5rem; min-height:44px; border-color:var(--line); } + .day-sep span { font-size:.72rem; } + + .rail { padding:.7rem .65rem; } + .form-grid { grid-template-columns:1fr; } + .handover-form { flex-direction:column; align-items:stretch; } + .handover-form button { min-height:44px; } + .thr-list { grid-template-columns:1fr; } + .thr-controls input, .thr-controls button { min-height:44px; } + .channel-row, .suppress-row, .risk-row { flex-wrap:wrap; } + .channel-row button, .suppress-row button, .risk-row button { min-height:40px; } + .detail-grid { gap:.7rem; } + .detail-actions { width:100%; } + .detail-actions button { flex:1; min-height:44px; } +} + /* ---- Reduced motion ---------------------------------------------- */ @media (prefers-reduced-motion:reduce) { *, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; } diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 7a56b24..5e893f4 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -1,7 +1,14 @@ /** * Events dashboard: polls GET /api/events every 5s, renders the list with - * severity text+icon+color (never color alone, per NF9), pins unacknowledged - * events to the top, and wires the acknowledge button to POST /events/{id}/ack. + * severity text+icon+color (never color alone, per NF9), and wires the acknowledge + * button to POST /events/{id}/ack. + * + * ORDERING (deliberate): strictly newest-first by occurred_at, ALWAYS. The list used to pin + * unacknowledged rows above acknowledged ones, which made the stream look like a timeline + * while actually being two concatenated queues — an incident's own follow-up landed pages + * away from it, and acknowledging a row teleported it out of view (the next click then hit a + * different event, because every row below had shifted up). Triage is served by the 미확인 + * view mode instead, which FILTERS rather than re-sorts, so the chronology never lies. * * XSS-safe: all dynamic values are set via textContent, never innerHTML. */ @@ -71,6 +78,57 @@ + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); } + // --- day grouping ------------------------------------------------------------------ + // A strictly chronological stream still needs a visible day boundary: without it the only + // signal that the list crossed midnight is two digits inside a mono stamp, and a row from + // last Tuesday reads as if it happened this morning. + var DOW = ['일', '월', '화', '수', '목', '금', '토']; + + function dayKey(occurredAt) { + var d = new Date(Number(occurredAt) * 1000); + if (isNaN(d.getTime())) return ''; + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); + } + + function dayLabel(occurredAt) { + var d = new Date(Number(occurredAt) * 1000); + if (isNaN(d.getTime())) return ''; + var today = new Date(); + var key = dayKey(occurredAt); + var rel = ''; + if (key === dayKey(today.getTime() / 1000)) rel = ' · 오늘'; + else if (key === dayKey(today.getTime() / 1000 - 86400)) rel = ' · 어제'; + return d.getFullYear() + '. ' + (d.getMonth() + 1) + '. ' + d.getDate() + + '. (' + DOW[d.getDay()] + ')' + rel; + } + + // --- action feedback ----------------------------------------------------------------- + // 확인/오탐 are audit-backed and NOT reversible from this console (acknowledged_by is an + // append-only record; a silent un-ack would be a worse property for a safety system than a + // visible one). So the affordance is confirmation, not undo: the row transitions in place + // and this line states exactly which event was just resolved, by type and camera. + var toastEl = null; + var toastTimer = null; + + function showToast(text) { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.className = 'action-toast'; + toastEl.setAttribute('role', 'status'); + toastEl.setAttribute('aria-live', 'polite'); + document.body.appendChild(toastEl); + } + toastEl.textContent = text; + toastEl.classList.add('is-on'); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(function () { toastEl.classList.remove('is-on'); }, 4000); + } + + function eventSummary(ev) { + var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || ''); + return what + (ev.camera_id ? ' · ' + ev.camera_id : ''); + } + function buildBadge(sev) { var info = severityInfo(sev); var badge = document.createElement('span'); @@ -93,6 +151,30 @@ var audioCtx = null; var alarmTimer = null; var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately + // Session-scoped mute. A repeating tone that cannot be silenced is how a safety console gets + // switched off entirely, so the operator gets a control — but a deliberately narrow one: it + // lives for this page only (no persistence), it is announced on screen while active, and a + // NEWLY arriving critical re-arms it, so muting a known backlog can never hide the next fall. + var muted = false; + var muteBtn = document.getElementById('alarm-mute'); + var muteState = document.getElementById('alarm-mute-state'); + + function renderMute() { + if (muteBtn) { + muteBtn.setAttribute('aria-pressed', muted ? 'true' : 'false'); + muteBtn.textContent = muted ? '음소거 해제' : '경보음 끄기'; + } + if (muteState) { + muteState.hidden = !muted; + muteState.textContent = muted ? '경보음 꺼짐 — 새 위급 발생 시 자동 해제' : ''; + } + } + + function setMuted(on) { + muted = on; + if (muted && alarmTimer) { clearInterval(alarmTimer); alarmTimer = null; } + renderMute(); + } function unlockAudio() { if (!audioCtx) { @@ -103,6 +185,7 @@ } function beep() { + if (muted) return; if (!audioCtx) return; try { var osc = audioCtx.createOscillator(); @@ -125,15 +208,24 @@ var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; if (ev.severity === 'critical' && !acked) { hasUnackedCritical = true; - if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); } + if (!beepedCritical[ev.event_id]) { + beepedCritical[ev.event_id] = true; + // A critical this session has never seen re-arms a muted console: the mute is for a + // known backlog, never for the next fall. + if (muted) setMuted(false); + beep(); + } } }); - if (hasUnackedCritical && !alarmTimer) { + if (hasUnackedCritical && !alarmTimer && !muted) { alarmTimer = setInterval(beep, 3000); - } else if (!hasUnackedCritical && alarmTimer) { + } else if ((!hasUnackedCritical || muted) && alarmTimer) { clearInterval(alarmTimer); alarmTimer = null; } + // Nothing left alarming: drop the mute so the control never sits "on" without a reason. + if (!hasUnackedCritical && muted) setMuted(false); + if (muteBtn) muteBtn.hidden = !hasUnackedCritical && !muted; } // No snapshot, or the file is gone (PIPA retention purge / a broken store). Both are the same @@ -210,11 +302,14 @@ typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view'; typeLink.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || ''); type.appendChild(typeLink); - // The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip — - // secondary encoding alongside the red tint, so it never rides on color alone. - if (!isAcked && ev.severity === 'critical') { + // "확인 필요" on EVERY unacknowledged row, not just criticals. The chip used to be a + // critical-only flourish, which left 경고/정보 rows with no textual state at all: whether + // they were handled could only be inferred from the presence of the action buttons in a + // far-right column. The chip is toned by severity so a critical still shouts and an info + // row only murmurs — but the FACT is always written, never inferred. + if (!isAcked) { var chip = document.createElement('span'); - chip.className = 'chip'; + chip.className = 'chip chip-' + sevRow; chip.textContent = '확인 필요'; type.appendChild(chip); } @@ -283,11 +378,12 @@ }) .then(function (res) { if (!res.ok) throw new Error('ack_failed:' + res.status); - // Reflect the ack on a "더 보기"-loaded (older-window) row too. loadEvents() only refreshes - // the live window, so without this an event acked from the paged tail snaps straight back - // to unacknowledged on the next render — inviting a duplicate ack that overwrites the - // original acknowledged_by audit record. - patchOlderAck(eventId); + // Patch BOTH windows immediately. The row must resolve in place on this frame, not on + // the next 5s poll: a row that keeps saying 미확인 for five seconds invites a second + // click that overwrites the original acknowledged_by audit record. + var ev = patchEventLocally(eventId, false); + renderList(); + showToast('확인 처리 — ' + (ev ? eventSummary(ev) : '이벤트 ' + eventId)); return loadEvents(); }) .catch(function () { @@ -295,22 +391,49 @@ }); } - function patchOlderAck(eventId, alsoFp) { - for (var i = 0; i < olderEvents.length; i++) { - var ev = olderEvents[i]; - if (ev && ev.event_id === eventId && !isAcked(ev)) { - // New object, never mutate the existing one (keeps combinedEvents' dedup predictable). - var patch = { acknowledged_by: window.APP_USER || '확인' }; - if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐'; - olderEvents[i] = Object.assign({}, ev, patch); + /** Apply the resolution to the live window AND the paged tail, and return the patched event. + * loadEvents() only refreshes the live window, so without the tail patch an event resolved + * from a "더 보기" page snaps back to 미확인 on the next render. */ + function patchEventLocally(eventId, alsoFp) { + var patch = { acknowledged_by: window.APP_USER || '확인' }; + if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐'; + var found = null; + [liveEvents, olderEvents].forEach(function (list) { + for (var i = 0; i < list.length; i++) { + var ev = list[i]; + if (ev && ev.event_id === eventId) { + // New object, never mutate the existing one (keeps combinedEvents' dedup predictable). + list[i] = Object.assign({}, ev, isAcked(ev) && !alsoFp ? {} : patch); + found = list[i]; + } } - } + }); + return found; } // The 오탐 tag: same terminality as an ack (the server also acknowledges), plus the event // feeds the per-camera FP-rate panel. By product decision this is where the loop ENDS — // the tag never auto-tunes edge thresholds; a human reads the rate and adjusts deliberately. + // + // Two-step on purpose: unlike 확인, this one asserts the DETECTOR was wrong. It moves a + // per-camera statistic that a human later re-tunes a threshold from, and there is no undo. + // The first click arms, a second within 4s commits; anything else disarms. + var fpArmTimer = null; + + function disarmFp(fpBtn) { + if (fpArmTimer) { clearTimeout(fpArmTimer); fpArmTimer = null; } + if (fpBtn) { fpBtn.classList.remove('is-armed'); fpBtn.textContent = '오탐'; } + } + function markFalsePositive(eventId, fpBtn, ackBtn) { + if (!fpBtn.classList.contains('is-armed')) { + fpBtn.classList.add('is-armed'); + fpBtn.textContent = '오탐 확정?'; + if (fpArmTimer) clearTimeout(fpArmTimer); + fpArmTimer = setTimeout(function () { disarmFp(fpBtn); }, 4000); + return; + } + disarmFp(fpBtn); fpBtn.disabled = true; if (ackBtn) ackBtn.disabled = true; fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', { @@ -319,7 +442,9 @@ }) .then(function (res) { if (!res.ok) throw new Error('fp_failed:' + res.status); - patchOlderAck(eventId, true); + var ev = patchEventLocally(eventId, true); + renderList(); + showToast('오탐 태그 — ' + (ev ? eventSummary(ev) : '이벤트 ' + eventId)); loadFpStats(); // the panel should reflect the tag immediately, not in 60s return loadEvents(); }) @@ -344,7 +469,24 @@ return ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; } + // The strip's scope has to be written down. It counts the newest window (up to 200 events), + // which is an unbounded stretch of time — hours on a busy site, months on a quiet one. Left + // captioned as a bare "미확인" it sat directly above the 24h brief's own 미확인 count and the + // two numbers disagreed with nothing to explain why. + var kpiCap = document.getElementById('kpi-cap'); + + function renderStatusCaption(events) { + if (!kpiCap) return; + if (events.length === 0) { kpiCap.textContent = '미확인'; kpiCap.removeAttribute('title'); return; } + var oldest = events[events.length - 1]; + var newest = events[0]; + kpiCap.textContent = '미확인 · 최근 ' + events.length + '건'; + kpiCap.title = '집계 범위: ' + formatTime(oldest.occurred_at) + ' ~ ' + formatTime(newest.occurred_at) + + ' (아래 배너의 "지난 24시간"과는 기준 구간이 다릅니다)'; + } + function renderStatus(events) { + renderStatusCaption(events); if (!statusRoot) return; var counts = { critical: 0, high: 0, warning: 0, info: 0 }; events.forEach(function (ev) { @@ -411,6 +553,18 @@ var filters = { severity: '', type: '', device: '', camera: '', unackedOnly: false }; var moreBtn = document.getElementById('load-more'); + // --- day-query mode ---------------------------------------------------------------- + // Accident investigation asks "what happened on the 24th", and the live console could only + // answer it by pressing 더 보기 until that day scrolled past. The keyset cursor is + // "<occurred_at>:<event_id>", so a specific day is reachable directly: seed the cursor at the + // end of that day and page backwards until the window covers it. While a query is active the + // stream shows ONLY that day; the poll (and therefore the KPI strip and the alarm) keeps + // running against the live window underneath, so investigating never blinds the console. + var QUERY_MAX_PAGES = 6; + var query = null; // null = live stream; else {sinceTs, untilTs, label, events, loading, truncated} + + function inQueryMode() { return query !== null; } + function matchesFilters(ev) { if (filters.severity && ev.severity !== filters.severity) return false; if (filters.type && ev.event_type !== filters.type) return false; @@ -434,14 +588,21 @@ renderStatus(liveEvents); // counts always from the full live window, never the filtered view updateAlarm(liveEvents); // alarm too — before any early return renderTree(); // rail: camera stats + the current selection + renderQueryBanner(); - var all = combinedEvents(); + // First paint waits for /api/devices to resolve (success OR failure, and at most + // DEVICE_GATE_MS). Rows print the device NAME; painting before the names are known showed + // raw ids that swapped a beat later — the operator's first read of the board was of values + // that then changed under them. + if (!devicesResolved && !deviceGateExpired) return; + var all = inQueryMode() ? query.events : combinedEvents(); + + // Strictly newest-first. See the ordering note at the top of this file: acknowledgement + // state must never reorder a chronology. var shown = all.filter(matchesFilters).sort(function (a, b) { - var aA = isAcked(a), bA = isAcked(b); - if (aA !== bA) return aA ? 1 : -1; // unacknowledged first var at = Number(a.occurred_at) || 0, bt = Number(b.occurred_at) || 0; - if (at !== bt) return bt - at; // then newest first + if (at !== bt) return bt - at; return (b.event_id || 0) - (a.event_id || 0); }); @@ -453,7 +614,8 @@ var sig = shown.map(function (ev) { return [ev.event_id, ev.acknowledged_by || '', ev.false_positive_by || '', ev.thumb_uuid || ''].join('|'); - }).join(',') + '#' + String(moreCursor === null) + '#' + String(!!anyFilter); + }).join(',') + '#' + String(moreCursor === null) + '#' + String(!!anyFilter) + + '#' + (inQueryMode() ? query.label + (query.loading ? ':loading' : '') : 'live'); if (sig === lastListSig) return; lastListSig = sig; @@ -466,23 +628,156 @@ if (shown.length === 0) { var empty = document.createElement('div'); empty.className = 'stream-empty'; - empty.textContent = anyFilter - ? '조건에 맞는 이벤트가 없습니다. 왼쪽에서 필터를 해제하면 전체 스트림으로 돌아갑니다.' - : '표시할 이벤트가 없습니다.'; + if (inQueryMode()) { + empty.textContent = query.loading + ? '해당 날짜를 조회하는 중…' + : query.label + '에는 ' + (anyFilter ? '조건에 맞는 ' : '') + '감지 이벤트가 없습니다.'; + } else if (anyFilter) { + empty.textContent = '조건에 맞는 이벤트가 없습니다. 왼쪽에서 필터를 해제하면 전체 스트림으로 돌아갑니다.'; + } else if (devicesLoaded && deviceList.length === 0) { + // 갓 설치된 시스템. "표시할 이벤트가 없습니다"는 여기서 최악의 문장이다 — 조용하다는 + // 뜻으로 읽히지만 실제로는 아무것도 감시하고 있지 않다. + empty.textContent = '등록된 장치가 없습니다. 이 시스템은 아직 아무것도 감시하고 있지 않습니다. '; + var setup = document.createElement('a'); + setup.href = B + '/setup'; + setup.textContent = '설치 점검 →'; + empty.appendChild(setup); + } else { + empty.textContent = '표시할 이벤트가 없습니다.'; + } root.appendChild(empty); } else { + // Day dividers: emitted whenever the calendar day changes going down the (strictly + // chronological) list, so crossing midnight is a visible event and not two digits + // buried in a mono timestamp. var frag = document.createDocumentFragment(); - shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); }); + var lastDay = null; + shown.forEach(function (ev) { + var key = dayKey(ev.occurred_at); + if (key && key !== lastDay) { + lastDay = key; + var sep = document.createElement('div'); + sep.className = 'day-sep'; + sep.setAttribute('role', 'separator'); + var lbl = document.createElement('span'); + lbl.textContent = dayLabel(ev.occurred_at); + sep.appendChild(lbl); + frag.appendChild(sep); + } + frag.appendChild(buildRow(ev)); + }); root.appendChild(frag); } if (refocus) { var back = root.querySelector('[data-focus="' + refocus + '"]'); if (back) back.focus(); } - if (moreBtn) moreBtn.hidden = (moreCursor === null); + renderBulk(); + // The keyboard cursor is index-based and the list was just rebuilt; clamp it rather than + // leaving it pointing past the end (where A would silently do nothing). + var rowCount = root.querySelectorAll('.event-row').length; + if (cursor >= rowCount) cursor = rowCount - 1; + if (cursor >= 0) { + var rows = [].slice.call(root.querySelectorAll('.event-row')); + rows.forEach(function (r, i) { r.classList.toggle('is-cursor', i === cursor); }); + } + // "더 보기" pages the LIVE stream only: in day-query mode the range is already fully + // fetched, so offering it there would page past the day the operator asked for. + if (moreBtn) moreBtn.hidden = inQueryMode() || (moreCursor === null); updatePageGapHint(); } + // --- day query: fetch + banner ------------------------------------------------------- + var queryBanner = null; + + function renderQueryBanner() { + if (!queryBanner) { + queryBanner = document.createElement('div'); + queryBanner.className = 'query-banner'; + var text = document.createElement('span'); + text.className = 'query-text'; + var back = document.createElement('button'); + back.type = 'button'; + back.textContent = '실시간 스트림으로'; + back.addEventListener('click', exitQuery); + queryBanner.appendChild(text); + queryBanner.appendChild(back); + if (root.parentNode) root.parentNode.insertBefore(queryBanner, root); + } + queryBanner.hidden = !inQueryMode(); + if (!inQueryMode()) return; + var t = queryBanner.querySelector('.query-text'); + if (query.loading) { + t.textContent = query.label + ' 조회 중…'; + } else { + t.textContent = query.label + ' 조회 · ' + query.events.length + '건' + + (query.truncated ? ' (표시 상한에 도달해 이 날의 가장 이른 구간이 잘렸습니다)' : ''); + } + queryBanner.classList.toggle('is-truncated', !!query.truncated); + } + + function exitQuery() { + query = null; + lastListSig = null; + if (dateInput) dateInput.value = ''; + renderList(); + } + + function runDayQuery(dateStr) { + var parts = String(dateStr || '').split('-'); + if (parts.length !== 3) return; + var start = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]), 0, 0, 0, 0); + if (isNaN(start.getTime())) return; + var sinceTs = Math.floor(start.getTime() / 1000); + var untilTs = sinceTs + 86400; + query = { + sinceTs: sinceTs, untilTs: untilTs, + label: dayLabel(sinceTs + 1), + events: [], loading: true, truncated: false + }; + lastListSig = null; + renderList(); + + // Page backwards from the end of the requested day until the window passes its start. + var page = 0; + function fetchPage(cursor) { + page += 1; + return fetch(B + '/api/events?limit=200&cursor=' + encodeURIComponent(cursor), + { headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) { window.location.href = B + '/login'; return null; } + if (!res.ok) throw new Error('query_failed:' + res.status); + return res.json(); + }) + .then(function (data) { + if (!data || !query || query.sinceTs !== sinceTs) return; // superseded/cancelled + var rows = data.events || []; + var reachedStart = false; + rows.forEach(function (ev) { + var at = Number(ev.occurred_at) || 0; + if (at < sinceTs) { reachedStart = true; return; } + if (at < untilTs) query.events.push(ev); + }); + if (!reachedStart && data.next_cursor && page < QUERY_MAX_PAGES) { + return fetchPage(data.next_cursor); + } + query.truncated = !reachedStart && !!data.next_cursor; + query.loading = false; + lastListSig = null; + renderList(); + }); + } + + fetchPage(untilTs + ':2147483647').catch(function () { + if (!query) return; + query.loading = false; + query.truncated = false; + lastListSig = null; + renderList(); + showToast('해당 날짜를 불러오지 못했습니다.'); + }); + } + function updatePageGapHint() { // A gap exists once new events have arrived since the operator paged: some events have slid out // of the live window into a band that was never fetched. Surface it honestly with a reload, @@ -596,6 +891,18 @@ var devicesLoaded = false; // false until the first success, so "empty" ≠ "not known yet" var devicesStale = false; // last poll failed: show the old state, but say that it is old var deviceLabelSig = ''; // device_id=name pairs last painted into the stream rows + // First-paint gate (see renderList): resolved on the first /api/devices outcome, success or + // failure. The timer is the backstop — a hung devices endpoint must delay the stream by a + // moment, never withhold it, because the events are the safety-critical half of this page. + var devicesResolved = false; + var deviceGateExpired = false; + var DEVICE_GATE_MS = 2500; + setTimeout(function () { + if (devicesResolved) return; + deviceGateExpired = true; + lastListSig = null; + renderList(); + }, DEVICE_GATE_MS); var SEV_RANK = { info: 0, warning: 1, high: 2, critical: 3 }; var CAM_SEP = '\u001f'; // (device, camera) key separator — a unit separator, never in an id @@ -672,10 +979,12 @@ return res.json(); }) .then(function (data) { + if (data && data.storage) renderStorage(data.storage); if (data && Array.isArray(data.devices)) { deviceList = data.devices; devicesLoaded = true; devicesStale = false; + devicesResolved = true; // The stream rows print the device's NAME, not its id, so a changed name set — the // first load above all, which lands after the first event render — has to repaint the // list too. Unchanged names (the steady state) only repaint the rail. @@ -689,16 +998,145 @@ // an empty one: a camera that dies afterwards would keep showing "온라인". Keep the last // state, but flag it. Cleared automatically by the next successful poll. devicesStale = true; - renderTree(); + // A failed devices poll still RESOLVES the first-paint gate: the stream must not be + // held hostage to the rail. Rows then print the raw device_id, which is the honest + // answer while no name is known. + if (!devicesResolved) { devicesResolved = true; lastListSig = null; renderList(); } + else renderTree(); }); } + // --- storage headroom -------------------------------------------------------------- + // A full snapshot volume does not announce itself: image writes start failing, ingest logs + // and carries on (an event without its picture still beats no event), and the board stays + // green while the evidence quietly stops being kept. Say it out loud instead. + var storageWarn = document.getElementById('storage-warn'); + + function renderStorage(s) { + if (!storageWarn) return; + if (!s || !s.low) { storageWarn.hidden = true; return; } + storageWarn.hidden = false; + storageWarn.textContent = ''; + storageWarn.appendChild(spanWith('warn-lead', '저장 공간 부족')); + storageWarn.appendChild(document.createTextNode( + ' — 스냅샷 볼륨 여유 ' + s.free_mb + 'MB (기준 ' + s.min_free_mb + 'MB). ' + + '이 상태가 지속되면 새 사건의 증거 이미지가 저장되지 않습니다. ' + + '보존기간 설정을 줄이거나 용량을 확보하세요.')); + } + + // --- bulk acknowledge ---------------------------------------------------------------- + // For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall + // is exactly the row a human must look at, and a control that can clear one in a sweep is a + // control that will eventually clear one nobody read. + var bulkBtn = document.getElementById('bulk-ack'); + var bulkArmed = false; + var bulkTimer = null; + + function bulkTargets() { + if (!filters.unackedOnly) return []; + var src = inQueryMode() ? query.events : combinedEvents(); + return src.filter(function (ev) { + return matchesFilters(ev) && !isAcked(ev) && ev.severity !== 'critical'; + }); + } + + function renderBulk() { + if (!bulkBtn) return; + var n = bulkTargets().length; + // Only offered in the triage view; in the chronological view a sweep has no clear scope. + bulkBtn.hidden = !filters.unackedOnly || n === 0; + if (bulkBtn.hidden) { disarmBulk(); return; } + if (!bulkArmed) bulkBtn.textContent = '위급 제외 ' + n + '건 일괄 확인'; + } + + function disarmBulk() { + if (bulkTimer) { clearTimeout(bulkTimer); bulkTimer = null; } + bulkArmed = false; + if (bulkBtn) bulkBtn.classList.remove('is-armed'); + } + + if (bulkBtn) { + bulkBtn.addEventListener('click', function () { + var targets = bulkTargets(); + if (!targets.length) return; + if (!bulkArmed) { + bulkArmed = true; + bulkBtn.classList.add('is-armed'); + bulkBtn.textContent = targets.length + '건을 확인 처리합니다. 한 번 더 누르세요'; + bulkTimer = setTimeout(function () { disarmBulk(); renderBulk(); }, 5000); + return; + } + disarmBulk(); + bulkBtn.disabled = true; + var done = 0; + Promise.all(targets.map(function (ev) { + return fetch(B + '/events/' + encodeURIComponent(ev.event_id) + '/ack', { + method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } + }).then(function (res) { + if (res.ok) { patchEventLocally(ev.event_id, false); done++; } + }).catch(function () {}); + })).then(function () { + bulkBtn.disabled = false; + lastListSig = null; + renderList(); + showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)'); + loadEvents(); + }); + }); + } + + // --- keyboard triage ----------------------------------------------------------------- + // A desk operator working a backlog should not have to move a mouse between rows. The + // shortcuts are deliberately non-destructive except for A: F still arms the two-step 오탐. + var cursor = -1; + + function rowsOnScreen() { + return [].slice.call(root.querySelectorAll('.event-row')); + } + + function moveCursor(delta) { + var rows = rowsOnScreen(); + if (!rows.length) return; + cursor = Math.max(0, Math.min(rows.length - 1, cursor < 0 ? 0 : cursor + delta)); + rows.forEach(function (r, i) { r.classList.toggle('is-cursor', i === cursor); }); + rows[cursor].scrollIntoView({ block: 'nearest' }); + } + + function cursorRow() { + var rows = rowsOnScreen(); + return cursor >= 0 && cursor < rows.length ? rows[cursor] : null; + } + + document.addEventListener('keydown', function (e) { + // Never steal a key from a field, and never from a modified chord (browser shortcuts). + var t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + var key = e.key.toLowerCase(); + if (key === 'j') { e.preventDefault(); moveCursor(1); return; } + if (key === 'k') { e.preventDefault(); moveCursor(-1); return; } + var row = cursorRow(); + if (!row) return; + if (key === 'a') { + var ack = row.querySelector('.ack-btn'); + if (ack) { e.preventDefault(); ack.click(); } + } else if (key === 'f') { + var fp = row.querySelector('.fp-btn'); + if (fp) { e.preventDefault(); fp.click(); } + } else if (e.key === 'Enter') { + var link = row.querySelector('.event-detail-link'); + if (link) { e.preventDefault(); window.location.href = link.href; } + } + }); + // --- rail: FP-rate panel ---------------------------------------------------------- // Per-(camera, type) false-positive rates over 30 days. Refreshed on its own slow cadence // (60s) — tag volume changes slowly — plus immediately after the operator tags one. var fpStatsRoot = document.getElementById('fp-stats-root'); var FP_STATS_INTERVAL_MS = 60000; var FP_STATS_SHOWN = 5; + var FP_MIN_SAMPLE = 5; // below this a ratio is noise, not a rate — see renderFpStats function renderFpStats(stats) { if (!fpStatsRoot) return; @@ -722,9 +1160,19 @@ row.appendChild(what); var rate = document.createElement('span'); var pct = Math.round((s.fp / Math.max(1, s.total)) * 100); - rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : ''); - rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')'; - // 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다). + // Sample gate. "100% (1/1)" is not a false-positive RATE, it is one tagged event — and + // printed in the same column as "33% (5/15)" it outranked it. Below the minimum sample + // the raw counts are shown instead, with no percentage and no hot styling, so nothing + // reads as a tuning signal until there is enough evidence to be one. + if (s.total < FP_MIN_SAMPLE) { + rate.className = 'fp-rate is-thin'; + rate.textContent = s.fp + '/' + s.total + ' (표본 부족)'; + rate.title = '오탐율은 ' + FP_MIN_SAMPLE + '건 이상 감지된 카메라·유형에만 표시합니다.'; + } else { + // 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다). + rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : ''); + rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')'; + } row.appendChild(rate); fpStatsRoot.appendChild(row); }); @@ -764,11 +1212,14 @@ if (t.events === 0) { parts.push('감지 이벤트 없음'); } else { - parts.push('감지 ' + t.events + '건' + (severe > 0 ? ' (중대 ' + severe + ')' : '')); + // "중대" is this product's word for 위급+높음; spell the two tiers out inline so the + // reader never has to guess how it relates to the 위급/높음 tiles right above. + parts.push('감지 ' + t.events + '건' + + (severe > 0 ? ' (중대 ' + severe + ' = 위급 ' + t.critical + ' + 높음 ' + t.high + ')' : '')); if (r.near_miss.near_fall > 0) parts.push('니어미스 ' + r.near_miss.near_fall); var avg = briefDuration(r.tta.avg_s); if (avg) parts.push('평균 대응 ' + avg); - parts.push(t.unacked > 0 ? '미확인 ' + t.unacked + '건 남음' : '전부 확인됨'); + parts.push(t.unacked > 0 ? '이 중 미확인 ' + t.unacked + '건' : '전부 확인됨'); } dayBrief.appendChild(document.createTextNode(' — ' + parts.join(' · ') + ' ')); var link = document.createElement('a'); @@ -793,10 +1244,43 @@ var typeSel = document.getElementById('type-filter'); if (typeSel) typeSel.addEventListener('change', function () { filters.type = typeSel.value; renderList(); }); if (cameraReset) cameraReset.addEventListener('click', clearCamera); - var unackedChk = document.getElementById('unacked-only'); - if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); }); if (moreBtn) moreBtn.addEventListener('click', loadMore); + // View mode — a FILTER, never a re-sort. "전체" is the chronology; "미확인" is the triage + // queue over the same chronology. Swapping between them can add or remove rows but can + // never move one relative to another, so the operator's mental map of the stream survives. + var modeBtns = [].slice.call(document.querySelectorAll('[data-view-mode]')); + function setViewMode(mode) { + filters.unackedOnly = (mode === 'unacked'); + modeBtns.forEach(function (b) { + var on = b.getAttribute('data-view-mode') === mode; + b.setAttribute('aria-pressed', on ? 'true' : 'false'); + b.classList.toggle('is-on', on); + }); + renderList(); + } + modeBtns.forEach(function (b) { + b.addEventListener('click', function () { setViewMode(b.getAttribute('data-view-mode')); }); + }); + if (modeBtns.length) setViewMode('all'); + + // 날짜 조회 (see runDayQuery) + var dateInput = document.getElementById('date-jump'); + var dateGo = document.getElementById('date-jump-go'); + if (dateGo && dateInput) { + dateGo.addEventListener('click', function () { + if (dateInput.value) runDayQuery(dateInput.value); + }); + dateInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && dateInput.value) { e.preventDefault(); runDayQuery(dateInput.value); } + }); + dateInput.max = dayKey(Date.now() / 1000); + } + + if (muteBtn) muteBtn.addEventListener('click', function () { setMuted(!muted); }); + renderMute(); + if (muteBtn) muteBtn.hidden = true; + loadCsrfToken(); loadEvents(); loadDevices(); diff --git a/server/public/assets/compliance.js b/server/public/assets/compliance.js new file mode 100644 index 0000000..33a8e0a --- /dev/null +++ b/server/public/assets/compliance.js @@ -0,0 +1,420 @@ +/** + * 안전 관리: 교대 인수인계 · 정비 억제 · 위험성평가 · 설비 성능 추이. + * + * XSS-safe: 모든 동적 값은 textContent. 그래프는 인라인 SVG 를 DOM API 로 조립한다 + * (문자열 innerHTML 금지 — 프로젝트 규약이자, 카메라 이름 같은 값이 마크업에 섞이는 것을 막는다). + */ +(function () { + 'use strict'; + + var B = window.APP_BASE || ''; + var root = document.querySelector('.compliance-page'); + if (!root) return; + + var csrf = null; + 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: '화재·연기' + }; + + 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 fmtDate(ts) { + var d = new Date(Number(ts) * 1000); + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); + } + function fmtStamp(ts) { + var d = new Date(Number(ts) * 1000); + return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()); + } + function fmtLeft(seconds) { + if (seconds <= 0) return '만료됨'; + if (seconds < 3600) return Math.ceil(seconds / 60) + '분 남음'; + return Math.floor(seconds / 3600) + '시간 ' + Math.round((seconds % 3600) / 60) + '분 남음'; + } + + function loadCsrf() { + return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d && d.token) csrf = d.token; }) + .catch(function () {}); + } + function post(path, body) { + return fetch(B + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' }, + body: JSON.stringify(body || {}) + }).then(function (r) { + if (r.status === 401) { window.location.href = B + '/login'; return null; } + return r.json().then(function (d) { return { ok: r.ok, data: d }; }); + }); + } + function getJson(path) { + return fetch(B + path, { headers: { Accept: 'application/json' } }) + .then(function (r) { + if (r.status === 401) { window.location.href = B + '/login'; return null; } + return r.ok ? r.json() : null; + }); + } + + var ERRORS = { + reason_required: '사유를 입력하세요 — 사유 없는 억제는 나중에 아무도 해석할 수 없습니다.', + invalid_window: '기간은 1분 이상 8시간(480분) 이하여야 합니다.', + invalid_scope_id: '대상을 선택하세요.', + unknown_scope_id: '등록되지 않은 대상입니다.', + invalid_rating: '강도·빈도는 1~5 사이여야 합니다.', + invalid_hazard: '위험요인을 입력하세요.', + invalid_control: '현재 대책을 입력하세요.', + forbidden: '권한이 없습니다.', + csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.' + }; + function errText(c) { return ERRORS[c] || ('처리에 실패했습니다 (' + String(c) + ')'); } + + // ============================================================ 교대 인수인계 + var pendingBox = document.getElementById('handover-pending'); + var handoverList = document.getElementById('handover-list'); + + function renderHandovers(d) { + var p = d.pending || {}; + pendingBox.textContent = ''; + var head = el('div', 'handover-head'); + head.appendChild(el('strong', null, '인수 대상 구간')); + head.appendChild(el('span', 'handover-range', + fmtStamp(p.period_start) + ' ~ ' + fmtStamp(p.period_end))); + pendingBox.appendChild(head); + + var stats = el('div', 'handover-stats'); + var a = el('div', 'handover-stat' + (p.unacked_count > 0 ? ' is-open' : '')); + a.appendChild(el('strong', null, String(p.unacked_count || 0))); + a.appendChild(el('span', null, '미확인')); + stats.appendChild(a); + var b = el('div', 'handover-stat' + (p.severe_count > 0 ? ' is-severe' : '')); + b.appendChild(el('strong', null, String(p.severe_count || 0))); + b.appendChild(el('span', null, '이 중 중대')); + stats.appendChild(b); + pendingBox.appendChild(stats); + if (p.from_user) { + pendingBox.appendChild(el('p', 'handover-from', '인계자: ' + p.from_user)); + } + + handoverList.textContent = ''; + var list = d.handovers || []; + if (!list.length) { + handoverList.appendChild(el('p', 'admin-empty', '기록된 인수인계가 없습니다.')); + return; + } + list.forEach(function (h) { + var row = el('div', 'handover-row'); + row.appendChild(el('span', 'handover-when', fmtStamp(h.created_at))); + row.appendChild(el('span', 'handover-who', + (h.from_user ? h.from_user + ' → ' : '') + h.to_user)); + row.appendChild(el('span', 'handover-count', + '미확인 ' + h.unacked_count + ' (중대 ' + h.severe_count + ')')); + if (h.note) row.appendChild(el('span', 'handover-note', h.note)); + handoverList.appendChild(row); + }); + } + + function loadHandovers() { + getJson('/api/handovers').then(function (d) { if (d) renderHandovers(d); }) + .catch(function () { pendingBox.textContent = '인수인계 정보를 불러오지 못했습니다.'; }); + } + + var handoverForm = document.getElementById('handover-form'); + if (handoverForm) { + handoverForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + var note = document.getElementById('handover-note'); + post('/api/handovers', { note: note.value.trim() }).then(function (r) { + if (!r) return; + if (r.ok) { note.value = ''; loadHandovers(); } + }); + }); + } + + // ============================================================ 억제 + var supList = document.getElementById('suppress-list'); + var supStatus = document.getElementById('sup-status'); + var supType = document.getElementById('sup-scope-type'); + var supId = document.getElementById('sup-scope-id'); + var deviceRows = []; + var cameraRows = []; + + function fillScopeOptions() { + var rows = supType.value === 'device' ? deviceRows : cameraRows; + supId.textContent = ''; + if (!rows.length) { + supId.appendChild(new Option(supType.value === 'device' ? '등록된 장치가 없습니다' : '관측된 카메라가 없습니다', '')); + return; + } + supId.appendChild(new Option('선택하세요', '')); + rows.forEach(function (r) { supId.appendChild(new Option(r.label, r.id)); }); + } + + function renderSuppressions(d) { + var now = d.now || Math.floor(Date.now() / 1000); + supList.textContent = ''; + var list = d.suppressions || []; + if (!list.length) { + supList.appendChild(el('p', 'admin-empty', '현재 억제 중인 대상이 없습니다.')); + return; + } + list.forEach(function (s) { + var row = el('div', 'suppress-row'); + var main = el('div', 'suppress-main'); + main.appendChild(el('span', 'suppress-scope', + (s.scope_type === 'device' ? '장치' : s.scope_type === 'camera' ? '카메라' : '구역') + ' ' + s.scope_id)); + main.appendChild(el('span', 'suppress-reason', s.reason)); + main.appendChild(el('span', 'suppress-meta', + s.created_by + ' · ' + fmtStamp(s.starts_at) + ' 시작 · ' + fmtLeft(s.ends_at - now))); + row.appendChild(main); + var stop = el('button', null, '해제'); + stop.type = 'button'; + stop.addEventListener('click', function () { + post('/api/suppressions/' + s.suppression_id + '/revoke').then(function (r) { + if (r && r.ok) loadSuppressions(); + }); + }); + row.appendChild(stop); + supList.appendChild(row); + }); + } + + function loadSuppressions() { + getJson('/api/suppressions').then(function (d) { if (d) renderSuppressions(d); }) + .catch(function () { supList.textContent = '억제 목록을 불러오지 못했습니다.'; }); + } + + var supForm = document.getElementById('suppress-form'); + if (supForm) { + supType.addEventListener('change', fillScopeOptions); + supForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + supStatus.textContent = ''; + post('/api/suppressions', { + scope_type: supType.value, + scope_id: supId.value, + minutes: Number(document.getElementById('sup-minutes').value) || 0, + reason: document.getElementById('sup-reason').value.trim() + }).then(function (r) { + if (!r) return; + if (!r.ok) { supStatus.textContent = errText(r.data && r.data.error); return; } + supStatus.textContent = '억제를 시작했습니다. ' + fmtStamp(r.data.ends_at) + ' 에 자동 해제됩니다.'; + document.getElementById('sup-reason').value = ''; + loadSuppressions(); + }); + }); + } + + // ============================================================ 위험성평가 + var riskList = document.getElementById('risk-list'); + var riskForm = document.getElementById('risk-form'); + var riskStatus = document.getElementById('risk-status'); + + function riskBand(score) { + if (score >= 15) return { cls: 'is-high', text: '높음' }; + if (score >= 8) return { cls: 'is-mid', text: '보통' }; + return { cls: 'is-low', text: '낮음' }; + } + + function renderRisks(list) { + riskList.textContent = ''; + if (!list.length) { + riskList.appendChild(el('p', 'admin-empty', + '등록된 위험요인이 없습니다. 감지 유형별로 무엇을 통제하고 있는지 기록해 두면 사고조사 패키지에 함께 실립니다.')); + return; + } + list.forEach(function (r) { + var band = riskBand(r.risk_score); + var row = el('div', 'risk-row'); + var score = el('div', 'risk-score ' + band.cls); + score.appendChild(el('strong', null, String(r.risk_score))); + score.appendChild(el('span', null, band.text)); + row.appendChild(score); + + var body = el('div', 'risk-body'); + body.appendChild(el('span', 'risk-type', TYPE_LABELS[r.event_type] || r.event_type)); + body.appendChild(el('span', 'risk-hazard', r.hazard)); + body.appendChild(el('span', 'risk-control', '대책: ' + r.control)); + var meta = '강도 ' + r.severity_rating + ' × 빈도 ' + r.likelihood_rating; + if (r.reviewed_by) meta += ' · ' + r.reviewed_by + ' ' + fmtDate(r.reviewed_at); + body.appendChild(el('span', 'risk-meta', meta)); + row.appendChild(body); + + var edit = el('button', null, '수정'); + edit.type = 'button'; + edit.addEventListener('click', function () { openRisk(r); }); + row.appendChild(edit); + riskList.appendChild(row); + }); + } + + function openRisk(r) { + riskForm.hidden = false; + riskStatus.textContent = ''; + var sel = document.getElementById('risk-type'); + if (!sel.options.length) { + Object.keys(TYPE_LABELS).forEach(function (k) { sel.appendChild(new Option(TYPE_LABELS[k], k)); }); + } + r = r || {}; + sel.value = r.event_type || 'fall'; + document.getElementById('risk-hazard').value = r.hazard || ''; + document.getElementById('risk-control').value = r.control || ''; + document.getElementById('risk-severity').value = r.severity_rating || 3; + document.getElementById('risk-likelihood').value = r.likelihood_rating || 3; + document.getElementById('risk-hazard').focus(); + } + + if (riskForm) { + document.getElementById('risk-new').addEventListener('click', function () { openRisk(null); }); + document.getElementById('risk-cancel').addEventListener('click', function () { riskForm.hidden = true; }); + riskForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + post('/api/risks', { + event_type: document.getElementById('risk-type').value, + hazard: document.getElementById('risk-hazard').value.trim(), + control: document.getElementById('risk-control').value.trim(), + severity_rating: Number(document.getElementById('risk-severity').value), + likelihood_rating: Number(document.getElementById('risk-likelihood').value) + }).then(function (r) { + if (!r) return; + if (!r.ok) { riskStatus.textContent = errText(r.data && r.data.error); return; } + riskStatus.textContent = '저장되었습니다.'; + riskForm.hidden = true; + loadRisks(); + }); + }); + } + + function loadRisks() { + getJson('/api/risks').then(function (d) { if (d) renderRisks(d.controls || []); }) + .catch(function () { riskList.textContent = '위험성평가를 불러오지 못했습니다.'; }); + } + + // ============================================================ 성능 추이 + var metricDevice = document.getElementById('metric-device'); + var metricChart = document.getElementById('metric-chart'); + var SVG_NS = 'http://www.w3.org/2000/svg'; + + function svg(tag, attrs) { + var n = document.createElementNS(SVG_NS, tag); + Object.keys(attrs || {}).forEach(function (k) { n.setAttribute(k, String(attrs[k])); }); + return n; + } + + /** 값이 없는 구간은 선을 잇지 않는다 — 없는 데이터를 직선으로 메우면 그래프가 거짓말을 한다. */ + function polylines(points, key, x, y) { + var segs = [], cur = []; + points.forEach(function (p, i) { + var v = p[key]; + if (v === null || v === undefined) { if (cur.length > 1) segs.push(cur); cur = []; return; } + cur.push(x(i) + ',' + y(v)); + }); + if (cur.length > 1) segs.push(cur); + return segs; + } + + function renderMetrics(d) { + metricChart.textContent = ''; + var pts = d.points || []; + if (pts.length < 2) { + metricChart.appendChild(el('p', 'admin-empty', + '이 장치의 성능 기록이 아직 충분하지 않습니다. 하트비트가 쌓이면 추이가 그려집니다.')); + return; + } + var W = 720, H = 160, PAD = 28; + var fpsVals = pts.map(function (p) { return p.fps; }).filter(function (v) { return v !== null; }); + var maxFps = Math.max(30, Math.ceil(Math.max.apply(null, fpsVals.length ? fpsVals : [30]))); + var x = function (i) { return PAD + (i / (pts.length - 1)) * (W - PAD * 2); }; + var yFps = function (v) { return H - PAD - (v / maxFps) * (H - PAD * 2); }; + + var chart = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, class: 'metric-svg', + role: 'img', 'aria-label': '프레임률 추이' }); + chart.appendChild(svg('line', { x1: PAD, y1: H - PAD, x2: W - PAD, y2: H - PAD, class: 'axis' })); + chart.appendChild(svg('line', { x1: PAD, y1: PAD, x2: PAD, y2: H - PAD, class: 'axis' })); + + polylines(pts, 'fps', x, yFps).forEach(function (seg) { + chart.appendChild(svg('polyline', { points: seg.join(' '), class: 'series-fps' })); + }); + + // 스로틀 구간은 배경 띠로 — 프레임률 하락의 원인을 같은 그림에서 읽을 수 있어야 한다. + pts.forEach(function (p, i) { + if (!p.throttle) return; + chart.appendChild(svg('rect', { x: x(i) - 1, y: PAD, width: 3, height: H - PAD * 2, + class: 'series-throttle' })); + }); + + metricChart.appendChild(chart); + + var legend = el('div', 'metric-legend'); + legend.appendChild(el('span', 'legend-fps', '프레임률 (최대 ' + maxFps + ')')); + legend.appendChild(el('span', 'legend-throttle', '과열 스로틀 구간')); + legend.appendChild(el('span', 'legend-range', + fmtStamp(pts[0].ts) + ' ~ ' + fmtStamp(pts[pts.length - 1].ts) + ' · ' + pts.length + '점')); + metricChart.appendChild(legend); + + var last = pts[pts.length - 1]; + var summary = el('p', 'metric-summary'); + var bits = []; + if (last.fps !== null) bits.push('현재 ' + last.fps.toFixed(1) + ' fps'); + if (last.temp !== null) bits.push(last.temp.toFixed(1) + '°C'); + if (last.queue_pending !== null) bits.push('전송 대기 ' + last.queue_pending + '건'); + if (last.cameras_erroring) bits.push('오류 카메라 ' + last.cameras_erroring + '대'); + summary.textContent = bits.join(' · '); + metricChart.appendChild(summary); + } + + function loadMetrics() { + var dev = metricDevice.value; + if (!dev) { metricChart.textContent = '장치를 선택하세요.'; return; } + metricChart.textContent = '불러오는 중…'; + getJson('/api/devices/' + encodeURIComponent(dev) + '/metrics?hours=24') + .then(function (d) { if (d) renderMetrics(d); }) + .catch(function () { metricChart.textContent = '성능 기록을 불러오지 못했습니다.'; }); + } + + if (metricDevice) metricDevice.addEventListener('change', loadMetrics); + + // ---------------------------------------------------------------- 공통 목록 + function loadTargets() { + return Promise.all([getJson('/api/devices'), getJson('/api/events?limit=200')]) + .then(function (res) { + var devs = (res[0] && res[0].devices) || []; + deviceRows = devs.map(function (d) { + return { id: d.device_id, label: (d.name || d.device_id) + ' · ' + d.device_id }; + }); + var seen = {}; + cameraRows = []; + ((res[1] && res[1].events) || []).forEach(function (e) { + if (!e.camera_id || seen[e.camera_id]) return; + seen[e.camera_id] = true; + cameraRows.push({ id: e.camera_id, label: e.camera_id }); + }); + cameraRows.sort(function (a, b) { return a.id < b.id ? -1 : 1; }); + fillScopeOptions(); + + metricDevice.textContent = ''; + metricDevice.appendChild(new Option(devs.length ? '장치를 선택하세요' : '등록된 장치가 없습니다', '')); + deviceRows.forEach(function (d) { metricDevice.appendChild(new Option(d.label, d.id)); }); + }) + .catch(function () {}); + } + + loadCsrf().then(function () { + loadTargets(); + loadHandovers(); + loadSuppressions(); + loadRisks(); + }); +})(); diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index ab0e6b1..f4967a8 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -3,6 +3,11 @@ * 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. @@ -32,8 +37,11 @@ }; // 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. + // 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: '부동 지속', @@ -43,16 +51,26 @@ 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/); @@ -62,16 +80,34 @@ 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.toLocaleString('ko-KR'); + 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) { @@ -90,8 +126,9 @@ return badge; } - function textNode(value) { + 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; } @@ -99,46 +136,159 @@ function addRow(label, valueNode) { var row = document.createElement('div'); row.className = 'detail-row'; - var k = document.createElement('span'); - k.className = 'detail-label'; - k.textContent = label + ': '; + var k = document.createElement('dt'); + k.textContent = label; + var v = document.createElement('dd'); + v.appendChild(valueNode); row.appendChild(k); - row.appendChild(valueNode); + row.appendChild(v); meta.appendChild(row); } - function render(data) { - var ev = data.event || {}; + // --- 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(ev.camera_id)); - addRow('발생 시각', textNode(formatTime(ev.occurred_at))); - addRow('지속(초)', textNode(ev.duration_s)); - addRow('확인', textNode(ev.acknowledged_by || '미확인')); - if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그')); + 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 snaps = data.snapshots || []; - if (snaps.length === 0) { + var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; }); + if (kept.length === 0) { var none = document.createElement('p'); - none.textContent = '스냅샷이 없습니다.'; + 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. + // 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 = ['이전', '직전', '발생 시점']; - var kept = snaps.filter(function (s) { return s && s.file_uuid; }); kept.forEach(function (s, i) { var fig = document.createElement('figure'); fig.className = 'detail-snap'; @@ -157,15 +307,86 @@ }); } - 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'); + // --- 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'); + }); + }); })(); diff --git a/server/public/assets/package.js b/server/public/assets/package.js new file mode 100644 index 0000000..2049cd3 --- /dev/null +++ b/server/public/assets/package.js @@ -0,0 +1,240 @@ +/** + * 사고조사 패키지. + * + * 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다: + * 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지, + * 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면 + * 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다. + * + * 인쇄 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 = '패키지를 불러오지 못했습니다.'; }); +})(); diff --git a/server/public/assets/report.js b/server/public/assets/report.js index 0b3e890..baecddb 100644 --- a/server/public/assets/report.js +++ b/server/public/assets/report.js @@ -19,6 +19,10 @@ near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트' }; + // 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다. + var PATTERN_MIN_PEAK = 4; // 요일×시간 격자에서 최다 칸이 이보다 적으면 무늬는 우연 + var ACTION_MIN_BEFORE = 5; // 개선조치 전후 비교의 최소 '전' 표본 + var root = document.getElementById('report-kpis'); if (!root) return; @@ -51,6 +55,21 @@ return (s / 3600).toFixed(1) + '시간'; } + // One stamp format across the product (app.js / detail.js use the same shape). This page + // printed toLocaleDateString('ko-KR') — "2026. 8. 2." — while the stream printed "08-02", + // so the same day looked like two different notations depending on the screen. + function pad2(n) { return n < 10 ? '0' + n : String(n); } + function fmtDate(ts) { + var d = new Date(Number(ts) * 1000); + if (isNaN(d.getTime())) return '-'; + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); + } + function fmtStamp(ts) { + var d = new Date(Number(ts) * 1000); + if (isNaN(d.getTime())) return '-'; + return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()); + } + // KPI tile: the headline number plus the one-line reading of what it means. function kpi(label, value, note, cls) { var t = el('div', 'kpi' + (cls ? ' ' + cls : '')); @@ -94,8 +113,7 @@ var severe = t.critical + t.high; rangeEl.textContent = '최근 ' + r.days + '일 · ' - + new Date((r.generated_at - r.days * 86400) * 1000).toLocaleDateString('ko-KR') - + ' ~ ' + new Date(r.generated_at * 1000).toLocaleDateString('ko-KR'); + + fmtDate(r.generated_at - r.days * 86400) + ' ~ ' + fmtDate(r.generated_at); // 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다. var alertEl = document.getElementById('report-alert'); @@ -136,6 +154,38 @@ trendLine(kFp, prevFpPct, fpPct, function (v) { return v + '%'; }); root.appendChild(kFp); + // 현장별 롤업 (본사 관점). 스코프 운영자에게는 서버가 null 을 주고 카드는 숨은 채로 남는다. + var sitesCard = document.getElementById('report-sites-card'); + var sitesEl = document.getElementById('report-sites'); + if (sitesCard && sitesEl) { + var sites = r.by_site; + // 현장이 하나뿐인 설치에서 한 줄짜리 표는 정보가 아니라 잡음이다. + var show = Array.isArray(sites) && sites.length > 1; + sitesCard.hidden = !show; + if (show) { + sitesEl.textContent = ''; + var table = el('div', 'site-rows'); + var maxSevere = sites.reduce(function (m, s) { return Math.max(m, s.critical_high); }, 0); + sites.forEach(function (s) { + var row = el('div', 'site-row' + (s.critical_high > 0 && s.critical_high === maxSevere ? ' is-worst' : '')); + row.appendChild(el('span', 'site-name', s.site || '현장 미지정')); + var nums = el('span', 'site-nums'); + nums.appendChild(el('strong', 'site-severe' + (s.critical_high > 0 ? ' is-hot' : ''), String(s.critical_high))); + nums.appendChild(el('span', null, '중대')); + nums.appendChild(el('strong', null, String(s.count))); + nums.appendChild(el('span', null, '전체')); + nums.appendChild(el('strong', 'site-unacked' + (s.unacked > 0 ? ' is-open' : ''), String(s.unacked))); + nums.appendChild(el('span', null, '미확인')); + nums.appendChild(el('span', 'site-cams', '카메라 ' + s.cameras)); + row.appendChild(nums); + table.appendChild(row); + }); + sitesEl.appendChild(table); + sitesEl.appendChild(el('p', 'report-empty', + '현장별 수치는 접근 권한이 전역인 계정에만 표시됩니다. 중대(위급+높음)가 가장 많은 현장을 강조했습니다.')); + } + } + typesEl.textContent = ''; var typeKeys = Object.keys(r.by_type); if (typeKeys.length === 0) typesEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.')); @@ -147,10 +197,19 @@ camerasEl.textContent = ''; if (r.by_camera.length === 0) camerasEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.')); var maxCam = r.by_camera.reduce(function (m, c) { return Math.max(m, c.count); }, 0); + // "위험 순위"인데 중대가 1건이라도 있으면 전부 빨강이었다 — 실제 현장에서는 거의 모든 + // 카메라가 그 조건을 만족하므로 순위가 순위 노릇을 못 했다. 최상위 카메라와, 그에 준하는 + // (중대 밀도가 최상위의 60% 이상인) 카메라만 강조해 '먼저 볼 곳'을 실제로 좁힌다. + var maxSevere = r.by_camera.reduce(function (m, c) { return Math.max(m, c.critical_high); }, 0); + var hotFloor = Math.max(1, Math.ceil(maxSevere * 0.6)); r.by_camera.forEach(function (c) { camerasEl.appendChild(barRow(c.camera_id, c.count, maxCam, - c.count + '건 · 중대 ' + c.critical_high, c.critical_high > 0)); + c.count + '건 · 중대 ' + c.critical_high, maxSevere > 0 && c.critical_high >= hotFloor)); }); + if (maxSevere > 0) { + camerasEl.appendChild(el('p', 'report-empty', + '중대(위급+높음)가 ' + hotFloor + '건 이상인 카메라를 강조했습니다.')); + } // 인체공학: 관측 시간 대비 각 밴드 노출 — 절대시간과 비율을 함께. ergoEl.textContent = ''; @@ -174,14 +233,18 @@ rulesEl.textContent = ''; var oc = r.occupancy; var rules = el('div', 'rule-stats'); - [['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay], - ['미확인 잔여', t.unacked]].forEach(function (pair) { - var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : '')); - box.appendChild(el('strong', null, String(pair[1]))); - box.appendChild(el('span', null, pair[0])); - rules.appendChild(box); - }); + // '미확인 잔여'는 존 규칙 위반이 아니다. 같은 2×2 격자에 같은 무게로 놓여 있어서 + // 네 번째 규칙처럼 읽혔다 — 처리 현황은 격자 밖 한 줄로 분리한다. + [['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay]] + .forEach(function (pair) { + var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : '')); + box.appendChild(el('strong', null, String(pair[1]))); + box.appendChild(el('span', null, pair[0])); + rules.appendChild(box); + }); rulesEl.appendChild(rules); + rulesEl.appendChild(el('p', 'report-empty', + '처리 현황 — 이 기간 감지 ' + t.events + '건 중 미확인 ' + t.unacked + '건.')); // 시간 패턴: 요일×시간 발생 밀도 — "언제가 위험한가"를 한 장에. var patternEl = document.getElementById('report-pattern'); @@ -190,6 +253,12 @@ var pt = r.pattern || { grid: [], max: 0 }; if (!pt.max) { patternEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.')); + } else if (pt.max < PATTERN_MIN_PEAK) { + // 168칸에 최대 2~3건이면 무늬는 우연이다. 7일 창에서는 거의 항상 이 상태이므로, + // 전폭 카드가 '패턴을 찾았다'고 말하지 않도록 게이트를 둔다. + patternEl.appendChild(el('p', 'report-empty', + '아직 시간대 패턴을 말할 만큼 감지가 쌓이지 않았습니다 (가장 많은 칸이 ' + pt.max + + '건). 기간을 30일 이상으로 넓히면 분포가 드러납니다.')); } else { var DAYS = ['월', '화', '수', '목', '금', '토', '일']; var table = el('div', 'pattern'); @@ -217,7 +286,7 @@ } } - footEl.textContent = '생성 ' + new Date(r.generated_at * 1000).toLocaleString('ko-KR') + footEl.textContent = '생성 ' + fmtStamp(r.generated_at) + ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.'; } @@ -235,16 +304,32 @@ .catch(function () { /* 기록 시 403으로 드러난다 */ }); } + // 이 줄은 안전회의에 그대로 올라간다. 표본 6건에서 나온 "▲33%"를 확정 색으로 칠하면 + // 회의에서 바로 반박당하고, 그 다음부터는 이 카드 전체가 신뢰를 잃는다. 표본이 임계 미만이면 + // 원시 수치만 중립 톤으로 적고 판정을 미룬다. 창 길이도 함께 적어 서로 다른 창의 두 행이 + // 같은 잣대로 읽히지 않게 한다. function actionEffect(a) { if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)'); var e = a.effect; - var txt = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)'; - if (e.before > 0 && e.after !== e.before) { - var pct = Math.round((e.after - e.before) / e.before * 100); - txt += ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%'; + var base = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)'; + if (e.before < ACTION_MIN_BEFORE) { + var thin = el('span', 'action-effect is-thin', base + ' · 표본 부족, 판정 보류'); + thin.title = '조치 전 감지가 ' + ACTION_MIN_BEFORE + '건 이상일 때만 증감률을 표시합니다.'; + return thin; } - var cls = 'action-effect' + (e.after < e.before ? ' is-better' : (e.after > e.before ? ' is-worse' : '')); - return el('span', cls, txt); + // 카운트 데이터의 자연 변동폭(√before, 푸아송 표준편차)을 넘지 못하는 차이는 조치의 효과가 + // 아니라 잡음이다. 6건→8건을 "▲33% 악화"로 칠하면 그 카드는 회의에서 한 번 반박당하고 + // 끝난다. 노이즈 범위 안이면 방향도 색도 주장하지 않는다. + var diff = e.after - e.before; + if (Math.abs(diff) <= Math.sqrt(e.before)) { + var flat = el('span', 'action-effect is-thin', base + ' · 유의미한 변화 아님 (통계적 잡음 범위)'); + flat.title = '카운트 데이터의 자연 변동폭(±' + (Math.round(Math.sqrt(e.before) * 10) / 10) + + '건) 안이라 조치 효과로 볼 수 없습니다.'; + return flat; + } + var pct = Math.round(diff / e.before * 100); + var txt = base + ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%'; + return el('span', 'action-effect' + (diff < 0 ? ' is-better' : ' is-worse'), txt); } function renderActions(list) { @@ -258,8 +343,7 @@ list.forEach(function (a) { var row = el('div', 'action-row'); var what = el('div', 'action-what'); - what.appendChild(el('span', 'action-date', - new Date(a.taken_at * 1000).toLocaleDateString('ko-KR'))); + what.appendChild(el('span', 'action-date', fmtDate(a.taken_at))); what.appendChild(el('span', 'action-cam', a.camera_id)); what.appendChild(el('span', 'action-note', a.note)); row.appendChild(what); diff --git a/server/public/assets/setup.js b/server/public/assets/setup.js new file mode 100644 index 0000000..3a2e4b0 --- /dev/null +++ b/server/public/assets/setup.js @@ -0,0 +1,168 @@ +/** + * 설치 점검 (온보딩). + * + * 납품 직후가 이 제품에서 가장 위험한 구간이다. 대시보드는 멀쩡히 뜨고, 로그인도 되고, + * 화면 어디에도 오류가 없는데 — 장치가 등록되지 않았거나, 존을 안 그렸거나, 통보 경로가 + * 비어 있으면 시스템은 "감시 중"으로 보이면서 실제로는 아무것도 하지 않는다. 그 상태가 + * 몇 주씩 유지되다가 사고가 나서야 발견되는 것이 최악의 시나리오다. + * + * 그래서 각 단계는 "설정했다고 표시"가 아니라 **실제 데이터를 조회해** 판정한다. 체크가 + * 켜졌다면 그 단계는 정말로 동작하고 있다. + * + * XSS-safe: 모든 동적 값은 textContent. + */ +(function () { + 'use strict'; + + var B = window.APP_BASE || ''; + var listEl = document.getElementById('setup-steps'); + if (!listEl) return; + + 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 getJson(path) { + return fetch(B + path, { headers: { Accept: 'application/json' } }) + .then(function (r) { + if (r.status === 401) { window.location.href = B + '/login'; return null; } + // 403 은 "권한 없음"이지 "미설정"이 아니다. 단계는 판정 보류로 남긴다. + if (r.status === 403) return { _forbidden: true }; + return r.ok ? r.json() : null; + }) + .catch(function () { return null; }); + } + + function render(steps) { + listEl.textContent = ''; + steps.forEach(function (s, i) { + var li = el('li', 'setup-step is-' + s.state); + var mark = el('span', 'setup-mark'); + mark.textContent = s.state === 'ok' ? '✓' : (s.state === 'unknown' ? '?' : String(i + 1)); + mark.setAttribute('aria-hidden', 'true'); + li.appendChild(mark); + + var body = el('div', 'setup-body'); + body.appendChild(el('h2', 'setup-title', s.title)); + body.appendChild(el('p', 'setup-status', s.status)); + // 이 단계가 안 되어 있으면 무엇이 벌어지는가 — 체크리스트를 지시가 아니라 이유로 만든다. + if (s.state !== 'ok' && s.why) body.appendChild(el('p', 'setup-why', s.why)); + if (s.href) { + var a = el('a', 'setup-link', s.linkText || '설정하러 가기 →'); + a.href = B + s.href; + body.appendChild(a); + } + li.appendChild(body); + listEl.appendChild(li); + }); + } + + Promise.all([ + getJson('/api/devices'), + getJson('/api/events?limit=200'), + getJson('/api/admin/channels') + ]).then(function (res) { + var devicesRes = res[0] || {}; + var eventsRes = res[1] || {}; + var channelsRes = res[2] || {}; + + var devices = devicesRes.devices || []; + var online = devices.filter(function (d) { return d.online; }); + var events = eventsRes.events || []; + var cameras = {}; + events.forEach(function (e) { if (e.camera_id) cameras[e.camera_id] = true; }); + var cameraCount = Object.keys(cameras).length; + + var steps = []; + + // 1. 장치 등록 + steps.push(devices.length === 0 ? { + state: 'todo', title: '① 장치 등록', + status: '등록된 엣지 장치가 없습니다.', + why: '장치 행이 없으면 현장 노드가 보내는 이벤트는 인증에서 거부됩니다. 장치별 비밀키는 관리자가 DB에 직접 발급합니다 (dist/PROVISION_SECRETS.md).', + } : { + state: 'ok', title: '① 장치 등록', + status: devices.length + '대 등록됨 · 현재 온라인 ' + online.length + '대' + }); + + // 2. 장치 통신 (하트비트) + steps.push(online.length === 0 ? { + state: devices.length ? 'todo' : 'blocked', title: '② 장치 통신', + status: devices.length ? '하트비트를 보내는 장치가 없습니다.' : '장치 등록 후 확인할 수 있습니다.', + why: '장치가 서버에 닿지 못하면 감지는 노드 안에서만 일어나고 대시보드는 영원히 비어 있습니다. 노드의 CAFE24_URL·DEVICE_SECRET 과 방화벽을 확인하세요.', + } : { + state: 'ok', title: '② 장치 통신', status: online.length + '대가 하트비트를 보내고 있습니다.' + }); + + // 3. 카메라 감지 + steps.push(cameraCount === 0 ? { + state: online.length ? 'todo' : 'blocked', title: '③ 카메라 감지', + status: online.length ? '아직 어떤 카메라도 이벤트를 보낸 적이 없습니다.' : '장치 통신 후 확인할 수 있습니다.', + why: '카메라가 붙어 있어도 이벤트가 한 번도 없으면 실제로 사람을 검출하고 있는지 알 수 없습니다. 감시 구역에서 한 번 앉았다 일어나 보고, 그 사건이 스트림에 뜨는지 확인하세요.', + href: '/', linkText: '감지 스트림 보기 →' + } : { + state: 'ok', title: '③ 카메라 감지', status: cameraCount + '대의 카메라가 이벤트를 보냈습니다.' + }); + + // 4. 위험구역 + getJson('/api/zones/' + encodeURIComponent(Object.keys(cameras)[0] || '_none_')) + .then(function (zoneRes) { + var zones = (zoneRes && zoneRes.zones) || []; + steps.push(zones.length === 0 ? { + state: cameraCount ? 'todo' : 'blocked', title: '④ 위험구역 설정', + status: cameraCount ? '첫 카메라에 저장된 구역이 없습니다.' : '카메라 감지 후 설정할 수 있습니다.', + why: '구역이 없으면 넘어짐·부동 감지는 동작하지만 제한구역 침입·협착·2인1조 같은 규칙은 전부 비활성입니다.', + href: '/zones', linkText: '구역 설정으로 →' + } : { + state: 'ok', title: '④ 위험구역 설정', + status: '첫 카메라에 구역 ' + zones.length + '개가 설정되어 있습니다.' + }); + + // 5. 통보 경로 — 이 제품에서 가장 자주 비어 있는 칸이다. + if (channelsRes._forbidden) { + steps.push({ + state: 'unknown', title: '⑤ 통보 경로', + status: '이 계정에는 통보 설정 권한이 없습니다. 관리자 계정으로 확인하세요.' + }); + } else { + var chans = (channelsRes.channels || []).filter(function (c) { return c.enabled; }); + var local = chans.filter(function (c) { return c.kind === 'local'; }); + steps.push(chans.length === 0 ? { + state: 'todo', title: '⑤ 통보 경로', + status: '사용 중인 통보 경로가 없습니다.', + why: '지금 상태에서는 감지가 되어도 브라우저를 보고 있는 사람에게만 전달됩니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다. 현장 경광등(엣지 GPIO)부터 설정하세요 — 인터넷이 끊겨도 동작합니다.', + href: '/admin', linkText: '통보 경로 설정 →' + } : { + state: local.length ? 'ok' : 'warn', title: '⑤ 통보 경로', + status: chans.length + '개 사용 중' + (local.length ? ' (현장 경광등 포함)' : ' — 현장 경광등 없음'), + why: local.length ? null : '메일·웹훅은 서버와 네트워크가 살아 있을 때만 닿습니다. 정전이나 회선 장애 때도 울릴 경로가 하나는 있어야 합니다.', + href: '/admin', linkText: '통보 경로 보기 →' + }); + } + + // 6. 통보 디스패처 — 서버 상태로는 알 수 없다. 정직하게 '확인 필요'로 남긴다. + steps.push({ + state: 'unknown', title: '⑥ 통보 디스패처 (크론)', + status: '이 화면에서는 확인할 수 없습니다.', + why: '메일·웹훅 통보와 미확인 에스컬레이션은 tools/dispatch_alerts.php 가 크론으로 돌아야 동작합니다. 호스팅 관리자 화면의 크론 설정에서 1분 주기로 등록되어 있는지 직접 확인하세요. (--dry-run 으로 먼저 시험할 수 있습니다.)' + }); + + // 7. 위험성평가 + getJson('/api/risks').then(function (riskRes) { + var controls = (riskRes && riskRes.controls) || []; + steps.push(controls.length === 0 ? { + state: 'todo', title: '⑦ 위험성평가 연계', + status: '등록된 위험요인이 없습니다.', + why: '없어도 감지는 동작합니다. 다만 사고조사 패키지와 관리체계 증빙에서 "무엇을 통제하고 있었나" 칸이 비게 됩니다.', + href: '/compliance', linkText: '안전 관리로 →' + } : { + state: 'ok', title: '⑦ 위험성평가 연계', status: controls.length + '개 항목이 등록되어 있습니다.' + }); + render(steps); + }); + }); + }); +})(); diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js index 850924b..81a2877 100644 --- a/server/public/assets/zone-draw.js +++ b/server/public/assets/zone-draw.js @@ -39,11 +39,17 @@ var heatmapBtn = document.getElementById('heatmap-btn'); var heatmapDaysInput = document.getElementById('heatmap-days'); + // Zone styling stays inside the app's severity ramp (app.css --warn/--crit/--high). The old + // table used a raw #a855f7 for 협착 — a purple that appears nowhere else in the product — and + // drew every zone as a bare 2px outline, which disappears against a real factory frame. Each + // level now carries a translucent FILL plus a stroke, and 협착 is separated by a dashed + // stroke rather than by an alien hue: shape does the work colour was being stretched to do. var LEVEL_STYLE = { - warning: { stroke: '#eab308', label: '경고' }, - restricted: { stroke: '#dc2626', label: '제한' }, - pinch: { stroke: '#a855f7', label: '협착' } + warning: { stroke: '#b7820a', fill: 'rgba(183,130,10,.16)', label: '경고', dash: null }, + restricted: { stroke: '#dc2626', fill: 'rgba(220,38,38,.16)', label: '제한', dash: null }, + pinch: { stroke: '#e2560c', fill: 'rgba(226,86,12,.18)', label: '협착', dash: [9, 5] } }; + var DRAFT_STYLE = { stroke: '#0b6b7a', fill: 'rgba(11,107,122,.14)', label: '편집중', dash: [5, 4] }; var backgroundImage = null; var existingZones = []; // [{zone_id, level, points: [[x,y],...]}] @@ -80,25 +86,59 @@ } } - function drawPolygon(points, strokeStyle, label, closed) { + function drawPolygon(points, style, label, closed) { if (points.length === 0) return; - ctx.strokeStyle = strokeStyle; - ctx.fillStyle = strokeStyle; - ctx.lineWidth = 2; + ctx.save(); ctx.beginPath(); ctx.moveTo(points[0][0], points[0][1]); for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]); if (closed && points.length > 2) ctx.closePath(); + // Fill first so the stroke sits on top of its own tint. + if (closed && points.length > 2 && style.fill) { + ctx.fillStyle = style.fill; + ctx.fill(); + } + ctx.strokeStyle = style.stroke; + ctx.lineWidth = 2.5; + ctx.lineJoin = 'round'; + if (style.dash) ctx.setLineDash(style.dash); ctx.stroke(); + ctx.setLineDash([]); + // Vertex handles: white-cored so they stay visible on both a bright floor and a dark shadow. points.forEach(function (p) { ctx.beginPath(); - ctx.arc(p[0], p[1], 3, 0, Math.PI * 2); + ctx.arc(p[0], p[1], 4, 0, Math.PI * 2); + ctx.fillStyle = '#ffffff'; ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = style.stroke; + ctx.stroke(); }); - if (label) { - ctx.font = '14px sans-serif'; - ctx.fillText(label, points[0][0] + 6, points[0][1] - 6); + if (label) drawLabelChip(label, points[0][0], points[0][1], style.stroke); + ctx.restore(); + } + + // A plain fillText label vanished into whatever the camera happened to be looking at. + // Draw it on its own solid chip so the level is legible over any frame. + function drawLabelChip(text, x, y, bg) { + ctx.save(); + ctx.font = '600 13px system-ui, sans-serif'; + ctx.textBaseline = 'middle'; + var padX = 7, h = 20; + var w = ctx.measureText(text).width + padX * 2; + var cx = Math.min(Math.max(x + 8, 2), canvas.width - w - 2); + var cy = Math.min(Math.max(y - 14, h / 2 + 2), canvas.height - h / 2 - 2); + ctx.fillStyle = bg; + if (ctx.roundRect) { + ctx.beginPath(); + ctx.roundRect(cx, cy - h / 2, w, h, 5); + ctx.fill(); + } else { + ctx.fillRect(cx, cy - h / 2, w, h); } + ctx.fillStyle = '#ffffff'; + ctx.fillText(text, cx + padX, cy); + ctx.restore(); } // ---- 위험 히트맵 오버레이 ------------------------------------------------------- @@ -133,7 +173,7 @@ function loadHeatmap() { var camera = cameraInput.value.trim(); - if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } var days = Math.max(1, Math.min(90, parseInt(heatmapDaysInput && heatmapDaysInput.value, 10) || 30)); setStatus('히트맵을 불러오는 중...'); fetch(B + '/api/heatmap/' + encodeURIComponent(camera) + '?days=' + days, @@ -171,11 +211,24 @@ ctx.drawImage(heatmapLayer, 0, 0); } existingZones.forEach(function (z) { - var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level }; - drawPolygon(z.points, style.stroke, style.label, true); + var style = LEVEL_STYLE[z.level] || { stroke: '#626a79', fill: 'rgba(98,106,121,.14)', label: z.level, dash: null }; + drawPolygon(z.points, style, style.label, true); }); - var current = LEVEL_STYLE[levelSelect.value] || { stroke: '#38bdf8', label: '' }; - drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', false); + drawPolygon(currentPoints, DRAFT_STYLE, currentPoints.length ? DRAFT_STYLE.label : '', false); + if (!backgroundImage && !existingZones.length && !currentPoints.length) drawCanvasHint(); + } + + // An empty black rectangle told the operator nothing about what to do with it. + function drawCanvasHint() { + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,.5)'; + ctx.font = '500 15px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('① 왼쪽에서 카메라를 고르고 "카메라 화면을 배경으로"를 누르세요.', + canvas.width / 2, canvas.height / 2 - 10); + ctx.fillText('② 그다음 이 위를 클릭해 구역 폴리곤을 그립니다.', + canvas.width / 2, canvas.height / 2 + 16); + ctx.restore(); } // Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire). @@ -203,24 +256,130 @@ function renderZoneList() { listEl.textContent = ''; + if (existingZones.length === 0) { + var none = document.createElement('li'); + none.className = 'zone-empty'; + none.textContent = '이 카메라에 저장된 구역이 없습니다.'; + listEl.appendChild(none); + return; + } existingZones.forEach(function (z) { var li = document.createElement('li'); - var style = LEVEL_STYLE[z.level] || { label: z.level }; + li.className = 'zone-item lv-' + z.level; var label = document.createElement('span'); - label.textContent = style.label + ' (' + z.points.length + '점)' + rulesLabel(z); + label.className = 'zone-item-label'; + var swatch = document.createElement('span'); + swatch.className = 'zone-swatch'; + swatch.setAttribute('aria-hidden', 'true'); + // Inline colour is the ONE place a raw hex belongs: it must match what the canvas drew. + swatch.style.background = (LEVEL_STYLE[z.level] || {}).fill || 'transparent'; + swatch.style.borderColor = (LEVEL_STYLE[z.level] || {}).stroke || '#626a79'; + label.appendChild(swatch); + label.appendChild(document.createTextNode( + (LEVEL_STYLE[z.level] || { label: z.level }).label + ' (' + z.points.length + '점)' + rulesLabel(z))); li.appendChild(label); + // Two-step delete. A zone is what makes 침입 alarms fire; it takes minutes to draw and + // the server has no restore. A single unconfirmed click was the least reversible action + // in the product and also the cheapest one to trigger by accident. var del = document.createElement('button'); del.type = 'button'; + del.className = 'zone-del'; del.textContent = '삭제'; - del.addEventListener('click', function () { deleteZone(z.zone_id); }); + var armed = false, armTimer = null; + del.addEventListener('click', function () { + if (!armed) { + armed = true; + del.classList.add('is-armed'); + del.textContent = '정말 삭제?'; + setStatus('한 번 더 누르면 이 구역이 삭제됩니다. 되돌릴 수 없습니다.'); + armTimer = setTimeout(function () { + armed = false; del.classList.remove('is-armed'); del.textContent = '삭제'; + }, 4000); + return; + } + if (armTimer) clearTimeout(armTimer); + deleteZone(z.zone_id); + }); li.appendChild(del); listEl.appendChild(li); }); } + // --- 장치 · 카메라 선택기 --------------------------------------------------------- + // The editor used to demand two free-text ids the operator had to remember, while the + // events console already knew both. Devices come from /api/devices; the cameras under each + // device are derived from the events they have reported (there is no camera registry). + // A camera that has never sent an event cannot appear here, which is what the manual + // fallback in the view is for. + var deviceSelect = document.getElementById('device-select'); + var cameraSelect = document.getElementById('camera-select'); + var manualBox = document.getElementById('zone-manual'); + var camerasByDevice = {}; + + function option(value, text) { + var o = document.createElement('option'); + o.value = value; + o.textContent = text; + return o; + } + + function fillCameraSelect(deviceId) { + if (!cameraSelect) return; + cameraSelect.textContent = ''; + var cams = camerasByDevice[deviceId] || []; + if (!deviceId) { cameraSelect.appendChild(option('', '장치를 먼저 선택하세요')); return; } + if (cams.length === 0) { + cameraSelect.appendChild(option('', '이 장치가 보고한 카메라 없음')); + return; + } + cameraSelect.appendChild(option('', '카메라를 선택하세요')); + cams.forEach(function (c) { cameraSelect.appendChild(option(c, c)); }); + } + + function applySelection() { + if (deviceSelect) deviceInput.value = deviceSelect.value; + if (cameraSelect) cameraInput.value = cameraSelect.value; + } + + function loadPickers() { + var devices = fetch(B + '/api/devices', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }); + var events = fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }); + return Promise.all([devices, events]).then(function (res) { + var devs = (res[0] && res[0].devices) || []; + var evs = (res[1] && res[1].events) || []; + evs.forEach(function (ev) { + if (!ev.camera_id) return; + var d = String(ev.device_id || ''); + if (!camerasByDevice[d]) camerasByDevice[d] = []; + if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id); + }); + Object.keys(camerasByDevice).forEach(function (d) { camerasByDevice[d].sort(); }); + + if (!deviceSelect) return; + deviceSelect.textContent = ''; + if (devs.length === 0) { + deviceSelect.appendChild(option('', '등록된 장치가 없습니다')); + if (manualBox) manualBox.open = true; + return; + } + deviceSelect.appendChild(option('', '장치를 선택하세요')); + devs.forEach(function (d) { + deviceSelect.appendChild(option(d.device_id, (d.name || d.device_id) + ' · ' + d.device_id)); + }); + // Honour a deep link (?device=&camera=) or a value already typed into the manual fields. + if (deviceInput.value) { + deviceSelect.value = deviceInput.value; + fillCameraSelect(deviceInput.value); + if (cameraInput.value) cameraSelect.value = cameraInput.value; + } + }); + } + function loadBackground(uuid) { if (!uuid) { backgroundImage = null; redraw(); return; } var img = new Image(); @@ -233,7 +392,7 @@ // API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope. function loadLatestFrame() { var camera = cameraInput.value.trim(); - if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } setStatus('현재 화면을 불러오는 중...'); fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' }) .then(function (r) { @@ -245,9 +404,9 @@ .catch(function (e) { setStatus(e.message); }); } - function loadZones() { + function loadZones(doneMsg) { var camera = cameraInput.value.trim(); - if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } setStatus('불러오는 중...'); fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } }) .then(function (res) { @@ -263,7 +422,9 @@ currentPoints = []; renderZoneList(); redraw(); - setStatus('구역 ' + existingZones.length + '개를 불러왔습니다.'); + setStatus(doneMsg || (existingZones.length + ? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.' + : '이 카메라에는 저장된 구역이 없습니다. 캔버스를 클릭해 새로 그리세요.')); }) .catch(function () { setStatus('불러오기에 실패했습니다.'); }); } @@ -271,7 +432,7 @@ function saveZone() { var camera = cameraInput.value.trim(); var device = deviceInput.value.trim(); - if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; } setStatus('저장 중...'); fetch(B + '/api/zones', { @@ -297,8 +458,7 @@ .then(function (data) { if (!data) return; currentPoints = []; - setStatus('저장되었습니다.'); - loadZones(); + loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.'); }) .catch(function () { setStatus('저장에 실패했습니다.'); }); } @@ -311,7 +471,7 @@ .then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; return; } if (!res.ok) throw new Error('delete_failed:' + res.status); - loadZones(); + loadZones('구역을 삭제했습니다.'); }) .catch(function () { setStatus('삭제에 실패했습니다.'); }); } @@ -327,17 +487,49 @@ }); levelSelect.addEventListener('change', redraw); - loadBtn.addEventListener('click', loadZones); - if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame); - if (heatmapBtn) heatmapBtn.addEventListener('click', toggleHeatmap); + loadBtn.addEventListener('click', function () { applySelection(); loadZones(); }); + if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); }); + if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); }); if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); }); - clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); }); - saveBtn.addEventListener('click', saveZone); + clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('그리던 폴리곤을 지웠습니다.'); }); + saveBtn.addEventListener('click', function () { applySelection(); saveZone(); }); + + if (deviceSelect) { + deviceSelect.addEventListener('change', function () { + deviceInput.value = deviceSelect.value; + fillCameraSelect(deviceSelect.value); + cameraInput.value = ''; + setStatus(deviceSelect.value ? '카메라를 선택하세요.' : '장치를 선택하세요.'); + }); + } + if (cameraSelect) { + // Selecting a camera is the operator's intent to work on it: load its zones and its latest + // frame at once, instead of making them press two more buttons to see anything at all. + cameraSelect.addEventListener('change', function () { + cameraInput.value = cameraSelect.value; + if (!cameraSelect.value) return; + currentPoints = []; + loadZones(); + loadLatestFrame(); + }); + } + // Typing into the manual fields wins over the selects (and clears them, so the panel never + // shows a camera other than the one being edited). + [deviceInput, cameraInput].forEach(function (inp) { + inp.addEventListener('input', function () { + if (deviceSelect && deviceSelect.value !== deviceInput.value) deviceSelect.value = ''; + if (cameraSelect && cameraSelect.value !== cameraInput.value) cameraSelect.value = ''; + }); + }); var params = new URLSearchParams(window.location.search); if (params.get('camera')) cameraInput.value = params.get('camera'); if (params.get('device')) deviceInput.value = params.get('device'); + if (params.get('camera') || params.get('device')) { if (manualBox) manualBox.open = false; } loadBackground(params.get('snapshot')); loadCsrfToken(); - if (cameraInput.value) loadZones(); + loadPickers(); + renderZoneList(); // paint the "저장된 구역 없음" placeholder before any camera is chosen + redraw(); + if (cameraInput.value) { loadZones(); loadLatestFrame(); } })(); diff --git a/server/public/index.php b/server/public/index.php index f0b0ca6..5c96ffd 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -41,13 +41,15 @@ use App\Db\Database; use App\Auth\PhpSessionStore; use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository, PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository, - PdoThresholdRepository, PdoAuditRepository, PdoActionRepository}; + PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository, + PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository}; use App\Ingest\IngestController; use App\Api\{ConfigController, HeartbeatController}; use App\Auth\{AuthController, CsrfController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController, DeviceStatusController, HeatmapController, FpStatsController, ReportController, - ExportController, ActionsController}; + ExportController, ActionsController, AdminConfigController, SuppressionController, + ComplianceController}; // Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so // an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off @@ -85,22 +87,31 @@ $users = new PdoUserRepository($pdo); $zones = new PdoZoneRepository($pdo); $thr = new PdoThresholdRepository($pdo); $audit = new PdoAuditRepository($pdo); +$suppressions = new PdoSuppressionRepository($pdo); +$alerts = new PdoAlertRepository($pdo); +$handovers = new PdoHandoverRepository($pdo); +$risks = new PdoRiskControlRepository($pdo); +$metrics = new PdoDeviceMetricRepository($pdo); +$actions = new PdoActionRepository($pdo); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); -$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate); -$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate); +$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts); +$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate, $metrics); $auth = new AuthController($users, $audit, $rate); $csrfCtl = new CsrfController(); $list = new EventListController($events, $snaps, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); -$deviceStatus = new DeviceStatusController($devices, $sess); +$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg); $heatmap = new HeatmapController($events, $sess); $fpStats = new FpStatsController($events, $sess); $report = new ReportController($events, $sess); $export = new ExportController($events, $snaps, $sess); -$actionsCtl = new ActionsController(new PdoActionRepository($pdo), $events, $sess); +$actionsCtl = new ActionsController($actions, $events, $sess); +$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess); +$suppressCtl = new SuppressionController($suppressions, $events, $devices, $audit, $sess); +$complianceCtl = new ComplianceController($events, $snaps, $actions, $alerts, $risks, $handovers, $audit, $sess); $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; @@ -133,10 +144,31 @@ $r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); $r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p)); $r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p)); +// --- 성능 추이 · 통보 · 억제 · 규정 -------------------------------------------------- +$r->add('GET', '/api/devices/{id}/metrics', fn(Request $q, array $p) => $deviceStatus->metrics($q, $p, $now)); +$r->add('GET', '/api/admin/thresholds', fn(Request $q) => $adminCtl->thresholds($q)); +$r->add('POST', '/api/admin/thresholds', fn(Request $q) => $adminCtl->saveThreshold($q)); +$r->add('POST', '/api/admin/thresholds/{id}/delete', fn(Request $q, array $p) => $adminCtl->deleteThreshold($q, $p)); +$r->add('GET', '/api/admin/channels', fn(Request $q) => $adminCtl->channels($q)); +$r->add('POST', '/api/admin/channels', fn(Request $q) => $adminCtl->saveChannel($q)); +$r->add('POST', '/api/admin/channels/{id}/delete', fn(Request $q, array $p) => $adminCtl->deleteChannel($q, $p)); +$r->add('GET', '/api/suppressions', fn(Request $q) => $suppressCtl->json($q)); +$r->add('POST', '/api/suppressions', fn(Request $q) => $suppressCtl->create($q)); +$r->add('POST', '/api/suppressions/{id}/revoke', fn(Request $q, array $p) => $suppressCtl->revoke($q, $p)); +$r->add('GET', '/api/risks', fn(Request $q) => $complianceCtl->risks($q)); +$r->add('POST', '/api/risks', fn(Request $q) => $complianceCtl->saveRisk($q)); +$r->add('POST', '/api/risks/{id}/delete', fn(Request $q, array $p) => $complianceCtl->deleteRisk($q, $p)); +$r->add('GET', '/api/handovers', fn(Request $q) => $complianceCtl->handovers($q)); +$r->add('POST', '/api/handovers', fn(Request $q) => $complianceCtl->acceptHandover($q)); +$r->add('GET', '/api/events/{id}/package', fn(Request $q, array $p) => $complianceCtl->package($q, $p)); $r->add('GET', '/', fn() => $render('events')); $r->add('GET', '/login', fn() => $render('login')); $r->add('GET', '/zones', fn() => $render('zones')); $r->add('GET', '/report', fn() => $render('report')); +$r->add('GET', '/admin', fn() => $render('admin')); +$r->add('GET', '/compliance', fn() => $render('compliance')); +$r->add('GET', '/setup', fn() => $render('setup')); $r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail')); +$r->add('GET', '/events/{id}/package/view', fn(Request $q, array $p) => $render('incident_package')); $r->dispatch(Request::fromGlobals())->send(); diff --git a/server/schema.sql b/server/schema.sql index 7068d69..f5d4ad5 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -102,13 +102,15 @@ CREATE TABLE move_actions ( action_id INT UNSIGNED NOT NULL AUTO_INCREMENT, camera_id VARCHAR(64) NOT NULL, site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert + event_id INT UNSIGNED NULL, -- 이 조치의 계기가 된 사건 (상세 화면에서 바로 기록할 때) note VARCHAR(500) NOT NULL, taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s) created_by VARCHAR(190) NOT NULL, created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (action_id), KEY ix_actions_taken (taken_at), - KEY ix_actions_site_taken (site, taken_at) + KEY ix_actions_site_taken (site, taken_at), + KEY ix_actions_event (event_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE move_zones ( @@ -163,7 +165,13 @@ CREATE TABLE move_events ( CONSTRAINT chk_events_type CHECK (event_type IN ( 'fall','no_motion','immobility','ghost','zone_warning', 'zone_restricted','pinch','monitoring_degraded','device_fault', - 'lone_worker','overcapacity','overstay','near_fall','ergo_report' + 'lone_worker','overcapacity','overstay','near_fall','ergo_report', + -- 감시 자체의 건전성: 카메라가 틀어지거나 가려지면 존 판정이 조용히 무의미해진다. + 'camera_moved','camera_obstructed','low_light', + -- 아무도 확인하지 않았다는 사실 자체를 사건으로 만든다 (에스컬레이션 디스패처가 발행). + 'unacked_escalation', + -- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다. + 'ppe_missing','proximity','fire_smoke' )), CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -204,6 +212,8 @@ CREATE TABLE move_thresholds ( zone_id CHAR(32) NULL, `key` VARCHAR(64) NOT NULL, `value` VARCHAR(255) NOT NULL, + updated_by VARCHAR(190) NULL, -- 편집 UI(관리자 전용)가 남기는 감사 흔적 + updated_at BIGINT UNSIGNED NULL, PRIMARY KEY (id), KEY ix_thresholds_device (device_id), UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`) @@ -242,9 +252,15 @@ CREATE TABLE move_rate_limits ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- --- audit_log: append-only action log (acknowledge / zone save+delete / login). --- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the --- Task 16 brief self-review -- this table is the plain append-only record only. +-- audit_log: append-only action log (acknowledge / zone save+delete / login / +-- threshold edit / suppression / handover). +-- +-- TAMPER-EVIDENT: each row carries prev_hash (the previous row's entry_hash) and +-- entry_hash = sha256(id|actor|action|target|before|after|ts|prev_hash). Editing or +-- deleting a row breaks every hash after it, so a doctored log is DETECTABLE even +-- though shared hosting cannot grant append-only rights at the DB level. Verified by +-- `php tools/verify_audit_chain.php`. Rows written before the chain existed keep NULL +-- hashes and the verifier reports them as the pre-chain segment rather than as damage. -- --------------------------------------------------------------------------- CREATE TABLE move_audit_log ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, @@ -254,9 +270,148 @@ CREATE TABLE move_audit_log ( before_json JSON NOT NULL, after_json JSON NOT NULL, ts BIGINT UNSIGNED NOT NULL, + prev_hash CHAR(64) NULL, + entry_hash CHAR(64) NULL, PRIMARY KEY (id), KEY ix_audit_log_ts (ts), - KEY ix_audit_log_actor (actor) + KEY ix_audit_log_actor (actor), + KEY ix_audit_log_entry_hash (entry_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- suppressions: planned-work alarm suppression (정비 모드 / 작업허가). +-- Detection is NOT paused -- events are still ingested and still appear in the console +-- and in reports. Only NOTIFICATION is withheld, and every suppression carries a +-- mandatory reason, a hard expiry (no open-ended suppression) and an audit entry. +-- Rationale: the single largest source of false alarms is planned maintenance, and a +-- console that cries wolf during every shutdown is a console the site switches off. +-- --------------------------------------------------------------------------- +CREATE TABLE move_suppressions ( + suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + scope_type VARCHAR(16) NOT NULL, -- device | camera | zone + scope_id VARCHAR(64) NOT NULL, + site VARCHAR(64) NULL, + reason VARCHAR(500) NOT NULL, + starts_at BIGINT UNSIGNED NOT NULL, + ends_at BIGINT UNSIGNED NOT NULL, + created_by VARCHAR(190) NOT NULL, + created_at BIGINT UNSIGNED NOT NULL, + revoked_by VARCHAR(190) NULL, + revoked_at BIGINT UNSIGNED NULL, + PRIMARY KEY (suppression_id), + KEY ix_suppressions_window (ends_at, starts_at), + KEY ix_suppressions_scope (scope_type, scope_id), + KEY ix_suppressions_site (site), + CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- alert_channels / alert_log: how a detection reaches a human. +-- Air-gapped sites cannot use FCM/Slack, so the channels are the ones a closed network +-- actually has: `local` (the edge node's own beacon/buzzer over GPIO -- the only path +-- that needs no network at all), `email` (in-house SMTP relay) and `webhook` (in-house +-- groupware / SMS gateway). step>0 channels fire only after escalate_after_s of the +-- event still being unacknowledged, which is what turns "we detected it" into "someone +-- was made to look at it". +-- --------------------------------------------------------------------------- +CREATE TABLE move_alert_channels ( + channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + site VARCHAR(64) NULL, + kind VARCHAR(16) NOT NULL, + name VARCHAR(190) NOT NULL, + config_json JSON NOT NULL, -- recipients / url / gpio pin. NEVER credentials. + min_severity VARCHAR(16) NOT NULL DEFAULT 'high', + enabled TINYINT(1) NOT NULL DEFAULT 1, + step INT UNSIGNED NOT NULL DEFAULT 0, + escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0, + created_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (channel_id), + KEY ix_alert_channels_site (site, enabled), + CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')), + CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE move_alert_log ( + alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id INT UNSIGNED NOT NULL, + channel_id INT UNSIGNED NOT NULL, + status VARCHAR(16) NOT NULL, + attempts INT UNSIGNED NOT NULL DEFAULT 0, + last_error VARCHAR(500) NULL, + queued_at BIGINT UNSIGNED NOT NULL, + sent_at BIGINT UNSIGNED NULL, + PRIMARY KEY (alert_id), + -- The dispatcher runs from cron and overlapping runs are expected; this unique key is + -- the only thing standing between a site and the same fall being phoned in five times. + UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id), + KEY ix_alert_log_status (status, queued_at), + CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')), + CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- shift_handovers: who took responsibility for what, and when. +-- The 24h brief tells an operator what happened; it does not record that anyone +-- accepted the backlog. Without that, unacknowledged events evaporate across a shift +-- change with nobody accountable for them. +-- --------------------------------------------------------------------------- +CREATE TABLE move_shift_handovers ( + handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + site VARCHAR(64) NULL, + from_user VARCHAR(190) NULL, + to_user VARCHAR(190) NOT NULL, + period_start BIGINT UNSIGNED NOT NULL, + period_end BIGINT UNSIGNED NOT NULL, + unacked_count INT UNSIGNED NOT NULL, + severe_count INT UNSIGNED NOT NULL, + note VARCHAR(1000) NULL, + created_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (handover_id), + KEY ix_handovers_site_created (site, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- risk_controls: 위험성평가 연계. One row per (site, detection type): the hazard it +-- stands for, the control currently in place, and the 5x5 rating. This is what lets the +-- system print "what are we controlling and how well", which is the artefact a safety +-- management system is actually audited on. +-- --------------------------------------------------------------------------- +CREATE TABLE move_risk_controls ( + control_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + site VARCHAR(64) NULL, + event_type VARCHAR(32) NOT NULL, + hazard VARCHAR(300) NOT NULL, + control VARCHAR(500) NOT NULL, + severity_rating TINYINT UNSIGNED NOT NULL, + likelihood_rating TINYINT UNSIGNED NOT NULL, + reviewed_by VARCHAR(190) NULL, + reviewed_at BIGINT UNSIGNED NULL, + updated_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (control_id), + UNIQUE KEY uq_risk_controls_site_type (site, event_type), + CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5), + CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- device_metrics: heartbeat telemetry as a TIME SERIES. +-- move_devices holds only the latest value, so a node whose inference latency doubled +-- overnight looks identical to a healthy one. Appending each heartbeat is what makes +-- slow degradation visible before it becomes a monitoring outage. +-- --------------------------------------------------------------------------- +CREATE TABLE move_device_metrics ( + metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + device_id VARCHAR(64) NOT NULL, + ts BIGINT UNSIGNED NOT NULL, + fps DOUBLE NULL, + temp DOUBLE NULL, + throttle TINYINT(1) NULL, + queue_pending INT UNSIGNED NULL, + cameras_total INT UNSIGNED NULL, + cameras_erroring INT UNSIGNED NULL, + errors INT UNSIGNED NULL, + PRIMARY KEY (metric_id), + KEY ix_device_metrics_device_ts (device_id, ts) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- =========================================================================== diff --git a/server/src/Alert/AlertDispatcher.php b/server/src/Alert/AlertDispatcher.php new file mode 100644 index 0000000..42c385d --- /dev/null +++ b/server/src/Alert/AlertDispatcher.php @@ -0,0 +1,130 @@ +<?php +declare(strict_types=1); +namespace App\Alert; + +use App\Repository\{AlertRepository, EventRepository, SuppressionRepository}; + +/** + * Decides WHICH notifications an event earns, and when. + * + * This is the piece the product was missing entirely: until now a detection reached a human only + * if a browser tab happened to be open and someone happened to be looking at it. A fall at 03:00 + * produced a row in a database and nothing else. + * + * Deliberately pure decision logic — no I/O, no clock, no sending. plan() takes the world as + * arguments and returns a list of deliveries to attempt, so every rule below (severity floors, + * escalation delays, suppression windows, already-acknowledged short-circuit) is unit testable + * without a database, an SMTP server or a wall clock. Sending lives in the channels; claiming + * and recording lives in the repository. + * + * THE RULES, and why each exists: + * + * - Severity floor per channel. A beacon that fires on 정보 events is a beacon nobody looks at. + * - step 0 fires immediately; step >0 fires only after escalate_after_s of the event still + * being unacknowledged. That is what converts "we detected it" into "somebody was made to + * look at it", and it is the only mechanism here that can wake a second person. + * - An acknowledged event escalates no further. Escalation exists to find a human, and one has + * already been found. + * - A false-positive tag stops everything, for the same reason. + * - Suppression (planned maintenance / work permit) blocks the SEND but is still recorded, as + * status='suppressed'. An investigator has to be able to see that an alarm was raised and + * deliberately withheld — a silently dropped notification is indistinguishable from one that + * was never generated, and that difference is exactly what an inquiry turns on. + */ +final class AlertDispatcher +{ + /** Ranked so a channel's min_severity acts as a floor, not an equality test. */ + private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3]; + + public function __construct( + private EventRepository $events, + private AlertRepository $alerts, + private SuppressionRepository $suppressions, + ) {} + + /** + * @param array $event a row from EventRepository (mapRow shape) + * @param array $channels channel rows for this event's site + * @return array<int,array{channel:array,suppressed:bool}> deliveries to attempt + */ + public function planFor(array $event, array $channels, int $now): array + { + // Resolved events stop the chain. Note this covers the escalation steps too: an event + // acknowledged 30 seconds in never reaches the 5-minute escalation channel. + if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) return []; + + $rank = self::SEVERITY_RANK[$event['severity']] ?? 0; + $age = $now - (int)$event['occurred_at']; + + // One suppression lookup per event covers all of its scopes. Zone is included because a + // permit is usually issued for an area, not for a camera. + $scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]]; + if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']]; + $suppressed = $this->suppressions->activeFor($scopes, $now) !== []; + + $out = []; + foreach ($channels as $ch) { + if (empty($ch['enabled'])) continue; + // `local` beacons are fired by the edge node itself the moment its own detector + // produces the event — no server round trip, and no dependency on this job running + // at all. Their rows exist only to carry the GPIO configuration down through + // GET /api/config, so the server has nothing to deliver and must not claim a + // delivery row for them (which would show up as a permanent failure). + if ($ch['kind'] === 'local') continue; + if ($rank < (self::SEVERITY_RANK[$ch['min_severity']] ?? 0)) continue; + // A site-scoped channel only serves its own site; a NULL-site channel is global. + if ($ch['site'] !== null && $ch['site'] !== ($event['site'] ?? null)) continue; + // Not yet due: a later dispatcher run will pick it up once the event has aged. + if ((int)$ch['step'] > 0 && $age < (int)$ch['escalate_after_s']) continue; + $out[] = ['channel' => $ch, 'suppressed' => $suppressed]; + } + return $out; + } + + /** + * Run one dispatch pass over the recent window. + * + * @param callable(array $event, array $channel): void $send throws on failure + * @return array{considered:int,sent:int,suppressed:int,failed:int,skipped:int} + */ + public function run(int $now, int $windowSeconds, int $limit, callable $send): array + { + $stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0]; + $events = $this->events->forNotification((float)($now - $windowSeconds), $limit); + // One channel read per site, not per event: a busy window is hundreds of events over a + // handful of sites, and this job runs on shared hosting where every query costs. + $channelsBySite = []; + + foreach ($events as $event) { + $stats['considered']++; + $site = $event['site'] ?? null; + $key = $site ?? "\x00global"; + if (!array_key_exists($key, $channelsBySite)) { + $channelsBySite[$key] = $this->alerts->channels($site); + } + foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) { + $channelId = (int)$delivery['channel']['channel_id']; + $eventId = (int)$event['event_id']; + // claim() is the duplicate guard. Losing it means another (possibly overlapping) + // dispatcher run owns this delivery — skip, do not send. + if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; } + if ($delivery['suppressed']) { + $this->alerts->markSuppressed($eventId, $channelId, $now); + $stats['suppressed']++; + continue; + } + try { + $send($event, $delivery['channel']); + $this->alerts->markSent($eventId, $channelId, $now); + $stats['sent']++; + } catch (\Throwable $e) { + // Never fatal: one unreachable SMTP relay must not stop the webhook that + // would have reached somebody. The row keeps the error for the retry pass. + $this->alerts->markFailed($eventId, $channelId, $e->getMessage(), $now); + $stats['failed']++; + } + } + } + return $stats; + } +} diff --git a/server/src/Alert/Channels.php b/server/src/Alert/Channels.php new file mode 100644 index 0000000..9c4df9d --- /dev/null +++ b/server/src/Alert/Channels.php @@ -0,0 +1,185 @@ +<?php +declare(strict_types=1); +namespace App\Alert; + +/** + * The senders. Two of the three channel kinds are delivered from here: + * + * email — an in-house SMTP relay via PHP mail(). Deliberately not a library: the delivered + * package must run on Cafe24 shared hosting with no composer step on the host, and + * adding an SMTP client to vendor/ for one function is weight the operator carries + * forever. Sites that need authenticated SMTP use the webhook channel instead. + * webhook — an HTTP POST to in-house groupware or an SMS gateway. + * + * The third kind, `local`, is NOT sent from here. A beacon or buzzer lives on the edge node's + * GPIO, and the edge fires it the instant its own detector produces the event — no server round + * trip, no network, no dependency on this cron job running. Its row here exists only to carry + * the configuration down to the device through GET /api/config. That is the one notification + * path that still works when the uplink is down, which is why it is the first one a site should + * configure. + * + * PRIVACY: notifications carry WHAT and WHERE, never the snapshot. Images are personal data + * under PIPA and are served only through the authenticated proxy; mailing one to a distribution + * list would move a worker's image outside every control the rest of the system maintains. The + * message therefore links to the incident instead. + */ +final class Channels +{ + private const 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' => '화재·연기', + ]; + private const SEVERITY_LABELS = [ + 'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보', + ]; + + /** @param string $baseUrl absolute dashboard URL, for the incident link. */ + public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {} + + public function send(array $event, array $channel): void + { + $config = json_decode($channel['config_json'] ?? '{}', true); + if (!is_array($config)) $config = []; + match ($channel['kind']) { + 'email' => $this->sendEmail($event, $config), + 'webhook' => $this->sendWebhook($event, $channel, $config), + // `local` is delivered by the edge itself (see the class comment). Recording it as + // sent here would be a lie; recording it as failed would bury the real failures. + 'local' => throw new \LogicException('local channels are delivered by the edge node, not the server'), + default => throw new \RuntimeException('unknown channel kind: ' . (string)$channel['kind']), + }; + } + + public function subject(array $event): string + { + $sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity']; + $type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type']; + return "[{$sev}] {$type} — " . (string)($event['camera_id'] ?? ''); + } + + public function body(array $event): string + { + $type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type']; + $sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity']; + $when = date('Y-m-d H:i:s', (int)$event['occurred_at']); + $link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view'; + return implode("\n", [ + "작업장 안전 감지 알림", + "", + "유형 : {$type}", + "심각도 : {$sev}", + "장치 : " . (string)($event['device_id'] ?? '-'), + "카메라 : " . (string)($event['camera_id'] ?? '-'), + "발생 시각: {$when}", + "지속 : " . (string)($event['duration_s'] ?? 0) . "초", + "", + "확인 및 증거 프레임: {$link}", + "", + "이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.", + "이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.", + ]); + } + + private function sendEmail(array $event, array $config): void + { + $to = $config['to'] ?? null; + $recipients = is_array($to) ? $to : (is_string($to) ? array_map('trim', explode(',', $to)) : []); + $recipients = array_values(array_filter($recipients, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL))); + if ($recipients === []) throw new \RuntimeException('email channel has no valid recipient'); + + $from = $config['from'] ?? null; + $headers = "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n"; + if (is_string($from) && filter_var($from, FILTER_VALIDATE_EMAIL)) { + $headers .= 'From: ' . $from . "\r\n"; + } + // Encode the subject: a raw Korean Subject header is mangled by most relays. + $subject = '=?UTF-8?B?' . base64_encode($this->subject($event)) . '?='; + // BCC-style: one call per recipient so one bad address cannot drop the whole batch, and + // so recipients do not see each other's addresses. + $failed = []; + foreach ($recipients as $rcpt) { + if (!@mail($rcpt, $subject, $this->body($event), $headers)) $failed[] = $rcpt; + } + if (count($failed) === count($recipients)) { + throw new \RuntimeException('mail() rejected every recipient: ' . implode(', ', $failed)); + } + } + + private function sendWebhook(array $event, array $channel, array $config): void + { + $url = $config['url'] ?? ''; + if (!is_string($url) || !filter_var($url, FILTER_VALIDATE_URL)) { + throw new \RuntimeException('webhook channel has no valid url'); + } + // http/https only. Without this a stored config could name file:// or gopher:// and turn + // the dispatcher into a local-file reader. + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + if (!in_array($scheme, ['http', 'https'], true)) { + throw new \RuntimeException('webhook url must be http(s)'); + } + + $payload = json_encode([ + 'event_id' => (int)$event['event_id'], + 'event_type' => $event['event_type'], + 'severity' => $event['severity'], + 'device_id' => $event['device_id'] ?? null, + 'camera_id' => $event['camera_id'] ?? null, + 'site' => $event['site'] ?? null, + 'occurred_at' => (int)$event['occurred_at'], + 'duration_s' => (float)($event['duration_s'] ?? 0), + 'subject' => $this->subject($event), + 'text' => $this->body($event), + 'url' => rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view', + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + $headers = ['Content-Type: application/json']; + // An optional shared token, so the receiving system can reject anything that is not us. + if (!empty($config['token']) && is_string($config['token'])) { + $headers[] = 'X-Alert-Token: ' . $config['token']; + } + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_CONNECTTIMEOUT => min(5, $this->timeoutSeconds), + // No redirects: a 302 from a compromised endpoint must not be able to walk this + // POST — with its token header — to another host. + CURLOPT_FOLLOWLOCATION => false, + ]); + $body = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $err = curl_error($ch); + curl_close($ch); + if ($body === false) throw new \RuntimeException('webhook transport error: ' . $err); + if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status); + return; + } + + $ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $payload, + 'timeout' => $this->timeoutSeconds, + 'ignore_errors' => true, + 'follow_location' => 0, + ]]); + $res = @file_get_contents($url, false, $ctx); + if ($res === false) throw new \RuntimeException('webhook transport error (stream)'); + $status = 0; + foreach ($http_response_header ?? [] as $h) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int)$m[1]; + } + if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status); + } +} diff --git a/server/src/Alert/EscalationWatch.php b/server/src/Alert/EscalationWatch.php new file mode 100644 index 0000000..0e99994 --- /dev/null +++ b/server/src/Alert/EscalationWatch.php @@ -0,0 +1,91 @@ +<?php +declare(strict_types=1); +namespace App\Alert; + +use App\Repository\EventRepository; + +/** + * Turns "nobody looked at it" into a first-class event. + * + * A critical detection that sits unacknowledged is a second, different failure from the + * detection itself, and until now it was invisible: the console showed a red row, and whether + * anyone ever came back to it left no trace anywhere. This watch emits an `unacked_escalation` + * event once an unresolved critical passes the threshold, which means it shows up in the stream, + * counts in the report, and — because it is an event like any other — is itself notifiable, so + * the escalation channels can carry it to a second person. + * + * IDEMPOTENCY comes from the event_uuid, not from a read: the uuid is derived from the origin + * event id, and move_events has a UNIQUE key on it. Two overlapping cron runs therefore produce + * one escalation, with the loser's INSERT colliding rather than duplicating — the same mechanism + * the ingest path already relies on for re-sent events. + */ +final class EscalationWatch +{ + /** Only genuine emergencies escalate. A 경고 that nobody acknowledged is a backlog, not an alarm. */ + private const ESCALATING_SEVERITIES = ['critical', 'high']; + + public function __construct(private EventRepository $events) {} + + /** Deterministic 32-hex uuid for the escalation of a given origin event. */ + public static function uuidFor(int $originEventId): string + { + // Prefixed so it is recognisable in the database, and hashed so it cannot collide with a + // device-generated uuid (which is random hex of the same width). + return substr('e5ca1a7e' . substr(hash('sha256', 'unacked:' . $originEventId), 0, 24), 0, 32); + } + + /** + * @param int $afterSeconds how long an unresolved event may sit before it escalates + * @return array<int,int> origin event ids that were escalated in this pass + */ + public function run(int $now, int $afterSeconds, int $windowSeconds, int $limit): array + { + $escalated = []; + foreach ($this->events->forNotification((float)($now - $windowSeconds), $limit) as $ev) { + if (!in_array($ev['severity'], self::ESCALATING_SEVERITIES, true)) continue; + if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue; + // An escalation must never escalate itself — that is an infinite ladder. + if ($ev['event_type'] === 'unacked_escalation') continue; + if (($now - (int)$ev['occurred_at']) < $afterSeconds) continue; + + $originId = (int)$ev['event_id']; + $uuid = self::uuidFor($originId); + if ($this->events->idByUuid($uuid) !== null) continue; // already escalated + + try { + $this->events->insert([ + 'event_uuid' => $uuid, + 'device_id' => $ev['device_id'], + 'site' => $ev['site'] ?? null, + 'camera_id' => $ev['camera_id'], + 'event_type' => 'unacked_escalation', + // The escalation inherits the severity it is escalating: a critical left + // unattended is still a critical, and downgrading it here would let it fall + // below the very channel floors that exist to wake someone. + 'severity' => $ev['severity'], + 'track_id' => $ev['track_id'] ?? null, + 'zone_id' => $ev['zone_id'] ?? null, + 'occurred_at' => (float)$now, + 'duration_s' => (float)($now - (int)$ev['occurred_at']), + 'metrics' => [ + 'origin_event_id' => $originId, + 'origin_event_type' => $ev['event_type'], + 'unacked_s' => $now - (int)$ev['occurred_at'], + ], + 'spatial_x' => $ev['spatial_x'] ?? null, + 'spatial_y' => $ev['spatial_y'] ?? null, + ]); + $escalated[] = $originId; + } catch (\Throwable $e) { + // A unique-key collision means a concurrent run won the race; anything else is + // worth surfacing but must not stop the remaining events from escalating. + if (!str_contains($e->getMessage(), 'uq_events_uuid') + && !str_contains($e->getMessage(), 'UNIQUE') + && !str_contains($e->getMessage(), 'Duplicate')) { + throw $e; + } + } + } + return $escalated; + } +} diff --git a/server/src/Api/ConfigController.php b/server/src/Api/ConfigController.php index 0c46646..97923f1 100644 --- a/server/src/Api/ConfigController.php +++ b/server/src/Api/ConfigController.php @@ -5,7 +5,8 @@ namespace App\Api; use App\Config; use App\Http\{Request, Response}; use App\Security\Hmac; -use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository}; +use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, + RateLimiterRepository, AlertRepository}; final class ConfigController { @@ -13,6 +14,7 @@ final class ConfigController private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds, private NonceRepository $nonces, private Config $cfg, private ?RateLimiterRepository $rateLimiter = null, + private ?AlertRepository $alerts = null, ) {} public function get(Request $req, int $now, string $clientIp = ''): Response @@ -40,6 +42,33 @@ final class ConfigController 'version' => $this->devices->configVersion($deviceId), 'zones' => $this->zones->forDevice($deviceId), 'thresholds' => $this->thresholds->forDevice($deviceId), + // Local beacon wiring (GPIO pin, latch/pulse, hold time). The node fires its own + // beacon the instant its detector produces a qualifying event — no server round trip, + // so it still works with the uplink down, which is the whole point of having it. + // Carried here because /api/config is the only authenticated channel the device has. + 'beacons' => $this->beaconsFor($deviceId), ], 200); } + + /** @return array<int,array> local-kind channels applicable to this device's site. */ + private function beaconsFor(string $deviceId): array + { + if ($this->alerts === null) return []; + $site = $this->devices->siteFor($deviceId); + $out = []; + foreach ($this->alerts->channels($site) as $ch) { + if ($ch['kind'] !== 'local') continue; + $config = json_decode($ch['config_json'], true); + if (!is_array($config)) continue; + $out[] = [ + 'name' => $ch['name'], + 'min_severity' => $ch['min_severity'], + 'gpio_pin' => (int)($config['gpio_pin'] ?? 0), + 'mode' => (string)($config['mode'] ?? 'latch'), + 'hold_s' => (float)($config['hold_s'] ?? 30.0), + 'active_high' => (bool)($config['active_high'] ?? false), + ]; + } + return $out; + } } diff --git a/server/src/Api/HeartbeatController.php b/server/src/Api/HeartbeatController.php index 1df5577..c1f82a0 100644 --- a/server/src/Api/HeartbeatController.php +++ b/server/src/Api/HeartbeatController.php @@ -5,12 +5,13 @@ namespace App\Api; use App\Config; use App\Http\{Request, Response}; use App\Security\Hmac; -use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository}; +use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, DeviceMetricRepository}; final class HeartbeatController { public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces, - private ?RateLimiterRepository $rateLimiter = null) {} + private ?RateLimiterRepository $rateLimiter = null, + private ?DeviceMetricRepository $metrics = null) {} public function post(Request $req, int $now, string $clientIp = ''): Response { @@ -46,6 +47,25 @@ final class HeartbeatController 'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null, 'at' => $now, ]); + + // Append the same beat to the time series. move_devices only ever holds the LATEST + // reading, which cannot answer "is this node getting worse?" — a device whose fps has + // been sliding for a week looks identical to a healthy one right up until it stops. + // Optional dependency so the many tests that construct this controller with four + // arguments keep working, and so a site that has not migrated yet still ingests. + // + // Absent readings stay absent: only keys the device actually sent are forwarded, because + // the repository stores a missing metric as NULL and coercing one to 0 here would make a + // device that cannot read its temperature look like a device sitting at 0°C. + if ($this->metrics !== null) { + $series = []; + foreach (['fps', 'temp', 'throttle', 'queue_pending', 'cameras_total', + 'cameras_erroring', 'errors'] as $k) { + if (array_key_exists($k, $b)) $series[$k] = $b[$k]; + } + if ($series !== []) $this->metrics->append($deviceId, $series, $now); + } + return Response::json(['ok' => true], 200); } } diff --git a/server/src/Api/ThresholdCatalog.php b/server/src/Api/ThresholdCatalog.php new file mode 100644 index 0000000..653f1e0 --- /dev/null +++ b/server/src/Api/ThresholdCatalog.php @@ -0,0 +1,101 @@ +<?php +declare(strict_types=1); +namespace App\Api; + +/** + * The tunables an operator may change, and the bounds they may change them within. + * + * This catalogue is the whole reason the threshold editor is safe to expose. The values it + * describes decide what the system will and will not detect: `fall_prone_s` too high and a fall + * is never confirmed; `cooldown_s` at 0 and one event re-fires every tick until the queue fills. + * The edge already rejects non-finite and non-positive values at startup (edge/config.py + * `_positive`), but by then the device is in a restart loop in a factory — the bounds below stop + * the same mistake at the keyboard, where somebody can still read the error. + * + * Keys mirror the edge's env names in lower case, which is the contract GET /api/config carries + * (see edge/zones.py thresholds_from_config and edge/runtime.py apply_thresholds). + * + * A key NOT listed here cannot be written through the API. That is deliberate: an unknown key + * would be delivered to the device, silently ignored, and look configured. + */ +final class ThresholdCatalog +{ + /** + * @return array<string,array{label:string,unit:string,min:float,max:float,default:float, + * step:float,integer:bool,help:string}> + */ + public static function all(): array + { + return [ + 'fall_prone_s' => [ + 'label' => '넘어짐 확정 대기', 'unit' => '초', + 'min' => 1.0, 'max' => 30.0, 'default' => 3.0, 'step' => 0.5, 'integer' => false, + 'help' => '쓰러진 자세가 이 시간 이상 지속되어야 넘어짐으로 확정합니다. 낮추면 반응이 빨라지지만 앉거나 웅크린 자세를 오탐할 수 있습니다.', + ], + 'fall_no_motion_s' => [ + 'label' => '넘어짐 후 미동 판정', 'unit' => '초', + 'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false, + 'help' => '넘어진 뒤 이 시간 동안 움직임이 없으면 응급 상황(미동 없음)으로 올립니다.', + ], + 'immobile_s' => [ + 'label' => '장시간 부동 판정', 'unit' => '초', + 'min' => 30.0, 'max' => 1800.0, 'default' => 120.0, 'step' => 10.0, 'integer' => false, + 'help' => '서 있든 앉아 있든 이 시간 동안 움직임이 없으면 이상으로 봅니다. 정적인 작업이 많은 공정에서는 올리세요.', + ], + 'ghost_reappear_s' => [ + 'label' => '소실 후 재출현 대기', 'unit' => '초', + 'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false, + 'help' => '위험 상태였던 사람이 화면에서 사라진 뒤 이 시간 안에 다시 나타나지 않으면 경보합니다.', + ], + 'zone_debounce_s' => [ + 'label' => '구역 진입 디바운스', 'unit' => '초', + 'min' => 0.2, 'max' => 10.0, 'default' => 0.75, 'step' => 0.05, 'integer' => false, + 'help' => '경계선을 스치듯 지나가는 것을 침입으로 세지 않기 위한 최소 체류 시간입니다.', + ], + 'cooldown_s' => [ + 'label' => '동일 이벤트 재발행 억제', 'unit' => '초', + 'min' => 5.0, 'max' => 600.0, 'default' => 30.0, 'step' => 5.0, 'integer' => false, + 'help' => '같은 사람·같은 유형의 이벤트를 이 시간 안에는 다시 보내지 않습니다. 너무 낮추면 한 사건이 수십 건으로 쪼개집니다.', + ], + 'motion_thr' => [ + 'label' => '움직임 임계', 'unit' => '', + 'min' => 0.05, 'max' => 2.0, 'default' => 0.25, 'step' => 0.05, 'integer' => false, + 'help' => '이 값보다 작은 키포인트 변화는 "정지"로 봅니다. 카메라가 흔들리는 현장은 올리고, 미세한 움직임까지 살리려면 내리세요.', + ], + 'infer_every' => [ + 'label' => '추론 주기', 'unit' => '틱마다 1회', + 'min' => 1.0, 'max' => 10.0, 'default' => 1.0, 'step' => 1.0, 'integer' => true, + 'help' => '장비가 전 카메라를 full-rate 로 처리하지 못할 때만 올립니다. 반응 정밀도를 내주고 모든 카메라를 살리는 절충입니다.', + ], + ]; + } + + public static function has(string $key): bool + { + return array_key_exists($key, self::all()); + } + + /** + * Validate a submitted value against the catalogue. + * + * @return array{0:bool,1:string} [ok, normalized-or-error-code] + */ + public static function normalize(string $key, mixed $raw): array + { + $spec = self::all()[$key] ?? null; + if ($spec === null) return [false, 'unknown_key']; + if (!is_numeric($raw)) return [false, 'not_numeric']; + $v = (float)$raw; + // NaN/INF slip past is_numeric for the strings "nan"/"inf" on some platforms, and both + // are exactly the values edge/config.py exists to reject. + if (!is_finite($v)) return [false, 'not_finite']; + if ($v < $spec['min'] || $v > $spec['max']) return [false, 'out_of_range']; + if ($spec['integer']) { + if (floor($v) !== $v) return [false, 'not_integer']; + return [true, (string)(int)$v]; + } + // Normalize the textual form so the same value always stores identically (the column is + // a VARCHAR and "3" vs "3.0" would otherwise read as two different settings). + return [true, rtrim(rtrim(number_format($v, 3, '.', ''), '0'), '.') ?: '0']; + } +} diff --git a/server/src/Config.php b/server/src/Config.php index b249fcc..a851400 100644 --- a/server/src/Config.php +++ b/server/src/Config.php @@ -26,4 +26,34 @@ final class Config * A site with a DB quota can opt in by setting this. */ public function eventRetentionDays(): int { return (int)($this->a['event_retention_days'] ?? 0); } + + /** + * Absolute, externally reachable base URL of the dashboard ("https://example.com/move_detect"). + * Notifications need it because they are read outside the browser session that produced them, + * so SCRIPT_NAME — which is how every in-app link is built — is not available. Empty by + * default and the dispatcher refuses to run without it, rather than mailing a broken link. + */ + public function publicBaseUrl(): string { return rtrim((string)($this->a['public_base_url'] ?? ''), '/'); } + + /** + * How long an unresolved critical/high event may sit before it becomes an + * `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by + * default: long enough that a present operator handles it first, short enough that a fall + * at 03:00 does not wait for the morning shift. + */ + public function escalateAfterSeconds(): int { return (int)($this->a['escalate_after_seconds'] ?? 300); } + + /** + * Retention for the heartbeat time series (move_device_metrics). At a 30s heartbeat each + * device writes ~2,880 rows/day, so on a shared-hosting quota this table is the one most + * likely to grow until it breaks ingest. 30 days keeps a month of degradation history. + */ + public function metricRetentionDays(): int { return (int)($this->a['metric_retention_days'] ?? 30); } + + /** + * Free-space floor for the snapshot volume, in megabytes. Below it the system raises a + * `device_fault` notice instead of discovering the problem as silently-failing image writes. + * 0 disables the check (a host where disk_free_space() is not meaningful). + */ + public function storageMinFreeMb(): int { return (int)($this->a['storage_min_free_mb'] ?? 200); } } diff --git a/server/src/Dashboard/ActionsController.php b/server/src/Dashboard/ActionsController.php index 44c2d59..9131028 100644 --- a/server/src/Dashboard/ActionsController.php +++ b/server/src/Dashboard/ActionsController.php @@ -58,8 +58,23 @@ final class ActionsController if (!SiteScope::allows(is_string($scope) ? $scope : null, $site)) { return Response::json(['error' => 'not_found'], 404); } + // Optional link back to the incident that prompted the fix. Recorded from the event + // detail page, so the report can later say WHICH accident a control came out of instead + // of leaving the chain of cause broken at the camera. + $eventId = null; + if (isset($b['event_id']) && is_numeric($b['event_id'])) { + $ev = $this->events->find((int)$b['event_id']); + // Silently dropping a bad id would record an action whose stated origin is a lie. + if ($ev === null || !SiteScope::allows(is_string($scope) ? $scope : null, $ev['site'] ?? null)) { + return Response::json(['error' => 'unknown_event'], 400); + } + if (($ev['camera_id'] ?? '') !== $camera) return Response::json(['error' => 'event_camera_mismatch'], 400); + $eventId = (int)$b['event_id']; + } + $id = $this->actions->insert([ - 'camera_id' => $camera, 'site' => $site, 'note' => $note, 'taken_at' => $takenAt, + 'camera_id' => $camera, 'site' => $site, 'event_id' => $eventId, + 'note' => $note, 'taken_at' => $takenAt, 'created_by' => (string)$this->sess->get('username'), ]); return Response::json(['action_id' => $id], 201); diff --git a/server/src/Dashboard/AdminConfigController.php b/server/src/Dashboard/AdminConfigController.php new file mode 100644 index 0000000..717bc94 --- /dev/null +++ b/server/src/Dashboard/AdminConfigController.php @@ -0,0 +1,247 @@ +<?php +declare(strict_types=1); +namespace App\Dashboard; + +use App\Api\ThresholdCatalog; +use App\Http\{Request, Response}; +use App\Auth\{SessionStore, Rbac}; +use App\Security\Csrf; +use App\Repository\{ThresholdRepository, AlertRepository, DeviceRepository, AuditRepository}; + +/** + * System configuration an admin can change from the browser: detection tunables and the + * notification channels. + * + * Both used to be unreachable. Thresholds existed as a table, were delivered to devices by + * GET /api/config, and the schema comment told the operator to edit them in phpMyAdmin — which + * in a delivered, air-gapped install means the customer could not retune a camera at all, even + * though the console's own false-positive panel exists to tell them which one needs it. + * Channels did not exist in any form: a detection reached a human only if somebody happened to + * be looking at the screen. + * + * Every write here is audited. A threshold change alters what the system is capable of + * detecting, and an investigation has to be able to ask when that changed and who changed it. + */ +final class AdminConfigController +{ + private const MAX_NAME = 190; + + public function __construct( + private ThresholdRepository $thresholds, + private AlertRepository $alerts, + private DeviceRepository $devices, + private AuditRepository $audit, + private SessionStore $sess, + ) {} + + private function guard(Request $req, bool $write): ?Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'manage_thresholds')) { + return Response::json(['error' => 'forbidden'], 403); + } + if ($write && !Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + return null; + } + + private function site(): ?string + { + $scope = $this->sess->get('site_scope'); + return is_string($scope) ? $scope : null; + } + + // ---------------------------------------------------------------- thresholds + public function thresholds(Request $req): Response + { + if ($r = $this->guard($req, false)) return $r; + return Response::json([ + // The catalogue travels with the values so the editor can render a bounded control + // (a slider with the real min/max and the real explanation) instead of a free-text + // key/value box that invites a typo the device will silently ignore. + 'catalog' => ThresholdCatalog::all(), + 'thresholds' => $this->thresholds->listAll($this->site()), + 'devices' => $this->devices->listStatus($this->site()), + ], 200); + } + + public function saveThreshold(Request $req): Response + { + if ($r = $this->guard($req, true)) return $r; + $b = $req->json(); + $deviceId = trim((string)($b['device_id'] ?? '')); + $key = trim((string)($b['key'] ?? '')); + $cameraId = trim((string)($b['camera_id'] ?? '')); + $cameraId = $cameraId === '' ? null : $cameraId; + + if ($deviceId === '' || mb_strlen($deviceId) > 64) return Response::json(['error' => 'invalid_device'], 400); + if ($cameraId !== null && mb_strlen($cameraId) > 64) return Response::json(['error' => 'invalid_camera'], 400); + [$ok, $value] = ThresholdCatalog::normalize($key, $b['value'] ?? null); + if (!$ok) return Response::json(['error' => $value], 400); + + // Scope BEFORE the write: a scoped admin must not retune another site's device. + if (!$this->devices->exists($deviceId)) return Response::json(['error' => 'unknown_device'], 400); + $site = $this->site(); + if ($site !== null && $this->devices->siteFor($deviceId) !== $site) { + return Response::json(['error' => 'not_found'], 404); + } + + $now = time(); + $actor = (string)$this->sess->get('username'); + // Capture the old value first — the audit entry is worthless without it. + $before = $this->thresholds->forDevice($deviceId)[$key] ?? null; + $id = $this->thresholds->set($deviceId, $cameraId, $key, $value, $actor, $now); + $this->audit->append($actor, 'threshold_set', "threshold:$deviceId:$key", + ['value' => $before], ['value' => $value, 'camera_id' => $cameraId]); + + // Bumping the device's config version is what makes the change actually reach the node: + // without it the daemon's next sync sees the same version and the new value sits in the + // database looking applied. This is the failure the config-drift indicator exists for. + $this->devices->bumpConfigVersion($deviceId); + + return Response::json(['id' => $id, 'value' => $value], 200); + } + + public function deleteThreshold(Request $req, array $params): Response + { + if ($r = $this->guard($req, true)) return $r; + $id = (int)($params['id'] ?? 0); + $row = $this->thresholds->find($id); + if ($row === null) return Response::json(['error' => 'not_found'], 404); + $site = $this->site(); + if ($site !== null && ($row['site'] ?? null) !== $site) return Response::json(['error' => 'not_found'], 404); + + if (!$this->thresholds->remove($id, $site)) return Response::json(['error' => 'not_found'], 404); + $actor = (string)$this->sess->get('username'); + $this->audit->append($actor, 'threshold_delete', 'threshold:' . $row['device_id'] . ':' . $row['key'], + ['value' => $row['value']], []); + $this->devices->bumpConfigVersion($row['device_id']); + return Response::json(['ok' => true], 200); + } + + // ---------------------------------------------------------------- alert channels + public function channels(Request $req): Response + { + if ($r = $this->guard($req, false)) return $r; + $out = []; + foreach ($this->alerts->allChannels($this->site()) as $c) { + $config = json_decode($c['config_json'], true); + if (!is_array($config)) $config = []; + // Never echo a shared secret back to the browser. It is write-only from the UI's + // point of view: the form can set a new token, but the page can never display the + // current one (a screen-shared config page must not leak the integration key). + if (isset($config['token'])) $config['token'] = '••••••••'; + $c['config'] = $config; + unset($c['config_json']); + $out[] = $c; + } + return Response::json(['channels' => $out], 200); + } + + public function saveChannel(Request $req): Response + { + if ($r = $this->guard($req, true)) return $r; + $b = $req->json(); + $kind = (string)($b['kind'] ?? ''); + $name = trim((string)($b['name'] ?? '')); + $minSeverity = (string)($b['min_severity'] ?? 'high'); + $step = (int)($b['step'] ?? 0); + $after = (int)($b['escalate_after_s'] ?? 0); + + if (!in_array($kind, ['local', 'email', 'webhook'], true)) return Response::json(['error' => 'invalid_kind'], 400); + if ($name === '' || mb_strlen($name) > self::MAX_NAME) return Response::json(['error' => 'invalid_name'], 400); + if (!in_array($minSeverity, ['critical', 'high', 'warning', 'info'], true)) { + return Response::json(['error' => 'invalid_severity'], 400); + } + if ($step < 0 || $step > 5) return Response::json(['error' => 'invalid_step'], 400); + // An escalation step with no delay would fire together with the immediate channels and + // wake the second person for every event — the opposite of an escalation. + if ($step > 0 && ($after < 30 || $after > 86400)) return Response::json(['error' => 'invalid_escalate_after'], 400); + + $config = $this->normalizeChannelConfig($kind, is_array($b['config'] ?? null) ? $b['config'] : []); + if ($config === null) return Response::json(['error' => 'invalid_config'], 400); + + $id = (int)($b['channel_id'] ?? 0); + if ($id > 0) { + $existing = null; + foreach ($this->alerts->allChannels($this->site()) as $c) { + if ((int)$c['channel_id'] === $id) { $existing = $c; break; } + } + if ($existing === null) return Response::json(['error' => 'not_found'], 404); + // A masked token means "leave it alone" — the GET never revealed the real one. + if (($config['token'] ?? null) === '••••••••') { + $old = json_decode($existing['config_json'], true); + if (is_array($old) && isset($old['token'])) $config['token'] = $old['token']; + else unset($config['token']); + } + } + + $newId = $this->alerts->saveChannel([ + 'channel_id' => $id > 0 ? $id : null, + 'site' => $this->site(), + 'kind' => $kind, + 'name' => $name, + 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'min_severity' => $minSeverity, + 'enabled' => !empty($b['enabled']) ? 1 : 0, + 'step' => $step, + 'escalate_after_s' => $step > 0 ? $after : 0, + 'created_at' => time(), + ]); + $actor = (string)$this->sess->get('username'); + // The audit entry records the shape, never the token. + $this->audit->append($actor, $id > 0 ? 'alert_channel_update' : 'alert_channel_create', + "alert_channel:$newId", [], ['kind' => $kind, 'name' => $name, 'step' => $step, + 'min_severity' => $minSeverity]); + return Response::json(['channel_id' => $newId], $id > 0 ? 200 : 201); + } + + public function deleteChannel(Request $req, array $params): Response + { + if ($r = $this->guard($req, true)) return $r; + $id = (int)($params['id'] ?? 0); + if (!$this->alerts->deleteChannel($id, $this->site())) return Response::json(['error' => 'not_found'], 404); + $this->audit->append((string)$this->sess->get('username'), 'alert_channel_delete', "alert_channel:$id", [], []); + return Response::json(['ok' => true], 200); + } + + /** @return array|null null = invalid for this kind. */ + private function normalizeChannelConfig(string $kind, array $in): ?array + { + if ($kind === 'email') { + $raw = $in['to'] ?? ''; + $list = is_array($raw) ? $raw : array_map('trim', explode(',', (string)$raw)); + $list = array_values(array_filter($list, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL))); + if ($list === []) return null; + $out = ['to' => $list]; + if (!empty($in['from']) && filter_var((string)$in['from'], FILTER_VALIDATE_EMAIL)) { + $out['from'] = (string)$in['from']; + } + return $out; + } + if ($kind === 'webhook') { + $url = (string)($in['url'] ?? ''); + if (!filter_var($url, FILTER_VALIDATE_URL)) return null; + // http(s) only — a stored file:// or gopher:// would turn the dispatcher into a + // local file reader running as the web user. + if (!in_array(strtolower((string)parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) return null; + $out = ['url' => $url]; + if (!empty($in['token']) && is_string($in['token'])) $out['token'] = $in['token']; + return $out; + } + // local: the edge fires this itself; the config is the GPIO wiring it should use. + $pin = isset($in['gpio_pin']) ? (int)$in['gpio_pin'] : 0; + if ($pin < 0 || $pin > 40) return null; // Pi header is 40 pins; 0 = unset/disabled + $mode = (string)($in['mode'] ?? 'latch'); + if (!in_array($mode, ['latch', 'pulse'], true)) return null; + $out = ['gpio_pin' => $pin, 'mode' => $mode]; + // How long a pulse lasts / how long a latch holds before auto-release. A latch that + // never releases needs somebody to walk to the node, which is not always possible. + $hold = isset($in['hold_s']) ? (float)$in['hold_s'] : 30.0; + if ($hold <= 0 || $hold > 3600) return null; + $out['hold_s'] = $hold; + $out['active_high'] = !empty($in['active_high']); + return $out; + } +} diff --git a/server/src/Dashboard/ComplianceController.php b/server/src/Dashboard/ComplianceController.php new file mode 100644 index 0000000..b27bf57 --- /dev/null +++ b/server/src/Dashboard/ComplianceController.php @@ -0,0 +1,225 @@ +<?php +declare(strict_types=1); +namespace App\Dashboard; + +use App\Http\{Request, Response}; +use App\Auth\{SessionStore, Rbac}; +use App\Security\{Csrf, SiteScope}; +use App\Repository\{EventRepository, SnapshotRepository, ActionRepository, AlertRepository, + RiskControlRepository, HandoverRepository, AuditRepository}; + +/** + * The evidence side of the product: what an inquiry, an insurer or a regulator asks for. + * + * - 사고조사 패키지 (package): one incident, reconstructed. The event, its evidence frames, the + * same camera's timeline either side of it, who was notified and whether the notification + * actually went out, what the operator did, and which risk control was supposed to cover it. + * Assembling that by hand after an accident is exactly when nobody has time to. + * - 위험성평가 (risk controls): the mapping from each detection type to the hazard it stands for + * and the control currently in place. This is the artefact a safety management system is + * audited on; without it the console is a gadget rather than part of a management system. + * - 교대 인수인계 (handover): a record that a named person accepted a named backlog. The 24h + * brief says what happened; this says who took responsibility for it. + */ +final class ComplianceController +{ + /** ±30 minutes of the same camera's activity around the incident. */ + private const CONTEXT_WINDOW_S = 1800; + private const CONTEXT_LIMIT = 200; + private const MAX_NOTE = 1000; + private const HANDOVER_LIST = 20; + + public function __construct( + private EventRepository $events, + private SnapshotRepository $snaps, + private ActionRepository $actions, + private AlertRepository $alerts, + private RiskControlRepository $risks, + private HandoverRepository $handovers, + private AuditRepository $audit, + private SessionStore $sess, + ) {} + + private function site(): ?string + { + $scope = $this->sess->get('site_scope'); + return is_string($scope) ? $scope : null; + } + + // ---------------------------------------------------------------- incident package + public function package(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $id = (int)($params['id'] ?? 0); + $event = $this->events->find($id); + // 404 (not 403) for an out-of-scope id, so a cross-site id is indistinguishable from a + // non-existent one — the same rule the rest of the dashboard follows. + if ($event === null || !SiteScope::allows($this->site(), $event['site'] ?? null)) { + return Response::json(['error' => 'not_found'], 404); + } + + $at = (float)$event['occurred_at']; + $site = $this->site(); + $timeline = $this->events->windowForCamera( + (string)$event['camera_id'], $at - self::CONTEXT_WINDOW_S, $at + self::CONTEXT_WINDOW_S, + $site, self::CONTEXT_LIMIT + ); + + // Improvement actions recorded against this camera, so the package shows whether the + // hazard had already been raised before the incident — the first question asked. + $cameraActions = []; + foreach ($this->actions->recent(50, $site) as $a) { + if (($a['camera_id'] ?? '') === $event['camera_id']) $cameraActions[] = $a; + } + + $risk = null; + foreach ($this->risks->listAll($site) as $r) { + if ($r['event_type'] === $event['event_type']) { $risk = $r; break; } + } + + return Response::json([ + 'event' => $event, + 'snapshots' => $this->snaps->forEvent($id), + 'timeline' => $timeline, + // Whether anyone was actually told. A package that only proves the system SAW the + // incident, without showing whether it reached a human, answers half the question. + 'notifications' => $this->alerts->forEvent($id), + 'actions' => $cameraActions, + 'risk_control' => $risk, + 'context_window_s' => self::CONTEXT_WINDOW_S, + 'generated_at' => time(), + 'generated_by' => (string)$this->sess->get('username'), + ], 200); + } + + // ---------------------------------------------------------------- risk controls + public function risks(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + return Response::json(['controls' => $this->risks->listAll($this->site())], 200); + } + + public function saveRisk(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) { + return Response::json(['error' => 'forbidden'], 403); + } + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + $b = $req->json(); + $type = trim((string)($b['event_type'] ?? '')); + $hazard = trim((string)($b['hazard'] ?? '')); + $control = trim((string)($b['control'] ?? '')); + $sev = (int)($b['severity_rating'] ?? 0); + $like = (int)($b['likelihood_rating'] ?? 0); + + if ($type === '' || mb_strlen($type) > 32) return Response::json(['error' => 'invalid_type'], 400); + if ($hazard === '' || mb_strlen($hazard) > 300) return Response::json(['error' => 'invalid_hazard'], 400); + if ($control === '' || mb_strlen($control) > 500) return Response::json(['error' => 'invalid_control'], 400); + if ($sev < 1 || $sev > 5 || $like < 1 || $like > 5) return Response::json(['error' => 'invalid_rating'], 400); + + $now = time(); + $actor = (string)$this->sess->get('username'); + $this->risks->upsert([ + 'site' => $this->site(), 'event_type' => $type, 'hazard' => $hazard, 'control' => $control, + 'severity_rating' => $sev, 'likelihood_rating' => $like, + 'reviewed_by' => $actor, 'reviewed_at' => $now, 'updated_at' => $now, + ]); + $this->audit->append($actor, 'risk_control_save', "risk:$type", [], + ['severity' => $sev, 'likelihood' => $like]); + return Response::json(['ok' => true], 200); + } + + public function deleteRisk(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) { + return Response::json(['error' => 'forbidden'], 403); + } + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + $id = (int)($params['id'] ?? 0); + if (!$this->risks->delete($id, $this->site())) return Response::json(['error' => 'not_found'], 404); + $this->audit->append((string)$this->sess->get('username'), 'risk_control_delete', "risk:$id", [], []); + return Response::json(['ok' => true], 200); + } + + // ---------------------------------------------------------------- shift handover + public function handovers(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + return Response::json([ + 'handovers' => $this->handovers->recent($this->site(), self::HANDOVER_LIST), + 'pending' => $this->pendingHandover(), + ], 200); + } + + /** + * What the next handover would certify. Computed here and used by BOTH the read endpoint and + * the accept endpoint, so the number that is displayed is by construction the number that + * gets recorded — a browser-supplied count could understate the very backlog the record + * exists to pin down. + */ + private function pendingHandover(): array + { + $site = $this->site(); + $last = $this->handovers->latest($site); + $now = time(); + // The period runs from the last handover to now; on the very first one fall back to 12 + // hours (one shift) rather than to the beginning of time. + $periodStart = $last !== null ? (int)$last['created_at'] : $now - 12 * 3600; + + $unacked = 0; + $severe = 0; + foreach ($this->events->page(200, null, null, null, $site) as $ev) { + if ((float)$ev['occurred_at'] < $periodStart) continue; + if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue; + $unacked++; + if (in_array($ev['severity'], ['critical', 'high'], true)) $severe++; + } + return [ + 'period_start' => $periodStart, + 'period_end' => $now, + 'unacked_count' => $unacked, + 'severe_count' => $severe, + 'from_user' => $last['to_user'] ?? null, + ]; + } + + public function acceptHandover(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) { + return Response::json(['error' => 'forbidden'], 403); + } + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + $b = $req->json(); + $note = trim((string)($b['note'] ?? '')); + if (mb_strlen($note) > self::MAX_NOTE) return Response::json(['error' => 'invalid_note'], 400); + + // Recomputed server-side, never taken from the request body (see pendingHandover). + $p = $this->pendingHandover(); + + $actor = (string)$this->sess->get('username'); + $now = time(); + $id = $this->handovers->create([ + 'site' => $this->site(), + 'from_user' => $p['from_user'] ?? null, + 'to_user' => $actor, + 'period_start' => (int)$p['period_start'], + 'period_end' => (int)$p['period_end'], + 'unacked_count' => (int)$p['unacked_count'], + 'severe_count' => (int)$p['severe_count'], + 'note' => $note !== '' ? $note : null, + 'created_at' => $now, + ]); + $this->audit->append($actor, 'shift_handover', "handover:$id", [], + ['unacked' => (int)$p['unacked_count'], 'severe' => (int)$p['severe_count']]); + return Response::json(['handover_id' => $id], 201); + } +} diff --git a/server/src/Dashboard/DeviceStatusController.php b/server/src/Dashboard/DeviceStatusController.php index b29207b..e0e4178 100644 --- a/server/src/Dashboard/DeviceStatusController.php +++ b/server/src/Dashboard/DeviceStatusController.php @@ -2,9 +2,11 @@ declare(strict_types=1); namespace App\Dashboard; +use App\Config; use App\Http\{Request, Response}; use App\Auth\SessionStore; -use App\Repository\DeviceRepository; +use App\Security\SiteScope; +use App\Repository\{DeviceRepository, DeviceMetricRepository}; /** * GET /api/devices — device/camera health for the dashboard's status panel. The edge already @@ -25,7 +27,14 @@ final class DeviceStatusController // yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls). private const OFFLINE_AFTER_S = 120; - public function __construct(private DeviceRepository $devices, private SessionStore $sess) {} + /** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */ + private const SERIES_WINDOW_S = 86400; + private const SERIES_LIMIT = 3000; + + public function __construct( + private DeviceRepository $devices, private SessionStore $sess, + private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null + ) {} public function json(Request $req, int $now): Response { @@ -54,6 +63,63 @@ final class DeviceStatusController ]; }, $this->devices->listStatus($site)); - return Response::json(['devices' => $devices], 200); + return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200); + } + + /** + * GET /api/devices/{id}/metrics — the heartbeat time series for one device. + * + * The status panel answers "is it alive right now"; this answers "has it been getting + * worse", which is the question that catches a node before it dies. A thermally throttling + * Pi loses fps for days before it stops. + */ + public function metrics(Request $req, array $params, int $now): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if ($this->metrics === null) return Response::json(['error' => 'not_available'], 404); + + $deviceId = (string)($params['id'] ?? ''); + $scope = $this->sess->get('site_scope'); + $site = is_string($scope) ? $scope : null; + // Scope before reading: telemetry reveals that a device exists and how it is doing. + if (!$this->devices->exists($deviceId) + || !SiteScope::allows($site, $this->devices->siteFor($deviceId))) { + return Response::json(['error' => 'not_found'], 404); + } + + $hours = (int)($req->query('hours') ?? 24); + $window = max(3600, min(self::SERIES_WINDOW_S * 7, $hours * 3600)); + return Response::json([ + 'device_id' => $deviceId, + 'from' => $now - $window, + 'points' => $this->metrics->series($deviceId, $now - $window, self::SERIES_LIMIT), + ], 200); + } + + /** + * Free space on the snapshot volume. + * + * On shared hosting a full disk does not announce itself: image writes start failing, the + * ingest path logs and moves on (by design — an event without its picture still beats no + * event), and the board keeps looking healthy while the evidence quietly stops being kept. + * Surfacing the number is what turns that into something somebody notices. + */ + private function storage(): ?array + { + if ($this->cfg === null) return null; + $minFreeMb = $this->cfg->storageMinFreeMb(); + if ($minFreeMb <= 0) return null; // explicitly disabled for this host + $dir = $this->cfg->snapshotDir(); + if (!is_dir($dir)) return null; + $free = @disk_free_space($dir); + $total = @disk_total_space($dir); + if ($free === false || $free === null) return null; // not meaningful on this host + $freeMb = (int)floor($free / 1048576); + return [ + 'free_mb' => $freeMb, + 'total_mb' => ($total !== false && $total !== null) ? (int)floor($total / 1048576) : null, + 'min_free_mb' => $minFreeMb, + 'low' => $freeMb < $minFreeMb, + ]; } } diff --git a/server/src/Dashboard/ReportController.php b/server/src/Dashboard/ReportController.php index 2b51068..1dbcffd 100644 --- a/server/src/Dashboard/ReportController.php +++ b/server/src/Dashboard/ReportController.php @@ -115,6 +115,9 @@ final class ReportController ], 'pattern' => $pattern, 'precursor_alert' => $spike, + // 현장별 롤업은 전역 운영자(본사)에게만 의미가 있다. 스코프 운영자에게는 정의상 + // 자기 현장 한 줄뿐이라 표를 만들 이유가 없고, 다른 현장 수치가 새면 안 된다. + 'by_site' => $site === null ? $this->events->siteSummary($since) : null, ], 200); } diff --git a/server/src/Dashboard/SuppressionController.php b/server/src/Dashboard/SuppressionController.php new file mode 100644 index 0000000..ae517f9 --- /dev/null +++ b/server/src/Dashboard/SuppressionController.php @@ -0,0 +1,136 @@ +<?php +declare(strict_types=1); +namespace App\Dashboard; + +use App\Http\{Request, Response}; +use App\Auth\{SessionStore, Rbac}; +use App\Security\{Csrf, SiteScope}; +use App\Repository\{SuppressionRepository, EventRepository, DeviceRepository, AuditRepository}; + +/** + * 정비 모드 / 작업허가 알람 억제. + * + * The single largest source of false alarms on a real site is planned work: a maintenance crew + * inside a restricted zone trips every intrusion rule they walk past. A console that cries wolf + * through every shutdown is a console the site turns off, and then the next real fall goes + * unseen. So the system needs a way to say "we know, it's planned". + * + * Three constraints make that safe rather than dangerous: + * - Detection is never paused. Events are ingested, listed and reported exactly as always; + * only NOTIFICATION is withheld, and the withholding is itself recorded (status + * 'suppressed' in the alert ledger). An investigator can always reconstruct what the + * cameras saw during the window. + * - A reason is mandatory. "Somebody silenced it" is not an answer anyone can act on later. + * - There is a hard expiry, capped at MAX_WINDOW_S. The failure mode of this feature is a + * zone muted "just for now" that nobody ever unmutes. + */ +final class SuppressionController +{ + private const MAX_REASON = 500; + /** 8 hours: one shift. Longer work needs a deliberate re-issue, which is the point. */ + private const MAX_WINDOW_S = 8 * 3600; + private const MAX_LEAD_S = 7 * 86400; // may be scheduled up to a week ahead + + public function __construct( + private SuppressionRepository $suppressions, + private EventRepository $events, + private DeviceRepository $devices, + private AuditRepository $audit, + private SessionStore $sess, + ) {} + + private function site(): ?string + { + $scope = $this->sess->get('site_scope'); + return is_string($scope) ? $scope : null; + } + + public function json(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + $now = time(); + $includeExpired = $req->query('all') === '1'; + return Response::json([ + 'now' => $now, + 'suppressions' => $this->suppressions->listAll($this->site(), $now, $includeExpired), + ], 200); + } + + public function create(Request $req): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + // Same permission as zone editing: issuing a maintenance window is an operational act by + // somebody who is on site, not an administrative one. + if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403); + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + + $b = $req->json(); + $scopeType = (string)($b['scope_type'] ?? ''); + $scopeId = trim((string)($b['scope_id'] ?? '')); + $reason = trim((string)($b['reason'] ?? '')); + $minutes = (int)($b['minutes'] ?? 0); + $startsAt = isset($b['starts_at']) && is_numeric($b['starts_at']) ? (int)$b['starts_at'] : time(); + + if (!in_array($scopeType, ['device', 'camera', 'zone'], true)) return Response::json(['error' => 'invalid_scope'], 400); + if ($scopeId === '' || mb_strlen($scopeId) > 64) return Response::json(['error' => 'invalid_scope_id'], 400); + if ($reason === '' || mb_strlen($reason) > self::MAX_REASON) return Response::json(['error' => 'reason_required'], 400); + if ($minutes < 1 || $minutes * 60 > self::MAX_WINDOW_S) return Response::json(['error' => 'invalid_window'], 400); + + $now = time(); + if ($startsAt < $now - 3600 || $startsAt > $now + self::MAX_LEAD_S) { + return Response::json(['error' => 'invalid_start'], 400); + } + + // Resolve the site from the scoped object, and refuse anything outside the caller's own + // scope BEFORE writing — a suppression is a licence to stop alarming, and it must not be + // possible to issue one for another site by guessing a camera name. + $site = $this->resolveSite($scopeType, $scopeId); + if ($site === false) return Response::json(['error' => 'unknown_scope_id'], 400); + if (!SiteScope::allows($this->site(), $site)) return Response::json(['error' => 'not_found'], 404); + + $actor = (string)$this->sess->get('username'); + $id = $this->suppressions->create([ + 'scope_type' => $scopeType, 'scope_id' => $scopeId, 'site' => $site, + 'reason' => $reason, 'starts_at' => $startsAt, 'ends_at' => $startsAt + $minutes * 60, + 'created_by' => $actor, 'created_at' => $now, + ]); + $this->audit->append($actor, 'suppression_create', "suppression:$id", [], + ['scope' => "$scopeType:$scopeId", 'minutes' => $minutes, 'reason' => $reason]); + return Response::json(['suppression_id' => $id, 'ends_at' => $startsAt + $minutes * 60], 201); + } + + public function revoke(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403); + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + $id = (int)($params['id'] ?? 0); + $now = time(); + if (!$this->suppressions->revoke($id, (string)$this->sess->get('username'), $now, $this->site())) { + return Response::json(['error' => 'not_found'], 404); + } + $this->audit->append((string)$this->sess->get('username'), 'suppression_revoke', "suppression:$id", [], []); + return Response::json(['ok' => true], 200); + } + + /** @return string|null|false site, or false when the scope target does not exist. */ + private function resolveSite(string $scopeType, string $scopeId): string|null|false + { + if ($scopeType === 'device') { + if (!$this->devices->exists($scopeId)) return false; + return $this->devices->siteFor($scopeId); + } + if ($scopeType === 'camera') { + // Cameras have no registry (see schema.sql); their events are the ownership record. + if (!$this->events->cameraExists($scopeId)) return false; + return $this->events->siteForCamera($scopeId); + } + // A zone id is opaque here; scope is inherited from the caller so a scoped operator can + // only ever create one inside their own site, and a global operator may suppress any. + return $this->site(); + } +} diff --git a/server/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php index 83bb20e..80e5a74 100644 --- a/server/src/Dashboard/ZoneConfigController.php +++ b/server/src/Dashboard/ZoneConfigController.php @@ -147,6 +147,11 @@ final class ZoneConfigController return Response::json(['error' => 'invalid_zone'], 400); } $this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]); + // Bump the device's config version so the drift indicator becomes meaningful: it now + // reads "edited, not yet picked up" until the node's next sync echoes the new version + // back. Nothing ever incremented it before, so the indicator could never light up and an + // edit that failed to reach a device was indistinguishable from one that arrived. + $this->devices->bumpConfigVersion($deviceId); return Response::json(['zone_id' => $zoneId], 201); } @@ -165,6 +170,7 @@ final class ZoneConfigController } if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404); $this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []); + $this->devices->bumpConfigVersion((string)($existing['device_id'] ?? '')); return Response::json(['ok' => true], 200); } } diff --git a/server/src/Repository/ActionRepository.php b/server/src/Repository/ActionRepository.php index b0163a3..38eaf62 100644 --- a/server/src/Repository/ActionRepository.php +++ b/server/src/Repository/ActionRepository.php @@ -4,7 +4,7 @@ namespace App\Repository; interface ActionRepository { - /** {camera_id, site, note, taken_at, created_by} -> action_id */ + /** {camera_id, site, note, taken_at, created_by, event_id?} -> action_id */ public function insert(array $action): int; /** * Most recent actions, newest taken_at first, capped at $limit. diff --git a/server/src/Repository/AlertRepository.php b/server/src/Repository/AlertRepository.php new file mode 100644 index 0000000..b223eed --- /dev/null +++ b/server/src/Repository/AlertRepository.php @@ -0,0 +1,34 @@ +<?php +declare(strict_types=1); +namespace App\Repository; + +/** Notification channels and the delivery ledger. */ +interface AlertRepository +{ + /** @return array<int,array> enabled channels, ordered by step then id. */ + public function channels(?string $site): array; + + public function allChannels(?string $site): array; + + public function saveChannel(array $row): int; + + public function deleteChannel(int $id, ?string $site): bool; + + /** + * Claim (event, channel) for delivery. Returns false when it was already claimed — + * that is the duplicate-send guard, enforced by a UNIQUE key rather than by a read. + */ + public function claim(int $eventId, int $channelId, int $now): bool; + + public function markSent(int $eventId, int $channelId, int $now): void; + + public function markFailed(int $eventId, int $channelId, string $error, int $now): void; + + public function markSuppressed(int $eventId, int $channelId, int $now): void; + + /** Delivery rows for one event, for the incident record. */ + public function forEvent(int $eventId): array; + + /** Rows still worth retrying (status=failed, attempts under the cap). */ + public function retryable(int $maxAttempts, int $limit): array; +} diff --git a/server/src/Repository/DeviceMetricRepository.php b/server/src/Repository/DeviceMetricRepository.php new file mode 100644 index 0000000..1a386bb --- /dev/null +++ b/server/src/Repository/DeviceMetricRepository.php @@ -0,0 +1,15 @@ +<?php +declare(strict_types=1); +namespace App\Repository; + +/** Heartbeat telemetry as a time series, so slow degradation is visible. */ +interface DeviceMetricRepository +{ + public function append(string $deviceId, array $metrics, int $now): void; + + /** @return array<int,array> ascending by ts. */ + public function series(string $deviceId, int $sinceTs, int $limit): array; + + /** Retention: drop points older than $before. Returns rows removed. */ + public function prune(int $before): int; +} diff --git a/server/src/Repository/DeviceRepository.php b/server/src/Repository/DeviceRepository.php index 39bd372..642875c 100644 --- a/server/src/Repository/DeviceRepository.php +++ b/server/src/Repository/DeviceRepository.php @@ -13,6 +13,14 @@ interface DeviceRepository public function siteFor(string $deviceId): ?string; public function touchHeartbeat(string $deviceId, array $meta): void; public function configVersion(string $deviceId): int; + /** + * Increment config_version so the device's next sync sees a NEW version and applies what + * changed. Without this a zone or threshold edit sits in the database looking applied while + * the node keeps running the old value — the exact drift the dashboard's config-drift + * indicator exists to expose, except nothing would ever have caused it to light up. + * Returns the new version (or 0 when the device does not exist). + */ + public function bumpConfigVersion(string $deviceId): int; /** All devices with their health telemetry, for the dashboard's device-status panel. * Site-scoped: a non-null $site returns only that site's devices (a scoped operator); diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index d3f4478..5eb376d 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -57,8 +57,29 @@ interface EventRepository public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array; /** Full mapped event rows in the window, newest first, capped — the label-dataset export. */ public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array; + /** + * Candidate rows for the alert dispatcher: every event in the window, OLDEST first, with the + * fields a notification needs. Severity thresholds, escalation timing and suppression are + * decided in PHP rather than in this query — the window is minutes wide so the row count is + * small, and keeping the decision out of SQL is what makes it unit-testable without a + * database (App\Alert\AlertDispatcher). Oldest-first so the earliest unhandled emergency is + * notified first when a backlog has built up behind a failed cron run. + */ + public function forNotification(float $sinceOccurredAt, int $limit): array; + /** + * Per-site rollup for the head-office view: [{site, count, critical_high, unacked, cameras}]. + * Only meaningful for a GLOBAL operator (site_scope null) — a scoped one sees one site by + * definition, and the report hides the card for them rather than showing a table of one row. + */ + public function siteSummary(float $sinceOccurredAt, ?float $untilOccurredAt = null): array; /** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */ public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int; + /** + * Full rows for one camera in [from, to), oldest first — the incident package's surrounding + * timeline. Reconstructing an accident means seeing what the same camera reported in the + * minutes either side, not just the one row somebody clicked. + */ + public function windowForCamera(string $cameraId, float $from, float $to, ?string $site, int $limit): array; /** * True if this camera has ever reported an event — the closest thing to "does this camera * exist", playing the role DeviceRepository::exists() plays for devices. diff --git a/server/src/Repository/HandoverRepository.php b/server/src/Repository/HandoverRepository.php new file mode 100644 index 0000000..95a3b6a --- /dev/null +++ b/server/src/Repository/HandoverRepository.php @@ -0,0 +1,13 @@ +<?php +declare(strict_types=1); +namespace App\Repository; + +/** Shift handover records: who accepted responsibility for which backlog. */ +interface HandoverRepository +{ + public function recent(?string $site, int $limit): array; + + public function latest(?string $site): ?array; + + public function create(array $row): int; +} diff --git a/server/src/Repository/Pdo/PdoActionRepository.php b/server/src/Repository/Pdo/PdoActionRepository.php index 507e90f..ada03ba 100644 --- a/server/src/Repository/Pdo/PdoActionRepository.php +++ b/server/src/Repository/Pdo/PdoActionRepository.php @@ -12,12 +12,15 @@ final class PdoActionRepository implements ActionRepository public function insert(array $action): int { $st = $this->pdo->prepare( - 'INSERT INTO move_actions (camera_id, site, note, taken_at, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?)' + 'INSERT INTO move_actions (camera_id, site, event_id, note, taken_at, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)' ); $st->execute([ (string)$action['camera_id'], $action['site'] ?? null, // denormalized from the camera's events by ActionsController + // The incident that prompted this fix, when it was recorded from an event's detail + // page. Nullable because an action can also be entered standalone from the report. + isset($action['event_id']) ? (int)$action['event_id'] : null, (string)$action['note'], (int)$action['taken_at'], (string)$action['created_by'], @@ -28,7 +31,7 @@ final class PdoActionRepository implements ActionRepository public function recent(int $limit, ?string $site): array { - $sql = 'SELECT action_id, camera_id, site, note, taken_at, created_by FROM move_actions'; + $sql = 'SELECT action_id, camera_id, site, event_id, note, taken_at, created_by FROM move_actions'; $binds = []; if ($site !== null) { // Fail-closed like every other scoped list: an empty-string scope matches nothing, @@ -46,6 +49,7 @@ final class PdoActionRepository implements ActionRepository 'action_id' => (int)$r['action_id'], 'camera_id' => $r['camera_id'], 'site' => $r['site'], + 'event_id' => $r['event_id'] !== null ? (int)$r['event_id'] : null, 'note' => $r['note'], 'taken_at' => (int)$r['taken_at'], 'created_by' => $r['created_by'], diff --git a/server/src/Repository/Pdo/PdoAlertRepository.php b/server/src/Repository/Pdo/PdoAlertRepository.php new file mode 100644 index 0000000..5f58faa --- /dev/null +++ b/server/src/Repository/Pdo/PdoAlertRepository.php @@ -0,0 +1,184 @@ +<?php +declare(strict_types=1); +namespace App\Repository\Pdo; + +use App\Repository\AlertRepository; +use PDO; +use PDOException; + +/** + * Notification channels and the delivery ledger. + * + * The ledger is not bookkeeping — it is the duplicate-send guard. The dispatcher runs from cron + * on shared hosting, where an overrunning job and the next scheduled one routinely overlap, so + * "read whether we sent it, then send it" is a race that phones the same fall in five times. + * claim() instead INSERTs a (event_id, channel_id) row and lets the UNIQUE key decide: exactly + * one caller wins, and it is the one that then does the sending. + */ +final class PdoAlertRepository implements AlertRepository +{ + public function __construct(private PDO $pdo) {} + + public function channels(?string $site): array + { + // A NULL-site channel is a global one (the single-site default install); it applies + // everywhere. A scoped channel applies only to its own site. + $sql = 'SELECT * FROM move_alert_channels WHERE enabled = 1'; + $args = []; + if ($site !== null) { $sql .= ' AND (site IS NULL OR site = ?)'; $args[] = $site; } + $sql .= ' ORDER BY step ASC, channel_id ASC'; + $st = $this->pdo->prepare($sql); + $st->execute($args); + return array_map([self::class, 'mapChannel'], $st->fetchAll()); + } + + public function allChannels(?string $site): array + { + $sql = 'SELECT * FROM move_alert_channels'; + $args = []; + if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; } + $sql .= ' ORDER BY step ASC, channel_id ASC'; + $st = $this->pdo->prepare($sql); + $st->execute($args); + return array_map([self::class, 'mapChannel'], $st->fetchAll()); + } + + public function saveChannel(array $row): int + { + if (!empty($row['channel_id'])) { + $st = $this->pdo->prepare( + 'UPDATE move_alert_channels + SET site = ?, kind = ?, name = ?, config_json = ?, min_severity = ?, + enabled = ?, step = ?, escalate_after_s = ? + WHERE channel_id = ?' + ); + $st->execute([ + $row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'], + $row['min_severity'], (int)$row['enabled'], (int)$row['step'], + (int)$row['escalate_after_s'], (int)$row['channel_id'], + ]); + return (int)$row['channel_id']; + } + $st = $this->pdo->prepare( + 'INSERT INTO move_alert_channels (site, kind, name, config_json, min_severity, enabled, step, escalate_after_s, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $st->execute([ + $row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'], + $row['min_severity'], (int)$row['enabled'], (int)$row['step'], + (int)$row['escalate_after_s'], $row['created_at'], + ]); + return (int)$this->pdo->lastInsertId(); + } + + public function deleteChannel(int $id, ?string $site): bool + { + $sql = 'DELETE FROM move_alert_channels WHERE channel_id = ?'; + $args = [$id]; + if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; } + $st = $this->pdo->prepare($sql); + $st->execute($args); + return $st->rowCount() > 0; + } + + public function claim(int $eventId, int $channelId, int $now): bool + { + try { + $st = $this->pdo->prepare( + 'INSERT INTO move_alert_log (event_id, channel_id, status, attempts, queued_at) + VALUES (?, ?, ?, 0, ?)' + ); + $st->execute([$eventId, $channelId, 'pending', $now]); + return true; + } catch (PDOException $e) { + // Duplicate key = another dispatcher run already owns this delivery. That is the + // designed outcome, not an error: losing the race means someone else is sending. + if (self::isDuplicate($e)) return false; + throw $e; + } + } + + public function markSent(int $eventId, int $channelId, int $now): void + { + $st = $this->pdo->prepare( + 'UPDATE move_alert_log SET status = ?, sent_at = ?, attempts = attempts + 1, last_error = NULL + WHERE event_id = ? AND channel_id = ?' + ); + $st->execute(['sent', $now, $eventId, $channelId]); + } + + public function markFailed(int $eventId, int $channelId, string $error, int $now): void + { + $st = $this->pdo->prepare( + 'UPDATE move_alert_log SET status = ?, attempts = attempts + 1, last_error = ? + WHERE event_id = ? AND channel_id = ?' + ); + // Truncate: a curl error body can be long and the column is not the place to store it. + $st->execute(['failed', mb_substr($error, 0, 500), $eventId, $channelId]); + } + + public function markSuppressed(int $eventId, int $channelId, int $now): void + { + $st = $this->pdo->prepare( + 'UPDATE move_alert_log SET status = ?, sent_at = ? WHERE event_id = ? AND channel_id = ?' + ); + $st->execute(['suppressed', $now, $eventId, $channelId]); + } + + public function forEvent(int $eventId): array + { + $st = $this->pdo->prepare( + 'SELECT l.*, c.name AS channel_name, c.kind + FROM move_alert_log l + LEFT JOIN move_alert_channels c ON c.channel_id = l.channel_id + WHERE l.event_id = ? ORDER BY l.alert_id ASC' + ); + $st->execute([$eventId]); + return array_map(static fn (array $r): array => [ + 'channel_id' => (int)$r['channel_id'], + 'channel_name' => $r['channel_name'] !== null ? (string)$r['channel_name'] : null, + 'kind' => $r['kind'] !== null ? (string)$r['kind'] : null, + 'status' => (string)$r['status'], + 'attempts' => (int)$r['attempts'], + 'last_error' => $r['last_error'] !== null ? (string)$r['last_error'] : null, + 'queued_at' => (int)$r['queued_at'], + 'sent_at' => $r['sent_at'] !== null ? (int)$r['sent_at'] : null, + ], $st->fetchAll()); + } + + public function retryable(int $maxAttempts, int $limit): array + { + $st = $this->pdo->prepare( + 'SELECT event_id, channel_id, attempts FROM move_alert_log + WHERE status = ? AND attempts < ? ORDER BY queued_at ASC LIMIT ' . max(1, min(500, $limit)) + ); + $st->execute(['failed', $maxAttempts]); + return array_map(static fn (array $r): array => [ + 'event_id' => (int)$r['event_id'], + 'channel_id' => (int)$r['channel_id'], + 'attempts' => (int)$r['attempts'], + ], $st->fetchAll()); + } + + private static function isDuplicate(PDOException $e): bool + { + // MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class + // so this holds for both drivers the project runs (MySQL in production, sqlite in tests). + return ($e->getCode() === '23000') || str_contains($e->getMessage(), 'UNIQUE constraint failed'); + } + + private static function mapChannel(array $r): array + { + return [ + 'channel_id' => (int)$r['channel_id'], + 'site' => $r['site'] !== null ? (string)$r['site'] : null, + 'kind' => (string)$r['kind'], + 'name' => (string)$r['name'], + 'config_json' => (string)$r['config_json'], + 'min_severity' => (string)$r['min_severity'], + 'enabled' => (int)$r['enabled'], + 'step' => (int)$r['step'], + 'escalate_after_s' => (int)$r['escalate_after_s'], + ]; + } +} diff --git a/server/src/Repository/Pdo/PdoAuditRepository.php b/server/src/Repository/Pdo/PdoAuditRepository.php index a2d6e95..84045a7 100644 --- a/server/src/Repository/Pdo/PdoAuditRepository.php +++ b/server/src/Repository/Pdo/PdoAuditRepository.php @@ -5,23 +5,79 @@ namespace App\Repository\Pdo; use App\Repository\AuditRepository; use PDO; +/** + * Tamper-EVIDENT audit log. + * + * Shared hosting cannot give the app an append-only table: the same DB user that inserts rows + * can also UPDATE and DELETE them, and there is no way to drop that privilege from inside the + * application. So the goal is not to make tampering impossible — it is to make it impossible to + * do QUIETLY. Each row carries the previous row's hash, and its own hash covers its contents + * plus that link. Change one row, or remove one, and every hash after it stops matching; the + * verifier (tools/verify_audit_chain.php) prints exactly where the chain forks. + * + * `id` is deliberately NOT part of the hash. Excluding it lets the whole row be written in a + * single INSERT (the hash is computed before the id exists), which keeps the append atomic; + * deletion is still detected, because the row after the hole carries a prev_hash that matches + * nothing that remains. + */ final class PdoAuditRepository implements AuditRepository { public function __construct(private PDO $pdo) {} public function append(string $actor, string $action, string $target, array $before, array $after): void { - $st = $this->pdo->prepare( - 'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts) - VALUES (?, ?, ?, ?, ?, ?)' - ); - $st->execute([ - $actor, - $action, - $target, - json_encode($before, JSON_THROW_ON_ERROR), - json_encode($after, JSON_THROW_ON_ERROR), - time(), - ]); + $beforeJson = json_encode($before, JSON_THROW_ON_ERROR); + $afterJson = json_encode($after, JSON_THROW_ON_ERROR); + $ts = time(); + + // The read of the tail and the write of the new row must not interleave with another + // append, or two rows would claim the same predecessor and the chain would fork. When + // this repository owns the transaction it also takes a row lock (MySQL); sqlite's + // single-writer model makes the lock unnecessary and it has no FOR UPDATE. + $ownTransaction = !$this->pdo->inTransaction(); + if ($ownTransaction) $this->pdo->beginTransaction(); + try { + $prev = $this->tailHash(); + $entry = self::hash($actor, $action, $target, $beforeJson, $afterJson, $ts, $prev); + $st = $this->pdo->prepare( + 'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts, prev_hash, entry_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + $st->execute([$actor, $action, $target, $beforeJson, $afterJson, $ts, $prev, $entry]); + if ($ownTransaction) $this->pdo->commit(); + } catch (\Throwable $e) { + if ($ownTransaction && $this->pdo->inTransaction()) $this->pdo->rollBack(); + throw $e; + } + } + + private function tailHash(): ?string + { + $sql = 'SELECT entry_hash FROM move_audit_log ORDER BY id DESC LIMIT 1'; + if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') { + $sql .= ' FOR UPDATE'; + } + $row = $this->pdo->query($sql)->fetch(); + if ($row === false) return null; // first entry ever: chain genesis + $hash = $row['entry_hash'] ?? null; + // NULL here means the tail predates the chain (rows written before the migration). + // Linking to NULL restarts the chain from this row, and the verifier reports the + // pre-chain rows as an unverifiable prefix rather than as tampering. + return is_string($hash) && $hash !== '' ? $hash : null; + } + + /** + * The hashed record. Field separator is a unit separator (0x1F), not a printable character: + * with a plain '|' an actor literally named "a|b" could be crafted to collide with a + * different (actor, action) split, and forging an audit entry is exactly the attack this + * chain exists to catch. + */ + public static function hash( + string $actor, string $action, string $target, + string $beforeJson, string $afterJson, int $ts, ?string $prev + ): string { + return hash('sha256', implode("\x1f", [ + $actor, $action, $target, $beforeJson, $afterJson, (string)$ts, $prev ?? '', + ])); } } diff --git a/server/src/Repository/Pdo/PdoDeviceMetricRepository.php b/server/src/Repository/Pdo/PdoDeviceMetricRepository.php new file mode 100644 index 0000000..56c5f59 --- /dev/null +++ b/server/src/Repository/Pdo/PdoDeviceMetricRepository.php @@ -0,0 +1,65 @@ +<?php +declare(strict_types=1); +namespace App\Repository\Pdo; + +use App\Repository\DeviceMetricRepository; +use PDO; + +/** + * Heartbeat telemetry as a time series. + * + * move_devices keeps only the newest value, which cannot answer "is this node getting worse?". + * At a 30s heartbeat one device writes ~2,880 rows/day, so prune() is not optional housekeeping + * — on a shared-hosting DB quota it is what keeps this table from becoming the reason ingest + * itself starts failing. + */ +final class PdoDeviceMetricRepository implements DeviceMetricRepository +{ + public function __construct(private PDO $pdo) {} + + public function append(string $deviceId, array $metrics, int $now): void + { + $st = $this->pdo->prepare( + 'INSERT INTO move_device_metrics (device_id, ts, fps, temp, throttle, queue_pending, + cameras_total, cameras_erroring, errors) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + // A missing metric stays NULL. Never coerce an absent reading to 0: a device that cannot + // read its temperature would then look like a device sitting at 0°C. + $num = static fn (string $k) => isset($metrics[$k]) && is_numeric($metrics[$k]) ? $metrics[$k] + 0 : null; + $st->execute([ + $deviceId, $now, + $num('fps'), $num('temp'), + isset($metrics['throttle']) ? (int)(bool)$metrics['throttle'] : null, + $num('queue_pending'), $num('cameras_total'), $num('cameras_erroring'), $num('errors'), + ]); + } + + public function series(string $deviceId, int $sinceTs, int $limit): array + { + $st = $this->pdo->prepare( + 'SELECT ts, fps, temp, throttle, queue_pending, cameras_total, cameras_erroring, errors + FROM move_device_metrics + WHERE device_id = ? AND ts >= ? + ORDER BY ts ASC LIMIT ' . max(1, min(5000, $limit)) + ); + $st->execute([$deviceId, $sinceTs]); + return array_map(static fn (array $r): array => [ + 'ts' => (int)$r['ts'], + 'fps' => $r['fps'] !== null ? (float)$r['fps'] : null, + 'temp' => $r['temp'] !== null ? (float)$r['temp'] : null, + 'throttle' => $r['throttle'] !== null ? (bool)$r['throttle'] : null, + 'queue_pending' => $r['queue_pending'] !== null ? (int)$r['queue_pending'] : null, + 'cameras_total' => $r['cameras_total'] !== null ? (int)$r['cameras_total'] : null, + 'cameras_erroring' => $r['cameras_erroring'] !== null ? (int)$r['cameras_erroring'] : null, + 'errors' => $r['errors'] !== null ? (int)$r['errors'] : null, + ], $st->fetchAll()); + } + + public function prune(int $before): int + { + $st = $this->pdo->prepare('DELETE FROM move_device_metrics WHERE ts < ?'); + $st->execute([$before]); + return $st->rowCount(); + } +} diff --git a/server/src/Repository/Pdo/PdoDeviceRepository.php b/server/src/Repository/Pdo/PdoDeviceRepository.php index fca4fc6..e93cf25 100644 --- a/server/src/Repository/Pdo/PdoDeviceRepository.php +++ b/server/src/Repository/Pdo/PdoDeviceRepository.php @@ -67,6 +67,18 @@ final class PdoDeviceRepository implements DeviceRepository return $version === false ? 1 : (int)$version; } + public function bumpConfigVersion(string $deviceId): int + { + // Increment in SQL, not read-modify-write: two admins saving different zones at the same + // moment would otherwise both write version+1 and one edit would never reach the device. + $st = $this->pdo->prepare( + 'UPDATE move_devices SET config_version = config_version + 1 WHERE device_id = ?' + ); + $st->execute([$deviceId]); + if ($st->rowCount() === 0) return 0; + return $this->configVersion($deviceId); + } + public function listStatus(?string $site): array { // Site-scope access control, same rule as the event list: a non-null scope filters to diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index 72d2ea9..c00d96b 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -321,6 +321,68 @@ final class PdoEventRepository implements EventRepository return array_map(self::mapRow(...), $st->fetchAll()); } + public function forNotification(float $sinceOccurredAt, int $limit): array + { + // No site filter: the dispatcher is a server-side job, not a logged-in operator, and it + // must be able to notify every site the installation serves. Channel rows carry their + // own site, which is where the scoping actually belongs. + $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events + WHERE occurred_at >= ? ORDER BY occurred_at ASC, event_id ASC LIMIT ?'; + $st = $this->pdo->prepare($sql); + $st->bindValue(1, sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR); + $st->bindValue(2, $limit, PDO::PARAM_INT); + $st->execute(); + return array_map(self::mapRow(...), $st->fetchAll()); + } + + public function siteSummary(float $sinceOccurredAt, ?float $untilOccurredAt = null): array + { + // COUNT(DISTINCT camera_id) alongside the totals: "three sites, one of which is watching + // with a single camera" is a different picture from "three comparable sites", and the + // head-office view is exactly where that distinction gets lost. + $sql = "SELECT site, + COUNT(*) AS count, + SUM(CASE WHEN severity IN ('critical','high') THEN 1 ELSE 0 END) AS critical_high, + SUM(CASE WHEN acknowledged_by IS NULL THEN 1 ELSE 0 END) AS unacked, + COUNT(DISTINCT camera_id) AS cameras + FROM move_events + WHERE occurred_at >= ? AND event_type <> 'ergo_report'"; + $binds = [sprintf('%.6f', $sinceOccurredAt)]; + if ($untilOccurredAt !== null) { + $sql .= ' AND occurred_at < ?'; + $binds[] = sprintf('%.6f', $untilOccurredAt); + } + $sql .= ' GROUP BY site ORDER BY critical_high DESC, count DESC'; + $st = $this->pdo->prepare($sql); + $st->execute($binds); + return array_map(static fn (array $r): array => [ + 'site' => $r['site'] !== null ? (string)$r['site'] : null, + 'count' => (int)$r['count'], + 'critical_high' => (int)$r['critical_high'], + 'unacked' => (int)$r['unacked'], + 'cameras' => (int)$r['cameras'], + ], $st->fetchAll()); + } + + public function windowForCamera(string $cameraId, float $from, float $to, ?string $site, int $limit): array + { + $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events + WHERE camera_id = ? AND occurred_at >= ? AND occurred_at < ?'; + $binds = [[$cameraId, PDO::PARAM_STR], [sprintf('%.6f', $from), PDO::PARAM_STR], + [sprintf('%.6f', $to), PDO::PARAM_STR]]; + if ($site !== null) { + $sql .= ' AND site = ?'; + $binds[] = [$site, PDO::PARAM_STR]; + } + $sql .= ' ORDER BY occurred_at ASC, event_id ASC LIMIT ?'; + $binds[] = [$limit, PDO::PARAM_INT]; + $st = $this->pdo->prepare($sql); + $pos = 1; + foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type); + $st->execute(); + return array_map(self::mapRow(...), $st->fetchAll()); + } + public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int { $sql = "SELECT COUNT(*) FROM move_events diff --git a/server/src/Repository/Pdo/PdoHandoverRepository.php b/server/src/Repository/Pdo/PdoHandoverRepository.php new file mode 100644 index 0000000..28ef32e --- /dev/null +++ b/server/src/Repository/Pdo/PdoHandoverRepository.php @@ -0,0 +1,66 @@ +<?php +declare(strict_types=1); +namespace App\Repository\Pdo; + +use App\Repository\HandoverRepository; +use PDO; + +/** + * Shift handover records. + * + * unacked_count/severe_count are stored as a SNAPSHOT taken at handover time rather than + * recomputed on read. They are the number the incoming operator actually accepted, and if a + * later query disagrees with it that disagreement is itself the finding — recomputing would + * silently erase it. + */ +final class PdoHandoverRepository implements HandoverRepository +{ + public function __construct(private PDO $pdo) {} + + public function recent(?string $site, int $limit): array + { + $sql = 'SELECT * FROM move_shift_handovers'; + $args = []; + if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; } + $sql .= ' ORDER BY created_at DESC, handover_id DESC LIMIT ' . max(1, min(100, $limit)); + $st = $this->pdo->prepare($sql); + $st->execute($args); + return array_map([self::class, 'mapRow'], $st->fetchAll()); + } + + public function latest(?string $site): ?array + { + return $this->recent($site, 1)[0] ?? null; + } + + public function create(array $row): int + { + $st = $this->pdo->prepare( + 'INSERT INTO move_shift_handovers (site, from_user, to_user, period_start, period_end, + unacked_count, severe_count, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $st->execute([ + $row['site'] ?? null, $row['from_user'] ?? null, $row['to_user'], + $row['period_start'], $row['period_end'], $row['unacked_count'], $row['severe_count'], + $row['note'] ?? null, $row['created_at'], + ]); + return (int)$this->pdo->lastInsertId(); + } + + private static function mapRow(array $r): array + { + return [ + 'handover_id' => (int)$r['handover_id'], + 'site' => $r['site'] !== null ? (string)$r['site'] : null, + 'from_user' => $r['from_user'] !== null ? (string)$r['from_user'] : null, + 'to_user' => (string)$r['to_user'], + 'period_start' => (int)$r['period_start'], + 'period_end' => (int)$r['period_end'], + 'unacked_count' => (int)$r['unacked_count'], + 'severe_count' => (int)$r['severe_count'], + 'note' => $r['note'] !== null ? (string)$r['note'] : null, + 'created_at' => (int)$r['created_at'], + ]; + } +} diff --git a/server/src/Repository/Pdo/PdoRiskControlRepository.php b/server/src/Repository/Pdo/PdoRiskControlRepository.php new file mode 100644 index 0000000..586f3d5 --- /dev/null +++ b/server/src/Repository/Pdo/PdoRiskControlRepository.php @@ -0,0 +1,92 @@ +<?php +declare(strict_types=1); +namespace App\Repository\Pdo; + +use App\Repository\RiskControlRepository; +use PDO; + +/** + * 위험성평가 매핑: one row per (site, detection type). + * + * The UNIQUE key is (site, event_type) and MySQL treats NULL sites as distinct, so a plain + * "INSERT ... ON DUPLICATE KEY UPDATE" would append a second global row instead of updating the + * first. upsert() therefore looks the row up explicitly, with an IS NULL predicate for the + * global case. + */ +final class PdoRiskControlRepository implements RiskControlRepository +{ + public function __construct(private PDO $pdo) {} + + public function listAll(?string $site): array + { + $sql = 'SELECT * FROM move_risk_controls'; + $args = []; + if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; } + // Highest risk score first: the table is read to decide what to fix next. + $sql .= ' ORDER BY (severity_rating * likelihood_rating) DESC, event_type ASC'; + $st = $this->pdo->prepare($sql); + $st->execute($args); + return array_map([self::class, 'mapRow'], $st->fetchAll()); + } + + public function upsert(array $row): void + { + $site = $row['site'] ?? null; + $sql = 'SELECT control_id FROM move_risk_controls WHERE event_type = ? AND site ' + . ($site === null ? 'IS NULL' : '= ?'); + $st = $this->pdo->prepare($sql); + $st->execute($site === null ? [$row['event_type']] : [$row['event_type'], $site]); + $id = $st->fetchColumn(); + + if ($id !== false) { + $up = $this->pdo->prepare( + 'UPDATE move_risk_controls + SET hazard = ?, control = ?, severity_rating = ?, likelihood_rating = ?, + reviewed_by = ?, reviewed_at = ?, updated_at = ? + WHERE control_id = ?' + ); + $up->execute([ + $row['hazard'], $row['control'], (int)$row['severity_rating'], (int)$row['likelihood_rating'], + $row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'], (int)$id, + ]); + return; + } + $ins = $this->pdo->prepare( + 'INSERT INTO move_risk_controls (site, event_type, hazard, control, severity_rating, + likelihood_rating, reviewed_by, reviewed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $ins->execute([ + $site, $row['event_type'], $row['hazard'], $row['control'], + (int)$row['severity_rating'], (int)$row['likelihood_rating'], + $row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'], + ]); + } + + public function delete(int $id, ?string $site): bool + { + $sql = 'DELETE FROM move_risk_controls WHERE control_id = ?'; + $args = [$id]; + if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; } + $st = $this->pdo->prepare($sql); + $st->execute($args); + return $st->rowCount() > 0; + } + + private static function mapRow(array $r): array + { + return [ + 'control_id' => (int)$r['control_id'], + 'site' => $r['site'] !== null ? (string)$r['site'] : null, + 'event_type' => (string)$r['event_type'], + 'hazard' => (string)$r['hazard'], + 'control' => (string)$r['control'], + 'severity_rating' => (int)$r['severity_rating'], + 'likelihood_rating' => (int)$r['likelihood_rating'], + 'risk_score' => (int)$r['severity_rating'] * (int)$r['likelihood_rating'], + 'reviewed_by' => $r['reviewed_by'] !== null ? (string)$r['reviewed_by'] : null, + 'reviewed_at' => $r['reviewed_at'] !== null ? (int)$r['reviewed_at'] : null, + 'updated_at' => (int)$r['updated_at'], + ]; + } +} diff --git a/server/src/Repository/Pdo/PdoSuppressionRepository.php b/server/src/Repository/Pdo/PdoSuppressionRepository.php new file mode 100644 index 0000000..75b2535 --- /dev/null +++ b/server/src/Repository/Pdo/PdoSuppressionRepository.php @@ -0,0 +1,109 @@ +<?php +declare(strict_types=1); +namespace App\Repository\Pdo; + +use App\Repository\SuppressionRepository; +use PDO; + +/** + * Planned-work alarm suppression. + * + * A suppression never stops detection or ingest — the events still land, still show in the + * console and still count in the report. It withholds NOTIFICATION only. That distinction is + * the whole design: an investigation after an accident must be able to see what the cameras saw + * during the maintenance window, and a "quiet mode" that dropped events would destroy exactly + * the evidence someone would later be prosecuted over. + * + * Every row has a hard expiry. There is no open-ended suppression, because the failure mode of + * this feature is somebody silencing a zone "for now" and nobody ever turning it back on. + */ +final class PdoSuppressionRepository implements SuppressionRepository +{ + public function __construct(private PDO $pdo) {} + + /** @param array<int,array{0:string,1:string}> $scopes [[type, id], ...] */ + public function activeFor(array $scopes, int $now): array + { + if ($scopes === []) return []; + $where = []; + $args = []; + foreach ($scopes as [$type, $id]) { + $where[] = '(scope_type = ? AND scope_id = ?)'; + $args[] = $type; + $args[] = (string)$id; + } + $sql = 'SELECT * FROM move_suppressions + WHERE revoked_at IS NULL AND starts_at <= ? AND ends_at > ? + AND (' . implode(' OR ', $where) . ')'; + $st = $this->pdo->prepare($sql); + $st->execute(array_merge([$now, $now], $args)); + return array_map([self::class, 'mapRow'], $st->fetchAll()); + } + + public function listAll(?string $site, int $now, bool $includeExpired = false): array + { + $sql = 'SELECT * FROM move_suppressions WHERE 1=1'; + $args = []; + if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; } + if (!$includeExpired) { + $sql .= ' AND revoked_at IS NULL AND ends_at > ?'; + $args[] = $now; + } + $sql .= ' ORDER BY created_at DESC LIMIT 200'; + $st = $this->pdo->prepare($sql); + $st->execute($args); + return array_map([self::class, 'mapRow'], $st->fetchAll()); + } + + public function create(array $row): int + { + $st = $this->pdo->prepare( + 'INSERT INTO move_suppressions (scope_type, scope_id, site, reason, starts_at, ends_at, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + $st->execute([ + $row['scope_type'], $row['scope_id'], $row['site'] ?? null, $row['reason'], + $row['starts_at'], $row['ends_at'], $row['created_by'], $row['created_at'], + ]); + return (int)$this->pdo->lastInsertId(); + } + + public function revoke(int $id, string $user, int $now, ?string $site): bool + { + // The site predicate is part of the UPDATE, not a prior read: a scoped operator must not + // be able to lift another site's suppression by guessing an id, and a check-then-write + // would leave a window between the two. + $sql = 'UPDATE move_suppressions SET revoked_by = ?, revoked_at = ? + WHERE suppression_id = ? AND revoked_at IS NULL'; + $args = [$user, $now, $id]; + if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; } + $st = $this->pdo->prepare($sql); + $st->execute($args); + return $st->rowCount() > 0; + } + + public function find(int $id): ?array + { + $st = $this->pdo->prepare('SELECT * FROM move_suppressions WHERE suppression_id = ?'); + $st->execute([$id]); + $r = $st->fetch(); + return $r === false ? null : self::mapRow($r); + } + + private static function mapRow(array $r): array + { + return [ + 'suppression_id' => (int)$r['suppression_id'], + 'scope_type' => (string)$r['scope_type'], + 'scope_id' => (string)$r['scope_id'], + 'site' => $r['site'] !== null ? (string)$r['site'] : null, + 'reason' => (string)$r['reason'], + 'starts_at' => (int)$r['starts_at'], + 'ends_at' => (int)$r['ends_at'], + 'created_by' => (string)$r['created_by'], + 'created_at' => (int)$r['created_at'], + 'revoked_by' => $r['revoked_by'] !== null ? (string)$r['revoked_by'] : null, + 'revoked_at' => $r['revoked_at'] !== null ? (int)$r['revoked_at'] : null, + ]; + } +} diff --git a/server/src/Repository/Pdo/PdoThresholdRepository.php b/server/src/Repository/Pdo/PdoThresholdRepository.php index 779faba..9012a02 100644 --- a/server/src/Repository/Pdo/PdoThresholdRepository.php +++ b/server/src/Repository/Pdo/PdoThresholdRepository.php @@ -5,13 +5,31 @@ namespace App\Repository\Pdo; use App\Repository\ThresholdRepository; use PDO; +/** + * Per-device (optionally per-camera) tunables, served to the edge by GET /api/config. + * + * These rows used to be editable only by hand in phpMyAdmin — which meant that after delivery + * the customer had no way to retune a camera at all, even though the console's false-positive + * panel exists precisely to tell them which camera needs it. The write methods below back the + * admin editor; every write records who made it, because a threshold change is a change to what + * the system will and will not detect, and an incident investigation has to be able to ask when + * it happened. + * + * Site scope is resolved through move_devices: a scoped operator must never see, let alone + * retune, another site's device. + */ final class PdoThresholdRepository implements ThresholdRepository { public function __construct(private PDO $pdo) {} public function forDevice(string $deviceId): array { - $st = $this->pdo->prepare('SELECT `key`, `value` FROM move_thresholds WHERE device_id = ?'); + // Device-wide rows only (camera_id IS NULL). Per-camera overrides are addressed by the + // edge through its own camera id and are not flattened into this map, which the daemon + // applies globally — merging them here would silently apply one camera's tuning to all. + $st = $this->pdo->prepare( + 'SELECT `key`, `value` FROM move_thresholds WHERE device_id = ? AND camera_id IS NULL' + ); $st->execute([$deviceId]); $out = []; @@ -20,4 +38,92 @@ final class PdoThresholdRepository implements ThresholdRepository } return $out; } + + public function listAll(?string $site): array + { + $sql = 'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at, + d.name AS device_name, d.site + FROM move_thresholds t + LEFT JOIN move_devices d ON d.device_id = t.device_id'; + $args = []; + if ($site !== null) { $sql .= ' WHERE d.site = ?'; $args[] = $site; } + $sql .= ' ORDER BY t.device_id, t.camera_id, t.`key`'; + $st = $this->pdo->prepare($sql); + $st->execute($args); + return array_map(static fn (array $r): array => [ + 'id' => (int)$r['id'], + 'device_id' => (string)$r['device_id'], + 'device_name' => $r['device_name'] !== null ? (string)$r['device_name'] : null, + 'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null, + 'key' => (string)$r['key'], + 'value' => (string)$r['value'], + 'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null, + 'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null, + ], $st->fetchAll()); + } + + public function set(string $deviceId, ?string $cameraId, string $key, string $value, string $actor, int $now): int + { + // The unique key is (device_id, camera_id, zone_id, key), and in MySQL a UNIQUE index + // treats NULLs as distinct — so "ON DUPLICATE KEY" would NOT collide for the common + // camera_id IS NULL row and every save would append a duplicate. Look it up explicitly. + $existing = $this->findScoped($deviceId, $cameraId, $key); + if ($existing !== null) { + $st = $this->pdo->prepare( + 'UPDATE move_thresholds SET `value` = ?, updated_by = ?, updated_at = ? WHERE id = ?' + ); + $st->execute([$value, $actor, $now, $existing]); + return $existing; + } + $st = $this->pdo->prepare( + 'INSERT INTO move_thresholds (device_id, camera_id, zone_id, `key`, `value`, updated_by, updated_at) + VALUES (?, ?, NULL, ?, ?, ?, ?)' + ); + $st->execute([$deviceId, $cameraId, $key, $value, $actor, $now]); + return (int)$this->pdo->lastInsertId(); + } + + private function findScoped(string $deviceId, ?string $cameraId, string $key): ?int + { + $sql = 'SELECT id FROM move_thresholds WHERE device_id = ? AND `key` = ? AND camera_id ' + . ($cameraId === null ? 'IS NULL' : '= ?'); + $args = $cameraId === null ? [$deviceId, $key] : [$deviceId, $key, $cameraId]; + $st = $this->pdo->prepare($sql); + $st->execute($args); + $id = $st->fetchColumn(); + return $id === false ? null : (int)$id; + } + + public function remove(int $id, ?string $site): bool + { + $row = $this->find($id); + if ($row === null) return false; + if ($site !== null && ($row['site'] ?? null) !== $site) return false; + $st = $this->pdo->prepare('DELETE FROM move_thresholds WHERE id = ?'); + $st->execute([$id]); + return $st->rowCount() > 0; + } + + public function find(int $id): ?array + { + $st = $this->pdo->prepare( + 'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at, d.site + FROM move_thresholds t + LEFT JOIN move_devices d ON d.device_id = t.device_id + WHERE t.id = ?' + ); + $st->execute([$id]); + $r = $st->fetch(); + if ($r === false) return null; + return [ + 'id' => (int)$r['id'], + 'device_id' => (string)$r['device_id'], + 'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null, + 'key' => (string)$r['key'], + 'value' => (string)$r['value'], + 'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null, + 'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null, + 'site' => $r['site'] !== null ? (string)$r['site'] : null, + ]; + } } diff --git a/server/src/Repository/RiskControlRepository.php b/server/src/Repository/RiskControlRepository.php new file mode 100644 index 0000000..436387c --- /dev/null +++ b/server/src/Repository/RiskControlRepository.php @@ -0,0 +1,13 @@ +<?php +declare(strict_types=1); +namespace App\Repository; + +/** 위험성평가: one row per (site, detection type) — the hazard it stands for and its control. */ +interface RiskControlRepository +{ + public function listAll(?string $site): array; + + public function upsert(array $row): void; + + public function delete(int $id, ?string $site): bool; +} diff --git a/server/src/Repository/SuppressionRepository.php b/server/src/Repository/SuppressionRepository.php new file mode 100644 index 0000000..9a24cfa --- /dev/null +++ b/server/src/Repository/SuppressionRepository.php @@ -0,0 +1,23 @@ +<?php +declare(strict_types=1); +namespace App\Repository; + +/** + * Planned-work alarm suppression. Detection and ingest are untouched — only notification is + * withheld — so an investigation after the fact still sees everything that happened. + */ +interface SuppressionRepository +{ + /** Active (not expired, not revoked) suppressions covering any of the given scopes. */ + public function activeFor(array $scopes, int $now): array; + + /** @return array<int,array> newest first; $site null = all sites (global operator). */ + public function listAll(?string $site, int $now, bool $includeExpired = false): array; + + public function create(array $row): int; + + /** @return bool false when the id does not exist or is already revoked. */ + public function revoke(int $id, string $user, int $now, ?string $site): bool; + + public function find(int $id): ?array; +} diff --git a/server/src/Repository/ThresholdRepository.php b/server/src/Repository/ThresholdRepository.php index 9acb517..3894616 100644 --- a/server/src/Repository/ThresholdRepository.php +++ b/server/src/Repository/ThresholdRepository.php @@ -4,5 +4,17 @@ namespace App\Repository; interface ThresholdRepository { + /** key => value, flattened for GET /api/config. Device-scoped rows only. */ public function forDevice(string $deviceId): array; + + /** Full rows (id, scope, key, value, updated_by/at) for the admin editor. */ + public function listAll(?string $site): array; + + /** Insert or update one tunable. Returns the row id. */ + public function set(string $deviceId, ?string $cameraId, string $key, string $value, string $actor, int $now): int; + + /** @return bool false when the id does not exist (or is out of the caller's site scope). */ + public function remove(int $id, ?string $site): bool; + + public function find(int $id): ?array; } diff --git a/server/src/View/admin.php b/server/src/View/admin.php new file mode 100644 index 0000000..6fb9a79 --- /dev/null +++ b/server/src/View/admin.php @@ -0,0 +1,110 @@ +<?php +require __DIR__ . '/layout.php'; +render_header('시스템 설정'); +?> +<div class="admin-page"> + <header class="page-head"> + <h1>시스템 설정</h1> + <p class="page-sub">감지 임계값과 통보 경로. 두 가지 모두 이 화면이 생기기 전에는 DB를 직접 고치는 것 외에 방법이 없었습니다. 모든 변경은 감사 기록에 남습니다.</p> + <p class="page-sub"><a class="setup-link" href="<?= view_url('/setup') ?>">설치 점검 — 지금 구성이 실제로 동작하는지 확인 →</a></p> + </header> + + <section class="admin-card"> + <div class="admin-card-head"> + <h2>통보 경로</h2> + <button type="button" id="channel-new" class="btn-primary">채널 추가</button> + </div> + <!-- 통보가 없으면 이 제품은 "사고를 기록하는 시스템"이지 "사고에 대응하는 시스템"이 + 아니다. 그래서 이 카드가 임계값보다 위에 있다. --> + <p class="admin-note"> + <strong>현장 경광등</strong>을 먼저 설정하세요 — 엣지 노드가 직접 울리므로 인터넷이 끊겨도 동작하는 유일한 경로입니다. + 메일·웹훅은 그 위에 얹는 보조 수단이며, 서버의 통보 디스패처(<code>tools/dispatch_alerts.php</code>)가 크론으로 실행 중이어야 합니다. + </p> + <div id="channel-list" class="channel-list">불러오는 중…</div> + <form id="channel-form" class="channel-form" hidden> + <input type="hidden" id="ch-id"> + <div class="form-grid"> + <label class="field"><span class="field-label">종류</span> + <select id="ch-kind"> + <option value="local">현장 경광등 · 부저 (엣지 GPIO)</option> + <option value="email">메일 (사내 SMTP)</option> + <option value="webhook">웹훅 (그룹웨어 · 문자 게이트웨이)</option> + </select> + </label> + <label class="field"><span class="field-label">이름</span> + <input type="text" id="ch-name" maxlength="60" placeholder="예: 1공장 경광등"> + </label> + <label class="field"><span class="field-label">최소 심각도</span> + <select id="ch-severity"> + <option value="critical">위급만</option> + <option value="high" selected>높음 이상</option> + <option value="warning">경고 이상</option> + <option value="info">전부</option> + </select> + </label> + <label class="field"><span class="field-label">단계 <span class="field-hint">0=즉시</span></span> + <input type="number" id="ch-step" min="0" max="5" value="0"> + </label> + <label class="field" id="ch-after-wrap" hidden><span class="field-label">에스컬레이션 지연 <span class="field-hint">초</span></span> + <input type="number" id="ch-after" min="30" max="86400" value="300"> + </label> + </div> + + <div class="form-grid kind-local"> + <label class="field"><span class="field-label">GPIO 핀 <span class="field-hint">BCM</span></span> + <input type="number" id="ch-pin" min="0" max="40" value="0"> + </label> + <label class="field"><span class="field-label">동작</span> + <select id="ch-mode"><option value="latch">유지 (hold 후 자동 해제)</option><option value="pulse">짧게 울림</option></select> + </label> + <label class="field"><span class="field-label">유지 시간 <span class="field-hint">초</span></span> + <input type="number" id="ch-hold" min="1" max="3600" value="30"> + </label> + <label class="filter-check"><input type="checkbox" id="ch-active-high"> HIGH에서 동작 <span class="field-hint">릴레이 모듈은 보통 끄세요</span></label> + </div> + + <div class="form-grid kind-email" hidden> + <label class="field"><span class="field-label">수신자 <span class="field-hint">쉼표로 구분</span></span> + <input type="text" id="ch-to" placeholder="safety@company.co.kr, lead@company.co.kr"> + </label> + <label class="field"><span class="field-label">발신자 <span class="field-hint">선택</span></span> + <input type="text" id="ch-from" placeholder="cctv@company.co.kr"> + </label> + </div> + + <div class="form-grid kind-webhook" hidden> + <label class="field"><span class="field-label">엔드포인트 URL</span> + <input type="text" id="ch-url" placeholder="https://groupware.internal/api/alert"> + </label> + <label class="field"><span class="field-label">공유 토큰 <span class="field-hint">선택 · 저장 후 표시되지 않음</span></span> + <input type="text" id="ch-token" autocomplete="off"> + </label> + </div> + + <label class="filter-check"><input type="checkbox" id="ch-enabled" checked> 사용</label> + <div class="step-actions"> + <button type="submit" class="btn-primary">저장</button> + <button type="button" id="ch-cancel">취소</button> + </div> + <p id="ch-status" role="status" class="admin-status"></p> + </form> + </section> + + <section class="admin-card"> + <div class="admin-card-head"> + <h2>감지 임계값</h2> + <label class="field admin-device-pick"><span class="field-label">장치</span> + <select id="thr-device"><option value="">불러오는 중…</option></select> + </label> + </div> + <p class="admin-note"> + 값을 바꾸면 해당 장치의 설정 버전이 올라가고, 다음 동기화(최대 수 분) 때 적용됩니다. + 적용 여부는 이벤트 화면 좌측 장치 목록의 <strong>설정 지연</strong> 표시로 확인할 수 있습니다. + </p> + <div id="thr-list" class="thr-list">장치를 선택하세요.</div> + <p id="thr-status" role="status" class="admin-status"></p> + </section> +</div> +<script src="<?= view_url('/assets/admin.js') ?>"></script> +<?php +render_footer(); diff --git a/server/src/View/compliance.php b/server/src/View/compliance.php new file mode 100644 index 0000000..21030ef --- /dev/null +++ b/server/src/View/compliance.php @@ -0,0 +1,107 @@ +<?php +require __DIR__ . '/layout.php'; +render_header('안전 관리'); +?> +<div class="compliance-page"> + <header class="page-head"> + <h1>안전 관리</h1> + <p class="page-sub">교대 인수인계 · 정비 중 알람 억제 · 위험성평가 · 감시 설비 상태.</p> + </header> + + <div class="comp-grid"> + <section class="admin-card"> + <div class="admin-card-head"><h2>교대 인수인계</h2></div> + <!-- 24시간 브리프는 "무슨 일이 있었나"를 말한다. 이 카드는 "누가 그 잔여를 넘겨받았나"를 + 기록한다 — 그 기록이 없으면 미처리 이벤트가 교대 사이에서 아무 책임 없이 증발한다. --> + <div id="handover-pending" class="handover-pending">불러오는 중…</div> + <form id="handover-form" class="handover-form"> + <label class="field"><span class="field-label">인수 메모 <span class="field-hint">선택</span></span> + <input type="text" id="handover-note" maxlength="200" placeholder="예: 프레스 라인 미확인 3건 우선 확인 요망"> + </label> + <button type="submit" class="btn-primary" id="handover-accept">인수 확인</button> + </form> + <h3 class="comp-subhead">최근 인수인계</h3> + <div id="handover-list" class="handover-list"></div> + </section> + + <section class="admin-card"> + <div class="admin-card-head"><h2>정비 중 알람 억제</h2></div> + <p class="admin-note"> + 억제 중에도 <strong>감지와 기록은 그대로 계속됩니다</strong> — 통보만 보류됩니다. + 사고 조사에서 그 시간대에 무슨 일이 있었는지 반드시 볼 수 있어야 하기 때문입니다. + 사유는 필수이고, 최대 8시간(한 교대)이며, 시간이 지나면 자동 해제됩니다. + </p> + <form id="suppress-form" class="form-grid"> + <label class="field"><span class="field-label">대상</span> + <select id="sup-scope-type"> + <option value="camera">카메라</option> + <option value="device">장치 전체</option> + </select> + </label> + <label class="field"><span class="field-label">대상 ID</span> + <select id="sup-scope-id"><option value="">불러오는 중…</option></select> + </label> + <label class="field"><span class="field-label">기간 <span class="field-hint">분</span></span> + <input type="number" id="sup-minutes" min="1" max="480" value="60"> + </label> + <label class="field sup-reason"><span class="field-label">사유 <span class="field-hint">필수</span></span> + <input type="text" id="sup-reason" maxlength="200" placeholder="예: 프레스 금형 교체 작업 (작업허가 2026-0812)"> + </label> + <button type="submit" class="btn-primary">억제 시작</button> + </form> + <p id="sup-status" role="status" class="admin-status"></p> + <div id="suppress-list" class="suppress-list"></div> + </section> + + <section class="admin-card comp-wide"> + <div class="admin-card-head"> + <h2>위험성평가 연계</h2> + <button type="button" id="risk-new">항목 추가</button> + </div> + <p class="admin-note"> + 각 감지 유형이 어떤 위험요인을 대신 보고 있는지, 현재 어떤 대책이 걸려 있는지를 기록합니다. + 관리체계 이행을 증명할 때 요구되는 표이고, 사고조사 패키지에도 해당 항목이 함께 실립니다. + </p> + <form id="risk-form" class="form-grid" hidden> + <label class="field"><span class="field-label">감지 유형</span> + <select id="risk-type"></select> + </label> + <label class="field"><span class="field-label">강도 <span class="field-hint">1~5</span></span> + <input type="number" id="risk-severity" min="1" max="5" value="3"> + </label> + <label class="field"><span class="field-label">빈도 <span class="field-hint">1~5</span></span> + <input type="number" id="risk-likelihood" min="1" max="5" value="3"> + </label> + <label class="field comp-span"><span class="field-label">위험요인</span> + <input type="text" id="risk-hazard" maxlength="150" placeholder="예: 프레스 진입로 유압유 누유로 인한 미끄럼 전도"> + </label> + <label class="field comp-span"><span class="field-label">현재 대책</span> + <input type="text" id="risk-control" maxlength="250" placeholder="예: 미끄럼 방지 매트 + 일일 누유 점검 + AI 넘어짐 감지"> + </label> + <div class="step-actions"> + <button type="submit" class="btn-primary">저장</button> + <button type="button" id="risk-cancel">취소</button> + </div> + </form> + <p id="risk-status" role="status" class="admin-status"></p> + <div id="risk-list" class="risk-list">불러오는 중…</div> + </section> + + <section class="admin-card comp-wide"> + <div class="admin-card-head"> + <h2>감시 설비 성능 추이</h2> + <label class="field admin-device-pick"><span class="field-label">장치</span> + <select id="metric-device"><option value="">불러오는 중…</option></select> + </label> + </div> + <p class="admin-note"> + 장치 목록은 "지금 살아 있는가"를 말합니다. 이 그래프는 "점점 나빠지고 있는가"를 말합니다 — + 열화로 서서히 죽는 노드는 멈추기 며칠 전부터 프레임률이 흘러내립니다. + </p> + <div id="metric-chart" class="metric-chart">장치를 선택하세요.</div> + </section> + </div> +</div> +<script src="<?= view_url('/assets/compliance.js') ?>"></script> +<?php +render_footer(); diff --git a/server/src/View/event_detail.php b/server/src/View/event_detail.php index 4b1cce9..98b7483 100644 --- a/server/src/View/event_detail.php +++ b/server/src/View/event_detail.php @@ -2,10 +2,52 @@ require __DIR__ . '/layout.php'; render_header('이벤트 상세'); ?> -<h1>이벤트 상세</h1> -<div id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">불러오는 중...</div> -<div id="event-detail-snapshots"></div> -<p><a href="<?= view_url('/') ?>">← 목록으로</a></p> +<div class="detail"> + <nav class="detail-crumb"><a href="<?= view_url('/') ?>">감지 스트림</a><span aria-hidden="true">›</span><span>이벤트 상세</span></nav> + <header class="detail-head"> + <div class="detail-headline"> + <h1 id="detail-title">이벤트 상세</h1> + <p id="detail-when" class="detail-when">불러오는 중…</p> + </div> + <!-- 판정은 증거를 본 화면에서 끝난다. 예전에는 여기서 판단하고 목록으로 되돌아가 + 그 행을 다시 찾아 눌러야 했다 — 판단하는 자리에 판단 도구가 없었다. --> + <div class="detail-actions" id="detail-actions" hidden></div> + </header> + + <div class="detail-links"> + <a id="detail-package" class="detail-link-btn" href="#">사고조사 패키지 →</a> + <a id="detail-zones" class="detail-link-btn" href="#">이 카메라 구역 설정 →</a> + </div> + + <div class="detail-grid"> + <section class="detail-card detail-evidence"> + <h2 class="detail-card-title">증거 프레임</h2> + <div id="event-detail-snapshots"></div> + <p class="detail-foot">프레임은 발생 시점을 향해 왼쪽에서 오른쪽으로 이어집니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.</p> + </section> + <aside class="detail-side"> + <div class="detail-card detail-meta"> + <h2 class="detail-card-title">이벤트 정보</h2> + <dl id="event-detail-meta" role="status" aria-live="polite" aria-busy="true"> + <div class="detail-row"><dt>상태</dt><dd>불러오는 중…</dd></div> + </dl> + </div> + <!-- 사건에서 조치로 바로 이어지게 한다. 조치를 리포트 화면에서만 기록할 수 있으면 + "무엇이 계기였나"가 끊기고, 나중에 그 대책이 어느 사고에서 나왔는지 알 수 없다. --> + <div class="detail-card detail-action-card"> + <h2 class="detail-card-title">개선 조치 기록</h2> + <p class="detail-card-hint">이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.</p> + <form id="detail-action-form"> + <label class="field"><span class="field-label">조치 내용</span> + <input type="text" id="detail-action-note" maxlength="200" placeholder="예: 진입로 미끄럼 방지 매트 설치"> + </label> + <button type="submit" class="btn-primary">조치 기록</button> + </form> + <p id="detail-action-status" role="status" class="admin-status"></p> + </div> + </aside> + </div> +</div> <script src="<?= view_url('/assets/detail.js') ?>"></script> <?php render_footer(); diff --git a/server/src/View/events.php b/server/src/View/events.php index 136e31d..1f1ed3b 100644 --- a/server/src/View/events.php +++ b/server/src/View/events.php @@ -45,7 +45,17 @@ render_header('이벤트'); <option value="device_fault">장치 이상</option> </select> </label> - <label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만 보기</label> + </div> + </section> + <section class="rail-block"> + <h2 class="rail-title">날짜 조회</h2> + <!-- 사고 조사 진입로: 특정 날짜를 직접 연다. 라이브 폴링은 그동안에도 계속 돌아 + KPI·경보가 멈추지 않는다 (app.js runDayQuery). --> + <div class="rail-date"> + <label class="field" for="date-jump">날짜 + <input type="date" id="date-jump"> + </label> + <button type="button" id="date-jump-go">조회</button> </div> </section> <section class="rail-block"> @@ -60,12 +70,32 @@ render_header('이벤트'); <div class="stream-title"> <h1>감지 스트림</h1> <span class="watch" id="watch-status" aria-live="polite">실시간 감시 중</span> + <!-- 경보음은 미확인 위급이 있을 때만 나타난다. 끌 수 없는 반복음은 안전 콘솔이 + 통째로 꺼지는 경로라, 세션 한정 음소거를 두되 새 위급이 오면 자동 해제된다. --> + <button type="button" id="alarm-mute" class="mute-btn" aria-pressed="false" hidden>경보음 끄기</button> + <span class="mute-state" id="alarm-mute-state" role="status" hidden></span> </div> <div class="kpi-wrap"> - <span class="kpi-cap">미확인</span> + <!-- 캡션은 JS가 실제 집계 구간으로 덮어쓴다 (app.js renderStatusCaption): + 이 스트립은 '최근 200건' 기준이라 아래 24시간 브리프와 기준이 다르다. --> + <span class="kpi-cap" id="kpi-cap">미확인</span> <div id="events-status" class="kpi-strip" aria-label="미확인 이벤트 현황"></div> </div> </header> + <!-- 뷰 모드: 정렬이 아니라 필터다. 전체=시간순 연대기, 미확인=그 위에 얹은 처리 큐. + 전환해도 행끼리의 상대 순서는 절대 바뀌지 않는다. --> + <div class="stream-tools"> + <div class="view-modes" role="group" aria-label="스트림 보기 모드"> + <button type="button" class="mode-btn" data-view-mode="all" aria-pressed="true">전체 (시간순)</button> + <button type="button" class="mode-btn" data-view-mode="unacked" aria-pressed="false">미확인만</button> + </div> + <!-- 백로그 처리용. 위급은 의도적으로 제외한다 — 넘어짐은 한 건씩 눈으로 봐야 한다. --> + <button type="button" id="bulk-ack" class="bulk-btn" hidden></button> + <span class="kbd-hint" aria-hidden="true"><kbd>J</kbd><kbd>K</kbd> 이동 · <kbd>A</kbd> 확인 · <kbd>Enter</kbd> 상세</span> + </div> + <!-- 스냅샷 저장 공간이 차면 이미지 기록이 조용히 실패한다 (이벤트는 계속 쌓인다). + 증거가 사라지고 있다는 사실을 대시보드가 먼저 말해야 한다. --> + <div id="storage-warn" class="storage-warn" role="status" hidden></div> <!-- Catch-up line for the operator who just sat down: what the last 24h held while nobody was watching. Hidden until the numbers arrive (an empty banner is noise). --> <div id="day-brief" class="day-brief" hidden></div> diff --git a/server/src/View/incident_package.php b/server/src/View/incident_package.php new file mode 100644 index 0000000..ea75eec --- /dev/null +++ b/server/src/View/incident_package.php @@ -0,0 +1,19 @@ +<?php +require __DIR__ . '/layout.php'; +render_header('사고조사 패키지'); +?> +<div class="package-page"> + <div class="package-toolbar"> + <nav class="detail-crumb"><a href="<?= view_url('/') ?>">감지 스트림</a><span aria-hidden="true">›</span><span>사고조사 패키지</span></nav> + <button type="button" id="package-print" class="btn-primary">인쇄 · PDF 저장</button> + </div> + + <!-- 사고 직후에는 이 문서를 손으로 조립할 시간이 없다. 사건 하나를 열면 증거 프레임, + 같은 카메라의 전후 타임라인, 통보 이력, 조치 이력, 해당 위험요인이 한 장으로 나온다. --> + <article class="package" id="package-root"> + <p class="package-loading">불러오는 중…</p> + </article> +</div> +<script src="<?= view_url('/assets/package.js') ?>"></script> +<?php +render_footer(); diff --git a/server/src/View/layout.php b/server/src/View/layout.php index 9d8cb7a..014fbcc 100644 --- a/server/src/View/layout.php +++ b/server/src/View/layout.php @@ -28,16 +28,26 @@ if (!function_exists('render_header')) { $username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null; // Which primary nav tab is active is derived from the page title so no view // has to pass an extra argument. Login/detail titles match neither -> no tab lit. + // 페이지 이름은 nav 라벨 · <h1> · <title> 세 곳이 같은 문자열을 쓴다. 예전에는 + // nav '리포트' / <title> '안전 리포트' / <h1> '안전 운영 리포트' 로 갈라져 있었고, + // 같은 화면을 부르는 이름이 셋이면 인수인계·매뉴얼에서 바로 어긋난다. $navEvents = ($title === '이벤트') ? ' is-active' : ''; $navZones = ($title === '구역 설정') ? ' is-active' : ''; $navReport = ($title === '안전 리포트') ? ' is-active' : ''; + $navCompliance = ($title === '안전 관리') ? ' is-active' : ''; + $navAdmin = ($title === '시스템 설정') ? ' is-active' : ''; + // 역할별 노출. 서버는 각 라우트에서 다시 검사하므로 이것은 보안 경계가 아니라 + // "누를 수 없는 메뉴를 보여주지 않는다"는 UI 배려다 — 눌러서 403 을 받는 것보다 낫다. + $role = is_string($_SESSION['role'] ?? null) ? $_SESSION['role'] : ''; + $canCompliance = in_array($role, ['admin', 'operator'], true); + $canAdmin = $role === 'admin'; ?> <!doctype html> <html lang="ko"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title><?= view_e($title) ?> · 이동 감지 대시보드 +<?= view_e($title) ?> · AI SAFETY CCTV + -

구역 설정

-
- - - - - - - - - +
+
+

구역 설정

+

카메라 화면 위에 위험구역을 그리면 엣지가 그 안의 작업자를 판정합니다. 저장 즉시 다음 설정 동기화 때 장치로 내려갑니다.

+
+ +
+ + + +
+ +

카메라를 선택하고 저장된 구역을 불러오세요.

+
+

저장된 구역

+
    +
    +
    +
    -
    - - - - -
    -
    - - - - - - -
    - - -

    1, 'site' => null, 'kind' => 'email', 'name' => 'shift lead', + 'config_json' => '{"to":["a@b.c"]}', 'min_severity' => 'high', + 'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0, + ]; + } + + private function event(array $over = []): array + { + return $over + [ + 'event_id' => 1, 'device_id' => 'pi-1', 'site' => null, 'camera_id' => 'cam-1', + 'event_type' => 'fall', 'severity' => 'critical', 'zone_id' => null, + 'occurred_at' => (float)(self::NOW - 10), 'duration_s' => 1.0, + 'acknowledged_by' => null, 'false_positive_by' => null, + ]; + } + + private function dispatcher( + ?InMemoryEventRepository $ev = null, ?InMemoryAlertRepository $al = null, + ?InMemorySuppressionRepository $su = null + ): AlertDispatcher { + return new AlertDispatcher($ev ?? new InMemoryEventRepository(), + $al ?? new InMemoryAlertRepository(), + $su ?? new InMemorySuppressionRepository()); + } + + // ---- 심각도 문턱 ------------------------------------------------------------ + + public function testSeverityFloorIsAFloorNotAnEquality(): void + { + $d = $this->dispatcher(); + // critical >= high 이므로 발송 대상이다. 예전 구현이 동등 비교였다면 넘어짐이 조용히 빠진다. + $this->assertCount(1, $d->planFor($this->event(['severity' => 'critical']), [$this->channel()], self::NOW)); + $this->assertCount(0, $d->planFor($this->event(['severity' => 'warning']), [$this->channel()], self::NOW)); + } + + public function testDisabledChannelNeverFires(): void + { + $d = $this->dispatcher(); + $this->assertCount(0, $d->planFor($this->event(), [$this->channel(['enabled' => 0])], self::NOW)); + } + + public function testLocalChannelsAreNotServerDelivered(): void + { + // 엣지가 자체 GPIO 로 울린다. 서버가 원장에 잡으면 영구 실패로 남는다. + $d = $this->dispatcher(); + $this->assertCount(0, $d->planFor($this->event(), [$this->channel(['kind' => 'local'])], self::NOW)); + } + + // ---- 해소된 이벤트 ---------------------------------------------------------- + + public function testAcknowledgedEventStopsTheChain(): void + { + $d = $this->dispatcher(); + $this->assertCount(0, $d->planFor($this->event(['acknowledged_by' => 'op']), [$this->channel()], self::NOW)); + } + + public function testFalsePositiveStopsTheChain(): void + { + $d = $this->dispatcher(); + $this->assertCount(0, $d->planFor($this->event(['false_positive_by' => 'op']), [$this->channel()], self::NOW)); + } + + // ---- 에스컬레이션 타이밍 ---------------------------------------------------- + + public function testEscalationChannelWaitsForItsDelay(): void + { + $d = $this->dispatcher(); + $ch = $this->channel(['channel_id' => 2, 'step' => 1, 'escalate_after_s' => 300]); + $young = $this->event(['occurred_at' => (float)(self::NOW - 60)]); + $old = $this->event(['occurred_at' => (float)(self::NOW - 600)]); + $this->assertCount(0, $d->planFor($young, [$ch], self::NOW), '아직 이르다'); + $this->assertCount(1, $d->planFor($old, [$ch], self::NOW), '지연 경과 후 발화'); + } + + // ---- 사이트 스코프 ---------------------------------------------------------- + + public function testSiteScopedChannelOnlyServesItsOwnSite(): void + { + $d = $this->dispatcher(); + $ch = $this->channel(['site' => 'factory-a']); + $this->assertCount(1, $d->planFor($this->event(['site' => 'factory-a']), [$ch], self::NOW)); + $this->assertCount(0, $d->planFor($this->event(['site' => 'factory-b']), [$ch], self::NOW)); + // site 가 NULL 인 채널은 전역 (단일 현장 기본 설치). + $this->assertCount(1, $d->planFor($this->event(['site' => 'factory-b']), [$this->channel()], self::NOW)); + } + + // ---- 억제 ------------------------------------------------------------------ + + public function testSuppressionWithholdsSendButStillRecordsIt(): void + { + $events = new InMemoryEventRepository(); + $id = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_restricted', + 'severity' => 'high', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]); + $alerts = new InMemoryAlertRepository([$this->channel()]); + $sup = new InMemorySuppressionRepository(); + $sup->create(['scope_type' => 'camera', 'scope_id' => 'cam-1', 'site' => null, + 'reason' => '정비', 'starts_at' => self::NOW - 60, 'ends_at' => self::NOW + 600, + 'created_by' => 'op', 'created_at' => self::NOW - 60]); + + $sent = 0; + $stats = $this->dispatcher($events, $alerts, $sup) + ->run(self::NOW, 3600, 100, function () use (&$sent) { $sent++; }); + + $this->assertSame(0, $sent, '억제 중에는 실제 발송이 없다'); + $this->assertSame(1, $stats['suppressed']); + // 그러나 원장에는 남는다 — 조사에서 "경보가 났지만 의도적으로 보류됐다"를 볼 수 있어야 한다. + $log = $alerts->forEvent($id); + $this->assertCount(1, $log); + $this->assertSame('suppressed', $log[0]['status']); + } + + // ---- 중복 발송 방지 --------------------------------------------------------- + + public function testSecondRunDoesNotResendTheSameEvent(): void + { + $events = new InMemoryEventRepository(); + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]); + $alerts = new InMemoryAlertRepository([$this->channel()]); + $d = $this->dispatcher($events, $alerts); + + $sent = 0; + $send = function () use (&$sent) { $sent++; }; + $d->run(self::NOW, 3600, 100, $send); + $d->run(self::NOW, 3600, 100, $send); // 크론 중첩 실행을 흉내낸다 + + $this->assertSame(1, $sent, '같은 사건을 두 번 전화하지 않는다'); + } + + public function testOneFailingChannelDoesNotStopTheOthers(): void + { + $events = new InMemoryEventRepository(); + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]); + $alerts = new InMemoryAlertRepository([ + $this->channel(['channel_id' => 1, 'name' => 'broken']), + $this->channel(['channel_id' => 2, 'name' => 'works']), + ]); + $ok = 0; + $stats = $this->dispatcher($events, $alerts)->run(self::NOW, 3600, 100, + function (array $e, array $ch) use (&$ok) { + if ($ch['name'] === 'broken') throw new \RuntimeException('relay down'); + $ok++; + }); + $this->assertSame(1, $ok, '불통 채널 하나가 나머지를 막지 않는다'); + $this->assertSame(1, $stats['failed']); + $this->assertSame(1, $stats['sent']); + } + + // ---- 미확인 에스컬레이션 ---------------------------------------------------- + + public function testUnackedCriticalBecomesItsOwnEvent(): void + { + $events = new InMemoryEventRepository(); + $origin = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); + $watch = new EscalationWatch($events); + + $this->assertSame([$origin], $watch->run(self::NOW, 300, 3600, 100)); + $escalations = array_values(array_filter($events->rows, + fn ($r) => ($r['event_type'] ?? '') === 'unacked_escalation')); + $this->assertCount(1, $escalations); + $this->assertSame('critical', $escalations[0]['severity'], '심각도를 낮추면 상위 채널 문턱을 못 넘는다'); + $this->assertSame($origin, $escalations[0]['metrics']['origin_event_id']); + } + + public function testEscalationIsIdempotent(): void + { + $events = new InMemoryEventRepository(); + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); + $watch = new EscalationWatch($events); + $watch->run(self::NOW, 300, 3600, 100); + $this->assertSame([], $watch->run(self::NOW, 300, 3600, 100), '두 번째 실행은 아무것도 만들지 않는다'); + } + + public function testAcknowledgedEventNeverEscalates(): void + { + $events = new InMemoryEventRepository(); + $id = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); + $events->acknowledge($id, 'op'); + $this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 3600, 100)); + } + + public function testEscalationDoesNotEscalateItself(): void + { + $events = new InMemoryEventRepository(); + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'unacked_escalation', + 'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 600), 'metrics' => []]); + $this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 3600, 100), + '무한 사다리 방지'); + } + + public function testWarningNeverEscalates(): void + { + $events = new InMemoryEventRepository(); + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_warning', + 'severity' => 'warning', 'occurred_at' => (float)(self::NOW - 6000), 'metrics' => []]); + $this->assertSame([], (new EscalationWatch($events))->run(self::NOW, 300, 86400, 100), + '미처리 경고는 백로그이지 응급이 아니다'); + } + + // ---- 메시지 내용 ------------------------------------------------------------ + + public function testMessageCarriesNoImageAndLinksToTheIncident(): void + { + $c = new Channels('https://example.com/move_detect'); + $body = $c->body($this->event(['event_id' => 42])); + $this->assertStringContainsString('https://example.com/move_detect/events/42/view', $body); + $this->assertStringContainsString('넘어짐', $body); + // 스냅샷은 PIPA 대상 개인정보다. 메일로 나가면 인증 프록시가 지키던 통제를 벗어난다. + $this->assertStringNotContainsString('/snap/', $body); + $this->assertStringContainsString('이미지가 포함되지 않습니다', $body); + } + + public function testWebhookRefusesNonHttpSchemes(): void + { + $c = new Channels('https://example.com'); + $this->expectException(\RuntimeException::class); + $c->send($this->event(), $this->channel(['kind' => 'webhook', 'config_json' => '{"url":"file:///etc/passwd"}'])); + } +} diff --git a/server/tests/AuditChainTest.php b/server/tests/AuditChainTest.php new file mode 100644 index 0000000..be9e69e --- /dev/null +++ b/server/tests/AuditChainTest.php @@ -0,0 +1,140 @@ +markTestSkipped('pdo_sqlite not available on this host'); + } + $this->pdo = new PDO('sqlite::memory:', null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->pdo->exec(<<<'SQL' + CREATE TABLE move_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + before_json TEXT NOT NULL, + after_json TEXT NOT NULL, + ts INTEGER NOT NULL, + prev_hash TEXT, + entry_hash TEXT + ); + SQL); + } + + private function rows(): array + { + return $this->pdo->query('SELECT * FROM move_audit_log ORDER BY id ASC')->fetchAll(); + } + + /** The verifier's core loop, mirrored here so the property is asserted, not just the tool. */ + private function chainIsIntact(): bool + { + $expected = null; + $started = false; + foreach ($this->rows() as $r) { + if ($r['entry_hash'] === null) { if ($started) return false; continue; } + if ($started && $r['prev_hash'] !== $expected) return false; + $started = true; + $recomputed = PdoAuditRepository::hash( + $r['actor'], $r['action'], $r['target'], $r['before_json'], $r['after_json'], + (int)$r['ts'], $r['prev_hash'] !== null && $r['prev_hash'] !== '' ? $r['prev_hash'] : null + ); + if (!hash_equals((string)$r['entry_hash'], $recomputed)) return false; + $expected = $r['entry_hash']; + } + return true; + } + + private function appendThree(): PdoAuditRepository + { + $repo = new PdoAuditRepository($this->pdo); + $repo->append('kim', 'acknowledge', 'event:1', [], ['ok' => true]); + $repo->append('lee', 'zone_delete', 'zone:abc', ['level' => 'restricted'], []); + $repo->append('kim', 'threshold_set', 'threshold:pi-1:fall_prone_s', ['value' => '3'], ['value' => '5']); + return $repo; + } + + public function testEachEntryLinksToItsPredecessor(): void + { + $this->appendThree(); + $rows = $this->rows(); + $this->assertCount(3, $rows); + $this->assertNull($rows[0]['prev_hash'], '첫 행은 사슬의 시작'); + $this->assertSame($rows[0]['entry_hash'], $rows[1]['prev_hash']); + $this->assertSame($rows[1]['entry_hash'], $rows[2]['prev_hash']); + $this->assertTrue($this->chainIsIntact()); + } + + public function testEditingARowBreaksTheChain(): void + { + $this->appendThree(); + // 감사 기록을 조용히 고치는 시나리오: 확인자를 바꿔치기한다. + $this->pdo->exec("UPDATE move_audit_log SET actor = 'park' WHERE id = 2"); + $this->assertFalse($this->chainIsIntact(), '수정이 탐지되어야 한다'); + } + + public function testDeletingARowBreaksTheChain(): void + { + $this->appendThree(); + $this->pdo->exec('DELETE FROM move_audit_log WHERE id = 2'); + $this->assertFalse($this->chainIsIntact(), '삭제가 탐지되어야 한다'); + } + + public function testInsertingARowWithoutTheChainIsDetected(): void + { + $this->appendThree(); + // 애플리케이션을 우회해 직접 INSERT — 해시가 없다. + $this->pdo->exec("INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts) + VALUES ('ghost', 'acknowledge', 'event:99', '[]', '[]', 1)"); + $this->assertFalse($this->chainIsIntact()); + } + + public function testRowsPredatingTheChainAreNotReportedAsDamage(): void + { + // 마이그레이션 이전 구간: 해시 없는 행이 앞에 쌓여 있다. + $this->pdo->exec("INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts) + VALUES ('old', 'login', 'user:1', '[]', '[]', 1)"); + $this->appendThree(); + $this->assertTrue($this->chainIsIntact(), + '정상 시스템이 매번 빨간불을 켜면 아무도 이 도구를 보지 않게 된다'); + } + + public function testAppendIsSafeWhenTheCallerAlreadyOwnsATransaction(): void + { + $repo = new PdoAuditRepository($this->pdo); + $this->pdo->beginTransaction(); + $repo->append('kim', 'zone_save', 'zone:1', [], []); + $this->pdo->commit(); + $this->assertCount(1, $this->rows()); + $this->assertTrue($this->chainIsIntact()); + } + + public function testFieldSeparatorCannotBeForgedByAValueContainingIt(): void + { + // '|' 구분자였다면 actor="a|b", action="c" 와 actor="a", action="b|c" 가 같은 해시를 + // 낳는다 — 감사 항목 위조가 성립한다. 0x1F 는 텍스트 필드에 나타나지 않는다. + $a = PdoAuditRepository::hash('a|b', 'c', 't', '[]', '[]', 1, null); + $b = PdoAuditRepository::hash('a', 'b|c', 't', '[]', '[]', 1, null); + $this->assertNotSame($a, $b); + } +} diff --git a/server/tests/PdoIntegrationTest.php b/server/tests/PdoIntegrationTest.php index dafb3ea..a0aa3dd 100644 --- a/server/tests/PdoIntegrationTest.php +++ b/server/tests/PdoIntegrationTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); namespace App\Tests; use App\Repository\Pdo\{PdoNonceRepository, PdoEventRepository, PdoZoneRepository, - PdoActionRepository, PdoSnapshotRepository}; + PdoActionRepository, PdoSnapshotRepository, PdoSuppressionRepository, + PdoAlertRepository, PdoThresholdRepository, PdoRiskControlRepository, + PdoHandoverRepository, PdoDeviceMetricRepository, PdoDeviceRepository}; use PDO; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -261,6 +263,176 @@ final class PdoIntegrationTest extends TestCase $this->assertCount(2, $snaps->forEvent($a)); } + // ================================================================================ + // 2026-08-09 확장: 억제 · 통보 · 임계값 편집 · 위험성평가 · 인수인계 · 성능 시계열. + // 이 클래스가 납품되는 SQL 이 실제로 실행되는 유일한 자리이므로, 새 저장소도 여기에서 + // 검증한다 — 인메모리 더블은 쿼리 의미를 PHP 로 재구현한 것이라 깨진 SQL 을 못 잡는다. + // ================================================================================ + + public function testSuppressionActiveWindowAndRevoke(): void + { + $repo = new PdoSuppressionRepository($this->pdo); + $now = 1_800_000_000; + $id = $repo->create(['scope_type' => 'camera', 'scope_id' => 'cam-1', 'site' => 'factory-a', + 'reason' => '금형 교체', 'starts_at' => $now - 60, 'ends_at' => $now + 600, + 'created_by' => 'op', 'created_at' => $now - 60]); + + $this->assertCount(1, $repo->activeFor([['camera', 'cam-1']], $now)); + $this->assertCount(0, $repo->activeFor([['camera', 'cam-2']], $now), '다른 카메라는 억제되지 않는다'); + $this->assertCount(0, $repo->activeFor([['camera', 'cam-1']], $now + 3600), '만료 후에는 되살아나지 않는다'); + $this->assertCount(0, $repo->activeFor([['camera', 'cam-1']], $now - 3600), '시작 전에는 적용되지 않는다'); + + // 스코프 밖 사용자는 해제할 수 없다 — 억제 해제도 접근제어 대상이다. + $this->assertFalse($repo->revoke($id, 'other', $now, 'factory-b')); + $this->assertTrue($repo->revoke($id, 'op', $now, 'factory-a')); + $this->assertCount(0, $repo->activeFor([['camera', 'cam-1']], $now), '해제 즉시 효력이 사라진다'); + $this->assertFalse($repo->revoke($id, 'op', $now, 'factory-a'), '두 번 해제되지 않는다'); + } + + public function testAlertClaimIsTheDuplicateSendGuard(): void + { + $events = new PdoEventRepository($this->pdo); + $id = $this->seedEvent($events, []); + $repo = new PdoAlertRepository($this->pdo); + $now = 1_800_000_000; + + $this->assertTrue($repo->claim($id, 7, $now)); + // 겹쳐 도는 크론 두 개. UNIQUE 키가 승자를 정한다 — 읽고-쓰기였다면 둘 다 보냈다. + $this->assertFalse($repo->claim($id, 7, $now)); + + $repo->markFailed($id, 7, 'relay down', $now); + $this->assertSame([['event_id' => $id, 'channel_id' => 7, 'attempts' => 1]], + $repo->retryable(5, 10)); + $repo->markSent($id, 7, $now + 5); + $this->assertSame([], $repo->retryable(5, 10)); + $log = $repo->forEvent($id); + $this->assertSame('sent', $log[0]['status']); + $this->assertNull($log[0]['last_error'], '성공하면 직전 오류를 지운다'); + } + + public function testAlertChannelsFilterBySiteAndOrderByStep(): void + { + $repo = new PdoAlertRepository($this->pdo); + $now = 1_800_000_000; + $repo->saveChannel(['site' => null, 'kind' => 'email', 'name' => 'global', 'config_json' => '{}', + 'min_severity' => 'high', 'enabled' => 1, 'step' => 1, 'escalate_after_s' => 300, 'created_at' => $now]); + $repo->saveChannel(['site' => 'factory-a', 'kind' => 'webhook', 'name' => 'a-immediate', 'config_json' => '{}', + 'min_severity' => 'critical', 'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0, 'created_at' => $now]); + $repo->saveChannel(['site' => 'factory-b', 'kind' => 'email', 'name' => 'b', 'config_json' => '{}', + 'min_severity' => 'high', 'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0, 'created_at' => $now]); + $repo->saveChannel(['site' => null, 'kind' => 'email', 'name' => 'off', 'config_json' => '{}', + 'min_severity' => 'info', 'enabled' => 0, 'step' => 0, 'escalate_after_s' => 0, 'created_at' => $now]); + + $names = array_column($repo->channels('factory-a'), 'name'); + // 전역 채널 + 자기 현장 채널만, step 오름차순(즉시 -> 에스컬레이션). + $this->assertSame(['a-immediate', 'global'], $names); + } + + public function testThresholdEditorRoundTripAndSiteScope(): void + { + $this->pdo->exec("INSERT INTO move_devices (device_id, name, site, secret) + VALUES ('pi-1', 'press', 'factory-a', 's'), ('pi-2', 'wh', 'factory-b', 's')"); + $repo = new PdoThresholdRepository($this->pdo); + $now = 1_800_000_000; + + $id = $repo->set('pi-1', null, 'fall_prone_s', '3', 'admin', $now); + // 같은 (device, camera, key) 재저장은 새 행이 아니라 갱신이어야 한다. UNIQUE 인덱스가 + // NULL 을 서로 다르게 취급하므로 ON DUPLICATE KEY 로는 매번 행이 늘어난다. + $again = $repo->set('pi-1', null, 'fall_prone_s', '5', 'admin', $now + 10); + $this->assertSame($id, $again); + $this->assertSame(['fall_prone_s' => '5'], $repo->forDevice('pi-1')); + + // 카메라별 재정의는 장치 전체 맵에 섞이지 않는다 — 섞이면 한 카메라 튜닝이 전 카메라에 적용된다. + $repo->set('pi-1', 'cam-9', 'fall_prone_s', '9', 'admin', $now); + $this->assertSame(['fall_prone_s' => '5'], $repo->forDevice('pi-1')); + + $this->assertCount(0, $repo->listAll('factory-b'), '타 현장 행은 보이지 않는다'); + $scoped = $repo->listAll('factory-a'); + $this->assertCount(2, $scoped); + $this->assertFalse($repo->remove($id, 'factory-b'), '타 현장은 지울 수 없다'); + $this->assertTrue($repo->remove($id, 'factory-a')); + } + + public function testRiskControlUpsertDoesNotDuplicateTheGlobalRow(): void + { + $repo = new PdoRiskControlRepository($this->pdo); + $now = 1_800_000_000; + $row = ['site' => null, 'event_type' => 'fall', 'hazard' => '단차 전도', 'control' => '매트', + 'severity_rating' => 5, 'likelihood_rating' => 3, 'updated_at' => $now]; + $repo->upsert($row); + $repo->upsert(['control' => '매트 + 난간'] + $row); + $all = $repo->listAll(null); + $this->assertCount(1, $all, 'NULL site 는 UNIQUE 에서 서로 다르게 취급되므로 명시적 조회가 필요하다'); + $this->assertSame('매트 + 난간', $all[0]['control']); + $this->assertSame(15, $all[0]['risk_score']); + } + + public function testHandoverSnapshotIsStoredNotRecomputed(): void + { + $repo = new PdoHandoverRepository($this->pdo); + $now = 1_800_000_000; + $repo->create(['site' => 'factory-a', 'from_user' => 'kim', 'to_user' => 'lee', + 'period_start' => $now - 43200, 'period_end' => $now, 'unacked_count' => 7, + 'severe_count' => 2, 'note' => '프레스 라인 주의', 'created_at' => $now]); + $latest = $repo->latest('factory-a'); + $this->assertSame(7, $latest['unacked_count']); + $this->assertSame('lee', $latest['to_user']); + $this->assertNull($repo->latest('factory-b')); + } + + public function testDeviceMetricSeriesAndPrune(): void + { + $repo = new PdoDeviceMetricRepository($this->pdo); + $t = 1_800_000_000; + $repo->append('pi-1', ['fps' => 24.5, 'temp' => 58.0, 'throttle' => false, 'queue_pending' => 0], $t - 100); + $repo->append('pi-1', ['fps' => 12.1, 'throttle' => true], $t); // temp 미보고 + $repo->append('pi-2', ['fps' => 30.0], $t); + + $series = $repo->series('pi-1', $t - 200, 100); + $this->assertCount(2, $series); + $this->assertSame($t - 100, $series[0]['ts'], '오름차순'); + // 보고되지 않은 값은 0 이 아니라 NULL 이어야 한다 — 0°C 로 앉아 있는 장비처럼 보이면 안 된다. + $this->assertNull($series[1]['temp']); + $this->assertTrue($series[1]['throttle']); + + // prune 은 ts < 기준시각만 지운다. 여기서는 pi-1 의 옛 점 하나뿐이고, 기준시각과 + // 같은 ts 를 가진 두 점(pi-1, pi-2)은 남는다 — 경계는 열린 구간이어야 방금 들어온 + // 하트비트가 같은 초에 지워지지 않는다. + $this->assertSame(1, $repo->prune($t)); + $this->assertCount(1, $repo->series('pi-1', 0, 100)); + $this->assertCount(1, $repo->series('pi-2', 0, 100)); + } + + public function testBumpConfigVersionIsAtomic(): void + { + $this->pdo->exec("INSERT INTO move_devices (device_id, secret, config_version) VALUES ('pi-1', 's', 4)"); + $repo = new PdoDeviceRepository($this->pdo); + $this->assertSame(5, $repo->bumpConfigVersion('pi-1')); + $this->assertSame(6, $repo->bumpConfigVersion('pi-1')); + $this->assertSame(0, $repo->bumpConfigVersion('pi-nope'), '없는 장비는 0'); + } + + public function testEventNotificationWindowIsOldestFirst(): void + { + $events = new PdoEventRepository($this->pdo); + $a = $this->seedEvent($events, ['occurred_at' => 200.0]); + $b = $this->seedEvent($events, ['occurred_at' => 100.0]); + $ids = array_column($events->forNotification(50.0, 10), 'event_id'); + // 밀린 백로그에서는 가장 오래된 미처리 응급부터 통보해야 한다. + $this->assertSame([$b, $a], $ids); + } + + public function testWindowForCameraIsBoundedAndScoped(): void + { + $events = new PdoEventRepository($this->pdo); + $in = $this->seedEvent($events, ['occurred_at' => 100.0, 'camera_id' => 'cam-1', 'site' => 'factory-a']); + $this->seedEvent($events, ['occurred_at' => 999.0, 'camera_id' => 'cam-1', 'site' => 'factory-a']); + $this->seedEvent($events, ['occurred_at' => 100.0, 'camera_id' => 'cam-2', 'site' => 'factory-a']); + $rows = $events->windowForCamera('cam-1', 50.0, 200.0, 'factory-a', 100); + $this->assertSame([$in], array_column($rows, 'event_id')); + $this->assertSame([], $events->windowForCamera('cam-1', 50.0, 200.0, 'factory-b', 100)); + } + private static function sqliteSchema(): string { return <<<'SQL' @@ -305,12 +477,128 @@ final class PdoIntegrationTest extends TestCase action_id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id TEXT NOT NULL, site TEXT, + event_id INTEGER, note TEXT NOT NULL, taken_at INTEGER NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL ); + CREATE TABLE move_devices ( + device_id TEXT PRIMARY KEY, + name TEXT, + site TEXT, + secret TEXT NOT NULL, + config_version INTEGER NOT NULL DEFAULT 1, + last_fps REAL, + last_temp REAL, + last_throttle INTEGER, + reported_config_version INTEGER, + last_heartbeat_at INTEGER, + created_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE move_thresholds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + camera_id TEXT, + zone_id TEXT, + `key` TEXT NOT NULL, + `value` TEXT NOT NULL, + updated_by TEXT, + updated_at INTEGER + ); + + CREATE TABLE move_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + before_json TEXT NOT NULL, + after_json TEXT NOT NULL, + ts INTEGER NOT NULL, + prev_hash TEXT, + entry_hash TEXT + ); + + CREATE TABLE move_suppressions ( + suppression_id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL, + scope_id TEXT NOT NULL, + site TEXT, + reason TEXT NOT NULL, + starts_at INTEGER NOT NULL, + ends_at INTEGER NOT NULL, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + revoked_by TEXT, + revoked_at INTEGER + ); + + CREATE TABLE move_alert_channels ( + channel_id INTEGER PRIMARY KEY AUTOINCREMENT, + site TEXT, + kind TEXT NOT NULL, + name TEXT NOT NULL, + config_json TEXT NOT NULL, + min_severity TEXT NOT NULL DEFAULT 'high', + enabled INTEGER NOT NULL DEFAULT 1, + step INTEGER NOT NULL DEFAULT 0, + escalate_after_s INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + + CREATE TABLE move_alert_log ( + alert_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + queued_at INTEGER NOT NULL, + sent_at INTEGER, + UNIQUE (event_id, channel_id) + ); + + CREATE TABLE move_shift_handovers ( + handover_id INTEGER PRIMARY KEY AUTOINCREMENT, + site TEXT, + from_user TEXT, + to_user TEXT NOT NULL, + period_start INTEGER NOT NULL, + period_end INTEGER NOT NULL, + unacked_count INTEGER NOT NULL, + severe_count INTEGER NOT NULL, + note TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE move_risk_controls ( + control_id INTEGER PRIMARY KEY AUTOINCREMENT, + site TEXT, + event_type TEXT NOT NULL, + hazard TEXT NOT NULL, + control TEXT NOT NULL, + severity_rating INTEGER NOT NULL, + likelihood_rating INTEGER NOT NULL, + reviewed_by TEXT, + reviewed_at INTEGER, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE move_device_metrics ( + metric_id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + ts INTEGER NOT NULL, + fps REAL, + temp REAL, + throttle INTEGER, + queue_pending INTEGER, + cameras_total INTEGER, + cameras_erroring INTEGER, + errors INTEGER + ); + CREATE TABLE move_event_snapshots ( snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, diff --git a/server/tests/RouteTableTest.php b/server/tests/RouteTableTest.php index ef5f645..689c3ba 100644 --- a/server/tests/RouteTableTest.php +++ b/server/tests/RouteTableTest.php @@ -38,7 +38,18 @@ final class RouteTableTest extends TestCase 'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions', 'GET /api/cameras/{camera}/latest-snapshot', 'POST /api/zones', 'POST /api/zones/{id}/delete', 'GET /snap/{uuid}', + // 성능 추이 · 임계값 · 통보 채널 · 억제 · 위험성평가 · 인수인계 · 사고조사 패키지 + 'GET /api/devices/{id}/metrics', + 'GET /api/admin/thresholds', 'POST /api/admin/thresholds', + 'POST /api/admin/thresholds/{id}/delete', + 'GET /api/admin/channels', 'POST /api/admin/channels', + 'POST /api/admin/channels/{id}/delete', + 'GET /api/suppressions', 'POST /api/suppressions', 'POST /api/suppressions/{id}/revoke', + 'GET /api/risks', 'POST /api/risks', 'POST /api/risks/{id}/delete', + 'GET /api/handovers', 'POST /api/handovers', + 'GET /api/events/{id}/package', 'GET /', 'GET /login', 'GET /zones', 'GET /report', 'GET /events/{id}/view', + 'GET /admin', 'GET /compliance', 'GET /setup', 'GET /events/{id}/package/view', ]; $actual = $this->registeredRoutes(); sort($expected); diff --git a/server/tests/SafetyConfigTest.php b/server/tests/SafetyConfigTest.php new file mode 100644 index 0000000..2043287 --- /dev/null +++ b/server/tests/SafetyConfigTest.php @@ -0,0 +1,328 @@ +set('user_id', 1); $s->set('username', '관리자'); $s->set('role', $role); $s->set('csrf', 'tok'); + if ($site !== null) $s->set('site_scope', $site); + return $s; + } + + private function post(array $body): Request + { + return new Request('POST', '/x', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []); + } + + private function get(array $query = []): Request + { + return new Request('GET', '/x', [], '', $query, []); + } + + // ================================================================ thresholds + + private function admin(string $role = 'admin', ?string $site = null, + ?InMemoryThresholdRepository $thr = null, + ?InMemoryDeviceRepository $dev = null, + ?InMemoryAuditRepository $audit = null, + ?InMemoryAlertRepository $alerts = null): AdminConfigController + { + return new AdminConfigController( + $thr ?? new InMemoryThresholdRepository(), + $alerts ?? new InMemoryAlertRepository(), + $dev ?? new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1]), + $audit ?? new InMemoryAuditRepository(), + $this->sess($role, $site) + ); + } + + public function testThresholdEditingIsAdminOnly(): void + { + $this->assertSame(403, $this->admin('operator')->thresholds($this->get())->status()); + $this->assertSame(403, $this->admin('viewer')->thresholds($this->get())->status()); + $this->assertSame(200, $this->admin('admin')->thresholds($this->get())->status()); + } + + public function testCatalogTravelsWithTheValuesSoTheEditorCanBoundItsInputs(): void + { + $res = $this->admin()->thresholds($this->get()); + $data = json_decode($res->body(), true); + $this->assertArrayHasKey('fall_prone_s', $data['catalog']); + // assertEquals, not assertSame: a whole float survives the JSON round trip as an int. + $this->assertEquals(30.0, $data['catalog']['fall_prone_s']['max']); + $this->assertEquals(1.0, $data['catalog']['fall_prone_s']['min']); + } + + public function testOutOfRangeValueIsRejected(): void + { + $c = $this->admin(); + $res = $c->saveThreshold($this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 900])); + $this->assertSame(400, $res->status()); + $this->assertSame('out_of_range', json_decode($res->body(), true)['error']); + } + + public function testNonFiniteValueIsRejected(): void + { + // edge/config.py 가 기동 시 거부하는 바로 그 값들 — 그때는 이미 공장 안 장비가 + // 재시작 루프에 빠진 뒤다. 키보드 앞에서 막는다. + foreach (['nan', 'inf', '-inf'] as $bad) { + $res = $this->admin()->saveThreshold( + $this->post(['device_id' => 'pi-1', 'key' => 'cooldown_s', 'value' => $bad])); + $this->assertSame(400, $res->status(), "rejects $bad"); + } + } + + public function testUnknownKeyIsRejectedRatherThanDeliveredAndIgnored(): void + { + $res = $this->admin()->saveThreshold( + $this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_seconds', 'value' => 3])); + $this->assertSame(400, $res->status()); + $this->assertSame('unknown_key', json_decode($res->body(), true)['error']); + } + + public function testSaveBumpsConfigVersionSoTheChangeReachesTheDevice(): void + { + $dev = new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 4]); + $c = $this->admin('admin', null, null, $dev); + $this->assertSame(200, $c->saveThreshold( + $this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]))->status()); + $this->assertSame(5, $dev->configVersion('pi-1'), + '버전이 오르지 않으면 값은 DB 에만 남고 장비는 옛 설정으로 계속 돈다'); + } + + public function testSaveIsAudited(): void + { + $audit = new InMemoryAuditRepository(); + $c = $this->admin('admin', null, null, null, $audit); + $c->saveThreshold($this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5])); + $this->assertCount(1, $audit->log); + $this->assertSame('threshold_set', $audit->log[0]['act']); + } + + public function testScopedAdminCannotRetuneAnotherSitesDevice(): void + { + $dev = new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1], [], ['pi-1' => 'factory-a']); + $c = $this->admin('admin', 'factory-b', null, $dev); + $this->assertSame(404, $c->saveThreshold( + $this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]))->status()); + } + + public function testCsrfIsRequired(): void + { + $c = $this->admin(); + $noToken = new Request('POST', '/x', [], json_encode(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]), [], []); + $this->assertSame(403, $c->saveThreshold($noToken)->status()); + } + + public function testIntegerKnobRejectsAFraction(): void + { + [$ok, $err] = ThresholdCatalog::normalize('infer_every', 1.5); + $this->assertFalse($ok); + $this->assertSame('not_integer', $err); + } + + public function testNormalizationIsStableSoOneValueHasOneStoredForm(): void + { + [, $a] = ThresholdCatalog::normalize('fall_prone_s', '3'); + [, $b] = ThresholdCatalog::normalize('fall_prone_s', 3.0); + [, $c] = ThresholdCatalog::normalize('fall_prone_s', '3.000'); + $this->assertSame($a, $b); + $this->assertSame($b, $c); + } + + // ================================================================ alert channels + + public function testWebhookTokenIsNeverEchoedBack(): void + { + $alerts = new InMemoryAlertRepository(); + $c = $this->admin('admin', null, null, null, null, $alerts); + $c->saveChannel($this->post(['kind' => 'webhook', 'name' => 'groupware', 'enabled' => 1, + 'min_severity' => 'high', 'config' => ['url' => 'https://in.house/hook', 'token' => 'SECRET']])); + $body = json_decode($c->channels($this->get())->body(), true); + $this->assertSame('••••••••', $body['channels'][0]['config']['token'], + '설정 화면을 화면공유해도 연동 키가 새지 않아야 한다'); + } + + public function testEscalationStepRequiresADelay(): void + { + $c = $this->admin(); + $res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'manager', 'enabled' => 1, + 'min_severity' => 'critical', 'step' => 1, 'escalate_after_s' => 0, + 'config' => ['to' => 'a@b.c']])); + $this->assertSame(400, $res->status(), '지연 0 짜리 에스컬레이션은 즉시 채널과 같이 울린다'); + } + + public function testWebhookRejectsNonHttpUrl(): void + { + $c = $this->admin(); + $res = $c->saveChannel($this->post(['kind' => 'webhook', 'name' => 'x', 'enabled' => 1, + 'min_severity' => 'high', 'config' => ['url' => 'file:///etc/passwd']])); + $this->assertSame(400, $res->status()); + } + + public function testEmailChannelNeedsAValidRecipient(): void + { + $c = $this->admin(); + $res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'x', 'enabled' => 1, + 'min_severity' => 'high', 'config' => ['to' => 'not-an-email']])); + $this->assertSame(400, $res->status()); + } + + // ================================================================ suppression + + private function suppression(string $role = 'operator', ?string $site = null, + ?InMemorySuppressionRepository $repo = null, + ?InMemoryEventRepository $events = null): SuppressionController + { + $ev = $events ?? new InMemoryEventRepository(); + return new SuppressionController( + $repo ?? new InMemorySuppressionRepository(), $ev, + new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1]), + new InMemoryAuditRepository(), $this->sess($role, $site) + ); + } + + public function testSuppressionRequiresAReason(): void + { + $res = $this->suppression()->create($this->post( + ['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 30, 'reason' => ''])); + $this->assertSame(400, $res->status()); + $this->assertSame('reason_required', json_decode($res->body(), true)['error']); + } + + public function testSuppressionCannotBeOpenEnded(): void + { + $c = $this->suppression(); + // 8시간(한 교대) 상한. 그 이상은 의도적인 재발급을 강제한다. + $this->assertSame(400, $c->create($this->post(['scope_type' => 'device', 'scope_id' => 'pi-1', + 'minutes' => 60 * 24, 'reason' => '정비']))->status()); + $this->assertSame(201, $c->create($this->post(['scope_type' => 'device', 'scope_id' => 'pi-1', + 'minutes' => 60 * 4, 'reason' => '프레스 금형 교체']))->status()); + } + + public function testViewerCannotSuppress(): void + { + $this->assertSame(403, $this->suppression('viewer')->create($this->post( + ['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 30, 'reason' => '정비']))->status()); + } + + public function testUnknownScopeTargetIsRejected(): void + { + $res = $this->suppression()->create($this->post( + ['scope_type' => 'device', 'scope_id' => 'pi-nope', 'minutes' => 30, 'reason' => '정비'])); + $this->assertSame(400, $res->status()); + } + + public function testRevokeIsScoped(): void + { + $repo = new InMemorySuppressionRepository(); + $id = $repo->create(['scope_type' => 'device', 'scope_id' => 'pi-1', 'site' => 'factory-a', + 'reason' => 'x', 'starts_at' => time() - 10, 'ends_at' => time() + 600, + 'created_by' => 'a', 'created_at' => time()]); + $c = $this->suppression('operator', 'factory-b', $repo); + $this->assertSame(404, $c->revoke($this->post([]), ['id' => $id])->status()); + } + + // ================================================================ compliance + + private function compliance(string $role, ?string $site, InMemoryEventRepository $events, + ?InMemoryRiskControlRepository $risks = null, + ?InMemoryHandoverRepository $handovers = null): ComplianceController + { + return new ComplianceController( + $events, new InMemorySnapshotRepository(), new InMemoryActionRepository(), + new InMemoryAlertRepository(), $risks ?? new InMemoryRiskControlRepository(), + $handovers ?? new InMemoryHandoverRepository(), new InMemoryAuditRepository(), + $this->sess($role, $site) + ); + } + + public function testIncidentPackageCarriesTheSurroundingTimeline(): void + { + $events = new InMemoryEventRepository(); + $at = (float)time(); + $before = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'near_fall', + 'severity' => 'warning', 'occurred_at' => $at - 300, 'metrics' => []]); + $main = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => $at, 'metrics' => []]); + $after = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'no_motion', + 'severity' => 'critical', 'occurred_at' => $at + 30, 'metrics' => []]); + // 다른 카메라의 동시 사건은 들어오면 안 된다. + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-2', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => $at, 'metrics' => []]); + + $res = $this->compliance('operator', null, $events)->package($this->get(), ['id' => $main]); + $this->assertSame(200, $res->status()); + $data = json_decode($res->body(), true); + $ids = array_column($data['timeline'], 'event_id'); + $this->assertSame([$before, $main, $after], $ids, '같은 카메라의 전후 맥락만, 시간순으로'); + } + + public function testPackageHidesAnotherSitesIncidentAs404(): void + { + $events = new InMemoryEventRepository(); + $id = $events->insert(['device_id' => 'pi-1', 'site' => 'factory-a', 'camera_id' => 'cam-1', + 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)time(), 'metrics' => []]); + $res = $this->compliance('operator', 'factory-b', $events)->package($this->get(), ['id' => $id]); + $this->assertSame(404, $res->status()); + } + + public function testRiskControlNeedsComplianceRole(): void + { + $events = new InMemoryEventRepository(); + $body = $this->post(['event_type' => 'fall', 'hazard' => '단차 전도', 'control' => '매트 설치', + 'severity_rating' => 5, 'likelihood_rating' => 3]); + $this->assertSame(403, $this->compliance('operator', null, $events)->saveRisk($body)->status()); + $this->assertSame(200, $this->compliance('admin', null, $events)->saveRisk($body)->status()); + } + + public function testRiskRatingIsBounded(): void + { + $events = new InMemoryEventRepository(); + $res = $this->compliance('admin', null, $events)->saveRisk($this->post( + ['event_type' => 'fall', 'hazard' => 'h', 'control' => 'c', + 'severity_rating' => 9, 'likelihood_rating' => 3])); + $this->assertSame(400, $res->status()); + } + + public function testHandoverCountsAreComputedServerSideNotTakenFromTheRequest(): void + { + $events = new InMemoryEventRepository(); + $now = (float)time(); + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', + 'severity' => 'critical', 'occurred_at' => $now - 60, 'metrics' => []]); + $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_warning', + 'severity' => 'warning', 'occurred_at' => $now - 30, 'metrics' => []]); + $handovers = new InMemoryHandoverRepository(); + + $c = $this->compliance('operator', null, $events, null, $handovers); + // 브라우저가 0 을 주장해도 서버가 다시 센다. + $res = $c->acceptHandover($this->post(['note' => '인수', 'unacked_count' => 0, 'severe_count' => 0])); + $this->assertSame(201, $res->status()); + $row = $handovers->recent(null, 1)[0]; + $this->assertSame(2, $row['unacked_count']); + $this->assertSame(1, $row['severe_count']); + $this->assertSame('관리자', $row['to_user']); + } +} diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index ef742a2..6297fe8 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -3,7 +3,8 @@ declare(strict_types=1); namespace App\Tests\Support; use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository, UserRepository, ZoneRepository, - ThresholdRepository, AuditRepository, ActionRepository}; + ThresholdRepository, AuditRepository, ActionRepository, SuppressionRepository, + AlertRepository, HandoverRepository, RiskControlRepository, DeviceMetricRepository}; final class InMemoryDeviceRepository implements DeviceRepository { public array $heartbeats = []; @@ -19,6 +20,11 @@ final class InMemoryDeviceRepository implements DeviceRepository { public function siteFor(string $d): ?string { return $this->sites[$d] ?? null; } public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; } public function configVersion(string $d): int { return $this->versions[$d] ?? 1; } + public function bumpConfigVersion(string $d): int { + if (!isset($this->secrets[$d]) && !isset($this->versions[$d])) return 0; + $this->versions[$d] = ($this->versions[$d] ?? 1) + 1; + return $this->versions[$d]; + } public function listStatus(?string $site): array { $rows = $this->statusRows; if ($site !== null) $rows = array_filter($rows, fn($d) => ($d['site'] ?? null) === $site); @@ -123,6 +129,39 @@ final class InMemoryEventRepository implements EventRepository { usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]); return array_slice(array_values($r), 0, $limit); } + public function forNotification(float $since, int $limit): array { + $r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since); + usort($r, fn($a, $b) => [$a['occurred_at'] ?? 0, $a['event_id']] <=> [$b['occurred_at'] ?? 0, $b['event_id']]); + return array_slice(array_values($r), 0, $limit); + } + public function siteSummary(float $since, ?float $until = null): array { + $by = []; + foreach ($this->rows as $r) { + if (($r['occurred_at'] ?? 0) < $since) continue; + if ($until !== null && ($r['occurred_at'] ?? 0) >= $until) continue; + if (($r['event_type'] ?? '') === 'ergo_report') continue; + $s = $r['site'] ?? null; + $k = $s === null ? "\x00" : $s; + if (!isset($by[$k])) $by[$k] = ['site' => $s, 'count' => 0, 'critical_high' => 0, + 'unacked' => 0, 'cameras' => [], ]; + $by[$k]['count']++; + if (in_array($r['severity'] ?? '', ['critical', 'high'], true)) $by[$k]['critical_high']++; + if (empty($r['acknowledged_by'])) $by[$k]['unacked']++; + if (!empty($r['camera_id'])) $by[$k]['cameras'][$r['camera_id']] = true; + } + $out = array_map(fn($x) => ['site' => $x['site'], 'count' => $x['count'], + 'critical_high' => $x['critical_high'], 'unacked' => $x['unacked'], + 'cameras' => count($x['cameras'])], array_values($by)); + usort($out, fn($a, $b) => [$b['critical_high'], $b['count']] <=> [$a['critical_high'], $a['count']]); + return $out; + } + public function windowForCamera(string $cam, float $from, float $to, ?string $site, int $limit): array { + $r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cam + && ($x['occurred_at'] ?? 0) >= $from && ($x['occurred_at'] ?? 0) < $to + && ($site === null || ($x['site'] ?? null) === $site)); + usort($r, fn($a, $b) => [$a['occurred_at'] ?? 0, $a['event_id']] <=> [$b['occurred_at'] ?? 0, $b['event_id']]); + return array_slice(array_values($r), 0, $limit); + } public function countForCamera(string $cam, float $from, float $to, ?string $site): int { return count(array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cam && ($x['occurred_at'] ?? 0) >= $from && ($x['occurred_at'] ?? 0) < $to @@ -247,10 +286,174 @@ final class InMemoryActionRepository implements ActionRepository { } } final class InMemoryThresholdRepository implements ThresholdRepository { - public function __construct(private array $data = []) {} - public function forDevice(string $d): array { return $this->data[$d] ?? []; } + public array $rows = []; private int $seq = 0; + /** @param array $data device_id => [key => value] (the pre-editor shape many tests use) */ + public function __construct(array $data = []) { + foreach ($data as $dev => $kv) foreach ($kv as $k => $v) { + $id = ++$this->seq; + $this->rows[$id] = ['id' => $id, 'device_id' => $dev, 'camera_id' => null, 'key' => $k, + 'value' => (string)$v, 'updated_by' => null, 'updated_at' => null, 'site' => null]; + } + } + public function forDevice(string $d): array { + // Device-wide rows only — the same contract PdoThresholdRepository has, so a test that + // passes here cannot pass for a reason the delivered query would not reproduce. + $out = []; + foreach ($this->rows as $r) if ($r['device_id'] === $d && $r['camera_id'] === null) $out[$r['key']] = $r['value']; + return $out; + } + public function listAll(?string $site): array { + return array_values(array_filter($this->rows, fn($r) => $site === null || $r['site'] === $site)); + } + public function set(string $dev, ?string $cam, string $k, string $v, string $actor, int $now): int { + foreach ($this->rows as $id => $r) { + if ($r['device_id'] === $dev && $r['camera_id'] === $cam && $r['key'] === $k) { + $this->rows[$id]['value'] = $v; + $this->rows[$id]['updated_by'] = $actor; + $this->rows[$id]['updated_at'] = $now; + return $id; + } + } + $id = ++$this->seq; + $this->rows[$id] = ['id' => $id, 'device_id' => $dev, 'camera_id' => $cam, 'key' => $k, + 'value' => $v, 'updated_by' => $actor, 'updated_at' => $now, 'site' => null]; + return $id; + } + public function remove(int $id, ?string $site): bool { + if (!isset($this->rows[$id])) return false; + if ($site !== null && $this->rows[$id]['site'] !== $site) return false; + unset($this->rows[$id]); return true; + } + public function find(int $id): ?array { return $this->rows[$id] ?? null; } } final class InMemoryAuditRepository implements AuditRepository { public array $log = []; public function append(string $a, string $act, string $t, array $b, array $af): void { $this->log[] = compact('a', 'act', 't', 'b', 'af'); } } +final class InMemorySuppressionRepository implements SuppressionRepository { + public array $rows = []; private int $seq = 0; + private function live(array $r, int $now): bool { + return $r['revoked_at'] === null && $r['starts_at'] <= $now && $r['ends_at'] > $now; + } + public function activeFor(array $scopes, int $now): array { + return array_values(array_filter($this->rows, function ($r) use ($scopes, $now) { + if (!$this->live($r, $now)) return false; + foreach ($scopes as [$type, $id]) if ($r['scope_type'] === $type && $r['scope_id'] === (string)$id) return true; + return false; + })); + } + public function listAll(?string $site, int $now, bool $includeExpired = false): array { + $r = array_values(array_filter($this->rows, fn($x) => + ($site === null || $x['site'] === $site) && ($includeExpired || $this->live($x, $now)))); + usort($r, fn($a, $b) => $b['created_at'] <=> $a['created_at']); + return $r; + } + public function create(array $row): int { + $id = ++$this->seq; + $this->rows[$id] = $row + ['suppression_id' => $id, 'revoked_by' => null, 'revoked_at' => null]; + return $id; + } + public function revoke(int $id, string $user, int $now, ?string $site): bool { + if (!isset($this->rows[$id]) || $this->rows[$id]['revoked_at'] !== null) return false; + if ($site !== null && $this->rows[$id]['site'] !== $site) return false; + $this->rows[$id]['revoked_by'] = $user; $this->rows[$id]['revoked_at'] = $now; return true; + } + public function find(int $id): ?array { return $this->rows[$id] ?? null; } +} +final class InMemoryAlertRepository implements AlertRepository { + public array $chan = []; public array $log = []; private int $seq = 0; + public function __construct(array $channels = []) { foreach ($channels as $c) $this->saveChannel($c); } + public function channels(?string $s): array { + $r = array_values(array_filter($this->chan, fn($c) => + $c['enabled'] && ($s === null || $c['site'] === null || $c['site'] === $s))); + usort($r, fn($a, $b) => [$a['step'], $a['channel_id']] <=> [$b['step'], $b['channel_id']]); + return $r; + } + public function allChannels(?string $s): array { + return array_values(array_filter($this->chan, fn($c) => $s === null || $c['site'] === $s)); + } + public function saveChannel(array $row): int { + $id = $row['channel_id'] ?? ++$this->seq; + $this->chan[$id] = $row + ['channel_id' => $id, 'site' => null, 'enabled' => 1, 'step' => 0, + 'escalate_after_s' => 0, 'min_severity' => 'high', 'config_json' => '{}']; + return $id; + } + public function deleteChannel(int $id, ?string $s): bool { + if (!isset($this->chan[$id])) return false; + if ($s !== null && $this->chan[$id]['site'] !== $s) return false; + unset($this->chan[$id]); return true; + } + private function key(int $e, int $c): string { return $e . ':' . $c; } + public function claim(int $e, int $c, int $now): bool { + if (isset($this->log[$this->key($e, $c)])) return false; + $this->log[$this->key($e, $c)] = ['event_id' => $e, 'channel_id' => $c, 'status' => 'pending', + 'attempts' => 0, 'last_error' => null, 'queued_at' => $now, 'sent_at' => null]; + return true; + } + public function markSent(int $e, int $c, int $now): void { + $k = $this->key($e, $c); + if (isset($this->log[$k])) { $this->log[$k]['status'] = 'sent'; $this->log[$k]['sent_at'] = $now; $this->log[$k]['attempts']++; } + } + public function markFailed(int $e, int $c, string $err, int $now): void { + $k = $this->key($e, $c); + if (isset($this->log[$k])) { $this->log[$k]['status'] = 'failed'; $this->log[$k]['last_error'] = $err; $this->log[$k]['attempts']++; } + } + public function markSuppressed(int $e, int $c, int $now): void { + $k = $this->key($e, $c); + if (isset($this->log[$k])) { $this->log[$k]['status'] = 'suppressed'; $this->log[$k]['sent_at'] = $now; } + } + public function forEvent(int $e): array { + return array_values(array_filter($this->log, fn($r) => $r['event_id'] === $e)); + } + public function retryable(int $max, int $limit): array { + return array_slice(array_values(array_filter($this->log, + fn($r) => $r['status'] === 'failed' && $r['attempts'] < $max)), 0, $limit); + } +} +final class InMemoryHandoverRepository implements HandoverRepository { + public array $rows = []; private int $seq = 0; + public function recent(?string $s, int $limit): array { + $r = array_values(array_filter($this->rows, fn($x) => $s === null || $x['site'] === $s)); + usort($r, fn($a, $b) => $b['created_at'] <=> $a['created_at']); + return array_slice($r, 0, $limit); + } + public function latest(?string $s): ?array { return $this->recent($s, 1)[0] ?? null; } + public function create(array $row): int { + $id = ++$this->seq; $this->rows[$id] = $row + ['handover_id' => $id]; return $id; + } +} +final class InMemoryRiskControlRepository implements RiskControlRepository { + public array $rows = []; private int $seq = 0; + public function listAll(?string $s): array { + return array_values(array_filter($this->rows, fn($r) => $s === null || $r['site'] === $s)); + } + public function upsert(array $row): void { + foreach ($this->rows as $id => $r) { + if ($r['site'] === ($row['site'] ?? null) && $r['event_type'] === $row['event_type']) { + $this->rows[$id] = $row + ['control_id' => $id]; return; + } + } + $id = ++$this->seq; $this->rows[$id] = $row + ['control_id' => $id]; + } + public function delete(int $id, ?string $s): bool { + if (!isset($this->rows[$id])) return false; + if ($s !== null && $this->rows[$id]['site'] !== $s) return false; + unset($this->rows[$id]); return true; + } +} +final class InMemoryDeviceMetricRepository implements DeviceMetricRepository { + public array $rows = []; + public function append(string $d, array $m, int $now): void { + $this->rows[] = ['device_id' => $d, 'ts' => $now] + $m; + } + public function series(string $d, int $since, int $limit): array { + $r = array_values(array_filter($this->rows, fn($x) => $x['device_id'] === $d && $x['ts'] >= $since)); + usort($r, fn($a, $b) => $a['ts'] <=> $b['ts']); + return array_slice($r, 0, $limit); + } + public function prune(int $before): int { + $n = count($this->rows); + $this->rows = array_values(array_filter($this->rows, fn($x) => $x['ts'] >= $before)); + return $n - count($this->rows); + } +} diff --git a/server/tools/backup.php b/server/tools/backup.php new file mode 100644 index 0000000..35f4dc5 --- /dev/null +++ b/server/tools/backup.php @@ -0,0 +1,157 @@ + [--no-snapshots]\n"); + exit(2); +} +if (!is_dir($outDir) && !@mkdir($outDir, 0700, true)) { + fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n"); + exit(2); +} +// 백업 파일에는 전 현장의 사고 기록과 개인정보가 들어 있다. 웹에서 읽히는 위치에 두면 +// 그 자체가 사고다. +$real = realpath($outDir); +if ($real !== false && str_contains(str_replace('\\', '/', $real), '/www/')) { + fwrite(STDERR, "거부: 백업 대상이 웹 공개 경로(www/) 안입니다. 이 파일은 사고 기록과 개인정보를 담습니다.\n"); + exit(2); +} + +$stamp = date('Ymd-His'); +$sqlPath = $outDir . '/move_detect-' . $stamp . '.sql'; +$fh = fopen($sqlPath, 'wb'); +if ($fh === false) { fwrite(STDERR, "덤프 파일을 열 수 없습니다.\n"); exit(2); } + +// 이 순서는 복구 시 참조 무결성 순서다 (events 가 snapshots/alert_log 보다 먼저). +$TABLES = [ + 'move_users', 'move_devices', 'move_cameras', 'move_zones', 'move_thresholds', + 'move_events', 'move_event_snapshots', 'move_actions', 'move_audit_log', + 'move_suppressions', 'move_alert_channels', 'move_alert_log', + 'move_shift_handovers', 'move_risk_controls', 'move_device_metrics', +]; +// nonce / rate_limits 는 재생성되는 휘발성 데이터라 일부러 제외한다. + +function q(PDO $pdo, $v): string +{ + if ($v === null) return 'NULL'; + if (is_int($v) || is_float($v)) return (string)$v; + return $pdo->quote((string)$v); +} + +fwrite($fh, "-- move_detect backup {$stamp}\n"); +fwrite($fh, "-- 복구: phpMyAdmin 에서 이 파일을 실행한 뒤 스냅샷 아카이브를 storage/snapshots/ 에 풉니다.\n"); +fwrite($fh, "SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n"); + +$counts = []; +foreach ($TABLES as $table) { + try { + $probe = $pdo->query("SELECT * FROM {$table} LIMIT 1"); + } catch (Throwable $e) { + // 마이그레이션 전 DB 에는 없는 표가 있다. 백업이 그걸로 실패하면 안 된다. + $counts[$table] = 'skipped'; + continue; + } + $cols = []; + for ($i = 0; $i < $probe->columnCount(); $i++) { + $cols[] = $probe->getColumnMeta($i)['name']; + } + $colList = '`' . implode('`, `', $cols) . '`'; + + $n = 0; + $offset = 0; + fwrite($fh, "\n-- {$table}\n"); + while (true) { + // 청크 단위: 이벤트가 수십만 건인 현장에서 한 번에 fetchAll 하면 메모리가 터진다. + $st = $pdo->prepare("SELECT * FROM {$table} LIMIT 500 OFFSET {$offset}"); + $st->execute(); + $rows = $st->fetchAll(PDO::FETCH_ASSOC); + if ($rows === []) break; + foreach ($rows as $row) { + $vals = []; + foreach ($cols as $c) $vals[] = q($pdo, $row[$c] ?? null); + fwrite($fh, "INSERT INTO {$table} ({$colList}) VALUES (" . implode(', ', $vals) . ");\n"); + $n++; + } + $offset += 500; + } + $counts[$table] = $n; +} +fwrite($fh, "\nSET FOREIGN_KEY_CHECKS=1;\n"); +fclose($fh); +@chmod($sqlPath, 0600); + +// ---- 스냅샷 --------------------------------------------------------------------------- +$snapNote = 'skipped (--no-snapshots)'; +if (!isset($args['no-snapshots'])) { + $dir = rtrim($cfg->snapshotDir(), '/\\'); + if (!is_dir($dir)) { + $snapNote = 'snapshot dir 없음'; + } elseif (!class_exists('ZipArchive')) { + // ext-zip 이 없으면 아카이브를 만들 수 없다. 조용히 넘어가면 이미지 없는 백업을 + // 완전한 백업으로 착각하게 된다. + $snapNote = 'ZipArchive 없음 — storage/snapshots/ 를 FTP 로 별도 내려받으세요'; + } else { + $zipPath = $outDir . '/move_detect-snapshots-' . $stamp . '.zip'; + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { + $files = glob($dir . '/*.jpg') ?: []; + foreach ($files as $f) $zip->addFile($f, basename($f)); + $zip->close(); + @chmod($zipPath, 0600); + $snapNote = count($files) . '개 -> ' . basename($zipPath); + } else { + $snapNote = '아카이브를 만들지 못했습니다'; + } + } +} + +// ---- 복구 안내 ------------------------------------------------------------------------- +$restore = $outDir . '/RESTORE.txt'; +file_put_contents($restore, implode("\n", [ + 'move_detect 복구 절차', + '', + '1. 새 DB 를 만들고 schema.sql 을 먼저 실행합니다 (표 구조).', + '2. migrations/ 의 SQL 을 날짜 순서대로 실행합니다.', + '3. 이 폴더의 move_detect-<시각>.sql 을 실행합니다 (데이터).', + '4. move_detect-snapshots-<시각>.zip 을 풀어 storage/snapshots/ 에 넣습니다.', + '5. config.php 의 DB 접속 정보를 새 DB 로 맞춥니다.', + '', + '주의:', + '- 이 백업에는 사고 기록과 작업자 이미지(개인정보)가 들어 있습니다.', + ' 웹에서 접근 가능한 경로에 두지 마십시오.', + '- move_nonces / move_rate_limits 는 휘발성이라 백업에 포함되지 않습니다 (자동 재생성).', + '- 복구 후 tools/verify_audit_chain.php 로 감사로그 무결성을 확인하십시오.', + '', + '생성: ' . date('c'), +]) . "\n"); + +fwrite(STDOUT, "DB 덤프: {$sqlPath}\n"); +foreach ($counts as $t => $n) fwrite(STDOUT, sprintf(" %-24s %s\n", $t, $n)); +fwrite(STDOUT, "스냅샷: {$snapNote}\n"); +fwrite(STDOUT, "복구 안내: {$restore}\n"); +exit(0); diff --git a/server/tools/bootstrap.php b/server/tools/bootstrap.php new file mode 100644 index 0000000..12a4386 --- /dev/null +++ b/server/tools/bootstrap.php @@ -0,0 +1,69 @@ + + is_file($d . '/config.php') + && is_file($d . '/vendor/autoload.php') + && is_file($d . '/src/Config.php'); + + for ($dir = dirname(__DIR__), $i = 0; $i < 4; $i++) { + foreach ([$dir . '/' . APP_PRIVATE_DIR, $dir] as $candidate) { + if ($isAppRoot($candidate)) return $candidate; + } + if (dirname($dir) === $dir) break; + $dir = dirname($dir); + } + fwrite(STDERR, "config.php / vendor/autoload.php / src/Config.php 를 찾지 못했습니다.\n"); + exit(1); +} + +/** @return array{0: \App\Config, 1: \PDO} */ +function tool_boot(): array +{ + $root = tool_app_root(); + require_once $root . '/vendor/autoload.php'; + $cfg = \App\Config::fromArray(require $root . '/config.php'); + return [$cfg, \App\Db\Database::connect($cfg)]; +} + +/** Parse `--key=value` / `--flag` argv into an array. */ +function tool_args(array $argv): array +{ + $out = []; + foreach (array_slice($argv, 1) as $a) { + if (!str_starts_with($a, '--')) continue; + $a = substr($a, 2); + if (str_contains($a, '=')) { + [$k, $v] = explode('=', $a, 2); + $out[$k] = $v; + } else { + $out[$a] = true; + } + } + return $out; +} + +/** true when the DSN targets sqlite — a few statements differ and the tools must not guess. */ +function tool_is_sqlite(\PDO $pdo): bool +{ + return $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) === 'sqlite'; +} diff --git a/server/tools/dispatch_alerts.php b/server/tools/dispatch_alerts.php new file mode 100644 index 0000000..8d8023e --- /dev/null +++ b/server/tools/dispatch_alerts.php @@ -0,0 +1,125 @@ + /usr/bin/php /path/to/move_detect_app/tools/dispatch_alerts.php >> /path/alerts.log 2>&1 + * + * 정확한 크론 표기는 server/docs/server-deploy.md §11 을 보십시오. + * + * 하는 일 3가지: + * 1. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다. + * 2. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널). + * 3. 실패한 전달을 재시도한다. + * + * 이 파일이 없으면 이 제품은 "사고를 기록하는 시스템"이지 "사고에 대응하는 시스템"이 아니다. + * 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞는 상황이 정확히 이 구멍이었다. + * + * --dry-run 은 어떤 통보가 나갔을지만 출력하고 아무것도 보내지 않으며 원장에도 쓰지 않는다. + * 채널을 처음 설정할 때 반드시 먼저 돌려볼 것 — 잘못된 수신자 목록으로 첫 발송을 하면 + * 그 사이트에서는 두 번 다시 이 기능을 켜지 않는다. + */ +declare(strict_types=1); + +require __DIR__ . '/bootstrap.php'; + +use App\Alert\{AlertDispatcher, Channels, EscalationWatch}; +use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository}; + +[$cfg, $pdo] = tool_boot(); +$args = tool_args($argv); + +$now = time(); +$dryRun = isset($args['dry-run']); +// 창은 재시도 여유를 포함해야 한다: 크론이 몇 번 건너뛰어도 그 사이 이벤트가 통보 없이 +// 흘러가면 안 된다. 기본 1시간. +$window = max(60, min(86400, (int)($args['window'] ?? 3600))); +$escalateAfter = max(30, min(86400, (int)($args['escalate-after'] ?? $cfg->escalateAfterSeconds()))); +$limit = max(1, min(2000, (int)($args['limit'] ?? 500))); +$maxAttempts = max(1, min(20, (int)($args['max-attempts'] ?? 5))); + +$events = new PdoEventRepository($pdo); +$alerts = new PdoAlertRepository($pdo); +$suppressions = new PdoSuppressionRepository($pdo); + +$baseUrl = $cfg->publicBaseUrl(); +if ($baseUrl === '') { + fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n"); + exit(2); +} +$channels = new Channels($baseUrl); + +// --- 1. 에스컬레이션 발행 ------------------------------------------------------------- +$watch = new EscalationWatch($events); +$escalated = $dryRun ? [] : $watch->run($now, $escalateAfter, $window, $limit); +if ($escalated !== []) { + fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n"); +} + +// --- 2. 통보 -------------------------------------------------------------------------- +$dispatcher = new AlertDispatcher($events, $alerts, $suppressions); + +// local 채널은 디스패처가 계획 단계에서 제외한다 (엣지가 자체 GPIO 로 즉시 울린다 — +// App\Alert\AlertDispatcher::planFor 주석 참조). 여기 도달하는 것은 email/webhook 뿐이다. +$send = function (array $event, array $channel) use ($channels, $dryRun): void { + if ($dryRun) { + fwrite(STDOUT, sprintf(" [dry-run] event=%d -> %s(%s) : %s\n", + $event['event_id'], $channel['kind'], $channel['name'], $channels->subject($event))); + return; + } + $channels->send($event, $channel); +}; + +$stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0]; +try { + if ($dryRun) { + // run() claims a ledger row per delivery, which is exactly what must NOT happen during a + // rehearsal: a claimed row is never retried, so a dry run would silently consume the + // first real notification for every event in the window. Plan only. + foreach ($events->forNotification((float)($now - $window), $limit) as $ev) { + $stats['considered']++; + foreach ($dispatcher->planFor($ev, $alerts->channels($ev['site'] ?? null), $now) as $d) { + if ($d['suppressed']) { $stats['suppressed']++; continue; } + $send($ev, $d['channel']); + $stats['sent']++; + } + } + } else { + $stats = $dispatcher->run($now, $window, $limit, $send); + } +} catch (Throwable $e) { + fwrite(STDERR, date('c') . ' dispatcher error: ' . $e->getMessage() . "\n"); + exit(1); +} + +// --- 3. 실패 재시도 ------------------------------------------------------------------- +$retried = 0; +if (!$dryRun) { + foreach ($alerts->retryable($maxAttempts, 100) as $row) { + $event = $events->find((int)$row['event_id']); + if ($event === null) continue; + // 재시도 시점에 이미 확인된 사건이면 더 보내지 않는다 — 사람이 이미 붙었다. + if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) continue; + $channel = null; + foreach ($alerts->allChannels(null) as $c) { + if ((int)$c['channel_id'] === (int)$row['channel_id']) { $channel = $c; break; } + } + if ($channel === null) continue; + try { + $channels->send($event, $channel); + $alerts->markSent((int)$row['event_id'], (int)$row['channel_id'], $now); + $retried++; + } catch (Throwable $e) { + $alerts->markFailed((int)$row['event_id'], (int)$row['channel_id'], $e->getMessage(), $now); + } + } +} + +fwrite(STDOUT, sprintf( + "%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d%s\n", + date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'], + $stats['failed'], $stats['skipped'], $retried, count($escalated), $dryRun ? ' (dry-run)' : '' +)); +exit(0); diff --git a/server/tools/maintain.php b/server/tools/maintain.php new file mode 100644 index 0000000..230dbcd --- /dev/null +++ b/server/tools/maintain.php @@ -0,0 +1,122 @@ +> /path/maintain.log 2>&1 + * + * 지우는 것과 지우지 않는 것이 이 도구의 핵심이다: + * + * 스냅샷 이미지 — 지운다. PIPA 대상 개인정보이고 보존기간이 정해져 있다. + * 성능 시계열 — 지운다. 장치당 하루 2,880행이라 공유호스팅 용량을 가장 먼저 잡아먹는다. + * nonce — 지운다. 만료된 재전송 방지 토큰은 아무 의미가 없다. + * 이벤트 기록 — **기본적으로 지우지 않는다**. 산업안전 조사나 노사 분쟁에서 수년 뒤에도 + * 필요할 수 있는 사고 기록이라, 시스템이 임의로 폐기하면 안 된다. + * config.php 의 event_retention_days 를 명시적으로 설정한 현장만 정리한다. + * + * --dry-run 으로 무엇이 지워질지 먼저 확인할 수 있다. + */ +declare(strict_types=1); + +require __DIR__ . '/bootstrap.php'; + +use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository}; + +[$cfg, $pdo] = tool_boot(); +$args = tool_args($argv); +$dry = isset($args['dry-run']); +$now = time(); + +function say(string $m): void { fwrite(STDOUT, date('c') . ' ' . $m . "\n"); } + +$done = []; + +// ---- 스냅샷 이미지 (PIPA) ------------------------------------------------------------ +$snapDays = $cfg->snapshotRetentionDays(); +if ($snapDays > 0) { + $cutoff = $now - $snapDays * 86400; + $snaps = new PdoSnapshotRepository($pdo, $cfg->snapshotDir()); + if ($dry) { + $st = $pdo->prepare('SELECT COUNT(*) FROM move_event_snapshots WHERE created_at < ?'); + $st->execute([$cutoff]); + $done[] = 'snapshots(dry) ' . (int)$st->fetchColumn(); + } else { + // 기존 보존 로직을 그대로 쓴다 (파일 + 행을 함께 지운다). 메서드가 없는 구버전 + // 저장소에서도 도구가 죽지 않도록 방어한다. + $purge = method_exists($snaps, 'purgeOlderThan') ? $snaps->purgeOlderThan($cutoff) : 0; + $done[] = "snapshots $purge"; + } +} else { + $done[] = 'snapshots disabled'; +} + +// ---- 성능 시계열 --------------------------------------------------------------------- +$metricDays = $cfg->metricRetentionDays(); +if ($metricDays > 0) { + $cutoff = $now - $metricDays * 86400; + if ($dry) { + $st = $pdo->prepare('SELECT COUNT(*) FROM move_device_metrics WHERE ts < ?'); + $st->execute([$cutoff]); + $done[] = 'metrics(dry) ' . (int)$st->fetchColumn(); + } else { + $done[] = 'metrics ' . (new PdoDeviceMetricRepository($pdo))->prune($cutoff); + } +} + +// ---- nonce --------------------------------------------------------------------------- +if ($dry) { + $st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?'); + $st->execute([$now]); + $done[] = 'nonces(dry) ' . (int)$st->fetchColumn(); +} else { + $st = $pdo->prepare('DELETE FROM move_nonces WHERE expires_at < ?'); + $st->execute([$now]); + $done[] = 'nonces ' . $st->rowCount(); +} + +// ---- rate limit 버킷 ------------------------------------------------------------------ +if (!$dry) { + $st = $pdo->prepare('DELETE FROM move_rate_limits WHERE window_start < ?'); + $st->execute([intdiv($now, 60) - 1440]); // 하루치만 남긴다 + $done[] = 'rate_limits ' . $st->rowCount(); +} + +// ---- 이벤트 기록 (기본 보존) ---------------------------------------------------------- +$eventDays = $cfg->eventRetentionDays(); +if ($eventDays > 0) { + $cutoff = (float)($now - $eventDays * 86400); + if ($dry) { + $st = $pdo->prepare('SELECT COUNT(*) FROM move_events WHERE occurred_at < ?'); + $st->execute([sprintf('%.6f', $cutoff)]); + $done[] = 'events(dry) ' . (int)$st->fetchColumn(); + } else { + // 한 번에 다 지우면 공유호스팅에서 락이 길어져 ingest 가 밀린다. 배치로 끊는다. + $events = new PdoEventRepository($pdo); + $total = 0; + for ($i = 0; $i < 20; $i++) { + $n = $events->purgeOlderThan($cutoff, 500); + $total += $n; + if ($n === 0) break; + } + $done[] = "events $total"; + } +} else { + $done[] = 'events kept (retention disabled — 사고 기록은 기본 영구 보존)'; +} + +// ---- 용량 경고 ------------------------------------------------------------------------- +$minFree = $cfg->storageMinFreeMb(); +if ($minFree > 0 && is_dir($cfg->snapshotDir())) { + $free = @disk_free_space($cfg->snapshotDir()); + if ($free !== false && $free !== null) { + $freeMb = (int)floor($free / 1048576); + if ($freeMb < $minFree) { + // 정리를 마친 뒤에도 부족하다면 사람이 개입해야 한다. 종료 코드로도 알린다. + say("WARNING: 스냅샷 볼륨 여유 {$freeMb}MB (기준 {$minFree}MB) — 보존기간을 줄이거나 용량을 확보하세요."); + say(implode(' · ', $done) . ($dry ? ' (dry-run)' : '')); + exit(3); + } + } +} + +say(implode(' · ', $done) . ($dry ? ' (dry-run)' : '')); +exit(0); diff --git a/server/tools/seed_demo.php b/server/tools/seed_demo.php new file mode 100644 index 0000000..dbea241 --- /dev/null +++ b/server/tools/seed_demo.php @@ -0,0 +1,373 @@ +snapshotDir(), '/\\'); + +function say(string $m): void { fwrite(STDOUT, $m . "\n"); } + +// ---------------------------------------------------------------- purge +function purge(PDO $pdo, string $snapshotDir): void +{ + $like = DEMO_PREFIX . '%'; + // Snapshot files first: once the rows go, the uuids are unrecoverable and the jpgs would + // sit orphaned on disk forever. + $st = $pdo->prepare( + 'SELECT s.file_uuid FROM move_event_snapshots s + JOIN move_events e ON e.event_id = s.event_id + WHERE e.device_id LIKE ?' + ); + $st->execute([$like]); + $removed = 0; + foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $uuid) { + $p = $snapshotDir . '/' . $uuid . '.jpg'; + if (is_file($p) && @unlink($p)) $removed++; + } + $pdo->prepare('DELETE FROM move_event_snapshots WHERE event_id IN + (SELECT event_id FROM move_events WHERE device_id LIKE ?)')->execute([$like]); + $pdo->prepare('DELETE FROM move_events WHERE device_id LIKE ?')->execute([$like]); + $pdo->prepare('DELETE FROM move_zones WHERE device_id LIKE ?')->execute([$like]); + $pdo->prepare('DELETE FROM move_devices WHERE device_id LIKE ?')->execute([$like]); + $pdo->prepare('DELETE FROM move_cameras WHERE device_id LIKE ?')->execute([$like]); + $pdo->prepare('DELETE FROM move_device_metrics WHERE device_id LIKE ?')->execute([$like]); + $pdo->prepare("DELETE FROM move_actions WHERE created_by = 'demo-seed'")->execute(); + // 데모 채널/위험요인은 이름·검토자에 표식이 있다. 실제 운영 행을 지우지 않도록 + // LIKE 로 좁힌다 — 시연용 DB 와 운영 DB 가 같은 호스트에 있는 경우가 실제로 있다. + $pdo->prepare("DELETE FROM move_alert_channels WHERE name LIKE '%(데모%'")->execute(); + $pdo->prepare("DELETE FROM move_risk_controls WHERE reviewed_by = '안전관리자' AND site = 'factory-a'")->execute(); + say("purged demo rows (snapshot files removed: $removed)"); +} + +if ($purge) { purge($pdo, $snapshotDir); if (!$force) exit(0); } + +$existing = (int)$pdo->query('SELECT COUNT(*) FROM move_events')->fetchColumn(); +if ($existing > 0 && !$force) { + fwrite(STDERR, "이미 이벤트 {$existing}건이 있는 DB입니다. 데모 데이터를 섞으려면 --force,\n" + . "기존 데모 행만 지우려면 --purge 를 쓰세요.\n"); + exit(1); +} + +// ---------------------------------------------------------------- fixtures +$now = time(); + +/** A workplace, not a test fixture: each camera has a different risk profile so the report, + * the ranking bars and the FP panel all have something real to say. */ +$devices = [ + ['id' => DEMO_PREFIX . 'press', 'name' => '1공장 프레스라인', 'site' => 'factory-a', + 'fps' => 24.8, 'temp' => 58.2, 'throttle' => 0, 'beat' => $now - 8], + ['id' => DEMO_PREFIX . 'wh', 'name' => '1공장 자재창고', 'site' => 'factory-a', + 'fps' => 23.9, 'temp' => 61.4, 'throttle' => 1, 'beat' => $now - 21], + ['id' => DEMO_PREFIX . 'paint', 'name' => '2공장 도장부스', 'site' => 'factory-a', + 'fps' => null, 'temp' => null, 'throttle' => null, 'beat' => $now - 5400], // offline +]; +$cameras = [ + DEMO_PREFIX . 'press' => ['cam-press-01', 'cam-press-02'], + DEMO_PREFIX . 'wh' => ['cam-wh-01', 'cam-wh-02'], + DEMO_PREFIX . 'paint' => ['cam-paint-01'], +]; +// Per camera: how often it fires, and what it tends to fire. A press line falls; a warehouse +// has zone and occupancy traffic; a paint booth is quiet but degrades. +$profile = [ + 'cam-press-01' => ['weight' => 4, 'types' => ['fall', 'near_fall', 'immobility', 'ergo_report', 'no_motion']], + 'cam-press-02' => ['weight' => 3, 'types' => ['near_fall', 'ergo_report', 'pinch', 'ghost']], + 'cam-wh-01' => ['weight' => 2, 'types' => ['zone_warning', 'overstay', 'lone_worker']], + 'cam-wh-02' => ['weight' => 3, 'types' => ['zone_restricted', 'overcapacity', 'overstay', 'zone_warning']], + 'cam-paint-01' => ['weight' => 1, 'types' => ['monitoring_degraded', 'device_fault', 'zone_warning']], +]; +$severityOf = [ + 'fall' => 'critical', 'no_motion' => 'critical', 'pinch' => 'critical', + 'immobility' => 'high', 'ghost' => 'high', 'zone_restricted' => 'high', 'lone_worker' => 'high', + 'near_fall' => 'warning', 'zone_warning' => 'warning', 'overcapacity' => 'warning', 'overstay' => 'warning', + 'ergo_report' => 'info', 'monitoring_degraded' => 'info', 'device_fault' => 'info', +]; + +$pdo->beginTransaction(); + +// devices + cameras +$dev = $pdo->prepare('INSERT INTO move_devices (device_id,name,site,secret,config_version,last_fps,last_temp, + last_throttle,reported_config_version,last_heartbeat_at,created_at) + VALUES (?,?,?,?,3,?,?,?,3,?,?)'); +$cam = $pdo->prepare('INSERT INTO move_cameras (camera_id,device_id,name,role,created_at) VALUES (?,?,?,?,?)'); +foreach ($devices as $d) { + // A demo secret that is obviously not a credential. Never a plausible-looking one: a demo + // row that looks provisioned invites someone to point a real device at it. + $dev->execute([$d['id'], $d['name'], $d['site'], 'DEMO-NOT-A-REAL-SECRET', + $d['fps'], $d['temp'], $d['throttle'], $d['beat'], $now - 86400 * 90]); + foreach ($cameras[$d['id']] as $c) { + $cam->execute([$c, $d['id'], $c, str_starts_with($c, 'cam-press') ? 'fall_critical' : 'zone', $now - 86400 * 90]); + } +} + +// ---------------------------------------------------------------- events +$ins = $pdo->prepare( + 'INSERT INTO move_events (event_uuid,device_id,site,camera_id,event_type,severity,track_id,zone_id, + occurred_at,duration_s,metrics_json,spatial_x,spatial_y,acknowledged_by,acknowledged_at, + false_positive_by,false_positive_at,received_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' +); + +$deviceOf = []; +foreach ($cameras as $devId => $list) foreach ($list as $c) $deviceOf[$c] = $devId; +$siteOf = []; +foreach ($devices as $d) $siteOf[$d['id']] = $d['site']; + +$seq = 0; +$made = 0; +mt_srand(20260809); // deterministic: two runs produce the same board, so screenshots diff cleanly + +function metricsFor(string $type, float $dur): ?string +{ + $m = ['confidence' => round(0.72 + mt_rand(0, 260) / 1000, 2)]; + if ($type === 'immobility') $m['immobility_s'] = round($dur, 1); + if ($type === 'no_motion') $m['prone_s'] = round($dur, 1); + if ($type === 'overstay') { $m['dwell_s'] = round($dur, 1); $m['limit_s'] = 1800; } + if ($type === 'lone_worker') { $m['alone_s'] = round($dur, 1); $m['workers'] = 1; $m['required'] = 2; } + if ($type === 'overcapacity'){ $m['workers'] = 5; $m['limit'] = 4; } + if ($type === 'ghost') { $m['waited_s'] = round($dur, 1); $m['last_state'] = 'FALLEN'; } + if ($type === 'fall') { $m['torso_angle'] = mt_rand(62, 88); $m['aspect_ratio'] = round(mt_rand(150, 260) / 100, 2); } + if ($type === 'ergo_report') { + $m['observed_s'] = 3600; $m['trunk_high_s'] = mt_rand(120, 900); + $m['trunk_mod_s'] = mt_rand(400, 1800); $m['arm_high_s'] = mt_rand(60, 600); + $m['squat_s'] = mt_rand(30, 400); + } + return json_encode($m, JSON_UNESCAPED_UNICODE); +} + +/** One event row. Returns the inserted event_id. */ +function emit(PDO $pdo, PDOStatement $ins, array $ctx): int +{ + $ins->execute([ + $ctx['uuid'], $ctx['device'], $ctx['site'], $ctx['camera'], $ctx['type'], $ctx['severity'], + $ctx['track'], null, $ctx['at'], $ctx['dur'], $ctx['metrics'], $ctx['x'], $ctx['y'], + $ctx['ackBy'], $ctx['ackAt'], $ctx['fpBy'], $ctx['fpAt'], (int)$ctx['at'], + ]); + return (int)$pdo->lastInsertId(); +} + +$eventIds = []; // newest-first, for snapshot attachment + +// Weighted camera picker. +$bag = []; +foreach ($profile as $c => $p) for ($i = 0; $i < $p['weight']; $i++) $bag[] = $c; + +for ($d = 0; $d < $days; $d++) { + // Fewer events per day the further back we go? No — keep the rate flat but let the + // hour-of-day distribution be realistic: a day shift peak, a smaller night peak. + $perDay = mt_rand(1, 5); + for ($k = 0; $k < $perDay; $k++) { + $camera = $bag[array_rand($bag)]; + $type = $profile[$camera]['types'][array_rand($profile[$camera]['types'])]; + $sev = $severityOf[$type] ?? 'info'; + // Shift-shaped hours: 07-17 heavy, 22-02 light, rest sparse. + $roll = mt_rand(1, 100); + $hour = $roll <= 65 ? mt_rand(7, 17) : ($roll <= 85 ? mt_rand(22, 25) % 24 : mt_rand(0, 23)); + $at = $now - $d * 86400; + $at = mktime($hour, mt_rand(0, 59), mt_rand(0, 59), (int)date('n', $at), (int)date('j', $at), (int)date('Y', $at)); + if ($at > $now) $at -= 86400; + + $dur = match ($type) { + 'immobility' => mt_rand(300, 900), 'overstay' => mt_rand(1800, 4200), + 'lone_worker' => mt_rand(60, 600), 'ergo_report' => 3600, + 'no_motion' => mt_rand(20, 90), default => round(mt_rand(4, 40) / 10, 1), + }; + // Older events are mostly handled; recent ones mostly are not — that is what a real + // board looks like, and it is what makes the 미확인 view worth having. + $handled = $d === 0 ? mt_rand(1, 100) <= 25 : ($d <= 2 ? mt_rand(1, 100) <= 70 : mt_rand(1, 100) <= 95); + $isFp = $handled && mt_rand(1, 100) <= 14; + + $seq++; + $ctx = [ + 'uuid' => substr(str_pad(DEMO_PREFIX . $seq, 32, '0', STR_PAD_LEFT), -32), + 'device' => $deviceOf[$camera], 'site' => $siteOf[$deviceOf[$camera]], 'camera' => $camera, + 'type' => $type, 'severity' => $sev, 'track' => 100 + $seq, 'at' => (float)$at, 'dur' => $dur, + 'metrics' => metricsFor($type, (float)$dur), + 'x' => round(mt_rand(120, 880) / 1000, 3), 'y' => round(mt_rand(200, 900) / 1000, 3), + 'ackBy' => $handled ? '안전관리자' : null, + 'ackAt' => $handled ? $at + mt_rand(40, 900) : null, + 'fpBy' => $isFp ? '안전관리자' : null, + 'fpAt' => $isFp ? $at + mt_rand(60, 1200) : null, + ]; + $eventIds[] = ['id' => emit($pdo, $ins, $ctx), 'camera' => $camera, 'at' => $at, 'type' => $type]; + $made++; + + // Incident cascade: a fall is followed by the no_motion the detector chain really emits + // seconds later, on the SAME track. This is the case the console has to render as two + // rows of one event — worth having in every demo. + if ($type === 'fall' && mt_rand(1, 100) <= 60) { + $seq++; + $ctx2 = $ctx; + $ctx2['uuid'] = substr(str_pad(DEMO_PREFIX . $seq, 32, '0', STR_PAD_LEFT), -32); + $ctx2['type'] = 'no_motion'; + $ctx2['severity'] = 'critical'; + $ctx2['at'] = (float)($at + mt_rand(20, 45)); + $ctx2['dur'] = mt_rand(20, 60); + $ctx2['metrics'] = metricsFor('no_motion', (float)$ctx2['dur']); + $eventIds[] = ['id' => emit($pdo, $ins, $ctx2), 'camera' => $camera, 'at' => (int)$ctx2['at'], 'type' => 'no_motion']; + $made++; + } + } +} + +// ---------------------------------------------------------------- zones +$zone = $pdo->prepare('INSERT INTO move_zones (zone_id,device_id,camera_id,level,polygon_json,rules_json,updated_at) + VALUES (?,?,?,?,?,?,?)'); +$zone->execute([bin2hex(random_bytes(16)), DEMO_PREFIX . 'press', 'cam-press-01', 'restricted', + json_encode([[0.18, 0.30], [0.66, 0.28], [0.70, 0.82], [0.20, 0.85]]), + json_encode(['min_workers' => 2]), $now]); +$zone->execute([bin2hex(random_bytes(16)), DEMO_PREFIX . 'press', 'cam-press-02', 'pinch', + json_encode([[0.55, 0.40], [0.86, 0.42], [0.84, 0.78], [0.53, 0.76]]), null, $now]); +$zone->execute([bin2hex(random_bytes(16)), DEMO_PREFIX . 'wh', 'cam-wh-02', 'warning', + json_encode([[0.08, 0.55], [0.92, 0.52], [0.94, 0.94], [0.06, 0.96]]), + json_encode(['max_workers' => 4, 'max_dwell_s' => 1800]), $now]); + +// ---------------------------------------------------------------- improvement actions +$act = $pdo->prepare('INSERT INTO move_actions (camera_id,site,note,taken_at,created_by,created_at) VALUES (?,?,?,?,?,?)'); +foreach ([ + ['cam-press-01', '프레스 진입로 미끄럼 방지 매트 설치', 34], + ['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12], + ['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5], +] as [$c, $note, $ago]) { + $act->execute([$c, 'factory-a', $note, $now - $ago * 86400, 'demo-seed', $now - $ago * 86400]); +} + +// ---------------------------------------------------------------- 통보 · 규정 · 성능 +// 새 화면(시스템 설정 / 안전 관리)이 빈 상태로만 시연되면 그 기능이 없는 것처럼 보인다. +// 다만 어떤 것도 실제로 발송되지 않도록, 채널은 전부 비활성으로 넣는다. +$ch = $pdo->prepare('INSERT INTO move_alert_channels (site,kind,name,config_json,min_severity,enabled,step,escalate_after_s,created_at) + VALUES (?,?,?,?,?,?,?,?,?)'); +$ch->execute(['factory-a', 'local', '1공장 경광등 (데모)', + json_encode(['gpio_pin' => 17, 'mode' => 'latch', 'hold_s' => 45, 'active_high' => false]), + 'high', 0, 0, 0, $now]); +// 메일/웹훅은 enabled=0. 데모 DB 로 실수로 크론을 돌려도 아무 데도 나가지 않는다. +$ch->execute(['factory-a', 'email', '안전관리자 메일 (데모 · 사용 안 함)', + json_encode(['to' => ['safety@example.invalid']]), 'critical', 0, 0, 0, $now]); +$ch->execute(['factory-a', 'email', '공장장 에스컬레이션 (데모 · 사용 안 함)', + json_encode(['to' => ['plant-manager@example.invalid']]), 'critical', 0, 1, 600, $now]); + +$risk = $pdo->prepare('INSERT INTO move_risk_controls (site,event_type,hazard,control,severity_rating,likelihood_rating,reviewed_by,reviewed_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?)'); +foreach ([ + ['fall', '프레스 진입로 유압유 누유로 인한 미끄럼 전도', '미끄럼 방지 매트 · 일일 누유 점검 · AI 넘어짐 감지', 5, 3], + ['pinch', '금형 교체 중 상하 금형 사이 협착', '광전센서 · 방호덮개 · 협착구역 침입 감지', 5, 2], + ['zone_restricted', '가동 중 프레스 방호구역 진입', '안전문 인터록 · 제한구역 침입 감지', 4, 3], + ['lone_worker', '자재창고 야간 단독 작업 중 사고 시 발견 지연', '2인 1조 편성 · 단독 작업 감지', 4, 2], + ['overstay', '도장부스 장시간 체류로 인한 유기용제 노출', '체류 상한 30분 · 국소배기 점검', 3, 3], +] as [$t, $hazard, $control, $sev, $like]) { + $risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, '안전관리자', $now - 30 * 86400, $now - 30 * 86400]); +} + +// 성능 시계열: 24시간치. pi-a2 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 — +// "지금 살아 있는가"와 "점점 나빠지는가"의 차이를 한눈에 보여주는 것이 이 화면의 목적이다. +$met = $pdo->prepare('INSERT INTO move_device_metrics (device_id,ts,fps,temp,throttle,queue_pending,cameras_total,cameras_erroring,errors) + VALUES (?,?,?,?,?,?,?,?,?)'); +for ($i = 288; $i >= 0; $i--) { // 5분 간격 24시간 + $ts = $now - $i * 300; + $phase = 1.0 - $i / 288.0; // 0 = 24시간 전, 1 = 지금 + $met->execute([DEMO_PREFIX . 'press', $ts, 24.5 + mt_rand(-4, 4) / 10, 56 + mt_rand(-15, 15) / 10, 0, + mt_rand(0, 2), 2, 0, 0]); + $hot = $phase > 0.55; + $met->execute([DEMO_PREFIX . 'wh', $ts, + $hot ? 24.0 - ($phase - 0.55) * 22 : 24.0 + mt_rand(-3, 3) / 10, + $hot ? 74 + ($phase - 0.55) * 14 : 60 + mt_rand(-15, 15) / 10, + $hot ? 1 : 0, $hot ? mt_rand(0, 40) : 0, 2, 0, $hot ? mt_rand(0, 3) : 0]); +} + +$pdo->commit(); +say("events: $made · devices: " . count($devices) . " · cameras: " . array_sum(array_map('count', $cameras))); +say('alert channels: 3 (전부 비활성 또는 로컬) · risk controls: 5 · device metrics: 24h'); + +// ---------------------------------------------------------------- snapshots +if (!$withImgs) { + say('snapshots: skipped (ext-gd 없음 또는 --no-images) — 행은 "무영상" 상태로 표시됩니다.'); + exit(0); +} +if (!is_dir($snapshotDir) && !@mkdir($snapshotDir, 0775, true)) { + say("snapshots: {$snapshotDir} 생성 실패 — 건너뜁니다."); + exit(0); +} + +/** + * A three-frame evidence timeline per event, for the newest N events only (a demo does not + * need 200 days of jpgs, and a shared-hosting quota does not want them). The worker is drawn + * post-blur — a flat silhouette — because that is what the delivered system actually stores: + * FaceBlurrer runs before anything is written. A demo image with a recognisable face would + * misrepresent the product's privacy behaviour. + */ +function drawFrame(string $camera, int $ts, int $step, string $type): \GdImage +{ + $im = imagecreatetruecolor(640, 360); + for ($y = 0; $y < 360; $y++) { + $v = (int)(44 + $y * 0.20); + imagefilledrectangle($im, 0, $y, 640, $y, imagecolorallocate($im, $v, $v + 4, $v + 10)); + } + imagefilledrectangle($im, 30, 70, 210, 300, imagecolorallocate($im, 74, 80, 89)); + for ($x = 250; $x < 630; $x += 42) { + imagefilledpolygon($im, [$x, 322, $x + 20, 322, $x + 4, 352, $x - 16, 352], + imagecolorallocate($im, 186, 156, 42)); + } + // Falling types tilt and descend across the three frames; others just drift. + $falling = in_array($type, ['fall', 'no_motion', 'near_fall'], true); + $drop = $falling ? $step * 30 : 0; + $cx = 380 + $step * 16; + $cy = 150 + $drop; + $skin = imagecolorallocate($im, 222, 224, 228); // flat: face blur already applied + $vest = imagecolorallocate($im, 236, 176, 40); + imagefilledellipse($im, $cx, $cy, 32, 32, $skin); + imagefilledrectangle($im, $cx - 21, $cy + 15, $cx + 21, $cy + max(24, 104 - $drop), $vest); + $box = imagecolorallocate($im, 226, 62, 62); + imagesetthickness($im, 3); + imagerectangle($im, $cx - 44, $cy - 28, $cx + 50, $cy + max(34, 118 - $drop), $box); + imagestring($im, 2, 12, 12, strtoupper($camera) . ' ' . date('Y-m-d H:i:s', $ts), imagecolorallocate($im, 198, 204, 212)); + return $im; +} + +$snapIns = $pdo->prepare('INSERT INTO move_event_snapshots (event_id,file_uuid,content_hash,created_at) VALUES (?,?,?,?)'); +usort($eventIds, fn ($a, $b) => $b['at'] <=> $a['at']); +$withEvidence = array_slice($eventIds, 0, 40); +$files = 0; +$pdo->beginTransaction(); +foreach ($withEvidence as $e) { + for ($f = 0; $f < 3; $f++) { + $uuid = bin2hex(random_bytes(16)); + $im = drawFrame($e['camera'], $e['at'] - (2 - $f) * 2, $f, $e['type']); + imagejpeg($im, $snapshotDir . '/' . $uuid . '.jpg', 82); + imagedestroy($im); + $snapIns->execute([$e['id'], $uuid, hash('sha256', $uuid), $e['at'] - (2 - $f) * 2]); + $files++; + } +} +$pdo->commit(); +say("snapshots: {$files}개 (" . count($withEvidence) . "건에 3프레임씩) -> {$snapshotDir}"); +say('완료. 데모 행만 지우려면: --purge'); diff --git a/server/tools/verify_audit_chain.php b/server/tools/verify_audit_chain.php new file mode 100644 index 0000000..6f6dd1b --- /dev/null +++ b/server/tools/verify_audit_chain.php @@ -0,0 +1,102 @@ +query( + 'SELECT id, actor, action, target, before_json, after_json, ts, prev_hash, entry_hash + FROM move_audit_log ORDER BY id ASC' +)->fetchAll(PDO::FETCH_ASSOC); + +$total = count($rows); +$unchained = 0; // 마이그레이션 이전 행 (해시 없음) +$verified = 0; +$breaks = []; // ['id' => .., 'why' => ..] +$expectedPrev = null; +$chainStarted = false; + +foreach ($rows as $r) { + $id = (int)$r['id']; + if ($r['entry_hash'] === null || $r['entry_hash'] === '') { + if ($chainStarted) { + // 체인이 시작된 뒤에 해시 없는 행이 나타났다 = 사슬을 우회해 삽입된 행. + $breaks[] = ['id' => $id, 'why' => '해시 없는 행이 체인 구간에 삽입됨']; + } else { + $unchained++; + } + continue; + } + + if (!$chainStarted) { + // 체인의 첫 행. prev_hash 는 NULL(제네시스)이거나 직전 구간이 없어 NULL 이다. + $chainStarted = true; + } elseif (($r['prev_hash'] ?? null) !== $expectedPrev) { + $breaks[] = [ + 'id' => $id, + 'why' => 'prev_hash 불일치 — 직전 행이 수정되었거나 삭제됨 (기대 ' + . substr((string)$expectedPrev, 0, 12) . '…, 실제 ' + . substr((string)($r['prev_hash'] ?? 'NULL'), 0, 12) . '…)', + ]; + } + + $recomputed = PdoAuditRepository::hash( + (string)$r['actor'], (string)$r['action'], (string)$r['target'], + (string)$r['before_json'], (string)$r['after_json'], (int)$r['ts'], + $r['prev_hash'] !== null && $r['prev_hash'] !== '' ? (string)$r['prev_hash'] : null + ); + if (!hash_equals((string)$r['entry_hash'], $recomputed)) { + $breaks[] = ['id' => $id, 'why' => '행 내용이 자신의 해시와 불일치 — 사후 수정됨']; + } else { + $verified++; + } + $expectedPrev = (string)$r['entry_hash']; +} + +$ok = $breaks === []; +$report = [ + 'ok' => $ok, + 'total_rows' => $total, + 'verified' => $verified, + 'pre_chain_rows' => $unchained, + 'breaks' => $breaks, +]; + +if ($asJson) { + echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n"; + exit($ok ? 0 : 1); +} + +echo "감사로그 사슬 검증\n"; +echo " 전체 행 : {$total}\n"; +echo " 검증 완료 : {$verified}\n"; +echo " 검증 불가 구간: {$unchained}" . ($unchained > 0 ? " (해시체인 도입 이전에 기록된 행)" : "") . "\n"; +if ($ok) { + echo "\n무결합니다 — 사슬이 끊긴 곳이 없습니다.\n"; + exit(0); +} +echo "\n손상 " . count($breaks) . "건:\n"; +foreach ($breaks as $b) { + echo " - id={$b['id']}: {$b['why']}\n"; +} +echo "\n이 지점 전후의 기록은 신뢰할 수 없습니다. DB 백업과 대조하십시오.\n"; +exit(1); diff --git a/tests/test_beacon.py b/tests/test_beacon.py new file mode 100644 index 0000000..b2bd194 --- /dev/null +++ b/tests/test_beacon.py @@ -0,0 +1,134 @@ +"""현장 경광등·부저 (GPIO). + +폐쇄망에서 유일하게 네트워크 없이 동작하는 통보 경로다. 그래서 여기서 지켜야 할 성질은 +"울린다"보다 "감지를 방해하지 않는다"에 가깝다: 배선이 없거나 고장 나도 파이프라인은 +계속 돌아야 하고, 그 사실은 조용히 묻히면 안 된다. + +실제로 핀 전압이 떨어지는지는 단위 테스트로 증명할 수 없다 — tests/hardware/ 의 OFF-HW +항목이다 (docs/hardware-bringup.md). +""" + +from edge.beacon import GpioBeacon, NullBeacon, build_beacons, fire_all +from edge.events import Event, EventType, Severity + + +class _FakePin: + def __init__(self): + self.states = [] + self.closed = False + + def set(self, high: bool): + self.states.append(high) + + def close(self): + self.closed = True + + @property + def level(self): + return self.states[-1] if self.states else None + + +def _beacon(**over) -> tuple[GpioBeacon, _FakePin]: + pin = _FakePin() + kwargs = dict(mode="latch", hold_s=10.0, active_high=True, min_severity="high", backend=pin) + kwargs.update(over) + return GpioBeacon(7, **kwargs), pin + + +def _event(sev: Severity) -> Event: + return Event(EventType.FALL, sev, "cam-1", 1, None, 0.0, 0.0, {}, None) + + +def test_fires_on_or_above_the_configured_severity(): + b, pin = _beacon(min_severity="high") + assert b.fire("critical", 0.0) is True + assert b.fire("high", 0.0) is True + assert b.fire("warning", 0.0) is False + assert b.fire("info", 0.0) is False + + +def test_active_low_wiring_is_inverted(): + # 릴레이 모듈은 보통 LOW 에서 도통한다. 설정으로 뒤집지 못하면 그런 현장에서는 + # 경광등이 평상시 켜져 있고 사고 때 꺼진다. + low, low_pin = _beacon(active_high=False) + low.fire("critical", 0.0) + assert low_pin.level is False, "active_low: 켜짐 = LOW" + + high, high_pin = _beacon(active_high=True) + high.fire("critical", 0.0) + assert high_pin.level is True + + +def test_hold_time_releases_the_latch(): + b, pin = _beacon(hold_s=10.0) + b.fire("critical", 0.0) + b.tick(5.0) + assert pin.level is True, "유지 시간 안에는 계속 울린다" + b.tick(10.0) + assert pin.level is False + + +def test_a_new_event_extends_the_hold_instead_of_restarting_it(): + # 사고가 이어지는 동안 경광등이 중간에 꺼졌다 켜지면 사람이 "끝났다"로 읽는다. + b, pin = _beacon(hold_s=10.0) + b.fire("critical", 0.0) + b.fire("critical", 8.0) + b.tick(11.0) + assert pin.level is True, "연장되었으므로 아직 울린다" + b.tick(18.0) + assert pin.level is False + + +def test_release_turns_it_off_and_closes_the_pin(): + b, pin = _beacon() + b.fire("critical", 0.0) + b.release() + assert pin.level is False + assert pin.closed is True + + +def test_fire_all_uses_the_worst_severity_present(): + b, pin = _beacon(min_severity="critical") + fired = fire_all([b], [_event(Severity.WARNING), _event(Severity.CRITICAL)], now=0.0) + assert fired == 1, "가장 높은 심각도로 판단한다 — 목록의 첫 항목이 아니라" + + b2, _ = _beacon(min_severity="critical") + assert fire_all([b2], [_event(Severity.WARNING), _event(Severity.HIGH)], now=0.0) == 0 + + +def test_fire_all_is_a_no_op_without_beacons_or_events(): + b, _ = _beacon() + assert fire_all([], [_event(Severity.CRITICAL)], now=0.0) == 0 + assert fire_all([b], [], now=0.0) == 0 + assert fire_all(None, None, now=0.0) == 0 + + +def test_null_beacon_satisfies_the_interface(): + n = NullBeacon() + assert n.fire("critical", 0.0) is False + n.tick(1.0) + n.release() + assert n.available is False + + +def test_unwired_pin_is_skipped_without_touching_hardware(): + # gpio_pin=0 은 "관리 화면에서 핀을 아직 안 정했다"는 뜻이다. 열려고 시도하면 안 된다. + logged = [] + built = build_beacons({"beacons": [{"gpio_pin": 0, "mode": "latch"}]}, log=logged.append) + assert built == [] + assert logged == [], "미배선은 오류가 아니다" + + +def test_failed_pin_is_logged_not_raised(): + # 이 환경에는 GPIO 가 없다. 감지가 멈추면 안 되고, 동시에 조용히 묻혀서도 안 된다. + logged = [] + built = build_beacons({"beacons": [{"gpio_pin": 17, "mode": "latch"}]}, log=logged.append) + assert built == [] + assert any("17" in m for m in logged), "경광등이 죽은 사실은 반드시 로그에 남는다" + + +def test_missing_beacon_config_is_a_no_op(): + assert build_beacons({}) == [] + assert build_beacons(None) == [] + assert build_beacons({"beacons": None}) == [] + assert build_beacons({"beacons": ["not-a-dict"]}) == [] diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 5de35f6..ac5c7b5 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -184,3 +184,113 @@ def test_loop_survives_flush_exception(): sync_every=99, heartbeat_every=99, flush_every=2) assert rt.steps == 5 # loop kept running past the flush exceptions assert counters["errors"] >= 1 + + +# --------------------------------------------------------------------------- +# 2026-08-09: 로컬 경광등 + 임계값 적용. +# 이 두 가지는 루프 안에서만 배선되므로 여기서 고정한다. +# --------------------------------------------------------------------------- + +class _TunableRuntime(_Runtime): + def __init__(self): + super().__init__() + self.thresholds = None + self.events = [] + + def step(self, now): + self.steps += 1 + return list(self.events) + + def apply_thresholds(self, thresholds, log=None): + self.thresholds = dict(thresholds) + return self.thresholds + + +class _FakeBeacon: + def __init__(self, pin=7): + self.pin = pin + self.mode = "latch" + self.hold_s = 10.0 + self.active_high = True + self.min_rank = 0 + self.fires = [] + self.ticks = 0 + self.released = False + + def fire(self, severity, now): + self.fires.append((severity, now)) + return True + + def tick(self, now): + self.ticks += 1 + + def release(self): + self.released = True + + +class _Ev: + def __init__(self, severity="critical"): + self.severity = severity + + +def test_thresholds_from_config_are_applied_to_the_runtime(): + # 서버는 늘 이 값을 보내고 있었고, 엣지는 파싱한 뒤 버렸다. 그 배선이 살아 있는지. + class Cl(_Client): + def fetch_config(self): + self.configs += 1 + return {"zones": [], "thresholds": {"fall_prone_s": 4.5}} + + rt = _TunableRuntime() + counters = run_loop(rt, Cl(), _Queue(), clock=lambda: 0.0, sleep=lambda s: None, + iterations=5, sync_every=5, heartbeat_every=99, flush_every=99, + build_beacons_fn=lambda cfg, log=None: []) + assert rt.thresholds == {"fall_prone_s": 4.5} + assert counters["tuned"] == 1 + + +def test_beacon_fires_on_the_tick_that_produced_the_event(): + # 서버 디스패처를 기다리지 않는다 — 업링크가 죽어 있어도 현장에서는 울려야 한다. + b = _FakeBeacon() + rt = _TunableRuntime() + rt.events = [_Ev("critical")] + counters = run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0, sleep=lambda s: None, + iterations=3, sync_every=99, heartbeat_every=99, flush_every=99, + beacons=[b], build_beacons_fn=lambda cfg, log=None: [b]) + assert len(b.fires) == 3 + assert b.fires[0][0] == "critical" + assert counters["beacon_fires"] == 3 + assert b.ticks == 3, "유지 시간 해제를 위해 매 틱 tick() 이 불린다" + + +def test_beacon_failure_never_stops_detection(): + # 경광등 배선 하나 때문에 그 카메라가 아무것도 못 보게 되는 것이 최악의 거래다. + class Boom(_FakeBeacon): + def fire(self, severity, now): + raise RuntimeError("gpio busy") + + rt = _TunableRuntime() + rt.events = [_Ev("critical")] + counters = run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0, sleep=lambda s: None, + iterations=4, sync_every=99, heartbeat_every=99, flush_every=99, + beacons=[Boom()], build_beacons_fn=lambda cfg, log=None: []) + assert rt.steps == 4 + assert counters["errors"] >= 1 + + +def test_beacons_are_not_reopened_when_the_wiring_is_unchanged(): + # 동기화마다 무조건 재생성하면 ~150틱마다 전 현장의 경광등이 한 번씩 깜빡인다. + b = _FakeBeacon() + rt = _TunableRuntime() + run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0, sleep=lambda s: None, + iterations=10, sync_every=2, heartbeat_every=99, flush_every=99, + beacons=[b], build_beacons_fn=lambda cfg, log=None: [_FakeBeacon()]) + assert b.released is False, "같은 배선이면 기존 비콘을 유지한다" + + +def test_changed_wiring_releases_the_old_beacon(): + b = _FakeBeacon(pin=7) + rt = _TunableRuntime() + run_loop(rt, _Client(), _Queue(), clock=lambda: 0.0, sleep=lambda s: None, + iterations=4, sync_every=2, heartbeat_every=99, flush_every=99, + beacons=[b], build_beacons_fn=lambda cfg, log=None: [_FakeBeacon(pin=13)]) + assert b.released is True, "핀이 바뀌면 이전 핀을 반드시 놓아준다" diff --git a/tests/test_integrity.py b/tests/test_integrity.py new file mode 100644 index 0000000..3c34f00 --- /dev/null +++ b/tests/test_integrity.py @@ -0,0 +1,142 @@ +"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하. + +이 감지기가 막으려는 것은 "조용한 실패"다: 카메라가 틀어져 존 판정이 통째로 무의미해졌는데 +프레임은 계속 들어오고 대시보드는 초록불인 상태. 따라서 테스트의 초점은 (1) 진짜 화각 변화를 +잡는가, (2) 사람이 지나가는 것을 화각 변화로 오인하지 않는가 두 가지다. +""" + +import numpy as np + +from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig + + +def _scene(shade: int, h: int = 120, w: int = 160) -> np.ndarray: + """단색 배경 위에 구조물이 있는 프레임. shade 로 '다른 장면'을 만든다.""" + f = np.full((h, w, 3), shade, dtype=np.uint8) + f[20:80, 20:60] = min(255, shade + 90) # 기계 블록 + f[90:110, :] = max(0, shade - 60) # 바닥 띠 + return f + + +def _cfg(**over) -> IntegrityConfig: + base = dict(warmup_s=1.0, sustain_s=3.0, obstructed_sustain_s=2.0, low_light_sustain_s=2.0) + base.update(over) + return IntegrityConfig(**base) + + +def test_no_finding_while_the_view_is_unchanged(): + w = CameraIntegrityWatch(_cfg()) + frame = _scene(120) + t = 0.0 + for _ in range(40): + assert w.observe(frame, t) == [] + t += 1.0 + + +def test_reports_camera_moved_once_the_change_persists(): + w = CameraIntegrityWatch(_cfg()) + before, after = _scene(120), _scene(20) + t = 0.0 + for _ in range(3): # warmup: 기준 프레임 확보 + w.observe(before, t); t += 1.0 + + # 화각이 바뀐 직후에는 아직 확정하지 않는다 — 순간 변화와 구분해야 한다. + assert w.observe(after, t) == [] + t += 1.0 + assert w.observe(after, t) == [] + t += 3.0 + found = [e for e, _ in w.observe(after, t)] + assert "camera_moved" in found + + +def test_a_person_walking_through_is_not_a_moved_camera(): + # 사람이 지나가면 MAD 는 오르지만 지속되지 않는다. 이 구분이 없으면 이 감지기는 + # 사람이 다니는 모든 현장에서 하루 종일 오탐을 낸다. + w = CameraIntegrityWatch(_cfg()) + empty = _scene(120) + t = 0.0 + for _ in range(3): + w.observe(empty, t); t += 1.0 + + walker = empty.copy() + walker[30:100, 70:95] = 240 # 화면 일부를 가로지르는 밝은 형체 + for _ in range(2): # sustain_s(3.0) 미만으로만 머문다 + assert w.observe(walker, t) == [] + t += 1.0 + for _ in range(6): + assert w.observe(empty, t) == [] # 지나간 뒤 정상 복귀 + t += 1.0 + + +def test_baseline_is_promoted_so_a_moved_camera_does_not_alarm_forever(): + w = CameraIntegrityWatch(_cfg(reemit_s=1.0)) + before, after = _scene(120), _scene(20) + t = 0.0 + for _ in range(3): + w.observe(before, t); t += 1.0 + w.observe(after, t) # 변화 시작 시각을 기록만 한다 + t += 5.0 + assert any(e == "camera_moved" for e, _ in w.observe(after, t)) + + # 새 화각이 기준이 되었으므로, 그대로 유지되는 한 다시 울리지 않는다. + for _ in range(20): + t += 1.0 + assert [e for e, _ in w.observe(after, t) if e == "camera_moved"] == [] + + +def test_covered_lens_is_reported(): + w = CameraIntegrityWatch(_cfg()) + flat = np.full((120, 160, 3), 90, dtype=np.uint8) # 분산 ~0: 천으로 덮인 상태 + t = 0.0 + assert w.observe(flat, t) == [] + t += 3.0 + found = [e for e, _ in w.observe(flat, t)] + assert "camera_obstructed" in found + + +def test_dark_scene_is_reported_as_low_light_not_as_obstruction(): + w = CameraIntegrityWatch(_cfg()) + dark = _scene(12) # 어둡지만 구조는 남아 있다 (분산 존재) + t = 0.0 + w.observe(dark, t) + t += 3.0 + found = [e for e, _ in w.observe(dark, t)] + assert "low_light" in found + assert "camera_obstructed" not in found + + +def test_findings_are_rate_limited(): + w = CameraIntegrityWatch(_cfg(reemit_s=1000.0)) + flat = np.full((120, 160, 3), 90, dtype=np.uint8) + t = 0.0 + w.observe(flat, t) + t += 3.0 + assert any(e == "camera_obstructed" for e, _ in w.observe(flat, t)) + for _ in range(10): + t += 1.0 + assert [e for e, _ in w.observe(flat, t) if e == "camera_obstructed"] == [] + + +def test_disabled_watch_reports_nothing(): + w = CameraIntegrityWatch(IntegrityConfig(enabled=False)) + flat = np.zeros((120, 160, 3), dtype=np.uint8) + for t in range(200): + assert w.observe(flat, float(t)) == [] + + +def test_tolerates_odd_frames_without_raising(): + # 파이프라인이 죽는 것이 오탐보다 나쁘다: 이상한 프레임은 조용히 무시한다. + w = CameraIntegrityWatch(_cfg()) + assert w.observe(None, 0.0) == [] + assert w.observe(np.zeros((2, 2, 3), dtype=np.uint8), 1.0) == [] # 격자보다 작다 + assert w.observe(np.zeros((0, 0, 3), dtype=np.uint8), 2.0) == [] + assert w.observe(np.zeros((4, 4, 4, 3), dtype=np.uint8), 3.0) == [] # 차원 이상 + + +def test_grayscale_frames_are_accepted(): + w = CameraIntegrityWatch(_cfg()) + gray = np.full((120, 160), 90, dtype=np.uint8) + t = 0.0 + w.observe(gray, t) + t += 3.0 + assert any(e == "camera_obstructed" for e, _ in w.observe(gray, t)) diff --git a/tests/test_pending_detectors.py b/tests/test_pending_detectors.py new file mode 100644 index 0000000..a89c2b6 --- /dev/null +++ b/tests/test_pending_detectors.py @@ -0,0 +1,94 @@ +"""모델 대기 감지기 (보호구 · 근접 · 화재). + +모델이 없는 상태가 **정상**이라는 것이 이 테스트들의 요지다. 배선은 있고 계약은 고정되어 +있으며, 모델이 확보되는 날 바꿀 것은 Model 구현 하나뿐이어야 한다. +""" + +from edge.detectors.pending import PendingConfig, PendingDetectors + + +class _Model: + def __init__(self, out=None, raises=False): + self.out = out or [] + self.raises = raises + self.calls = 0 + + def detect(self, frame, persons): + self.calls += 1 + if self.raises: + raise RuntimeError("hef not loaded") + return self.out + + +def test_disabled_by_default_and_never_calls_a_model(): + m = _Model([{"kind": "ppe_missing", "confidence": 1.0}]) + d = PendingDetectors(model=m) + assert d.active is False + assert d.observe(None, [], 0.0) == [] + assert m.calls == 0, "비활성이면 추론 비용도 들지 않아야 한다" + + +def test_enabled_without_a_model_is_still_inert(): + # 가장 위험한 상태를 막는다: "곧 됩니다"로 켜 두면 현장은 감지되고 있다고 믿는다. + d = PendingDetectors(PendingConfig(enabled=True), model=None) + assert d.active is False + assert d.observe(None, [], 0.0) == [] + + +def test_emits_when_a_model_is_supplied(): + m = _Model([{"kind": "ppe_missing", "confidence": 0.9, "track_id": 3, + "spatial": (0.4, 0.7), "metrics": {"missing": "helmet"}}]) + d = PendingDetectors(PendingConfig(enabled=True), model=m) + assert d.active is True + out = d.observe(None, [], 0.0) + assert len(out) == 1 + kind, metrics, track_id, spatial = out[0] + assert kind == "ppe_missing" + assert track_id == 3 + assert spatial == (0.4, 0.7) + assert metrics["missing"] == "helmet" + assert metrics["confidence"] == 0.9 + + +def test_low_confidence_is_dropped(): + # 보호구 오탐은 곧 현장 반발이고, 반발은 시스템 전원을 내리는 것으로 끝난다. + m = _Model([{"kind": "ppe_missing", "confidence": 0.5}]) + d = PendingDetectors(PendingConfig(enabled=True), model=m) + assert d.observe(None, [], 0.0) == [] + + +def test_unknown_kind_is_ignored(): + m = _Model([{"kind": "telepathy", "confidence": 1.0}]) + d = PendingDetectors(PendingConfig(enabled=True), model=m) + assert d.observe(None, [], 0.0) == [] + + +def test_reemit_window_collapses_a_sustained_condition_into_one_event(): + m = _Model([{"kind": "ppe_missing", "confidence": 0.99, "track_id": 1}]) + cfg = PendingConfig(enabled=True) + cfg.reemit_s["ppe_missing"] = 600.0 + d = PendingDetectors(cfg, model=m) + assert len(d.observe(None, [], 0.0)) == 1 + assert d.observe(None, [], 60.0) == [], "한 사람이 한 시간을 그러고 있어도 사건은 하나다" + assert len(d.observe(None, [], 700.0)) == 1 + + +def test_model_failure_does_not_break_the_pipeline(): + logged = [] + d = PendingDetectors(PendingConfig(enabled=True), model=_Model(raises=True), log=logged.append) + assert d.observe(None, [], 0.0) == [] + assert logged, "실패는 조용히 넘어가지 않는다" + + +def test_forget_clears_per_track_state(): + m = _Model([{"kind": "proximity", "confidence": 0.99, "track_id": 5}]) + d = PendingDetectors(PendingConfig(enabled=True), model=m) + d.observe(None, [], 0.0) + d.forget(5) + assert len(d.observe(None, [], 1.0)) == 1, "트랙이 사라지면 억제 상태도 사라진다" + + +def test_garbage_model_output_is_survived(): + m = _Model(["nope", None, 42, {"no_kind": 1}]) + d = PendingDetectors(PendingConfig(enabled=True), model=m) + assert d.observe(None, [], 0.0) == [] diff --git a/tests/test_tuning.py b/tests/test_tuning.py new file mode 100644 index 0000000..479d600 --- /dev/null +++ b/tests/test_tuning.py @@ -0,0 +1,112 @@ +"""서버 임계값이 실제로 감지기에 적용되는지. + +이 경로는 지금까지 통째로 비어 있었다: 서버에 표가 있고, /api/config 가 값을 실어 보내고, +edge/zones.py 가 파싱한 뒤 그 dict 를 버렸다. 관리자가 무엇을 바꾸든 장비는 부팅 시 +edge.env 값으로 영원히 돌았다. 아래 테스트가 그 배선을 고정한다. +""" + +import math + +from edge.config import load_config +from edge.runtime import PipelineRuntime +from edge.tuning import apply_thresholds + + +class _Src: + def read(self): + return False, None + + +class _Pose: + def infer(self, frame): + return [] + + +class _Sink: + def try_send(self, event, snaps): + pass + + +def _runtime() -> PipelineRuntime: + cfg = load_config({"DEVICE_ID": "pi-1", "CAMERA_ID": "cam-1", + "CAFE24_URL": "https://x.example", "DEVICE_SECRET": "0" * 32}) + return PipelineRuntime(cfg, _Src(), _Pose(), _Sink()) + + +def test_every_catalogued_key_reaches_its_detector(): + rt = _runtime() + applied = rt.apply_thresholds({ + "fall_prone_s": 5.0, "fall_no_motion_s": 20.0, "immobile_s": 300.0, + "ghost_reappear_s": 25.0, "zone_debounce_s": 1.5, "cooldown_s": 60.0, + "motion_thr": 0.4, "infer_every": 2, + }) + e = rt.engine + assert e.fall.cfg.prone_confirm_s == 5.0 + assert e.fall.cfg.no_motion_s == 20.0 + assert e.immobility.cfg.immobile_s == 300.0 + assert e.ghost.cfg.reappear_s == 25.0 + assert e.zone.cfg.debounce_s == 1.5 + assert e.cfg.cooldown_s == 60.0 + assert rt.infer_every == 2 + assert set(applied) == {"fall_prone_s", "fall_no_motion_s", "immobile_s", "ghost_reappear_s", + "zone_debounce_s", "cooldown_s", "motion_thr", "infer_every"} + + +def test_motion_threshold_is_shared_by_both_detectors_that_use_it(): + # edge/config.py 는 기동 시 같은 MOTION_THR 을 fall 과 immobility 양쪽에 넣는다. + # 한쪽만 갱신하면 두 감지기가 서로 다른 기준으로 "정지"를 판단하게 된다. + rt = _runtime() + rt.apply_thresholds({"motion_thr": 0.75}) + assert rt.engine.fall.cfg.motion_thr == 0.75 + assert rt.engine.immobility.cfg.motion_thr == 0.75 + + +def test_bad_values_are_ignored_not_fatal(caplog): + # 이 경로는 24/7 동기화 루프다. 서버가 보낸 한 값이 이상하다고 공장 안 장비를 + # 재시작 루프에 빠뜨리면 그 카메라는 그 순간부터 아무것도 못 본다. + rt = _runtime() + before = rt.engine.fall.cfg.prone_confirm_s + logged = [] + applied = apply_thresholds(rt, { + "fall_prone_s": float("nan"), # 비교가 항상 False -> 감지기가 조용히 꺼진다 + "cooldown_s": 0, # 비교가 항상 True -> 매 틱 재발행 + "immobile_s": -5, + "ghost_reappear_s": "abc", + }, log=logged.append) + assert applied == {} + assert rt.engine.fall.cfg.prone_confirm_s == before + assert not math.isnan(rt.engine.fall.cfg.prone_confirm_s) + assert len(logged) == 4, "무시한 값은 반드시 로그로 남는다 — 조용한 무시가 가장 나쁘다" + + +def test_unknown_keys_are_ignored_silently(): + # 서버가 신버전, 엣지가 구버전인 정상 상황. 경고를 낼 일이 아니다. + rt = _runtime() + logged = [] + applied = apply_thresholds(rt, {"some_future_knob": 1.0}, log=logged.append) + assert applied == {} + assert logged == [] + + +def test_infer_every_is_bounded(): + # 서버가 실수로 1000 을 보내면 그 카메라는 사실상 감시를 멈춘다. + rt = _runtime() + rt.apply_thresholds({"infer_every": 1000}) + assert rt.infer_every == 10 + rt.apply_thresholds({"infer_every": 0}) + assert rt.infer_every == 10, "0 은 유효하지 않으므로 직전 값을 유지한다" + + +def test_fractional_infer_every_is_rejected(): + rt = _runtime() + logged = [] + apply_thresholds(rt, {"infer_every": 1.5}, log=logged.append) + assert rt.infer_every == 1 + assert logged + + +def test_empty_or_non_dict_is_a_no_op(): + rt = _runtime() + assert apply_thresholds(rt, {}) == {} + assert apply_thresholds(rt, None) == {} + assert apply_thresholds(rt, "nope") == {}