feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영

UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

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

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

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

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

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

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

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

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

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

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

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

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

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
This commit is contained in:
유창욱 2026-08-18 07:51:23 +09:00
parent c3227c179c
commit e00ad69aa3
85 changed files with 9368 additions and 234 deletions

View file

@ -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 배치 상한(정전 복구 시 감지 정지

View file

@ -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 라벨 · `<h1>` · `<title>` 세 곳이 동일 문자열 (`이벤트`/`구역 설정`/`안전 리포트`).
날짜 표기는 `YYYY-MM-DD` 로 3개 화면 통일 (각 파일의 로컬 `formatTime`/`fmtDate`).
- DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/`.
존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시
ZoneConfigController가 검증, /api/config로 엣지 전달

View file

@ -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:

176
edge/beacon.py Normal file
View file

@ -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))

View file

@ -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:

151
edge/detectors/integrity.py Normal file
View file

@ -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}

103
edge/detectors/pending.py Normal file
View file

@ -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

View file

@ -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,
}

View file

@ -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)

101
edge/tuning.py Normal file
View file

@ -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

View file

@ -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,
];

View file

@ -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"

View file

@ -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,
];

View file

@ -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)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
경로입니다.

View file

@ -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_%';

View file

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

View file

@ -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; }

View file

@ -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).
/** 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 || '오탐';
olderEvents[i] = Object.assign({}, ev, patch);
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);
// 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 + ')';
// 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
}
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();

View file

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

View file

@ -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,7 +307,77 @@
});
}
fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
// --- 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);
@ -168,4 +388,5 @@
meta.textContent = '불러오기 실패';
meta.removeAttribute('aria-busy');
});
});
})();

View file

@ -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 = '패키지를 불러오지 못했습니다.'; });
})();

View file

@ -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) {
// '미확인 잔여'는 존 규칙 위반이 아니다. 같은 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);

View file

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

View file

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

View file

@ -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();

View file

@ -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;
-- ===========================================================================

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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'];
}
}

View file

@ -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); }
}

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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,
];
}
}

View file

@ -115,6 +115,9 @@ final class ReportController
],
'pattern' => $pattern,
'precursor_alert' => $spike,
// 현장별 롤업은 전역 운영자(본사)에게만 의미가 있다. 스코프 운영자에게는 정의상
// 자기 현장 한 줄뿐이라 표를 만들 이유가 없고, 다른 현장 수치가 새면 안 된다.
'by_site' => $site === null ? $this->events->siteSummary($since) : null,
], 200);
}

View file

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

View file

@ -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);
}
}

View file

@ -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.

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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.

View file

@ -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;
}

View file

@ -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'],

View file

@ -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'],
];
}
}

View file

@ -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
{
$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)
VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts, prev_hash, entry_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$st->execute([
$actor,
$action,
$target,
json_encode($before, JSON_THROW_ON_ERROR),
json_encode($after, JSON_THROW_ON_ERROR),
time(),
]);
$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 ?? '',
]));
}
}

View file

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

View file

@ -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

View file

@ -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

View file

@ -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'],
];
}
}

View file

@ -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'],
];
}
}

View file

@ -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,
];
}
}

View file

@ -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,
];
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

110
server/src/View/admin.php Normal file
View file

@ -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();

View file

@ -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();

View file

@ -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 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><a href="<?= view_url('/') ?>">← 목록으로</a></p>
<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();

View file

@ -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>

View file

@ -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();

View file

@ -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) ?> · 이동 감지 대시보드</title>
<title><?= view_e($title) ?> · AI SAFETY CCTV</title>
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
<script nonce="<?= view_e(\App\Security\Csp::nonce()) ?>">
window.APP_BASE =<?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
@ -65,7 +75,13 @@ window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON
<nav class="app-nav">
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a>
<a href="<?= view_url('/zones') ?>" class="app-nav-link<?= $navZones ?>">구역 설정</a>
<a href="<?= view_url('/report') ?>" class="app-nav-link<?= $navReport ?>">리포트</a>
<a href="<?= view_url('/report') ?>" class="app-nav-link<?= $navReport ?>">안전 리포트</a>
<?php if ($canCompliance): ?>
<a href="<?= view_url('/compliance') ?>" class="app-nav-link<?= $navCompliance ?>">안전 관리</a>
<?php endif; ?>
<?php if ($canAdmin): ?>
<a href="<?= view_url('/admin') ?>" class="app-nav-link<?= $navAdmin ?>">시스템 설정</a>
<?php endif; ?>
</nav>
<div class="app-session">
<?php if ($username !== null): ?>

View file

@ -5,7 +5,7 @@ render_header('안전 리포트');
<div class="report">
<header class="report-head">
<div>
<h1>안전 운영 리포트</h1>
<h1>안전 리포트</h1>
<p id="report-range" class="report-range">기간 집계 중…</p>
</div>
<div class="report-controls">
@ -24,6 +24,11 @@ render_header('안전 리포트');
<div id="report-kpis" class="report-kpis" aria-label="핵심 지표"></div>
<div class="report-grid">
<!-- 본사(전역 운영자) 전용. 스코프 운영자에게는 자기 현장 한 줄뿐이라 숨긴다. -->
<section class="report-card report-card-wide" id="report-sites-card" hidden>
<h2>현장별 현황</h2>
<div id="report-sites"></div>
</section>
<section class="report-card">
<h2>유형별 감지</h2>
<div id="report-types" class="bar-list"></div>

25
server/src/View/setup.php Normal file
View file

@ -0,0 +1,25 @@
<?php
require __DIR__ . '/layout.php';
render_header('설치 점검');
?>
<div class="setup-page">
<header class="page-head">
<h1>설치 점검</h1>
<p class="page-sub">현장에 시스템을 세울 때의 순서입니다. 각 단계는 실제 상태를 조회해 표시되므로, 체크가 켜졌다면 그 단계는 정말로 동작하고 있는 것입니다.</p>
</header>
<!-- 납품 직후가 이 제품에서 가장 위험한 구간이다: 화면은 멀쩡히 뜨는데 장치가 등록되지
않았거나, 존이 없거나, 통보 경로가 비어 있으면 "감시 중"으로 보이면서 아무것도
감시하지 않는다. 각 단계가 무엇을 확인하는지와, 안 되면 무엇이 벌어지는지를 함께 쓴다. -->
<ol id="setup-steps" class="setup-steps">
<li class="setup-step is-checking">상태를 확인하는 중…</li>
</ol>
<p class="setup-foot">
이 화면은 언제든 다시 열어 현재 구성을 점검할 수 있습니다.
문제가 있는 단계만 남기려면 <a href="<?= view_url('/setup') ?>">새로고침</a>하세요.
</p>
</div>
<script src="<?= view_url('/assets/setup.js') ?>"></script>
<?php
render_footer();

View file

@ -2,39 +2,92 @@
require __DIR__ . '/layout.php';
render_header('구역 설정');
?>
<div class="zone-page">
<header class="page-head">
<h1>구역 설정</h1>
<div class="zone-controls">
<label for="device-id">장치 ID</label>
<input type="text" id="device-id" placeholder="device-01">
<label for="camera-id">카메라 ID</label>
<input type="text" id="camera-id" placeholder="cam-01">
<button type="button" id="zone-load-btn">불러오기</button>
<button type="button" id="zone-frame-btn">현재 화면 불러오기</button>
<button type="button" id="heatmap-btn" aria-pressed="false">위험 히트맵</button>
<label for="heatmap-days">기간(일)</label>
<input type="number" id="heatmap-days" min="1" max="90" value="30">
<p class="page-sub">카메라 화면 위에 위험구역을 그리면 엣지가 그 안의 작업자를 판정합니다. 저장 즉시 다음 설정 동기화 때 장치로 내려갑니다.</p>
</header>
<div class="zone-console">
<!-- 좌측 패널은 이벤트 콘솔의 레일과 같은 문법(카드 + 소문자 캡션)이다.
작업 순서 그대로 ①선택 → ②그리기 → ③규칙·저장 으로 내려간다. -->
<aside class="zone-panel">
<section class="zone-step">
<h2 class="rail-title"><span class="step-no">1</span> 카메라 선택</h2>
<!-- 예전에는 장치·카메라 ID를 외워서 타이핑해야 했다. 시스템이 아는 값을
사람에게 묻지 않는다 — 목록은 /api/devices + 관측된 카메라에서 채운다. -->
<label class="field">장치
<select id="device-select" aria-label="장치 선택"><option value="">불러오는 중…</option></select>
</label>
<label class="field">카메라
<select id="camera-select" aria-label="카메라 선택"><option value="">장치를 먼저 선택하세요</option></select>
</label>
<details class="zone-manual" id="zone-manual">
<summary>목록에 없는 카메라 직접 입력</summary>
<p class="zone-manual-why">이벤트를 한 번도 보낸 적 없는 새 카메라는 목록에 나타나지 않습니다.</p>
<label class="field" for="device-id">장치 ID
<input type="text" id="device-id" autocomplete="off" spellcheck="false">
</label>
<label class="field" for="camera-id">카메라 ID
<input type="text" id="camera-id" autocomplete="off" spellcheck="false">
</label>
</details>
<div class="step-actions">
<button type="button" id="zone-load-btn">저장된 구역 불러오기</button>
<button type="button" id="zone-frame-btn">카메라 화면을 배경으로</button>
</div>
<div class="zone-controls">
<label for="zone-level">등급</label>
</section>
<section class="zone-step">
<h2 class="rail-title"><span class="step-no">2</span> 구역 그리기</h2>
<p class="step-hint">캔버스를 클릭해 점을 3개 이상 찍으면 폴리곤이 됩니다.</p>
<label class="field">등급
<select id="zone-level">
<option value="warning">경고 (warning)</option>
<option value="restricted">제한 (restricted)</option>
<option value="pinch">협착 (pinch)</option>
<option value="warning">경고 — 진입 시 알림</option>
<option value="restricted">제한 — 진입 자체가 위반</option>
<option value="pinch">협착 — 끼임 위험</option>
</select>
<button type="button" id="zone-clear-btn">현재 폴리곤 지우기</button>
<button type="button" id="zone-save-btn">저장</button>
</label>
<div class="step-actions">
<button type="button" id="zone-clear-btn">그리던 폴리곤 지우기</button>
</div>
<div class="zone-controls">
<label for="rule-min-workers">최소 인원 (2인 1조)</label>
<div class="zone-overlay-tools">
<button type="button" id="heatmap-btn" aria-pressed="false">위험 히트맵</button>
<label class="field" for="heatmap-days">기간(일)
<input type="number" id="heatmap-days" min="1" max="90" value="30">
</label>
</div>
</section>
<section class="zone-step">
<h2 class="rail-title"><span class="step-no">3</span> 점유 규칙 · 저장</h2>
<p class="step-hint">지금 그리는 구역에 적용됩니다. 비워두면 규칙 없이 저장됩니다.</p>
<label class="field" for="rule-min-workers">
<span class="field-label">최소 인원 <span class="field-hint">2인 1조 강제</span></span>
<input type="number" id="rule-min-workers" min="2" max="50" placeholder="없음">
<label for="rule-max-workers">최대 인원</label>
</label>
<label class="field" for="rule-max-workers">
<span class="field-label">최대 인원 <span class="field-hint">정원 초과 감지</span></span>
<input type="number" id="rule-max-workers" min="1" max="50" placeholder="없음">
<label for="rule-max-dwell">체류 상한 (분)</label>
</label>
<label class="field" for="rule-max-dwell">
<span class="field-label">체류 상한 <span class="field-hint">분</span></span>
<input type="number" id="rule-max-dwell" min="1" max="1440" placeholder="없음">
</div>
</label>
<button type="button" id="zone-save-btn" class="btn-primary">이 구역 저장</button>
</section>
</aside>
<section class="zone-stage">
<canvas id="zone-canvas" width="960" height="540" tabindex="0" aria-label="구역 편집 캔버스: 클릭하여 폴리곤 점을 추가합니다"></canvas>
<ul id="zone-list" aria-live="polite"></ul>
<p id="zone-status" role="status"></p>
<p id="zone-status" role="status" class="zone-status">카메라를 선택하고 저장된 구역을 불러오세요.</p>
<section class="zone-saved">
<h2 class="rail-title">저장된 구역</h2>
<ul id="zone-list" aria-live="polite" class="zone-list"></ul>
</section>
</section>
</div>
</div>
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
<?php
render_footer();

View file

@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Alert\{AlertDispatcher, EscalationWatch, Channels};
use App\Tests\Support\{InMemoryEventRepository, InMemoryAlertRepository, InMemorySuppressionRepository};
use PHPUnit\Framework\TestCase;
/**
* The notification path — the piece whose absence meant a fall at 03:00 produced a database row
* and nothing else. These tests pin the rules that decide whether a human is actually reached.
*/
final class AlertDispatchTest extends TestCase
{
private const NOW = 1_800_000_000;
private function channel(array $over = []): array
{
return $over + [
'channel_id' => 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"}']));
}
}

View file

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Repository\Pdo\PdoAuditRepository;
use PDO;
use PHPUnit\Framework\TestCase;
/**
* Tamper-evidence of the audit log.
*
* Runs against real sqlite rather than a double: the whole property being tested is a property of
* what actually gets written to a database, and a PHP reimplementation of the chain would only
* ever prove that the test agrees with itself.
*/
final class AuditChainTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
$this->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);
}
}

View file

@ -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,

View file

@ -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);

View file

@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Api\ThresholdCatalog;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\{AdminConfigController, SuppressionController, ComplianceController};
use App\Tests\Support\{InMemoryThresholdRepository, InMemoryAlertRepository, InMemoryDeviceRepository,
InMemoryAuditRepository, InMemorySuppressionRepository, InMemoryEventRepository,
InMemorySnapshotRepository, InMemoryActionRepository, InMemoryRiskControlRepository,
InMemoryHandoverRepository};
use PHPUnit\Framework\TestCase;
/**
* Threshold editing, alarm suppression and the compliance surfaces.
*
* These are the screens that let a delivered site tune and operate itself. Before them the
* customer's only route to a threshold was phpMyAdmin, and their only route to silencing a
* maintenance window was to ignore the alarms — which is how a safety console ends up switched
* off.
*/
final class SafetyConfigTest extends TestCase
{
private function sess(string $role = 'admin', ?string $site = null): ArraySessionStore
{
$s = new ArraySessionStore();
$s->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']);
}
}

View file

@ -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);
}
}

157
server/tools/backup.php Normal file
View file

@ -0,0 +1,157 @@
<?php
/**
* 백업 — DB 덤프 + 스냅샷 아카이브.
*
* php tools/backup.php --out=/path/to/backups
* php tools/backup.php --out=/path --no-snapshots (DB만; 이미지가 클 때)
*
* 왜 필요한가
* 납품 후 고객이 스스로 하기 가장 어려운 일이 백업이다. 카페24 관리자 화면의 DB 백업은
* `move_` 표만 골라내지 못하고, 스냅샷 파일은 아예 대상이 아니다. 사고 기록과 그 증거
* 이미지가 함께 남지 않으면 백업의 의미가 절반이다 — 조사에서 필요한 것은 둘 다이다.
*
* 왜 mysqldump 를 쓰지 않는가
* 공유호스팅에는 shell_exec 도 mysqldump 도 없는 경우가 흔하다. PDO 로 읽어 SQL 텍스트를
* 직접 쓰면 어디서든 돈다. 대신 대용량에서는 느리므로, 이벤트 표는 청크로 읽는다.
*
* 복구
* phpMyAdmin 에서 덤프 SQL 을 실행하고, 스냅샷 tar 를 storage/snapshots/ 에 풀면 된다.
* 자세한 절차는 출력 파일 옆의 RESTORE.txt 에 함께 기록된다.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
$outDir = rtrim((string)($args['out'] ?? ''), '/\\');
if ($outDir === '') {
fwrite(STDERR, "사용법: php tools/backup.php --out=<디렉터리> [--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);

View file

@ -0,0 +1,69 @@
<?php
/**
* Shared CLI bootstrap for the tools in this directory.
*
* Locates the app root the same way public/index.php does (config.php + vendor/autoload.php +
* src/Config.php, probing the private container first), so a tool run from the delivered
* package finds the same config the web app uses. Returns [Config, PDO].
*
* CLI ONLY: these tools write to the database. Refuse to run over the web, where an operator
* could be tricked into hitting them with a browser.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
const APP_PRIVATE_DIR = 'move_detect_app';
function tool_app_root(): string
{
$isAppRoot = static fn (string $d): bool =>
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';
}

View file

@ -0,0 +1,125 @@
<?php
/**
* 통보 디스패처 — 크론 진입점.
*
* 크론 등록 (1분 주기). 아래 한 줄을 그대로 쓰되, 앞의 분 필드는 별표+슬래시+1 입니다 —
* 이 주석 안에 그대로 적으면 PHP 블록 주석이 거기서 끝나 파일이 파싱되지 않습니다:
*
* <매분> /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);

122
server/tools/maintain.php Normal file
View file

@ -0,0 +1,122 @@
<?php
/**
* 보존기간 정리 — 크론.
*
* 17 3 * * * /usr/bin/php /path/to/move_detect_app/tools/maintain.php >> /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);

373
server/tools/seed_demo.php Normal file
View file

@ -0,0 +1,373 @@
<?php
/**
* 데모/QA 시드 생성기.
*
* php -d extension=pdo_sqlite server/tools/seed_demo.php --days=45 --force
*
* WHY THIS EXISTS: every demo, every UI review and every regression check needs a database that
* looks like a real site — several devices, cameras with different risk profiles, an incident
* cascade (fall → no_motion on the same track), acknowledged and unacknowledged rows, false
* positives, evidence images, zones with rules, improvement actions. Building that by hand each
* time is how screens get signed off against data that never occurs in production (a single
* severity, no empty states, no long tail).
*
* SAFETY: this writes demo rows. It refuses to touch a database that already holds events
* unless --force is given, and it never runs over HTTP (see bootstrap.php). Every row it writes
* is tagged so --purge can remove exactly those rows and nothing else.
*
* Images need ext-gd. Without it the seeder still runs and the rows simply have no snapshot,
* which is itself a state worth seeing (PIPA retention purges produce it in production).
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
// Demo rows are marked in the one field no ingest path writes: a device_id prefix. That makes
// --purge exact instead of "delete everything and hope".
const DEMO_PREFIX = 'demo-';
$days = max(1, min(365, (int)($args['days'] ?? 45)));
$force = isset($args['force']);
$purge = isset($args['purge']);
$withImgs = !isset($args['no-images']) && function_exists('imagecreatetruecolor');
$snapshotDir = rtrim($cfg->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');

View file

@ -0,0 +1,102 @@
<?php
/**
* 감사로그 사슬 검증.
*
* php server/tools/verify_audit_chain.php [--json]
*
* 종료 코드: 0 = 무결, 1 = 사슬 손상(수정/삭제 흔적), 2 = 실행 오류.
*
* 사고 조사나 감독기관 제출 시 "이 로그가 손대지 않은 것인가"에 답하는 도구다. 손상이
* 발견되면 **어느 행에서** 끊겼는지까지 알려준다 — 그 지점 전후가 조사 대상이다.
*
* 마이그레이션 이전에 쌓인 행은 해시가 없다. 이를 손상으로 보고하지 않고 '검증 불가 구간'
* 으로 따로 세는 것이 중요하다. 둘을 섞으면 정상 시스템이 매번 빨간불을 켜고, 그러면
* 아무도 이 도구를 안 보게 된다.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use App\Repository\Pdo\PdoAuditRepository;
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
$asJson = isset($args['json']);
$rows = $pdo->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);

134
tests/test_beacon.py Normal file
View file

@ -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"]}) == []

View file

@ -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, "핀이 바뀌면 이전 핀을 반드시 놓아준다"

142
tests/test_integrity.py Normal file
View file

@ -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))

View file

@ -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) == []

112
tests/test_tuning.py Normal file
View file

@ -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") == {}