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:
parent
c3227c179c
commit
e00ad69aa3
85 changed files with 9368 additions and 234 deletions
|
|
@ -1,5 +1,55 @@
|
||||||
# INDEX — 작업 기록 (완료 결과만)
|
# 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
|
- **2026-08-08** 감사 잔여 확정 항목 일괄 처리 (VERSION 0.9.0 신설). **보안**: CSP nonce
|
||||||
도입(인라인 2블록, unsafe-inline 없음)+보안헤더를 PHP로 이관(개발/납품 동일), /logout CSRF,
|
도입(인라인 2블록, unsafe-inline 없음)+보안헤더를 PHP로 이관(개발/납품 동일), /logout CSRF,
|
||||||
계정 잠금 423→401(사용자명 열거 제거). **엣지**: 큐 flush 배치 상한(정전 복구 시 감지 정지
|
계정 잠금 423→401(사용자명 열거 제거). **엣지**: 큐 flush 배치 상한(정전 복구 시 감지 정지
|
||||||
|
|
|
||||||
|
|
@ -25,21 +25,48 @@
|
||||||
| `daemon.py` / `runtime.py` / `health.py` | 상주 루프 · 하트비트 텔레메트리 |
|
| `daemon.py` / `runtime.py` / `health.py` | 상주 루프 · 하트비트 텔레메트리 |
|
||||||
| `adapters/` | hailo_pose · movenet_pose(CPU 폴백) · picamera_source(CSI) · uvc_source(USB) |
|
| `adapters/` | hailo_pose · movenet_pose(CPU 폴백) · picamera_source(CSI) · uvc_source(USB) |
|
||||||
| `probe.py` / `tools/` | 카메라 사전 점검 · HEF/모델 fetch (빌드머신 전용) |
|
| `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)
|
## 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` ·
|
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
||||||
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
||||||
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
|
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
|
||||||
`GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
|
`GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
|
||||||
ack 겸함) · `GET /api/report`(운영 지표+시간패턴+전조경보+추이) ·
|
ack 겸함) · `GET /api/report`(운영 지표+시간패턴+전조경보+추이) ·
|
||||||
`GET /api/export/labels`(라벨 JSONL, 관리자) · `GET/POST /api/actions`(개선 조치·전후 효과) ·
|
`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/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
|
||||||
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
|
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시·시스템설정·억제·규정 ·
|
||||||
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·
|
`src/Alert/` 통보 결정·발송·에스컬레이션 · `src/Repository/` 14종 (인터페이스+PDO)
|
||||||
`zone-draw.js` · `detail.js` · `app.css`
|
- 감사로그는 **해시체인**(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/`.
|
- DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/`.
|
||||||
존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시
|
존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시
|
||||||
ZoneConfigController가 검증, /api/config로 엣지 전달
|
ZoneConfigController가 검증, /api/config로 엣지 전달
|
||||||
|
|
|
||||||
16
edge/app.py
16
edge/app.py
|
|
@ -8,7 +8,7 @@ from edge.detectors.zone import ZoneIntrusionDetector
|
||||||
from edge.engine import EventEngine
|
from edge.engine import EventEngine
|
||||||
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
||||||
from edge.models import BBox
|
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.
|
# 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.
|
# 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
|
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()
|
ok, frame = source.read()
|
||||||
if not ok:
|
if not ok:
|
||||||
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
|
# 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.
|
# 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
|
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)
|
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:
|
for tr in lost:
|
||||||
pool.forget(tr.track_id)
|
pool.forget(tr.track_id)
|
||||||
if events:
|
if events:
|
||||||
|
|
|
||||||
176
edge/beacon.py
Normal file
176
edge/beacon.py
Normal 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))
|
||||||
|
|
@ -7,6 +7,14 @@ import os
|
||||||
_LOG_EVERY = 300
|
_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)
|
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
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,
|
sync_every=150, heartbeat_every=900, flush_every=300,
|
||||||
frame_interval=0.0, zones_for=None, log=None,
|
frame_interval=0.0, zones_for=None, log=None,
|
||||||
heartbeat_interval_s=None, health_fn=None, configured_cameras=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
|
# `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
|
# 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,
|
# 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
|
# 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
|
# 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.
|
# 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
|
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
|
||||||
|
|
||||||
def note(key, label, exc=None):
|
def note(key, label, exc=None):
|
||||||
|
|
@ -85,12 +103,48 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
else:
|
else:
|
||||||
counters["frames"] += 1
|
counters["frames"] += 1
|
||||||
streak.pop(id(rt), None) # recovered — reset the streak
|
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:
|
try:
|
||||||
if sync_every and i % sync_every == 0:
|
if sync_every and i % sync_every == 0:
|
||||||
cfg = client.fetch_config()
|
cfg = client.fetch_config()
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
for rt in runtimes:
|
for rt in runtimes:
|
||||||
rt.apply_zones(zones_for(cfg, rt))
|
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
|
# 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.
|
# config-drift indicator is meaningless until the device reports what it runs.
|
||||||
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
||||||
|
|
|
||||||
151
edge/detectors/integrity.py
Normal file
151
edge/detectors/integrity.py
Normal 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
103
edge/detectors/pending.py
Normal 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
|
||||||
|
|
@ -18,6 +18,19 @@ class EventType(str, Enum):
|
||||||
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
|
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
|
||||||
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
|
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
|
||||||
ERGO_REPORT = "ergo_report" # 자세 부담 노출 주기 리포트 (카메라 단위, 익명)
|
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):
|
class Severity(str, Enum):
|
||||||
|
|
@ -44,6 +57,15 @@ _SEVERITY = {
|
||||||
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO.
|
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO.
|
||||||
EventType.NEAR_FALL: Severity.INFO,
|
EventType.NEAR_FALL: Severity.INFO,
|
||||||
EventType.ERGO_REPORT: 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ from edge.detectors.fall import FallDetector
|
||||||
from edge.detectors.immobility import ImmobilityWatchdog
|
from edge.detectors.immobility import ImmobilityWatchdog
|
||||||
from edge.detectors.ghost import GhostIncidentDetector
|
from edge.detectors.ghost import GhostIncidentDetector
|
||||||
from edge.detectors.zone import ZoneIntrusionDetector
|
from edge.detectors.zone import ZoneIntrusionDetector
|
||||||
|
from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig
|
||||||
from edge.engine import EventEngine
|
from edge.engine import EventEngine
|
||||||
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
||||||
|
from edge.tuning import apply_thresholds
|
||||||
|
|
||||||
|
|
||||||
class PipelineRuntime:
|
class PipelineRuntime:
|
||||||
|
|
@ -37,6 +39,10 @@ class PipelineRuntime:
|
||||||
)
|
)
|
||||||
self.ring = RingBuffer()
|
self.ring = RingBuffer()
|
||||||
self.snapshotter = Snapshotter(FaceBlurrer())
|
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:
|
def step(self, now: float) -> list:
|
||||||
self._tick += 1
|
self._tick += 1
|
||||||
|
|
@ -51,7 +57,17 @@ class PipelineRuntime:
|
||||||
return [] if ok else None
|
return [] if ok else None
|
||||||
return run_once(self.source, self.pose, self.tracker, self.pool,
|
return run_once(self.source, self.pose, self.tracker, self.pool,
|
||||||
self.engine, self.ring, self.snapshotter, self.sink,
|
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:
|
def apply_zones(self, zones: list) -> None:
|
||||||
self.zone_detector.zones = list(zones)
|
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
101
edge/tuning.py
Normal 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
|
||||||
|
|
@ -14,4 +14,17 @@ return [
|
||||||
// PIPA retention: worker snapshots are purged after this many days (0 disables).
|
// PIPA retention: worker snapshots are purged after this many days (0 disables).
|
||||||
'snapshot_retention_days' => 7,
|
'snapshot_retention_days' => 7,
|
||||||
'event_retention_days' => 0, // 0 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료)
|
'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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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.
|
# FRESH database only. Like schema.sql, delete from the host after applying.
|
||||||
cp -r "$SERVER/migrations" "$PRIV/migrations"
|
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.
|
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
|
||||||
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
|
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
|
||||||
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
|
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,19 @@ return [
|
||||||
// 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다.
|
// 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다.
|
||||||
// DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다.
|
// DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다.
|
||||||
'event_retention_days' => 0,
|
'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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
|
- [ ] `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`
|
§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.
|
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)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
|
||||||
|
경로입니다.
|
||||||
|
|
|
||||||
191
server/migrations/2026-08-09-safety-platform.sql
Normal file
191
server/migrations/2026-08-09-safety-platform.sql
Normal 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_%';
|
||||||
322
server/public/assets/admin.js
Normal file
322
server/public/assets/admin.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -173,8 +173,11 @@ label { color:var(--ink-2); font-size:.84rem; }
|
||||||
.rail-filters { display:flex; flex-direction:column; gap:.55rem; }
|
.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 .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); }
|
.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; }
|
/* Rail: 날짜 조회 (사고 조사 진입로). */
|
||||||
.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
|
.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) --------------------------------- */
|
/* ---- Device → camera tree (rail) --------------------------------- */
|
||||||
/* Devices carry the heartbeat telemetry (online/offline, fps, temp, throttle, drift); the
|
/* 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; }
|
.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);} }
|
@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 -------------------- */
|
/* ---- KPI strip: compact unacknowledged counts -------------------- */
|
||||||
/* Computed client-side from the /api/events window (no extra endpoint). A tight
|
/* 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. */
|
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-time .ev-at { font-family:var(--mono); color:var(--ink-3); }
|
||||||
.event-detail-link { color:inherit; text-decoration:none; }
|
.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; }
|
.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. */
|
/* 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; }
|
.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);
|
.fp-btn { flex:none; padding:.32rem .55rem; font-size:.75rem; color:var(--ink-3);
|
||||||
background:none; border-color:transparent; box-shadow:none; }
|
background:none; border-color:transparent; box-shadow:none; }
|
||||||
.fp-btn:hover { color:var(--ink-2); border-color:var(--line); background:#fff; }
|
.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; }
|
.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; }
|
.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-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 { flex:none; font-family:var(--mono); color:var(--ink-3); }
|
||||||
.fp-rate.is-hot { color:var(--warn-ink); font-weight:700; }
|
.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. */
|
/* "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;
|
.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); }
|
.login-form button:hover { background:var(--accent-deep); border-color:var(--accent-deep); }
|
||||||
.form-error { color:var(--crit-ink); font-size:.85rem; }
|
.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 editor ------------------------------------------------- */
|
||||||
.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; }
|
/* Same console grammar as the events page (panel rail + stage), so the two screens read as one
|
||||||
.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; }
|
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 {
|
#zone-canvas {
|
||||||
|
width:100%; height:auto; display:block;
|
||||||
border:1px solid var(--line); border-radius:10px;
|
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);
|
box-shadow:var(--shadow);
|
||||||
}
|
}
|
||||||
#zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; }
|
.zone-status {
|
||||||
#zone-list li {
|
margin:0; padding:.42rem .65rem; font-size:.79rem; color:var(--ink-2);
|
||||||
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
background:var(--panel-2); border:1px solid var(--line); border-radius:8px;
|
||||||
max-width:380px; padding:.5rem .75rem;
|
}
|
||||||
background:var(--card); border:1px solid var(--line); border-radius:9px;
|
.zone-saved { display:flex; flex-direction:column; gap:.4rem; }
|
||||||
font-size:.85rem; color:var(--ink-2);
|
.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 -------------------------------------------- */
|
/* ---- Incident detail -------------------------------------------- */
|
||||||
.detail-row { margin:.35rem 0; }
|
/* The screen an accident is judged on. It was a bare label:value list with a default blue link,
|
||||||
.detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; }
|
in a 200px column inside a 1240px shell — the least finished surface in the product and the
|
||||||
#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; }
|
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
|
/* 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 { 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 figcaption { font-size:.72rem; font-weight:700; color:var(--ink-3); font-family:var(--mono); }
|
||||||
.detail-snap:last-child figcaption { color:var(--ink); }
|
.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 ----------------- */
|
/* ---- Responsive: rail collapses above the stream ----------------- */
|
||||||
@media (max-width:860px) {
|
@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 { height:100%; border-radius:999px; background:var(--accent); }
|
||||||
.bar-fill.is-hot { background:var(--crit-ink); }
|
.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 {
|
.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);
|
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); }
|
.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); }
|
.kpi-trend.is-better { color:var(--accent); }
|
||||||
|
|
||||||
.report-card-wide { grid-column:1 / -1; }
|
.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; }
|
.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-better { color:var(--accent); font-weight:700; }
|
||||||
.action-effect.is-worse { color:var(--crit-ink); font-weight:700; }
|
.action-effect.is-worse { color:var(--crit-ink); font-weight:700; }
|
||||||
.action-effect.is-pending { font-style:normal; }
|
.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 { display:flex; flex-direction:column; gap:2px; overflow-x:auto; }
|
||||||
.pattern-row { display:flex; align-items:center; gap:2px; }
|
.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); }
|
.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; }
|
.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 ---------------------------------------------- */
|
/* ---- Reduced motion ---------------------------------------------- */
|
||||||
@media (prefers-reduced-motion:reduce) {
|
@media (prefers-reduced-motion:reduce) {
|
||||||
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
|
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
/**
|
/**
|
||||||
* Events dashboard: polls GET /api/events every 5s, renders the list with
|
* Events dashboard: polls GET /api/events every 5s, renders the list with
|
||||||
* severity text+icon+color (never color alone, per NF9), pins unacknowledged
|
* severity text+icon+color (never color alone, per NF9), and wires the acknowledge
|
||||||
* events to the top, and wires the acknowledge button to POST /events/{id}/ack.
|
* 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.
|
* XSS-safe: all dynamic values are set via textContent, never innerHTML.
|
||||||
*/
|
*/
|
||||||
|
|
@ -71,6 +78,57 @@
|
||||||
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
+ 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) {
|
function buildBadge(sev) {
|
||||||
var info = severityInfo(sev);
|
var info = severityInfo(sev);
|
||||||
var badge = document.createElement('span');
|
var badge = document.createElement('span');
|
||||||
|
|
@ -93,6 +151,30 @@
|
||||||
var audioCtx = null;
|
var audioCtx = null;
|
||||||
var alarmTimer = null;
|
var alarmTimer = null;
|
||||||
var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately
|
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() {
|
function unlockAudio() {
|
||||||
if (!audioCtx) {
|
if (!audioCtx) {
|
||||||
|
|
@ -103,6 +185,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function beep() {
|
function beep() {
|
||||||
|
if (muted) return;
|
||||||
if (!audioCtx) return;
|
if (!audioCtx) return;
|
||||||
try {
|
try {
|
||||||
var osc = audioCtx.createOscillator();
|
var osc = audioCtx.createOscillator();
|
||||||
|
|
@ -125,15 +208,24 @@
|
||||||
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||||||
if (ev.severity === 'critical' && !acked) {
|
if (ev.severity === 'critical' && !acked) {
|
||||||
hasUnackedCritical = true;
|
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);
|
alarmTimer = setInterval(beep, 3000);
|
||||||
} else if (!hasUnackedCritical && alarmTimer) {
|
} else if ((!hasUnackedCritical || muted) && alarmTimer) {
|
||||||
clearInterval(alarmTimer);
|
clearInterval(alarmTimer);
|
||||||
alarmTimer = null;
|
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
|
// 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.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
|
||||||
typeLink.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
|
typeLink.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
|
||||||
type.appendChild(typeLink);
|
type.appendChild(typeLink);
|
||||||
// The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip —
|
// "확인 필요" on EVERY unacknowledged row, not just criticals. The chip used to be a
|
||||||
// secondary encoding alongside the red tint, so it never rides on color alone.
|
// critical-only flourish, which left 경고/정보 rows with no textual state at all: whether
|
||||||
if (!isAcked && ev.severity === 'critical') {
|
// 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');
|
var chip = document.createElement('span');
|
||||||
chip.className = 'chip';
|
chip.className = 'chip chip-' + sevRow;
|
||||||
chip.textContent = '확인 필요';
|
chip.textContent = '확인 필요';
|
||||||
type.appendChild(chip);
|
type.appendChild(chip);
|
||||||
}
|
}
|
||||||
|
|
@ -283,11 +378,12 @@
|
||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) throw new Error('ack_failed:' + res.status);
|
if (!res.ok) throw new Error('ack_failed:' + res.status);
|
||||||
// Reflect the ack on a "더 보기"-loaded (older-window) row too. loadEvents() only refreshes
|
// Patch BOTH windows immediately. The row must resolve in place on this frame, not on
|
||||||
// the live window, so without this an event acked from the paged tail snaps straight back
|
// the next 5s poll: a row that keeps saying 미확인 for five seconds invites a second
|
||||||
// to unacknowledged on the next render — inviting a duplicate ack that overwrites the
|
// click that overwrites the original acknowledged_by audit record.
|
||||||
// original acknowledged_by audit record.
|
var ev = patchEventLocally(eventId, false);
|
||||||
patchOlderAck(eventId);
|
renderList();
|
||||||
|
showToast('확인 처리 — ' + (ev ? eventSummary(ev) : '이벤트 ' + eventId));
|
||||||
return loadEvents();
|
return loadEvents();
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
|
|
@ -295,22 +391,49 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchOlderAck(eventId, alsoFp) {
|
/** Apply the resolution to the live window AND the paged tail, and return the patched event.
|
||||||
for (var i = 0; i < olderEvents.length; i++) {
|
* loadEvents() only refreshes the live window, so without the tail patch an event resolved
|
||||||
var ev = olderEvents[i];
|
* from a "더 보기" page snaps back to 미확인 on the next render. */
|
||||||
if (ev && ev.event_id === eventId && !isAcked(ev)) {
|
function patchEventLocally(eventId, alsoFp) {
|
||||||
// New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
|
var patch = { acknowledged_by: window.APP_USER || '확인' };
|
||||||
var patch = { acknowledged_by: window.APP_USER || '확인' };
|
if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐';
|
||||||
if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐';
|
var found = null;
|
||||||
olderEvents[i] = Object.assign({}, ev, patch);
|
[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
|
// 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 —
|
// 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.
|
// 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) {
|
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;
|
fpBtn.disabled = true;
|
||||||
if (ackBtn) ackBtn.disabled = true;
|
if (ackBtn) ackBtn.disabled = true;
|
||||||
fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', {
|
fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', {
|
||||||
|
|
@ -319,7 +442,9 @@
|
||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) throw new Error('fp_failed:' + res.status);
|
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
|
loadFpStats(); // the panel should reflect the tag immediately, not in 60s
|
||||||
return loadEvents();
|
return loadEvents();
|
||||||
})
|
})
|
||||||
|
|
@ -344,7 +469,24 @@
|
||||||
return ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
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) {
|
function renderStatus(events) {
|
||||||
|
renderStatusCaption(events);
|
||||||
if (!statusRoot) return;
|
if (!statusRoot) return;
|
||||||
var counts = { critical: 0, high: 0, warning: 0, info: 0 };
|
var counts = { critical: 0, high: 0, warning: 0, info: 0 };
|
||||||
events.forEach(function (ev) {
|
events.forEach(function (ev) {
|
||||||
|
|
@ -411,6 +553,18 @@
|
||||||
var filters = { severity: '', type: '', device: '', camera: '', unackedOnly: false };
|
var filters = { severity: '', type: '', device: '', camera: '', unackedOnly: false };
|
||||||
var moreBtn = document.getElementById('load-more');
|
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) {
|
function matchesFilters(ev) {
|
||||||
if (filters.severity && ev.severity !== filters.severity) return false;
|
if (filters.severity && ev.severity !== filters.severity) return false;
|
||||||
if (filters.type && ev.event_type !== filters.type) 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
|
renderStatus(liveEvents); // counts always from the full live window, never the filtered view
|
||||||
updateAlarm(liveEvents); // alarm too — before any early return
|
updateAlarm(liveEvents); // alarm too — before any early return
|
||||||
renderTree(); // rail: camera stats + the current selection
|
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 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;
|
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);
|
return (b.event_id || 0) - (a.event_id || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -453,7 +614,8 @@
|
||||||
var sig = shown.map(function (ev) {
|
var sig = shown.map(function (ev) {
|
||||||
return [ev.event_id, ev.acknowledged_by || '', ev.false_positive_by || '',
|
return [ev.event_id, ev.acknowledged_by || '', ev.false_positive_by || '',
|
||||||
ev.thumb_uuid || ''].join('|');
|
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;
|
if (sig === lastListSig) return;
|
||||||
lastListSig = sig;
|
lastListSig = sig;
|
||||||
|
|
||||||
|
|
@ -466,23 +628,156 @@
|
||||||
if (shown.length === 0) {
|
if (shown.length === 0) {
|
||||||
var empty = document.createElement('div');
|
var empty = document.createElement('div');
|
||||||
empty.className = 'stream-empty';
|
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);
|
root.appendChild(empty);
|
||||||
} else {
|
} 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();
|
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);
|
root.appendChild(frag);
|
||||||
}
|
}
|
||||||
if (refocus) {
|
if (refocus) {
|
||||||
var back = root.querySelector('[data-focus="' + refocus + '"]');
|
var back = root.querySelector('[data-focus="' + refocus + '"]');
|
||||||
if (back) back.focus();
|
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();
|
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() {
|
function updatePageGapHint() {
|
||||||
// A gap exists once new events have arrived since the operator paged: some events have slid out
|
// 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,
|
// 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 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 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
|
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 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
|
var CAM_SEP = '\u001f'; // (device, camera) key separator — a unit separator, never in an id
|
||||||
|
|
@ -672,10 +979,12 @@
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
|
if (data && data.storage) renderStorage(data.storage);
|
||||||
if (data && Array.isArray(data.devices)) {
|
if (data && Array.isArray(data.devices)) {
|
||||||
deviceList = data.devices;
|
deviceList = data.devices;
|
||||||
devicesLoaded = true;
|
devicesLoaded = true;
|
||||||
devicesStale = false;
|
devicesStale = false;
|
||||||
|
devicesResolved = true;
|
||||||
// The stream rows print the device's NAME, not its id, so a changed name set — the
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// state, but flag it. Cleared automatically by the next successful poll.
|
||||||
devicesStale = true;
|
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 ----------------------------------------------------------
|
// --- rail: FP-rate panel ----------------------------------------------------------
|
||||||
// Per-(camera, type) false-positive rates over 30 days. Refreshed on its own slow cadence
|
// 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.
|
// (60s) — tag volume changes slowly — plus immediately after the operator tags one.
|
||||||
var fpStatsRoot = document.getElementById('fp-stats-root');
|
var fpStatsRoot = document.getElementById('fp-stats-root');
|
||||||
var FP_STATS_INTERVAL_MS = 60000;
|
var FP_STATS_INTERVAL_MS = 60000;
|
||||||
var FP_STATS_SHOWN = 5;
|
var FP_STATS_SHOWN = 5;
|
||||||
|
var FP_MIN_SAMPLE = 5; // below this a ratio is noise, not a rate — see renderFpStats
|
||||||
|
|
||||||
function renderFpStats(stats) {
|
function renderFpStats(stats) {
|
||||||
if (!fpStatsRoot) return;
|
if (!fpStatsRoot) return;
|
||||||
|
|
@ -722,9 +1160,19 @@
|
||||||
row.appendChild(what);
|
row.appendChild(what);
|
||||||
var rate = document.createElement('span');
|
var rate = document.createElement('span');
|
||||||
var pct = Math.round((s.fp / Math.max(1, s.total)) * 100);
|
var pct = Math.round((s.fp / Math.max(1, s.total)) * 100);
|
||||||
rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : '');
|
// Sample gate. "100% (1/1)" is not a false-positive RATE, it is one tagged event — and
|
||||||
rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')';
|
// printed in the same column as "33% (5/15)" it outranked it. Below the minimum sample
|
||||||
// 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
|
// the raw counts are shown instead, with no percentage and no hot styling, so nothing
|
||||||
|
// reads as a tuning signal until there is enough evidence to be one.
|
||||||
|
if (s.total < FP_MIN_SAMPLE) {
|
||||||
|
rate.className = 'fp-rate is-thin';
|
||||||
|
rate.textContent = s.fp + '/' + s.total + ' (표본 부족)';
|
||||||
|
rate.title = '오탐율은 ' + FP_MIN_SAMPLE + '건 이상 감지된 카메라·유형에만 표시합니다.';
|
||||||
|
} else {
|
||||||
|
// 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
|
||||||
|
rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : '');
|
||||||
|
rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')';
|
||||||
|
}
|
||||||
row.appendChild(rate);
|
row.appendChild(rate);
|
||||||
fpStatsRoot.appendChild(row);
|
fpStatsRoot.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
@ -764,11 +1212,14 @@
|
||||||
if (t.events === 0) {
|
if (t.events === 0) {
|
||||||
parts.push('감지 이벤트 없음');
|
parts.push('감지 이벤트 없음');
|
||||||
} else {
|
} 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);
|
if (r.near_miss.near_fall > 0) parts.push('니어미스 ' + r.near_miss.near_fall);
|
||||||
var avg = briefDuration(r.tta.avg_s);
|
var avg = briefDuration(r.tta.avg_s);
|
||||||
if (avg) parts.push('평균 대응 ' + avg);
|
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(' · ') + ' '));
|
dayBrief.appendChild(document.createTextNode(' — ' + parts.join(' · ') + ' '));
|
||||||
var link = document.createElement('a');
|
var link = document.createElement('a');
|
||||||
|
|
@ -793,10 +1244,43 @@
|
||||||
var typeSel = document.getElementById('type-filter');
|
var typeSel = document.getElementById('type-filter');
|
||||||
if (typeSel) typeSel.addEventListener('change', function () { filters.type = typeSel.value; renderList(); });
|
if (typeSel) typeSel.addEventListener('change', function () { filters.type = typeSel.value; renderList(); });
|
||||||
if (cameraReset) cameraReset.addEventListener('click', clearCamera);
|
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);
|
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();
|
loadCsrfToken();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
loadDevices();
|
loadDevices();
|
||||||
|
|
|
||||||
420
server/public/assets/compliance.js
Normal file
420
server/public/assets/compliance.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -3,6 +3,11 @@
|
||||||
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
|
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
|
||||||
* plus each snapshot as an <img src="/snap/{file_uuid}">.
|
* 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
|
* XSS-safe: every dynamic value is written via textContent or a validated URL
|
||||||
* attribute, never innerHTML. Severity is shown as icon + text (never color
|
* 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.
|
* 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
|
// 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 = {
|
var METRIC_LABELS = {
|
||||||
|
confidence: '검출 신뢰도', score: '검출 점수',
|
||||||
observed_s: '관측 시간', trunk_high_s: '허리 고위험(60°+) 노출',
|
observed_s: '관측 시간', trunk_high_s: '허리 고위험(60°+) 노출',
|
||||||
trunk_mod_s: '허리 중위험(20~60°) 노출', arm_high_s: '팔 거상(90°+) 노출',
|
trunk_mod_s: '허리 중위험(20~60°) 노출', arm_high_s: '팔 거상(90°+) 노출',
|
||||||
squat_s: '웅크림 노출', prone_s: '엎드림 지속', immobility_s: '부동 지속',
|
squat_s: '웅크림 노출', prone_s: '엎드림 지속', immobility_s: '부동 지속',
|
||||||
|
|
@ -43,16 +51,26 @@
|
||||||
last_state: '소실 시 상태'
|
last_state: '소실 시 상태'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var STATE_LABELS = { FALLING: '넘어지는 중', FALLEN: '넘어짐', NO_MOTION: '미동 없음', UPRIGHT: '기립' };
|
||||||
|
|
||||||
function metricValue(key, v) {
|
function metricValue(key, v) {
|
||||||
if (/_s$/.test(key) && typeof v === 'number') {
|
if (/_s$/.test(key) && typeof v === 'number') {
|
||||||
if (v >= 90) return Math.round(v / 60) + '분 ' + Math.round(v % 60) + '초';
|
if (v >= 90) return Math.round(v / 60) + '분 ' + Math.round(v % 60) + '초';
|
||||||
return v + '초';
|
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);
|
return String(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
var meta = document.getElementById('event-detail-meta');
|
var meta = document.getElementById('event-detail-meta');
|
||||||
var snapBox = document.getElementById('event-detail-snapshots');
|
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;
|
if (!meta) return;
|
||||||
|
|
||||||
var m = location.pathname.match(/\/events\/(\d+)\/view/);
|
var m = location.pathname.match(/\/events\/(\d+)\/view/);
|
||||||
|
|
@ -62,16 +80,34 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var id = m[1];
|
var id = m[1];
|
||||||
|
var currentEvent = null;
|
||||||
|
var csrfToken = null;
|
||||||
|
var deviceNames = {};
|
||||||
|
|
||||||
function severityInfo(sev) {
|
function severityInfo(sev) {
|
||||||
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
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) {
|
function formatTime(occurredAt) {
|
||||||
if (!occurredAt && occurredAt !== 0) return '-';
|
if (!occurredAt && occurredAt !== 0) return '-';
|
||||||
var d = new Date(Number(occurredAt) * 1000);
|
var d = new Date(Number(occurredAt) * 1000);
|
||||||
if (isNaN(d.getTime())) return '-';
|
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) {
|
function buildBadge(sev) {
|
||||||
|
|
@ -90,8 +126,9 @@
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
function textNode(value) {
|
function textNode(value, cls) {
|
||||||
var span = document.createElement('span');
|
var span = document.createElement('span');
|
||||||
|
if (cls) span.className = cls;
|
||||||
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
|
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
@ -99,46 +136,159 @@
|
||||||
function addRow(label, valueNode) {
|
function addRow(label, valueNode) {
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'detail-row';
|
row.className = 'detail-row';
|
||||||
var k = document.createElement('span');
|
var k = document.createElement('dt');
|
||||||
k.className = 'detail-label';
|
k.textContent = label;
|
||||||
k.textContent = label + ': ';
|
var v = document.createElement('dd');
|
||||||
|
v.appendChild(valueNode);
|
||||||
row.appendChild(k);
|
row.appendChild(k);
|
||||||
row.appendChild(valueNode);
|
row.appendChild(v);
|
||||||
meta.appendChild(row);
|
meta.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(data) {
|
// --- resolution actions --------------------------------------------------------------
|
||||||
var ev = data.event || {};
|
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.textContent = '';
|
||||||
meta.removeAttribute('aria-busy');
|
meta.removeAttribute('aria-busy');
|
||||||
|
|
||||||
addRow('심각도', buildBadge(ev.severity));
|
addRow('심각도', buildBadge(ev.severity));
|
||||||
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
|
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
|
||||||
addRow('카메라', textNode(ev.camera_id));
|
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
|
||||||
addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
|
addRow('카메라', textNode(ev.camera_id, 'mono'));
|
||||||
addRow('지속(초)', textNode(ev.duration_s));
|
addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono'));
|
||||||
addRow('확인', textNode(ev.acknowledged_by || '미확인'));
|
addRow('지속', textNode(fmtDuration(ev.duration_s)));
|
||||||
if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그'));
|
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는 이 블록이 본문이다.
|
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
|
||||||
var metrics = ev.metrics || {};
|
var metrics = ev.metrics || {};
|
||||||
Object.keys(metrics).forEach(function (key) {
|
Object.keys(metrics).forEach(function (key) {
|
||||||
addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[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;
|
if (!snapBox) return;
|
||||||
snapBox.textContent = '';
|
snapBox.textContent = '';
|
||||||
var snaps = data.snapshots || [];
|
var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; });
|
||||||
if (snaps.length === 0) {
|
if (kept.length === 0) {
|
||||||
var none = document.createElement('p');
|
var none = document.createElement('p');
|
||||||
none.textContent = '스냅샷이 없습니다.';
|
none.className = 'detail-empty';
|
||||||
|
none.textContent = '이 이벤트에는 저장된 스냅샷이 없습니다 (미촬영이거나 PIPA 보존기간이 지나 삭제됨).';
|
||||||
snapBox.appendChild(none);
|
snapBox.appendChild(none);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by
|
// 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 CAPTIONS = ['이전', '직전', '발생 시점'];
|
||||||
var kept = snaps.filter(function (s) { return s && s.file_uuid; });
|
|
||||||
kept.forEach(function (s, i) {
|
kept.forEach(function (s, i) {
|
||||||
var fig = document.createElement('figure');
|
var fig = document.createElement('figure');
|
||||||
fig.className = 'detail-snap';
|
fig.className = 'detail-snap';
|
||||||
|
|
@ -157,15 +307,86 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
// --- outbound links + improvement action ---------------------------------------------
|
||||||
.then(function (res) {
|
function renderLinks() {
|
||||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
var ev = currentEvent || {};
|
||||||
if (!res.ok) throw new Error('detail_failed:' + res.status);
|
var pkg = document.getElementById('detail-package');
|
||||||
return res.json();
|
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
|
||||||
})
|
var zones = document.getElementById('detail-zones');
|
||||||
.then(function (data) { if (data) render(data); })
|
if (zones) {
|
||||||
.catch(function () {
|
// Deep-link the zone editor straight at this camera. Reading an incident and then having
|
||||||
meta.textContent = '불러오기 실패';
|
// to re-select the camera by hand on the next screen is the same "the system knows this
|
||||||
meta.removeAttribute('aria-busy');
|
// and asks you anyway" problem the editor's own picker exists to remove.
|
||||||
|
zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
|
||||||
|
+ '&camera=' + encodeURIComponent(ev.camera_id || '');
|
||||||
|
zones.hidden = !ev.camera_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireActionForm() {
|
||||||
|
var form = document.getElementById('detail-action-form');
|
||||||
|
var note = document.getElementById('detail-action-note');
|
||||||
|
var status = document.getElementById('detail-action-status');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var text = (note.value || '').trim();
|
||||||
|
if (!text || !currentEvent) return;
|
||||||
|
status.textContent = '기록 중…';
|
||||||
|
fetch(B + '/api/actions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
camera_id: currentEvent.camera_id,
|
||||||
|
note: text,
|
||||||
|
event_id: currentEvent.event_id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return; }
|
||||||
|
if (!r.ok) throw new Error('action_failed');
|
||||||
|
note.value = '';
|
||||||
|
status.textContent = '기록되었습니다. 안전 리포트의 [개선 조치 효과]에서 전후 비교를 볼 수 있습니다.';
|
||||||
|
})
|
||||||
|
.catch(function () { status.textContent = '기록에 실패했습니다. 권한을 확인하세요.'; });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
currentEvent = data.event || {};
|
||||||
|
renderHead();
|
||||||
|
renderMeta();
|
||||||
|
renderActions();
|
||||||
|
renderLinks();
|
||||||
|
renderSnapshots(data.snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device NAMES, so the incident record says "1공장 프레스라인" and not "pi-a1" — the same
|
||||||
|
// provenance the stream row shows. A failure here is non-fatal: the raw id stands in.
|
||||||
|
function loadDevices() {
|
||||||
|
return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d || !Array.isArray(d.devices)) return;
|
||||||
|
d.devices.forEach(function (dev) { if (dev.name) deviceNames[dev.device_id] = dev.name; });
|
||||||
|
})
|
||||||
|
.catch(function () { /* raw id stands in */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCsrf();
|
||||||
|
wireActionForm();
|
||||||
|
// Names first so the record never paints an id and then swap it for a name a beat later.
|
||||||
|
loadDevices().then(function () {
|
||||||
|
return fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
if (!res.ok) throw new Error('detail_failed:' + res.status);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function (data) { if (data) render(data); })
|
||||||
|
.catch(function () {
|
||||||
|
meta.textContent = '불러오기 실패';
|
||||||
|
meta.removeAttribute('aria-busy');
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
240
server/public/assets/package.js
Normal file
240
server/public/assets/package.js
Normal 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 = '패키지를 불러오지 못했습니다.'; });
|
||||||
|
})();
|
||||||
|
|
@ -19,6 +19,10 @@
|
||||||
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
|
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
|
||||||
|
var PATTERN_MIN_PEAK = 4; // 요일×시간 격자에서 최다 칸이 이보다 적으면 무늬는 우연
|
||||||
|
var ACTION_MIN_BEFORE = 5; // 개선조치 전후 비교의 최소 '전' 표본
|
||||||
|
|
||||||
var root = document.getElementById('report-kpis');
|
var root = document.getElementById('report-kpis');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
||||||
|
|
@ -51,6 +55,21 @@
|
||||||
return (s / 3600).toFixed(1) + '시간';
|
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.
|
// KPI tile: the headline number plus the one-line reading of what it means.
|
||||||
function kpi(label, value, note, cls) {
|
function kpi(label, value, note, cls) {
|
||||||
var t = el('div', 'kpi' + (cls ? ' ' + cls : ''));
|
var t = el('div', 'kpi' + (cls ? ' ' + cls : ''));
|
||||||
|
|
@ -94,8 +113,7 @@
|
||||||
var severe = t.critical + t.high;
|
var severe = t.critical + t.high;
|
||||||
|
|
||||||
rangeEl.textContent = '최근 ' + r.days + '일 · '
|
rangeEl.textContent = '최근 ' + r.days + '일 · '
|
||||||
+ new Date((r.generated_at - r.days * 86400) * 1000).toLocaleDateString('ko-KR')
|
+ fmtDate(r.generated_at - r.days * 86400) + ' ~ ' + fmtDate(r.generated_at);
|
||||||
+ ' ~ ' + new Date(r.generated_at * 1000).toLocaleDateString('ko-KR');
|
|
||||||
|
|
||||||
// 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다.
|
// 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다.
|
||||||
var alertEl = document.getElementById('report-alert');
|
var alertEl = document.getElementById('report-alert');
|
||||||
|
|
@ -136,6 +154,38 @@
|
||||||
trendLine(kFp, prevFpPct, fpPct, function (v) { return v + '%'; });
|
trendLine(kFp, prevFpPct, fpPct, function (v) { return v + '%'; });
|
||||||
root.appendChild(kFp);
|
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 = '';
|
typesEl.textContent = '';
|
||||||
var typeKeys = Object.keys(r.by_type);
|
var typeKeys = Object.keys(r.by_type);
|
||||||
if (typeKeys.length === 0) typesEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
|
if (typeKeys.length === 0) typesEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
|
||||||
|
|
@ -147,10 +197,19 @@
|
||||||
camerasEl.textContent = '';
|
camerasEl.textContent = '';
|
||||||
if (r.by_camera.length === 0) camerasEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
|
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);
|
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) {
|
r.by_camera.forEach(function (c) {
|
||||||
camerasEl.appendChild(barRow(c.camera_id, c.count, maxCam,
|
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 = '';
|
ergoEl.textContent = '';
|
||||||
|
|
@ -174,14 +233,18 @@
|
||||||
rulesEl.textContent = '';
|
rulesEl.textContent = '';
|
||||||
var oc = r.occupancy;
|
var oc = r.occupancy;
|
||||||
var rules = el('div', 'rule-stats');
|
var rules = el('div', 'rule-stats');
|
||||||
[['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay],
|
// '미확인 잔여'는 존 규칙 위반이 아니다. 같은 2×2 격자에 같은 무게로 놓여 있어서
|
||||||
['미확인 잔여', t.unacked]].forEach(function (pair) {
|
// 네 번째 규칙처럼 읽혔다 — 처리 현황은 격자 밖 한 줄로 분리한다.
|
||||||
var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : ''));
|
[['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay]]
|
||||||
box.appendChild(el('strong', null, String(pair[1])));
|
.forEach(function (pair) {
|
||||||
box.appendChild(el('span', null, pair[0]));
|
var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : ''));
|
||||||
rules.appendChild(box);
|
box.appendChild(el('strong', null, String(pair[1])));
|
||||||
});
|
box.appendChild(el('span', null, pair[0]));
|
||||||
|
rules.appendChild(box);
|
||||||
|
});
|
||||||
rulesEl.appendChild(rules);
|
rulesEl.appendChild(rules);
|
||||||
|
rulesEl.appendChild(el('p', 'report-empty',
|
||||||
|
'처리 현황 — 이 기간 감지 ' + t.events + '건 중 미확인 ' + t.unacked + '건.'));
|
||||||
|
|
||||||
// 시간 패턴: 요일×시간 발생 밀도 — "언제가 위험한가"를 한 장에.
|
// 시간 패턴: 요일×시간 발생 밀도 — "언제가 위험한가"를 한 장에.
|
||||||
var patternEl = document.getElementById('report-pattern');
|
var patternEl = document.getElementById('report-pattern');
|
||||||
|
|
@ -190,6 +253,12 @@
|
||||||
var pt = r.pattern || { grid: [], max: 0 };
|
var pt = r.pattern || { grid: [], max: 0 };
|
||||||
if (!pt.max) {
|
if (!pt.max) {
|
||||||
patternEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
|
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 {
|
} else {
|
||||||
var DAYS = ['월', '화', '수', '목', '금', '토', '일'];
|
var DAYS = ['월', '화', '수', '목', '금', '토', '일'];
|
||||||
var table = el('div', 'pattern');
|
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 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
|
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,16 +304,32 @@
|
||||||
.catch(function () { /* 기록 시 403으로 드러난다 */ });
|
.catch(function () { /* 기록 시 403으로 드러난다 */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이 줄은 안전회의에 그대로 올라간다. 표본 6건에서 나온 "▲33%"를 확정 색으로 칠하면
|
||||||
|
// 회의에서 바로 반박당하고, 그 다음부터는 이 카드 전체가 신뢰를 잃는다. 표본이 임계 미만이면
|
||||||
|
// 원시 수치만 중립 톤으로 적고 판정을 미룬다. 창 길이도 함께 적어 서로 다른 창의 두 행이
|
||||||
|
// 같은 잣대로 읽히지 않게 한다.
|
||||||
function actionEffect(a) {
|
function actionEffect(a) {
|
||||||
if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)');
|
if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)');
|
||||||
var e = a.effect;
|
var e = a.effect;
|
||||||
var txt = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)';
|
var base = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)';
|
||||||
if (e.before > 0 && e.after !== e.before) {
|
if (e.before < ACTION_MIN_BEFORE) {
|
||||||
var pct = Math.round((e.after - e.before) / e.before * 100);
|
var thin = el('span', 'action-effect is-thin', base + ' · 표본 부족, 판정 보류');
|
||||||
txt += ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%';
|
thin.title = '조치 전 감지가 ' + ACTION_MIN_BEFORE + '건 이상일 때만 증감률을 표시합니다.';
|
||||||
|
return thin;
|
||||||
}
|
}
|
||||||
var cls = 'action-effect' + (e.after < e.before ? ' is-better' : (e.after > e.before ? ' is-worse' : ''));
|
// 카운트 데이터의 자연 변동폭(√before, 푸아송 표준편차)을 넘지 못하는 차이는 조치의 효과가
|
||||||
return el('span', cls, txt);
|
// 아니라 잡음이다. 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) {
|
function renderActions(list) {
|
||||||
|
|
@ -258,8 +343,7 @@
|
||||||
list.forEach(function (a) {
|
list.forEach(function (a) {
|
||||||
var row = el('div', 'action-row');
|
var row = el('div', 'action-row');
|
||||||
var what = el('div', 'action-what');
|
var what = el('div', 'action-what');
|
||||||
what.appendChild(el('span', 'action-date',
|
what.appendChild(el('span', 'action-date', fmtDate(a.taken_at)));
|
||||||
new Date(a.taken_at * 1000).toLocaleDateString('ko-KR')));
|
|
||||||
what.appendChild(el('span', 'action-cam', a.camera_id));
|
what.appendChild(el('span', 'action-cam', a.camera_id));
|
||||||
what.appendChild(el('span', 'action-note', a.note));
|
what.appendChild(el('span', 'action-note', a.note));
|
||||||
row.appendChild(what);
|
row.appendChild(what);
|
||||||
|
|
|
||||||
168
server/public/assets/setup.js
Normal file
168
server/public/assets/setup.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -39,11 +39,17 @@
|
||||||
var heatmapBtn = document.getElementById('heatmap-btn');
|
var heatmapBtn = document.getElementById('heatmap-btn');
|
||||||
var heatmapDaysInput = document.getElementById('heatmap-days');
|
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 = {
|
var LEVEL_STYLE = {
|
||||||
warning: { stroke: '#eab308', label: '경고' },
|
warning: { stroke: '#b7820a', fill: 'rgba(183,130,10,.16)', label: '경고', dash: null },
|
||||||
restricted: { stroke: '#dc2626', label: '제한' },
|
restricted: { stroke: '#dc2626', fill: 'rgba(220,38,38,.16)', label: '제한', dash: null },
|
||||||
pinch: { stroke: '#a855f7', label: '협착' }
|
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 backgroundImage = null;
|
||||||
var existingZones = []; // [{zone_id, level, points: [[x,y],...]}]
|
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;
|
if (points.length === 0) return;
|
||||||
ctx.strokeStyle = strokeStyle;
|
ctx.save();
|
||||||
ctx.fillStyle = strokeStyle;
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(points[0][0], points[0][1]);
|
ctx.moveTo(points[0][0], points[0][1]);
|
||||||
for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||||
if (closed && points.length > 2) ctx.closePath();
|
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.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
// Vertex handles: white-cored so they stay visible on both a bright floor and a dark shadow.
|
||||||
points.forEach(function (p) {
|
points.forEach(function (p) {
|
||||||
ctx.beginPath();
|
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.fill();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = style.stroke;
|
||||||
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
if (label) {
|
if (label) drawLabelChip(label, points[0][0], points[0][1], style.stroke);
|
||||||
ctx.font = '14px sans-serif';
|
ctx.restore();
|
||||||
ctx.fillText(label, points[0][0] + 6, points[0][1] - 6);
|
}
|
||||||
|
|
||||||
|
// 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() {
|
function loadHeatmap() {
|
||||||
var camera = cameraInput.value.trim();
|
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));
|
var days = Math.max(1, Math.min(90, parseInt(heatmapDaysInput && heatmapDaysInput.value, 10) || 30));
|
||||||
setStatus('히트맵을 불러오는 중...');
|
setStatus('히트맵을 불러오는 중...');
|
||||||
fetch(B + '/api/heatmap/' + encodeURIComponent(camera) + '?days=' + days,
|
fetch(B + '/api/heatmap/' + encodeURIComponent(camera) + '?days=' + days,
|
||||||
|
|
@ -171,11 +211,24 @@
|
||||||
ctx.drawImage(heatmapLayer, 0, 0);
|
ctx.drawImage(heatmapLayer, 0, 0);
|
||||||
}
|
}
|
||||||
existingZones.forEach(function (z) {
|
existingZones.forEach(function (z) {
|
||||||
var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level };
|
var style = LEVEL_STYLE[z.level] || { stroke: '#626a79', fill: 'rgba(98,106,121,.14)', label: z.level, dash: null };
|
||||||
drawPolygon(z.points, style.stroke, style.label, true);
|
drawPolygon(z.points, style, style.label, true);
|
||||||
});
|
});
|
||||||
var current = LEVEL_STYLE[levelSelect.value] || { stroke: '#38bdf8', label: '' };
|
drawPolygon(currentPoints, DRAFT_STYLE, currentPoints.length ? DRAFT_STYLE.label : '', false);
|
||||||
drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', 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).
|
// Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire).
|
||||||
|
|
@ -203,24 +256,130 @@
|
||||||
|
|
||||||
function renderZoneList() {
|
function renderZoneList() {
|
||||||
listEl.textContent = '';
|
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) {
|
existingZones.forEach(function (z) {
|
||||||
var li = document.createElement('li');
|
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');
|
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);
|
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');
|
var del = document.createElement('button');
|
||||||
del.type = 'button';
|
del.type = 'button';
|
||||||
|
del.className = 'zone-del';
|
||||||
del.textContent = '삭제';
|
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);
|
li.appendChild(del);
|
||||||
|
|
||||||
listEl.appendChild(li);
|
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) {
|
function loadBackground(uuid) {
|
||||||
if (!uuid) { backgroundImage = null; redraw(); return; }
|
if (!uuid) { backgroundImage = null; redraw(); return; }
|
||||||
var img = new Image();
|
var img = new Image();
|
||||||
|
|
@ -233,7 +392,7 @@
|
||||||
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
|
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
|
||||||
function loadLatestFrame() {
|
function loadLatestFrame() {
|
||||||
var camera = cameraInput.value.trim();
|
var camera = cameraInput.value.trim();
|
||||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
|
||||||
setStatus('현재 화면을 불러오는 중...');
|
setStatus('현재 화면을 불러오는 중...');
|
||||||
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
|
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
|
|
@ -245,9 +404,9 @@
|
||||||
.catch(function (e) { setStatus(e.message); });
|
.catch(function (e) { setStatus(e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadZones() {
|
function loadZones(doneMsg) {
|
||||||
var camera = cameraInput.value.trim();
|
var camera = cameraInput.value.trim();
|
||||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
|
||||||
setStatus('불러오는 중...');
|
setStatus('불러오는 중...');
|
||||||
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
|
|
@ -263,7 +422,9 @@
|
||||||
currentPoints = [];
|
currentPoints = [];
|
||||||
renderZoneList();
|
renderZoneList();
|
||||||
redraw();
|
redraw();
|
||||||
setStatus('구역 ' + existingZones.length + '개를 불러왔습니다.');
|
setStatus(doneMsg || (existingZones.length
|
||||||
|
? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.'
|
||||||
|
: '이 카메라에는 저장된 구역이 없습니다. 캔버스를 클릭해 새로 그리세요.'));
|
||||||
})
|
})
|
||||||
.catch(function () { setStatus('불러오기에 실패했습니다.'); });
|
.catch(function () { setStatus('불러오기에 실패했습니다.'); });
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +432,7 @@
|
||||||
function saveZone() {
|
function saveZone() {
|
||||||
var camera = cameraInput.value.trim();
|
var camera = cameraInput.value.trim();
|
||||||
var device = deviceInput.value.trim();
|
var device = deviceInput.value.trim();
|
||||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
|
||||||
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
|
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
|
||||||
setStatus('저장 중...');
|
setStatus('저장 중...');
|
||||||
fetch(B + '/api/zones', {
|
fetch(B + '/api/zones', {
|
||||||
|
|
@ -297,8 +458,7 @@
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
currentPoints = [];
|
currentPoints = [];
|
||||||
setStatus('저장되었습니다.');
|
loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.');
|
||||||
loadZones();
|
|
||||||
})
|
})
|
||||||
.catch(function () { setStatus('저장에 실패했습니다.'); });
|
.catch(function () { setStatus('저장에 실패했습니다.'); });
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +471,7 @@
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.status === 401) { window.location.href = B + '/login'; return; }
|
if (res.status === 401) { window.location.href = B + '/login'; return; }
|
||||||
if (!res.ok) throw new Error('delete_failed:' + res.status);
|
if (!res.ok) throw new Error('delete_failed:' + res.status);
|
||||||
loadZones();
|
loadZones('구역을 삭제했습니다.');
|
||||||
})
|
})
|
||||||
.catch(function () { setStatus('삭제에 실패했습니다.'); });
|
.catch(function () { setStatus('삭제에 실패했습니다.'); });
|
||||||
}
|
}
|
||||||
|
|
@ -327,17 +487,49 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
levelSelect.addEventListener('change', redraw);
|
levelSelect.addEventListener('change', redraw);
|
||||||
loadBtn.addEventListener('click', loadZones);
|
loadBtn.addEventListener('click', function () { applySelection(); loadZones(); });
|
||||||
if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
|
if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); });
|
||||||
if (heatmapBtn) heatmapBtn.addEventListener('click', toggleHeatmap);
|
if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); });
|
||||||
if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); });
|
if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); });
|
||||||
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
|
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('그리던 폴리곤을 지웠습니다.'); });
|
||||||
saveBtn.addEventListener('click', saveZone);
|
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);
|
var params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('camera')) cameraInput.value = params.get('camera');
|
if (params.get('camera')) cameraInput.value = params.get('camera');
|
||||||
if (params.get('device')) deviceInput.value = params.get('device');
|
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'));
|
loadBackground(params.get('snapshot'));
|
||||||
loadCsrfToken();
|
loadCsrfToken();
|
||||||
if (cameraInput.value) loadZones();
|
loadPickers();
|
||||||
|
renderZoneList(); // paint the "저장된 구역 없음" placeholder before any camera is chosen
|
||||||
|
redraw();
|
||||||
|
if (cameraInput.value) { loadZones(); loadLatestFrame(); }
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,15 @@ use App\Db\Database;
|
||||||
use App\Auth\PhpSessionStore;
|
use App\Auth\PhpSessionStore;
|
||||||
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
||||||
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
||||||
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository};
|
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
|
||||||
|
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
|
||||||
use App\Ingest\IngestController;
|
use App\Ingest\IngestController;
|
||||||
use App\Api\{ConfigController, HeartbeatController};
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
use App\Auth\{AuthController, CsrfController};
|
use App\Auth\{AuthController, CsrfController};
|
||||||
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
|
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
|
||||||
DeviceStatusController, HeatmapController, FpStatsController, ReportController,
|
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
|
// 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
|
// 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);
|
$zones = new PdoZoneRepository($pdo);
|
||||||
$thr = new PdoThresholdRepository($pdo);
|
$thr = new PdoThresholdRepository($pdo);
|
||||||
$audit = new PdoAuditRepository($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);
|
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
|
||||||
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate);
|
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
|
||||||
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
|
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate, $metrics);
|
||||||
$auth = new AuthController($users, $audit, $rate);
|
$auth = new AuthController($users, $audit, $rate);
|
||||||
$csrfCtl = new CsrfController();
|
$csrfCtl = new CsrfController();
|
||||||
$list = new EventListController($events, $snaps, $sess);
|
$list = new EventListController($events, $snaps, $sess);
|
||||||
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
||||||
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||||
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
$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);
|
$heatmap = new HeatmapController($events, $sess);
|
||||||
$fpStats = new FpStatsController($events, $sess);
|
$fpStats = new FpStatsController($events, $sess);
|
||||||
$report = new ReportController($events, $sess);
|
$report = new ReportController($events, $sess);
|
||||||
$export = new ExportController($events, $snaps, $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 {
|
$render = function (string $view): Response {
|
||||||
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
|
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', fn(Request $q) => $zoneCtl->save($q));
|
||||||
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
|
$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', '/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', '/', fn() => $render('events'));
|
||||||
$r->add('GET', '/login', fn() => $render('login'));
|
$r->add('GET', '/login', fn() => $render('login'));
|
||||||
$r->add('GET', '/zones', fn() => $render('zones'));
|
$r->add('GET', '/zones', fn() => $render('zones'));
|
||||||
$r->add('GET', '/report', fn() => $render('report'));
|
$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}/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();
|
$r->dispatch(Request::fromGlobals())->send();
|
||||||
|
|
|
||||||
|
|
@ -102,13 +102,15 @@ CREATE TABLE move_actions (
|
||||||
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
camera_id VARCHAR(64) NOT NULL,
|
camera_id VARCHAR(64) NOT NULL,
|
||||||
site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert
|
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,
|
note VARCHAR(500) NOT NULL,
|
||||||
taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s)
|
taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s)
|
||||||
created_by VARCHAR(190) NOT NULL,
|
created_by VARCHAR(190) NOT NULL,
|
||||||
created_at BIGINT UNSIGNED NOT NULL,
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
PRIMARY KEY (action_id),
|
PRIMARY KEY (action_id),
|
||||||
KEY ix_actions_taken (taken_at),
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE move_zones (
|
CREATE TABLE move_zones (
|
||||||
|
|
@ -163,7 +165,13 @@ CREATE TABLE move_events (
|
||||||
CONSTRAINT chk_events_type CHECK (event_type IN (
|
CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||||
'fall','no_motion','immobility','ghost','zone_warning',
|
'fall','no_motion','immobility','ghost','zone_warning',
|
||||||
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
'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'))
|
CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
@ -204,6 +212,8 @@ CREATE TABLE move_thresholds (
|
||||||
zone_id CHAR(32) NULL,
|
zone_id CHAR(32) NULL,
|
||||||
`key` VARCHAR(64) NOT NULL,
|
`key` VARCHAR(64) NOT NULL,
|
||||||
`value` VARCHAR(255) NOT NULL,
|
`value` VARCHAR(255) NOT NULL,
|
||||||
|
updated_by VARCHAR(190) NULL, -- 편집 UI(관리자 전용)가 남기는 감사 흔적
|
||||||
|
updated_at BIGINT UNSIGNED NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY ix_thresholds_device (device_id),
|
KEY ix_thresholds_device (device_id),
|
||||||
UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`)
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- audit_log: append-only action log (acknowledge / zone save+delete / login).
|
-- 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
|
-- threshold edit / suppression / handover).
|
||||||
-- Task 16 brief self-review -- this table is the plain append-only record only.
|
--
|
||||||
|
-- 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 (
|
CREATE TABLE move_audit_log (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|
@ -254,9 +270,148 @@ CREATE TABLE move_audit_log (
|
||||||
before_json JSON NOT NULL,
|
before_json JSON NOT NULL,
|
||||||
after_json JSON NOT NULL,
|
after_json JSON NOT NULL,
|
||||||
ts BIGINT UNSIGNED NOT NULL,
|
ts BIGINT UNSIGNED NOT NULL,
|
||||||
|
prev_hash CHAR(64) NULL,
|
||||||
|
entry_hash CHAR(64) NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY ix_audit_log_ts (ts),
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- ===========================================================================
|
-- ===========================================================================
|
||||||
|
|
|
||||||
130
server/src/Alert/AlertDispatcher.php
Normal file
130
server/src/Alert/AlertDispatcher.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
server/src/Alert/Channels.php
Normal file
185
server/src/Alert/Channels.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
server/src/Alert/EscalationWatch.php
Normal file
91
server/src/Alert/EscalationWatch.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ namespace App\Api;
|
||||||
use App\Config;
|
use App\Config;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Security\Hmac;
|
use App\Security\Hmac;
|
||||||
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
|
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository,
|
||||||
|
RateLimiterRepository, AlertRepository};
|
||||||
|
|
||||||
final class ConfigController
|
final class ConfigController
|
||||||
{
|
{
|
||||||
|
|
@ -13,6 +14,7 @@ final class ConfigController
|
||||||
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
|
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
|
||||||
private NonceRepository $nonces, private Config $cfg,
|
private NonceRepository $nonces, private Config $cfg,
|
||||||
private ?RateLimiterRepository $rateLimiter = null,
|
private ?RateLimiterRepository $rateLimiter = null,
|
||||||
|
private ?AlertRepository $alerts = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function get(Request $req, int $now, string $clientIp = ''): Response
|
public function get(Request $req, int $now, string $clientIp = ''): Response
|
||||||
|
|
@ -40,6 +42,33 @@ final class ConfigController
|
||||||
'version' => $this->devices->configVersion($deviceId),
|
'version' => $this->devices->configVersion($deviceId),
|
||||||
'zones' => $this->zones->forDevice($deviceId),
|
'zones' => $this->zones->forDevice($deviceId),
|
||||||
'thresholds' => $this->thresholds->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);
|
], 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ namespace App\Api;
|
||||||
use App\Config;
|
use App\Config;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Security\Hmac;
|
use App\Security\Hmac;
|
||||||
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository};
|
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, DeviceMetricRepository};
|
||||||
|
|
||||||
final class HeartbeatController
|
final class HeartbeatController
|
||||||
{
|
{
|
||||||
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces,
|
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
|
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,
|
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
|
||||||
'at' => $now,
|
'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);
|
return Response::json(['ok' => true], 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
server/src/Api/ThresholdCatalog.php
Normal file
101
server/src/Api/ThresholdCatalog.php
Normal 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,4 +26,34 @@ final class Config
|
||||||
* A site with a DB quota can opt in by setting this.
|
* A site with a DB quota can opt in by setting this.
|
||||||
*/
|
*/
|
||||||
public function eventRetentionDays(): int { return (int)($this->a['event_retention_days'] ?? 0); }
|
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); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,23 @@ final class ActionsController
|
||||||
if (!SiteScope::allows(is_string($scope) ? $scope : null, $site)) {
|
if (!SiteScope::allows(is_string($scope) ? $scope : null, $site)) {
|
||||||
return Response::json(['error' => 'not_found'], 404);
|
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([
|
$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'),
|
'created_by' => (string)$this->sess->get('username'),
|
||||||
]);
|
]);
|
||||||
return Response::json(['action_id' => $id], 201);
|
return Response::json(['action_id' => $id], 201);
|
||||||
|
|
|
||||||
247
server/src/Dashboard/AdminConfigController.php
Normal file
247
server/src/Dashboard/AdminConfigController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
server/src/Dashboard/ComplianceController.php
Normal file
225
server/src/Dashboard/ComplianceController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
namespace App\Dashboard;
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\SessionStore;
|
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
|
* 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).
|
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
|
||||||
private const OFFLINE_AFTER_S = 120;
|
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
|
public function json(Request $req, int $now): Response
|
||||||
{
|
{
|
||||||
|
|
@ -54,6 +63,63 @@ final class DeviceStatusController
|
||||||
];
|
];
|
||||||
}, $this->devices->listStatus($site));
|
}, $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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,9 @@ final class ReportController
|
||||||
],
|
],
|
||||||
'pattern' => $pattern,
|
'pattern' => $pattern,
|
||||||
'precursor_alert' => $spike,
|
'precursor_alert' => $spike,
|
||||||
|
// 현장별 롤업은 전역 운영자(본사)에게만 의미가 있다. 스코프 운영자에게는 정의상
|
||||||
|
// 자기 현장 한 줄뿐이라 표를 만들 이유가 없고, 다른 현장 수치가 새면 안 된다.
|
||||||
|
'by_site' => $site === null ? $this->events->siteSummary($since) : null,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
136
server/src/Dashboard/SuppressionController.php
Normal file
136
server/src/Dashboard/SuppressionController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,11 @@ final class ZoneConfigController
|
||||||
return Response::json(['error' => 'invalid_zone'], 400);
|
return Response::json(['error' => 'invalid_zone'], 400);
|
||||||
}
|
}
|
||||||
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
|
$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);
|
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);
|
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->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
|
||||||
|
$this->devices->bumpConfigVersion((string)($existing['device_id'] ?? ''));
|
||||||
return Response::json(['ok' => true], 200);
|
return Response::json(['ok' => true], 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace App\Repository;
|
||||||
|
|
||||||
interface ActionRepository
|
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;
|
public function insert(array $action): int;
|
||||||
/**
|
/**
|
||||||
* Most recent actions, newest taken_at first, capped at $limit.
|
* Most recent actions, newest taken_at first, capped at $limit.
|
||||||
|
|
|
||||||
34
server/src/Repository/AlertRepository.php
Normal file
34
server/src/Repository/AlertRepository.php
Normal 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;
|
||||||
|
}
|
||||||
15
server/src/Repository/DeviceMetricRepository.php
Normal file
15
server/src/Repository/DeviceMetricRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,14 @@ interface DeviceRepository
|
||||||
public function siteFor(string $deviceId): ?string;
|
public function siteFor(string $deviceId): ?string;
|
||||||
public function touchHeartbeat(string $deviceId, array $meta): void;
|
public function touchHeartbeat(string $deviceId, array $meta): void;
|
||||||
public function configVersion(string $deviceId): int;
|
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.
|
/** 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);
|
* Site-scoped: a non-null $site returns only that site's devices (a scoped operator);
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,29 @@ interface EventRepository
|
||||||
public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array;
|
public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array;
|
||||||
/** Full mapped event rows in the window, newest first, capped — the label-dataset export. */
|
/** Full mapped event rows in the window, newest first, capped — the label-dataset export. */
|
||||||
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array;
|
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. */
|
/** 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;
|
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
|
* 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.
|
* exist", playing the role DeviceRepository::exists() plays for devices.
|
||||||
|
|
|
||||||
13
server/src/Repository/HandoverRepository.php
Normal file
13
server/src/Repository/HandoverRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -12,12 +12,15 @@ final class PdoActionRepository implements ActionRepository
|
||||||
public function insert(array $action): int
|
public function insert(array $action): int
|
||||||
{
|
{
|
||||||
$st = $this->pdo->prepare(
|
$st = $this->pdo->prepare(
|
||||||
'INSERT INTO move_actions (camera_id, site, note, taken_at, created_by, created_at)
|
'INSERT INTO move_actions (camera_id, site, event_id, note, taken_at, created_by, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
$st->execute([
|
$st->execute([
|
||||||
(string)$action['camera_id'],
|
(string)$action['camera_id'],
|
||||||
$action['site'] ?? null, // denormalized from the camera's events by ActionsController
|
$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'],
|
(string)$action['note'],
|
||||||
(int)$action['taken_at'],
|
(int)$action['taken_at'],
|
||||||
(string)$action['created_by'],
|
(string)$action['created_by'],
|
||||||
|
|
@ -28,7 +31,7 @@ final class PdoActionRepository implements ActionRepository
|
||||||
|
|
||||||
public function recent(int $limit, ?string $site): array
|
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 = [];
|
$binds = [];
|
||||||
if ($site !== null) {
|
if ($site !== null) {
|
||||||
// Fail-closed like every other scoped list: an empty-string scope matches nothing,
|
// 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'],
|
'action_id' => (int)$r['action_id'],
|
||||||
'camera_id' => $r['camera_id'],
|
'camera_id' => $r['camera_id'],
|
||||||
'site' => $r['site'],
|
'site' => $r['site'],
|
||||||
|
'event_id' => $r['event_id'] !== null ? (int)$r['event_id'] : null,
|
||||||
'note' => $r['note'],
|
'note' => $r['note'],
|
||||||
'taken_at' => (int)$r['taken_at'],
|
'taken_at' => (int)$r['taken_at'],
|
||||||
'created_by' => $r['created_by'],
|
'created_by' => $r['created_by'],
|
||||||
|
|
|
||||||
184
server/src/Repository/Pdo/PdoAlertRepository.php
Normal file
184
server/src/Repository/Pdo/PdoAlertRepository.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,23 +5,79 @@ namespace App\Repository\Pdo;
|
||||||
use App\Repository\AuditRepository;
|
use App\Repository\AuditRepository;
|
||||||
use PDO;
|
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
|
final class PdoAuditRepository implements AuditRepository
|
||||||
{
|
{
|
||||||
public function __construct(private PDO $pdo) {}
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
public function append(string $actor, string $action, string $target, array $before, array $after): void
|
public function append(string $actor, string $action, string $target, array $before, array $after): void
|
||||||
{
|
{
|
||||||
$st = $this->pdo->prepare(
|
$beforeJson = json_encode($before, JSON_THROW_ON_ERROR);
|
||||||
'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts)
|
$afterJson = json_encode($after, JSON_THROW_ON_ERROR);
|
||||||
VALUES (?, ?, ?, ?, ?, ?)'
|
$ts = time();
|
||||||
);
|
|
||||||
$st->execute([
|
// The read of the tail and the write of the new row must not interleave with another
|
||||||
$actor,
|
// append, or two rows would claim the same predecessor and the chain would fork. When
|
||||||
$action,
|
// this repository owns the transaction it also takes a row lock (MySQL); sqlite's
|
||||||
$target,
|
// single-writer model makes the lock unnecessary and it has no FOR UPDATE.
|
||||||
json_encode($before, JSON_THROW_ON_ERROR),
|
$ownTransaction = !$this->pdo->inTransaction();
|
||||||
json_encode($after, JSON_THROW_ON_ERROR),
|
if ($ownTransaction) $this->pdo->beginTransaction();
|
||||||
time(),
|
try {
|
||||||
]);
|
$prev = $this->tailHash();
|
||||||
|
$entry = self::hash($actor, $action, $target, $beforeJson, $afterJson, $ts, $prev);
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts, prev_hash, entry_hash)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$st->execute([$actor, $action, $target, $beforeJson, $afterJson, $ts, $prev, $entry]);
|
||||||
|
if ($ownTransaction) $this->pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($ownTransaction && $this->pdo->inTransaction()) $this->pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tailHash(): ?string
|
||||||
|
{
|
||||||
|
$sql = 'SELECT entry_hash FROM move_audit_log ORDER BY id DESC LIMIT 1';
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||||
|
$sql .= ' FOR UPDATE';
|
||||||
|
}
|
||||||
|
$row = $this->pdo->query($sql)->fetch();
|
||||||
|
if ($row === false) return null; // first entry ever: chain genesis
|
||||||
|
$hash = $row['entry_hash'] ?? null;
|
||||||
|
// NULL here means the tail predates the chain (rows written before the migration).
|
||||||
|
// Linking to NULL restarts the chain from this row, and the verifier reports the
|
||||||
|
// pre-chain rows as an unverifiable prefix rather than as tampering.
|
||||||
|
return is_string($hash) && $hash !== '' ? $hash : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hashed record. Field separator is a unit separator (0x1F), not a printable character:
|
||||||
|
* with a plain '|' an actor literally named "a|b" could be crafted to collide with a
|
||||||
|
* different (actor, action) split, and forging an audit entry is exactly the attack this
|
||||||
|
* chain exists to catch.
|
||||||
|
*/
|
||||||
|
public static function hash(
|
||||||
|
string $actor, string $action, string $target,
|
||||||
|
string $beforeJson, string $afterJson, int $ts, ?string $prev
|
||||||
|
): string {
|
||||||
|
return hash('sha256', implode("\x1f", [
|
||||||
|
$actor, $action, $target, $beforeJson, $afterJson, (string)$ts, $prev ?? '',
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
server/src/Repository/Pdo/PdoDeviceMetricRepository.php
Normal file
65
server/src/Repository/Pdo/PdoDeviceMetricRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,18 @@ final class PdoDeviceRepository implements DeviceRepository
|
||||||
return $version === false ? 1 : (int)$version;
|
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
|
public function listStatus(?string $site): array
|
||||||
{
|
{
|
||||||
// Site-scope access control, same rule as the event list: a non-null scope filters to
|
// Site-scope access control, same rule as the event list: a non-null scope filters to
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,68 @@ final class PdoEventRepository implements EventRepository
|
||||||
return array_map(self::mapRow(...), $st->fetchAll());
|
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
|
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int
|
||||||
{
|
{
|
||||||
$sql = "SELECT COUNT(*) FROM move_events
|
$sql = "SELECT COUNT(*) FROM move_events
|
||||||
|
|
|
||||||
66
server/src/Repository/Pdo/PdoHandoverRepository.php
Normal file
66
server/src/Repository/Pdo/PdoHandoverRepository.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
92
server/src/Repository/Pdo/PdoRiskControlRepository.php
Normal file
92
server/src/Repository/Pdo/PdoRiskControlRepository.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
109
server/src/Repository/Pdo/PdoSuppressionRepository.php
Normal file
109
server/src/Repository/Pdo/PdoSuppressionRepository.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,31 @@ namespace App\Repository\Pdo;
|
||||||
use App\Repository\ThresholdRepository;
|
use App\Repository\ThresholdRepository;
|
||||||
use PDO;
|
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
|
final class PdoThresholdRepository implements ThresholdRepository
|
||||||
{
|
{
|
||||||
public function __construct(private PDO $pdo) {}
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
public function forDevice(string $deviceId): array
|
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]);
|
$st->execute([$deviceId]);
|
||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
|
|
@ -20,4 +38,92 @@ final class PdoThresholdRepository implements ThresholdRepository
|
||||||
}
|
}
|
||||||
return $out;
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
server/src/Repository/RiskControlRepository.php
Normal file
13
server/src/Repository/RiskControlRepository.php
Normal 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;
|
||||||
|
}
|
||||||
23
server/src/Repository/SuppressionRepository.php
Normal file
23
server/src/Repository/SuppressionRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,17 @@ namespace App\Repository;
|
||||||
|
|
||||||
interface ThresholdRepository
|
interface ThresholdRepository
|
||||||
{
|
{
|
||||||
|
/** key => value, flattened for GET /api/config. Device-scoped rows only. */
|
||||||
public function forDevice(string $deviceId): array;
|
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
110
server/src/View/admin.php
Normal 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();
|
||||||
107
server/src/View/compliance.php
Normal file
107
server/src/View/compliance.php
Normal 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();
|
||||||
|
|
@ -2,10 +2,52 @@
|
||||||
require __DIR__ . '/layout.php';
|
require __DIR__ . '/layout.php';
|
||||||
render_header('이벤트 상세');
|
render_header('이벤트 상세');
|
||||||
?>
|
?>
|
||||||
<h1>이벤트 상세</h1>
|
<div class="detail">
|
||||||
<div id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
<nav class="detail-crumb"><a href="<?= view_url('/') ?>">감지 스트림</a><span aria-hidden="true">›</span><span>이벤트 상세</span></nav>
|
||||||
<div id="event-detail-snapshots"></div>
|
<header class="detail-head">
|
||||||
<p><a href="<?= view_url('/') ?>">← 목록으로</a></p>
|
<div class="detail-headline">
|
||||||
|
<h1 id="detail-title">이벤트 상세</h1>
|
||||||
|
<p id="detail-when" class="detail-when">불러오는 중…</p>
|
||||||
|
</div>
|
||||||
|
<!-- 판정은 증거를 본 화면에서 끝난다. 예전에는 여기서 판단하고 목록으로 되돌아가
|
||||||
|
그 행을 다시 찾아 눌러야 했다 — 판단하는 자리에 판단 도구가 없었다. -->
|
||||||
|
<div class="detail-actions" id="detail-actions" hidden></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="detail-links">
|
||||||
|
<a id="detail-package" class="detail-link-btn" href="#">사고조사 패키지 →</a>
|
||||||
|
<a id="detail-zones" class="detail-link-btn" href="#">이 카메라 구역 설정 →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<section class="detail-card detail-evidence">
|
||||||
|
<h2 class="detail-card-title">증거 프레임</h2>
|
||||||
|
<div id="event-detail-snapshots"></div>
|
||||||
|
<p class="detail-foot">프레임은 발생 시점을 향해 왼쪽에서 오른쪽으로 이어집니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.</p>
|
||||||
|
</section>
|
||||||
|
<aside class="detail-side">
|
||||||
|
<div class="detail-card detail-meta">
|
||||||
|
<h2 class="detail-card-title">이벤트 정보</h2>
|
||||||
|
<dl id="event-detail-meta" role="status" aria-live="polite" aria-busy="true">
|
||||||
|
<div class="detail-row"><dt>상태</dt><dd>불러오는 중…</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<!-- 사건에서 조치로 바로 이어지게 한다. 조치를 리포트 화면에서만 기록할 수 있으면
|
||||||
|
"무엇이 계기였나"가 끊기고, 나중에 그 대책이 어느 사고에서 나왔는지 알 수 없다. -->
|
||||||
|
<div class="detail-card detail-action-card">
|
||||||
|
<h2 class="detail-card-title">개선 조치 기록</h2>
|
||||||
|
<p class="detail-card-hint">이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.</p>
|
||||||
|
<form id="detail-action-form">
|
||||||
|
<label class="field"><span class="field-label">조치 내용</span>
|
||||||
|
<input type="text" id="detail-action-note" maxlength="200" placeholder="예: 진입로 미끄럼 방지 매트 설치">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary">조치 기록</button>
|
||||||
|
</form>
|
||||||
|
<p id="detail-action-status" role="status" class="admin-status"></p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="<?= view_url('/assets/detail.js') ?>"></script>
|
<script src="<?= view_url('/assets/detail.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,17 @@ render_header('이벤트');
|
||||||
<option value="device_fault">장치 이상</option>
|
<option value="device_fault">장치 이상</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="rail-block">
|
<section class="rail-block">
|
||||||
|
|
@ -60,12 +70,32 @@ render_header('이벤트');
|
||||||
<div class="stream-title">
|
<div class="stream-title">
|
||||||
<h1>감지 스트림</h1>
|
<h1>감지 스트림</h1>
|
||||||
<span class="watch" id="watch-status" aria-live="polite">실시간 감시 중</span>
|
<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>
|
||||||
<div class="kpi-wrap">
|
<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 id="events-status" class="kpi-strip" aria-label="미확인 이벤트 현황"></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
<!-- 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). -->
|
nobody was watching. Hidden until the numbers arrive (an empty banner is noise). -->
|
||||||
<div id="day-brief" class="day-brief" hidden></div>
|
<div id="day-brief" class="day-brief" hidden></div>
|
||||||
|
|
|
||||||
19
server/src/View/incident_package.php
Normal file
19
server/src/View/incident_package.php
Normal 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();
|
||||||
|
|
@ -28,16 +28,26 @@ if (!function_exists('render_header')) {
|
||||||
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
|
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
|
||||||
// Which primary nav tab is active is derived from the page title so no view
|
// 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.
|
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
|
||||||
|
// 페이지 이름은 nav 라벨 · <h1> · <title> 세 곳이 같은 문자열을 쓴다. 예전에는
|
||||||
|
// nav '리포트' / <title> '안전 리포트' / <h1> '안전 운영 리포트' 로 갈라져 있었고,
|
||||||
|
// 같은 화면을 부르는 이름이 셋이면 인수인계·매뉴얼에서 바로 어긋난다.
|
||||||
$navEvents = ($title === '이벤트') ? ' is-active' : '';
|
$navEvents = ($title === '이벤트') ? ' is-active' : '';
|
||||||
$navZones = ($title === '구역 설정') ? ' is-active' : '';
|
$navZones = ($title === '구역 설정') ? ' is-active' : '';
|
||||||
$navReport = ($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>
|
<!doctype html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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') ?>">
|
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
|
||||||
<script nonce="<?= view_e(\App\Security\Csp::nonce()) ?>">
|
<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) ?>;
|
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">
|
<nav class="app-nav">
|
||||||
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a>
|
<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('/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>
|
</nav>
|
||||||
<div class="app-session">
|
<div class="app-session">
|
||||||
<?php if ($username !== null): ?>
|
<?php if ($username !== null): ?>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ render_header('안전 리포트');
|
||||||
<div class="report">
|
<div class="report">
|
||||||
<header class="report-head">
|
<header class="report-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>안전 운영 리포트</h1>
|
<h1>안전 리포트</h1>
|
||||||
<p id="report-range" class="report-range">기간 집계 중…</p>
|
<p id="report-range" class="report-range">기간 집계 중…</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="report-controls">
|
<div class="report-controls">
|
||||||
|
|
@ -24,6 +24,11 @@ render_header('안전 리포트');
|
||||||
<div id="report-kpis" class="report-kpis" aria-label="핵심 지표"></div>
|
<div id="report-kpis" class="report-kpis" aria-label="핵심 지표"></div>
|
||||||
|
|
||||||
<div class="report-grid">
|
<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">
|
<section class="report-card">
|
||||||
<h2>유형별 감지</h2>
|
<h2>유형별 감지</h2>
|
||||||
<div id="report-types" class="bar-list"></div>
|
<div id="report-types" class="bar-list"></div>
|
||||||
|
|
|
||||||
25
server/src/View/setup.php
Normal file
25
server/src/View/setup.php
Normal 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();
|
||||||
|
|
@ -2,39 +2,92 @@
|
||||||
require __DIR__ . '/layout.php';
|
require __DIR__ . '/layout.php';
|
||||||
render_header('구역 설정');
|
render_header('구역 설정');
|
||||||
?>
|
?>
|
||||||
<h1>구역 설정</h1>
|
<div class="zone-page">
|
||||||
<div class="zone-controls">
|
<header class="page-head">
|
||||||
<label for="device-id">장치 ID</label>
|
<h1>구역 설정</h1>
|
||||||
<input type="text" id="device-id" placeholder="device-01">
|
<p class="page-sub">카메라 화면 위에 위험구역을 그리면 엣지가 그 안의 작업자를 판정합니다. 저장 즉시 다음 설정 동기화 때 장치로 내려갑니다.</p>
|
||||||
<label for="camera-id">카메라 ID</label>
|
</header>
|
||||||
<input type="text" id="camera-id" placeholder="cam-01">
|
|
||||||
<button type="button" id="zone-load-btn">불러오기</button>
|
<div class="zone-console">
|
||||||
<button type="button" id="zone-frame-btn">현재 화면 불러오기</button>
|
<!-- 좌측 패널은 이벤트 콘솔의 레일과 같은 문법(카드 + 소문자 캡션)이다.
|
||||||
<button type="button" id="heatmap-btn" aria-pressed="false">위험 히트맵</button>
|
작업 순서 그대로 ①선택 → ②그리기 → ③규칙·저장 으로 내려간다. -->
|
||||||
<label for="heatmap-days">기간(일)</label>
|
<aside class="zone-panel">
|
||||||
<input type="number" id="heatmap-days" min="1" max="90" value="30">
|
<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>
|
||||||
|
</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">경고 — 진입 시 알림</option>
|
||||||
|
<option value="restricted">제한 — 진입 자체가 위반</option>
|
||||||
|
<option value="pinch">협착 — 끼임 위험</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="step-actions">
|
||||||
|
<button type="button" id="zone-clear-btn">그리던 폴리곤 지우기</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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="없음">
|
||||||
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
<div class="zone-controls">
|
|
||||||
<label for="zone-level">등급</label>
|
|
||||||
<select id="zone-level">
|
|
||||||
<option value="warning">경고 (warning)</option>
|
|
||||||
<option value="restricted">제한 (restricted)</option>
|
|
||||||
<option value="pinch">협착 (pinch)</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" id="zone-clear-btn">현재 폴리곤 지우기</button>
|
|
||||||
<button type="button" id="zone-save-btn">저장</button>
|
|
||||||
</div>
|
|
||||||
<div class="zone-controls">
|
|
||||||
<label for="rule-min-workers">최소 인원 (2인 1조)</label>
|
|
||||||
<input type="number" id="rule-min-workers" min="2" max="50" placeholder="없음">
|
|
||||||
<label for="rule-max-workers">최대 인원</label>
|
|
||||||
<input type="number" id="rule-max-workers" min="1" max="50" placeholder="없음">
|
|
||||||
<label for="rule-max-dwell">체류 상한 (분)</label>
|
|
||||||
<input type="number" id="rule-max-dwell" min="1" max="1440" placeholder="없음">
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
|
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
242
server/tests/AlertDispatchTest.php
Normal file
242
server/tests/AlertDispatchTest.php
Normal 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"}']));
|
||||||
|
}
|
||||||
|
}
|
||||||
140
server/tests/AuditChainTest.php
Normal file
140
server/tests/AuditChainTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,9 @@ declare(strict_types=1);
|
||||||
namespace App\Tests;
|
namespace App\Tests;
|
||||||
|
|
||||||
use App\Repository\Pdo\{PdoNonceRepository, PdoEventRepository, PdoZoneRepository,
|
use App\Repository\Pdo\{PdoNonceRepository, PdoEventRepository, PdoZoneRepository,
|
||||||
PdoActionRepository, PdoSnapshotRepository};
|
PdoActionRepository, PdoSnapshotRepository, PdoSuppressionRepository,
|
||||||
|
PdoAlertRepository, PdoThresholdRepository, PdoRiskControlRepository,
|
||||||
|
PdoHandoverRepository, PdoDeviceMetricRepository, PdoDeviceRepository};
|
||||||
use PDO;
|
use PDO;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -261,6 +263,176 @@ final class PdoIntegrationTest extends TestCase
|
||||||
$this->assertCount(2, $snaps->forEvent($a));
|
$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
|
private static function sqliteSchema(): string
|
||||||
{
|
{
|
||||||
return <<<'SQL'
|
return <<<'SQL'
|
||||||
|
|
@ -305,12 +477,128 @@ final class PdoIntegrationTest extends TestCase
|
||||||
action_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
action_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
camera_id TEXT NOT NULL,
|
camera_id TEXT NOT NULL,
|
||||||
site TEXT,
|
site TEXT,
|
||||||
|
event_id INTEGER,
|
||||||
note TEXT NOT NULL,
|
note TEXT NOT NULL,
|
||||||
taken_at INTEGER NOT NULL,
|
taken_at INTEGER NOT NULL,
|
||||||
created_by TEXT NOT NULL,
|
created_by TEXT NOT NULL,
|
||||||
created_at INTEGER 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 (
|
CREATE TABLE move_event_snapshots (
|
||||||
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
event_id INTEGER NOT NULL,
|
event_id INTEGER NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,18 @@ final class RouteTableTest extends TestCase
|
||||||
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
|
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
|
||||||
'GET /api/cameras/{camera}/latest-snapshot', 'POST /api/zones',
|
'GET /api/cameras/{camera}/latest-snapshot', 'POST /api/zones',
|
||||||
'POST /api/zones/{id}/delete', 'GET /snap/{uuid}',
|
'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 /', 'GET /login', 'GET /zones', 'GET /report', 'GET /events/{id}/view',
|
||||||
|
'GET /admin', 'GET /compliance', 'GET /setup', 'GET /events/{id}/package/view',
|
||||||
];
|
];
|
||||||
$actual = $this->registeredRoutes();
|
$actual = $this->registeredRoutes();
|
||||||
sort($expected);
|
sort($expected);
|
||||||
|
|
|
||||||
328
server/tests/SafetyConfigTest.php
Normal file
328
server/tests/SafetyConfigTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,8 @@ declare(strict_types=1);
|
||||||
namespace App\Tests\Support;
|
namespace App\Tests\Support;
|
||||||
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
|
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
|
||||||
EventRepository, SnapshotRepository, UserRepository, ZoneRepository,
|
EventRepository, SnapshotRepository, UserRepository, ZoneRepository,
|
||||||
ThresholdRepository, AuditRepository, ActionRepository};
|
ThresholdRepository, AuditRepository, ActionRepository, SuppressionRepository,
|
||||||
|
AlertRepository, HandoverRepository, RiskControlRepository, DeviceMetricRepository};
|
||||||
|
|
||||||
final class InMemoryDeviceRepository implements DeviceRepository {
|
final class InMemoryDeviceRepository implements DeviceRepository {
|
||||||
public array $heartbeats = [];
|
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 siteFor(string $d): ?string { return $this->sites[$d] ?? null; }
|
||||||
public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
|
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 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 {
|
public function listStatus(?string $site): array {
|
||||||
$rows = $this->statusRows;
|
$rows = $this->statusRows;
|
||||||
if ($site !== null) $rows = array_filter($rows, fn($d) => ($d['site'] ?? null) === $site);
|
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']]);
|
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);
|
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 {
|
public function countForCamera(string $cam, float $from, float $to, ?string $site): int {
|
||||||
return count(array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cam
|
return count(array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cam
|
||||||
&& ($x['occurred_at'] ?? 0) >= $from && ($x['occurred_at'] ?? 0) < $to
|
&& ($x['occurred_at'] ?? 0) >= $from && ($x['occurred_at'] ?? 0) < $to
|
||||||
|
|
@ -247,10 +286,174 @@ final class InMemoryActionRepository implements ActionRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final class InMemoryThresholdRepository implements ThresholdRepository {
|
final class InMemoryThresholdRepository implements ThresholdRepository {
|
||||||
public function __construct(private array $data = []) {}
|
public array $rows = []; private int $seq = 0;
|
||||||
public function forDevice(string $d): array { return $this->data[$d] ?? []; }
|
/** @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 {
|
final class InMemoryAuditRepository implements AuditRepository {
|
||||||
public array $log = [];
|
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'); }
|
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
157
server/tools/backup.php
Normal 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);
|
||||||
69
server/tools/bootstrap.php
Normal file
69
server/tools/bootstrap.php
Normal 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';
|
||||||
|
}
|
||||||
125
server/tools/dispatch_alerts.php
Normal file
125
server/tools/dispatch_alerts.php
Normal 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
122
server/tools/maintain.php
Normal 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
373
server/tools/seed_demo.php
Normal 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');
|
||||||
102
server/tools/verify_audit_chain.php
Normal file
102
server/tools/verify_audit_chain.php
Normal 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
134
tests/test_beacon.py
Normal 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"]}) == []
|
||||||
|
|
@ -184,3 +184,113 @@ def test_loop_survives_flush_exception():
|
||||||
sync_every=99, heartbeat_every=99, flush_every=2)
|
sync_every=99, heartbeat_every=99, flush_every=2)
|
||||||
assert rt.steps == 5 # loop kept running past the flush exceptions
|
assert rt.steps == 5 # loop kept running past the flush exceptions
|
||||||
assert counters["errors"] >= 1
|
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
142
tests/test_integrity.py
Normal 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))
|
||||||
94
tests/test_pending_detectors.py
Normal file
94
tests/test_pending_detectors.py
Normal 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
112
tests/test_tuning.py
Normal 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") == {}
|
||||||
Loading…
Reference in a new issue