diff --git a/docs/INDEX.md b/docs/INDEX.md
index eca2a5b..2be2f8d 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -1,5 +1,55 @@
# INDEX — 작업 기록 (완료 결과만)
+- **2026-08-09** 안전 플랫폼 확장 — 로드맵 23건 중 소프트웨어로 완결 가능한 19건 반영.
+ 마이그레이션 `2026-08-09-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장).
+ **통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다.
+ ① 엣지 GPIO 경광등(`edge/beacon.py`) — 네트워크 없이 동작하는 유일한 경로, /api/config 로 설정 수신
+ ② 메일·웹훅 채널 + 크론 디스패처(`tools/dispatch_alerts.php`, `--dry-run` 지원)
+ ③ 미확인 위급 → `unacked_escalation` 이벤트 발행(멱등) → 상위 채널로 에스컬레이션.
+ 중복 발송은 (event,channel) UNIQUE 로 막는다(크론 중첩 실행 대비).
+ **조용한 실패 제거**: 카메라 화각 이동·가림·조도 저하 감지(`detectors/integrity.py`) —
+ 존 판정이 무의미해진 것을 처음으로 알 수 있게 됨. 스토리지 여유 감시.
+ **엣지에 닿지 않던 배선 2건 수리**: `thresholds_from_config()` 가 파싱만 하고 버려서 임계값이
+ 장비에 적용되지 않던 것(`edge/tuning.py`), 존/임계값 저장 시 `config_version` 을 올리지 않아
+ 설정 드리프트 표시가 영원히 꺼져 있던 것.
+ **관리 화면 신설**: 임계값 편집(카탈로그 기반 범위 강제 — 엣지가 기동 시 거부하는 값을 키보드
+ 앞에서 막는다) · 통보 채널 · 정비 억제(사유 필수·최대 8시간·감지는 계속되고 통보만 보류) ·
+ 위험성평가 · 교대 인수인계 · 설비 성능 추이 · 설치 점검(`/setup`, 실제 상태 조회 기반).
+ **증거·규정**: 감사로그 해시체인(수정·삭제·우회삽입 전부 탐지, `tools/verify_audit_chain.php`) ·
+ 사고조사 패키지 1클릭(전후 타임라인+통보 이력+위험요인+조치 이력+서명란, 인쇄 CSS) ·
+ 이벤트→조치 직접 기록 · 현장별 롤업(전역 계정만).
+ **운영**: 일괄 확인(위급 제외·2단계) · 키보드 트리아지(J/K/A/F/Enter) · 모바일 레이아웃
+ (44px 타깃) · 보존기간 정리(`tools/maintain.php`) · 백업/복구(`tools/backup.php`, www 안 출력 거부) ·
+ 데모 시드(`tools/seed_demo.php`).
+ 검증: pytest 통과(3 skip = 하드웨어·환경) · PHPUnit 221 (skip 0, +59) · Chrome 실렌더로
+ 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→변조 탐지 전 경로 확인 ·
+ cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개) 재생성, 소스 패리티 통과.
+ **미착수 4건(모델·하드웨어 선행)**: 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보와
+ HEF 컴파일이 관문이라 감지기 배선·이벤트 계약·테스트만 완성하고 기본 비활성으로 둠
+ (`edge/detectors/pending.py`). PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.
+
+- **2026-08-09** UI/UX 전면 점검 후 21건 일괄 반영 (동선 7 · 전달 9 · 시각 5).
+ **동선**: ① 스트림 정렬을 '미확인 우선'에서 **엄격 시간순**으로 되돌림 — 확인 상태가 연대기를
+ 뒤집어 사고 재구성이 불가능했고, 확인 시 행이 목록 밖으로 사라져 아래 행이 밀려 올라오며
+ 오클릭으로 다른 이벤트가 확인 처리될 수 있었다. 확인은 제자리 전환 + 확정 토스트로 변경.
+ ② 처리 큐는 정렬이 아니라 뷰모드(전체/미확인만) 필터로 분리. ③ 이벤트 상세에 확인/오탐 액션
+ 신설(판정 화면에 판정 도구가 없던 문제). ④ 오탐·구역삭제 2단계 확정(되돌릴 수 없는 동작).
+ ⑤ 구역 설정에 장치·카메라 선택기(자유 텍스트 ID 암기 제거, 가짜 플레이스홀더 제거).
+ ⑥ 날짜 조회(키셋 커서 시딩, 최대 6페이지) — 사고 조사 진입로. ⑦ 경보음 세션 음소거
+ (새 위급 도착 시 자동 해제).
+ **전달**: KPI 스트립에 실제 집계 범위 표기(24h 브리프와 숫자 충돌 해소), 중대=위급+높음
+ 인라인 정의, 페이지 명칭 3종·날짜 포맷 3종 통일, '확인 필요' 칩을 전 심각도로 확장,
+ 상세 `CONFIDENCE`→`검출 신뢰도 %`, 오탐율·시간패턴 표본 게이트, 개선조치 효과에 푸아송
+ 잡음 범위(√before) 판정 보류, 카메라 위험 순위 강조 기준을 상대값으로, 날짜 구분선.
+ **시각**: 구역 설정·이벤트 상세를 카드 셸로 재구성(둘 다 스타일 없는 기본 폼이었음),
+ 히트맵 토글 aria-pressed 시각 상태 신설(CSS 규칙 자체가 없었음), 존 색을 팔레트 정합
+ (팔레트 외 보라 제거, 채움+라벨칩+협착 점선), 주 동작 accent 승격, 장치명 플리커 제거.
+ 검증: pytest 통과 · PHPUnit 162 (skip 0) · Chrome 실렌더로 4개 화면 + 확인/오탐/날짜조회/
+ 삭제 2단계/히트맵 토글 실동작 확인 · cafe24 번들 재생성(소스 패리티 통과).
+ **미해결(설계 판단)**: 확인 취소(undo)는 넣지 않음 — `acknowledged_by`는 감사 로그가 남는
+ 추가 전용 기록이라, 무음 되돌리기는 안전 시스템에서 더 나쁜 속성. 서버에 unack 라우트를
+ 두는 안은 별도 결정 필요.
+
- **2026-08-08** 감사 잔여 확정 항목 일괄 처리 (VERSION 0.9.0 신설). **보안**: CSP nonce
도입(인라인 2블록, unsafe-inline 없음)+보안헤더를 PHP로 이관(개발/납품 동일), /logout CSRF,
계정 잠금 423→401(사용자명 열거 제거). **엣지**: 큐 flush 배치 상한(정전 복구 시 감지 정지
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index 8613fbf..506e405 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -25,21 +25,48 @@
| `daemon.py` / `runtime.py` / `health.py` | 상주 루프 · 하트비트 텔레메트리 |
| `adapters/` | hailo_pose · movenet_pose(CPU 폴백) · picamera_source(CSI) · uvc_source(USB) |
| `probe.py` / `tools/` | 카메라 사전 점검 · HEF/모델 fetch (빌드머신 전용) |
+| `beacon.py` | 현장 경광등·부저 (GPIO). **네트워크 없이 동작하는 유일한 통보 경로** — /api/config 의 `beacons` 로 설정 수신, 하드웨어 없으면 NullBeacon 으로 폴백 |
+| `tuning.py` | 서버 임계값 → 살아 있는 감지기에 적용. 이전에는 `thresholds_from_config()` 가 파싱만 하고 버려서 관리자 변경이 장비에 닿지 않았다 |
+| `detectors/integrity.py` | 카메라 화각 이동·가림·조도 저하 (`camera_moved`/`camera_obstructed`/`low_light`). 존 판정이 조용히 무의미해지는 실패 모드를 드러낸다 |
+| `detectors/pending.py` | 보호구·중장비 근접·화재 — 배선/계약 완료, **모델 미확보로 기본 비활성** (AGPL 아닌 가중치 + HEF 컴파일 필요) |
## server/ (PHP 8 + PDO — Cafe24)
-- 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php`
+- 진입: `public/index.php` (라우트 전부 여기),
+ 뷰: `src/View/{layout,events,login,zones,report,event_detail,admin,compliance,setup,incident_package}.php`
+- `src/Alert/`: `AlertDispatcher`(어떤 통보를 낼지 결정 — 순수 로직, I/O 없음) ·
+ `Channels`(email/webhook 발송; `local` 은 엣지가 자체 GPIO 로) · `EscalationWatch`(미확인 위급 →
+ `unacked_escalation` 이벤트 발행, event_uuid 로 멱등)
+- `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명.
+ 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게)
+- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
+ `backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA).
+ 전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
`GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
ack 겸함) · `GET /api/report`(운영 지표+시간패턴+전조경보+추이) ·
`GET /api/export/labels`(라벨 JSONL, 관리자) · `GET/POST /api/actions`(개선 조치·전후 효과) ·
- 페이지 `/`, `/login`, `/zones`, `/report`(안전 리포트), `/events/{id}/view`
+ `GET /api/devices/{id}/metrics`(성능 시계열) · `GET/POST /api/admin/thresholds*`(임계값 편집,
+ admin) · `GET/POST /api/admin/channels*`(통보 채널, admin) · `GET/POST /api/suppressions*`(정비
+ 억제) · `GET/POST /api/risks*`(위험성평가) · `GET/POST /api/handovers`(교대 인수인계) ·
+ `GET /api/events/{id}/package`(사고조사 패키지) ·
+ 페이지 `/`, `/login`, `/zones`, `/report`(안전 리포트), `/compliance`(안전 관리),
+ `/admin`(시스템 설정), `/setup`(설치 점검), `/events/{id}/view`, `/events/{id}/package/view`
- `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
- `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
-- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·
- `zone-draw.js` · `detail.js` · `app.css`
+ `src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시·시스템설정·억제·규정 ·
+ `src/Alert/` 통보 결정·발송·에스컬레이션 · `src/Repository/` 14종 (인터페이스+PDO)
+- 감사로그는 **해시체인**(prev_hash/entry_hash). 수정·삭제·우회삽입이 전부 탐지된다 —
+ `tools/verify_audit_chain.php`, 손상 시 종료 코드 1
+- 프런트: `public/assets/app.js`(이벤트 콘솔 — 엄격 시간순 정렬 · 뷰모드(전체/미확인만) ·
+ 날짜 조회(커서 시딩) · 확인 제자리 전환+토스트 · 오탐 2단계 · 경보음 음소거 · 날짜 구분선) ·
+ `device-tree.js`(장치→카메라 트리 레일) · `zone-draw.js`(장치·카메라 선택기 · 존 채움/라벨칩 ·
+ 삭제 2단계) · `detail.js`(상세 + 확인/오탐 판정) · `report.js`(표본·유의성 게이트) · `app.css`
+- UI 불변식: 스트림 정렬은 **항상 occurred_at 내림차순** — 확인 상태가 순서를 바꾸면 안 됨
+ (연대기 = 사고 재구성 근거). 처리 큐는 정렬이 아니라 필터(`filters.unackedOnly`)로만 만든다.
+- 페이지 명칭은 nav 라벨 · `
` · `` 세 곳이 동일 문자열 (`이벤트`/`구역 설정`/`안전 리포트`).
+ 날짜 표기는 `YYYY-MM-DD` 로 3개 화면 통일 (각 파일의 로컬 `formatTime`/`fmtDate`).
- DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/`.
존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시
ZoneConfigController가 검증, /api/config로 엣지 전달
diff --git a/edge/app.py b/edge/app.py
index 66bb3b9..1519778 100644
--- a/edge/app.py
+++ b/edge/app.py
@@ -8,7 +8,7 @@ from edge.detectors.zone import ZoneIntrusionDetector
from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.models import BBox
-from edge.events import EventType
+from edge.events import Event, EventType, severity_for
# Evidence timeline offsets (seconds before the event) — 3 frames tell how it developed.
# The server accepts at most 3 snapshots per event (EventValidator), so this is the cap.
@@ -34,7 +34,8 @@ def _head_boxes(persons: list) -> list:
return boxes
-def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
+def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None,
+ integrity=None):
ok, frame = source.read()
if not ok:
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
@@ -70,6 +71,17 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
wall_now = wall_fn() if wall_fn is not None else None
events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now)
+ # Camera-integrity findings are about the FRAME, not about any track, so they are produced
+ # here (where the frame is) rather than inside the engine (which only sees tracks). They
+ # matter because a camera that has been knocked askew keeps producing perfectly healthy
+ # frames while every zone drawn on it now points somewhere else — the failure that used to
+ # be invisible until after an incident.
+ if integrity is not None:
+ stamp = wall_now if wall_now is not None else now
+ for etype, metrics in integrity.observe(frame, now):
+ et = EventType(etype)
+ events.append(Event(et, severity_for(et), engine.cfg.camera_id, None, None,
+ stamp, 0.0, metrics, None))
for tr in lost:
pool.forget(tr.track_id)
if events:
diff --git a/edge/beacon.py b/edge/beacon.py
new file mode 100644
index 0000000..f895fec
--- /dev/null
+++ b/edge/beacon.py
@@ -0,0 +1,176 @@
+"""현장 경광등·부저 — 엣지 노드가 스스로 울리는 유일한 통보 경로.
+
+WHY LOCAL
+ 폐쇄망 현장에서는 FCM 도 Slack 도 쓸 수 없고, 사내 SMTP·웹훅조차 없는 곳이 있다. 게다가
+ 서버 경유 통보는 업링크가 살아 있을 때만 동작한다 — 정전이나 스위치 장애로 회선이 끊긴
+ 순간은 사고 위험이 오히려 높아지는 때다. GPIO 출력은 그 모든 것과 무관하게 동작한다:
+ 감지기가 이벤트를 만든 그 자리에서, 네트워크 없이, 수십 밀리초 안에.
+
+ 그래서 이것이 어떤 사이트든 **가장 먼저 설정해야 할 통보 경로**다. 서버측 채널
+ (App\\Alert\\Channels)은 이 위에 얹는 보조 수단이다.
+
+설정 전달
+ 서버 /api/config 의 `beacons` 배열로 내려온다 (핀 번호, latch/pulse, 유지 시간,
+ active_high). 관리 화면에서 켜고 끄므로 현장에서 edge.env 를 고칠 필요가 없다.
+
+하드웨어 부재
+ gpiozero / RPi.GPIO 가 없거나(개발 PC, CI) 핀 열기에 실패하면 NullBeacon 으로 조용히
+ 떨어진다. 경광등이 없다고 감지가 멈추면 안 된다 — 통보는 감지의 부가 기능이지 전제가
+ 아니다. 다만 그 사실은 로그로 남긴다: 아무도 모르게 경광등이 죽어 있는 것이 최악이다.
+
+실장비 검증
+ 핀이 실제로 떨어지는지는 이 파일의 단위 테스트로 증명할 수 없다. tests/hardware/ 의
+ OFF-HW 항목으로 남긴다 (docs/hardware-bringup.md).
+"""
+
+import time
+
+_SEVERITY_RANK = {"info": 0, "warning": 1, "high": 2, "critical": 3}
+
+
+class NullBeacon:
+ """하드웨어가 없을 때의 무동작 구현. 인터페이스는 동일하다."""
+
+ available = False
+
+ def fire(self, severity: str, now: float) -> bool:
+ return False
+
+ def tick(self, now: float) -> None:
+ pass
+
+ def release(self) -> None:
+ pass
+
+
+class GpioBeacon:
+ """핀 하나를 latch(고정) 또는 pulse(일정 시간 후 해제) 로 구동한다.
+
+ `latch` 는 사람이 확인할 때까지 계속 울리는 대신 hold_s 뒤 자동 해제한다. 무한 latch 는
+ 현장에 사람이 갈 수 없는 시간대(야간·주말)에 아무도 끌 수 없는 소음이 되고, 그러면 다음
+ 주에 배선이 뽑혀 있다.
+ """
+
+ available = True
+
+ def __init__(self, pin: int, *, mode: str = "latch", hold_s: float = 30.0,
+ active_high: bool = False, min_severity: str = "high",
+ backend=None, log=None):
+ self.pin = int(pin)
+ self.mode = mode if mode in ("latch", "pulse") else "latch"
+ self.hold_s = max(0.5, float(hold_s))
+ self.active_high = bool(active_high)
+ self.min_rank = _SEVERITY_RANK.get(min_severity, 2)
+ self._on_until: float | None = None
+ self._log = log or (lambda m: None)
+ self._backend = backend if backend is not None else _open_pin(self.pin, self.active_high)
+
+ def _write(self, on: bool) -> None:
+ # active_high=False 는 릴레이 모듈의 통상 배선(LOW 에서 도통)이다. 반대로 물린
+ # 현장에서 경광등이 상시 켜져 있는 것을 설정으로 뒤집을 수 있어야 한다.
+ self._backend.set(on if self.active_high else not on)
+
+ def fire(self, severity: str, now: float) -> bool:
+ if _SEVERITY_RANK.get(severity, 0) < self.min_rank:
+ return False
+ self._write(True)
+ # 이미 울리는 중에 새 이벤트가 오면 유지 시간을 연장한다 — 사고가 이어지는 동안
+ # 경광등이 중간에 꺼졌다 켜지면 사람이 "끝났다"로 읽는다.
+ self._on_until = now + self.hold_s
+ return True
+
+ def tick(self, now: float) -> None:
+ """루프가 매 틱 호출한다. 유지 시간이 지나면 해제."""
+ if self._on_until is not None and now >= self._on_until:
+ self._write(False)
+ self._on_until = None
+
+ def release(self) -> None:
+ try:
+ self._write(False)
+ finally:
+ close = getattr(self._backend, "close", None)
+ if close:
+ close()
+
+
+class _PinBackend:
+ """gpiozero / RPi.GPIO 중 있는 쪽에 얇게 붙는다."""
+
+ def __init__(self, device=None, rpi=None, pin: int = 0):
+ self._device = device
+ self._rpi = rpi
+ self._pin = pin
+
+ def set(self, high: bool) -> None:
+ if self._device is not None:
+ if high:
+ self._device.on()
+ else:
+ self._device.off()
+ elif self._rpi is not None:
+ self._rpi.output(self._pin, self._rpi.HIGH if high else self._rpi.LOW)
+
+ def close(self) -> None:
+ if self._device is not None:
+ self._device.close()
+ elif self._rpi is not None:
+ self._rpi.cleanup(self._pin)
+
+
+def _open_pin(pin: int, active_high: bool): # pragma: no cover (hardware)
+ try:
+ from gpiozero import DigitalOutputDevice
+ # initial_value=False 는 "비활성" 을 뜻한다. active_high 해석은 _write 가 담당하므로
+ # 여기서는 항상 raw 출력으로 열고, 부팅 순간 경광등이 번쩍이지 않도록 초기값을 준다.
+ dev = DigitalOutputDevice(pin, active_high=True, initial_value=not active_high)
+ return _PinBackend(device=dev, pin=pin)
+ except Exception:
+ pass
+ try:
+ import RPi.GPIO as GPIO
+ GPIO.setmode(GPIO.BCM)
+ GPIO.setup(pin, GPIO.OUT, initial=GPIO.LOW if active_high else GPIO.HIGH)
+ return _PinBackend(rpi=GPIO, pin=pin)
+ except Exception:
+ raise RuntimeError("no usable GPIO backend")
+
+
+def build_beacons(config: dict, log=None) -> list:
+ """/api/config 의 `beacons` 배열 -> 구동 가능한 비콘 목록.
+
+ 핀 하나가 열리지 않아도 나머지는 살린다. 전부 실패하면 빈 목록이고, 그때도 감지·전송은
+ 그대로 돈다.
+ """
+ log = log or (lambda m: None)
+ out = []
+ for b in (config or {}).get("beacons") or []:
+ if not isinstance(b, dict):
+ continue
+ pin = int(b.get("gpio_pin") or 0)
+ if pin <= 0:
+ continue # 0 = 미배선 (관리 화면에서 핀을 아직 안 정한 상태)
+ try:
+ out.append(GpioBeacon(
+ pin,
+ mode=str(b.get("mode") or "latch"),
+ hold_s=float(b.get("hold_s") or 30.0),
+ active_high=bool(b.get("active_high")),
+ min_severity=str(b.get("min_severity") or "high"),
+ log=log,
+ ))
+ except Exception as e:
+ # 조용히 넘어가면 아무도 경광등이 죽은 줄 모른다.
+ log(f"beacon pin {pin} unavailable ({type(e).__name__}: {e}); alerts on this node "
+ f"will not sound locally")
+ return out
+
+
+def fire_all(beacons, events, now: float | None = None) -> int:
+ """이벤트 목록 중 가장 높은 심각도로 비콘을 울린다. @return 실제로 울린 비콘 수."""
+ if not beacons or not events:
+ return 0
+ now = time.monotonic() if now is None else now
+ worst = max((getattr(e.severity, "value", e.severity) for e in events),
+ key=lambda s: _SEVERITY_RANK.get(s, 0))
+ return sum(1 for b in beacons if b.fire(worst, now))
diff --git a/edge/daemon.py b/edge/daemon.py
index fbcdae9..ac9cfa4 100644
--- a/edge/daemon.py
+++ b/edge/daemon.py
@@ -7,6 +7,14 @@ import os
_LOG_EVERY = 300
+def _beacon_signature(beacons):
+ """Identity of a beacon set, so a sync only re-opens GPIO when the wiring actually changed.
+ Rebuilding unconditionally would blink every strobe on every sync (every ~150 ticks)."""
+ return tuple(sorted((getattr(b, "pin", 0), getattr(b, "mode", ""), getattr(b, "hold_s", 0.0),
+ getattr(b, "active_high", False), getattr(b, "min_rank", 0))
+ for b in (beacons or [])))
+
+
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
import sys
import traceback
@@ -19,7 +27,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None, log=None,
heartbeat_interval_s=None, health_fn=None, configured_cameras=None,
- notify=None):
+ notify=None, beacons=None, build_beacons_fn=None):
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
# cameras share this loop, one shared clock read per tick, and the periodic
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
@@ -39,7 +47,17 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
# The ping happens at the TOP of each tick, before any camera work. That is the point: it
# proves the loop is turning. Pinging after the work would still ping while a camera step
# blocks forever, which is the exact failure the watchdog exists to catch.
- counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
+ # Local beacons (siren / strobe on the node's own GPIO). This is the ONE notification path
+ # that survives a dead uplink, so it fires here — the instant this loop sees the event —
+ # rather than waiting for the server-side dispatcher. Configured from /api/config, so the
+ # list is rebuilt on sync; None means "not built yet", [] means "built, nothing wired".
+ if beacons is None:
+ beacons = []
+ if build_beacons_fn is None:
+ from edge.beacon import build_beacons as build_beacons_fn
+ from edge.beacon import fire_all as _fire_beacons
+ counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0,
+ "beacon_fires": 0, "tuned": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc=None):
@@ -85,12 +103,48 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
else:
counters["frames"] += 1
streak.pop(id(rt), None) # recovered — reset the streak
+ # Sound the local beacon on this tick's events. Wrapped: a wiring fault must
+ # never take down the detection loop — a node that stopped watching because
+ # its strobe failed would be the worst possible trade.
+ if produced and beacons:
+ try:
+ counters["beacon_fires"] += _fire_beacons(beacons, produced, now)
+ except Exception as e:
+ note("beacon", "beacon fire failed", e)
+ # Release latched beacons whose hold time has elapsed.
+ for b in beacons:
+ try:
+ b.tick(now)
+ except Exception as e:
+ note("beacon", "beacon tick failed", e)
try:
if sync_every and i % sync_every == 0:
cfg = client.fetch_config()
if cfg is not None:
for rt in runtimes:
rt.apply_zones(zones_for(cfg, rt))
+ # Thresholds too. The server has always SENT these; nothing ever applied
+ # them, so an administrator's retuning never reached the device.
+ thresholds = cfg.get("thresholds") if isinstance(cfg, dict) else None
+ if isinstance(thresholds, dict) and thresholds:
+ for rt in runtimes:
+ applier = getattr(rt, "apply_thresholds", None)
+ if applier is None:
+ continue
+ applied = applier(thresholds, log=lambda m: log(m, None))
+ counters["tuned"] += len(applied)
+ # Rebuild the local beacons from the published wiring. Doing it on sync
+ # (not only at boot) is what lets an administrator turn a site's strobe on
+ # without anyone walking to the node.
+ try:
+ rebuilt = build_beacons_fn(cfg if isinstance(cfg, dict) else {},
+ log=lambda m: log(m, None))
+ if _beacon_signature(rebuilt) != _beacon_signature(beacons):
+ for b in beacons:
+ b.release()
+ beacons = rebuilt
+ except Exception as e:
+ note("beacon", "beacon rebuild failed", e)
# Remember the config version so heartbeats can echo it back — the server's
# config-drift indicator is meaningless until the device reports what it runs.
if isinstance(cfg, dict) and cfg.get("version") is not None:
diff --git a/edge/detectors/integrity.py b/edge/detectors/integrity.py
new file mode 100644
index 0000000..66ff455
--- /dev/null
+++ b/edge/detectors/integrity.py
@@ -0,0 +1,151 @@
+"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하.
+
+WHY THIS EXISTS
+ 존(위험구역) 판정은 "카메라가 어제와 같은 곳을 보고 있다"는 전제 위에 통째로 서 있다.
+ 청소하다 삼각대를 스치거나, 지게차가 브래킷을 치거나, 누가 각도를 조금 돌리면 제한구역
+ 폴리곤은 엉뚱한 바닥을 가리키게 된다. 그런데 시스템은 아무 오류도 내지 않는다 — 프레임은
+ 계속 들어오고, 사람은 계속 검출되고, 대시보드는 초록불이다. 침입 감지가 죽었다는 사실을
+ 누군가 사고가 난 뒤에야 알게 되는 것이 이 제품의 가장 조용한 실패 모드였다.
+
+접근
+ 무거운 특징점 매칭 대신 저해상도 그레이스케일 요약본을 기준으로 삼는다. Pi 에서 매 프레임
+ 돌아야 하고, 우리가 감지하려는 것은 미세한 흔들림이 아니라 "다른 곳을 보고 있다" 수준의
+ 변화이기 때문에 이 해상도로 충분하다. 세 신호를 각각 다른 물리량으로 잡는다:
+
+ moved — 기준 대비 평균 절대 차이(MAD)가 임계 초과. 각도가 바뀌면 화면 전체가 바뀐다.
+ obstructed — 프레임 자체의 분산 급락. 렌즈를 천으로 덮으면 평탄한 회색이 된다.
+ low_light — 평균 밝기 하강. 야간·조명 고장 시 검출력이 떨어졌음을 스스로 신고한다.
+
+ 사람이 화면을 지나가도 MAD 는 오른다. 그래서 **지속 시간**을 요구한다: 순간 변화가 아니라
+ sustain_s 이상 유지될 때만 확정한다. 사람은 지나가고 화각 변화는 남는다는 성질을 쓴다.
+
+기준 프레임
+ 부팅 후 warmup_s 동안 조용히 관찰하다가 그 시점의 요약본을 기준으로 잡는다. 부팅 직후
+ 한 장을 그냥 쓰면 마침 그때 사람이 서 있었을 경우 그 사람이 영원히 배경이 된다.
+ 확정 이벤트를 낸 뒤에는 기준을 현재 화면으로 재설정한다 — 재설정하지 않으면 옮겨진
+ 카메라가 cooldown 마다 같은 이벤트를 영원히 재발행한다.
+
+의존성 없음: numpy 만 쓴다 (OpenCV 도 불필요). 폐쇄망 번들에 새 휠을 추가하지 않는다.
+"""
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass
+class IntegrityConfig:
+ enabled: bool = True
+ # 기준 프레임을 잡기까지의 관찰 시간. 이 사이 화면에 사람이 있어도 평균이 희석된다.
+ warmup_s: float = 20.0
+ # 요약본 해상도. 32x24 면 사람 하나가 화면의 몇 퍼센트인지 구분하기엔 충분하고,
+ # 조명 깜빡임 같은 화소 단위 잡음은 자연히 뭉개진다.
+ grid_w: int = 32
+ grid_h: int = 24
+ # 0..255 스케일의 평균 절대 차이. 30 은 "화면 절반 이상이 다른 밝기" 수준으로,
+ # 사람 한 명이 지나가는 것(보통 5~15)과 명확히 갈린다.
+ moved_mad: float = 30.0
+ # 화각 변화로 확정하기까지 필요한 지속 시간. 지나가는 사람은 이 시간을 못 채운다.
+ sustain_s: float = 8.0
+ # 렌즈 가림: 프레임 분산이 이 밑이면 사실상 단색 화면이다.
+ obstructed_var: float = 25.0
+ obstructed_sustain_s: float = 5.0
+ # 조도: 평균 밝기가 이 밑이면 포즈 추정 신뢰도가 급격히 떨어진다.
+ low_light_mean: float = 40.0
+ low_light_sustain_s: float = 60.0
+ # 확정 후 같은 유형을 다시 낼 때까지의 최소 간격 (엔진 cooldown 과 별개의 2차 방어).
+ reemit_s: float = 900.0
+
+
+class CameraIntegrityWatch:
+ """프레임을 받아 (event_type, metrics) 목록을 돌려준다. 이벤트 생성은 엔진이 한다."""
+
+ def __init__(self, cfg: IntegrityConfig | None = None):
+ self.cfg = cfg or IntegrityConfig()
+ self._baseline: np.ndarray | None = None
+ self._first_seen: float | None = None
+ # 각 상태가 "연속으로 참이기 시작한 시각". None 이면 지금은 정상.
+ self._since: dict[str, float | None] = {"moved": None, "obstructed": None, "low_light": None}
+ self._last_emit: dict[str, float] = {}
+
+ # ---------------------------------------------------------------- helpers
+ def _summarize(self, frame) -> np.ndarray | None:
+ """프레임 -> (grid_h, grid_w) float32 그레이스케일 요약본."""
+ if frame is None:
+ return None
+ a = np.asarray(frame)
+ if a.ndim == 3:
+ # BGR/RGB 어느 쪽이든 평균 그레이면 충분하다 (우리가 재는 것은 구조 변화이지 색이 아니다).
+ a = a.mean(axis=2)
+ elif a.ndim != 2:
+ return None
+ if a.size == 0:
+ return None
+ h, w = a.shape[:2]
+ gh, gw = self.cfg.grid_h, self.cfg.grid_w
+ if h < gh or w < gw:
+ return None
+ # 정수 인덱싱으로 다운샘플: 보간 라이브러리 없이 균등 격자를 뽑는다.
+ ys = (np.linspace(0, h - 1, gh)).astype(np.int32)
+ xs = (np.linspace(0, w - 1, gw)).astype(np.int32)
+ return a[np.ix_(ys, xs)].astype(np.float32)
+
+ def _hold(self, key: str, active: bool, now: float, need_s: float) -> bool:
+ """상태가 need_s 이상 연속 유지되면 True 를 한 번 돌려준다."""
+ if not active:
+ self._since[key] = None
+ return False
+ if self._since[key] is None:
+ self._since[key] = now
+ return False
+ if now - self._since[key] < need_s:
+ return False
+ last = self._last_emit.get(key)
+ if last is not None and now - last < self.cfg.reemit_s:
+ return False
+ self._last_emit[key] = now
+ return True
+
+ # ---------------------------------------------------------------- main
+ def observe(self, frame, now: float) -> list[tuple[str, dict]]:
+ """@return [(event_type_value, metrics), ...] — 보통 빈 리스트."""
+ if not self.cfg.enabled:
+ return []
+ summary = self._summarize(frame)
+ if summary is None:
+ return []
+
+ if self._first_seen is None:
+ self._first_seen = now
+ mean = float(summary.mean())
+ var = float(summary.var())
+
+ out: list[tuple[str, dict]] = []
+
+ # 조도와 가림은 기준 프레임이 없어도 판정할 수 있다 — 프레임 자체의 성질이다.
+ if self._hold("obstructed", var < self.cfg.obstructed_var, now, self.cfg.obstructed_sustain_s):
+ out.append(("camera_obstructed", {"variance": round(var, 2), "mean_luma": round(mean, 1)}))
+ if self._hold("low_light", mean < self.cfg.low_light_mean, now, self.cfg.low_light_sustain_s):
+ out.append(("low_light", {"mean_luma": round(mean, 1)}))
+
+ # 화각 이동은 기준이 있어야 한다.
+ if self._baseline is None:
+ if now - self._first_seen >= self.cfg.warmup_s:
+ self._baseline = summary
+ return out
+
+ mad = float(np.abs(summary - self._baseline).mean())
+ if self._hold("moved", mad > self.cfg.moved_mad, now, self.cfg.sustain_s):
+ out.append(("camera_moved", {"mad": round(mad, 1), "threshold": self.cfg.moved_mad}))
+ # 새 화각을 기준으로 승격한다. 하지 않으면 옮겨진 카메라가 영원히 같은 경보를 낸다.
+ # (존은 여전히 무효 상태다 — 그래서 이 이벤트가 HIGH 이고, 사람이 존을 다시 그려야 한다.)
+ self._baseline = summary
+ self._since["moved"] = None
+
+ return out
+
+ def reset_baseline(self) -> None:
+ """존을 다시 그린 뒤 등, 현재 화각을 정상으로 재선언할 때."""
+ self._baseline = None
+ self._first_seen = None
+ self._since = {k: None for k in self._since}
diff --git a/edge/detectors/pending.py b/edge/detectors/pending.py
new file mode 100644
index 0000000..7de43a2
--- /dev/null
+++ b/edge/detectors/pending.py
@@ -0,0 +1,103 @@
+"""모델 대기 감지기 — 보호구 미착용 · 중장비 근접 · 화재/연기.
+
+STATUS: 배선 완료, 모델 없음. 기본 비활성이며 이 상태에서는 어떤 이벤트도 발화하지 않는다.
+
+왜 모델 없이 이 파일이 있는가
+ 이 세 가지는 현장에서 가장 자주 요구되는 항목이고(특히 보호구), 나중에 급하게 끼워
+ 넣으면 이벤트 계약·심각도·쿨다운·스냅샷 정책을 그때그때 즉흥적으로 정하게 된다. 계약을
+ 지금 고정해 두면 모델이 확보되는 날 바꿀 것은 `Model` 구현 하나뿐이다.
+
+무엇이 막고 있는가 (정직하게)
+ ultralytics(YOLO) 계열은 AGPL 이라 이 프로젝트에서 사용 금지다(CLAUDE.md). 따라서
+ - 보호구/중장비: AGPL 이 아닌 라이선스의 가중치 + Hailo HEF 컴파일이 선행되어야 한다.
+ - 화재/연기: 위와 같거나, 열화상 센서 같은 다른 감지 방식을 택해야 한다.
+ 확보 전까지 `enabled=False` 이며, 켜더라도 `model=None` 이면 아무 일도 하지 않는다.
+ "곧 됩니다" 상태로 켜 두는 것이 가장 위험하다 — 현장은 감지되고 있다고 믿는다.
+
+모델 인터페이스 (구현체가 만족해야 하는 계약)
+ detect(frame, persons) -> list[dict]
+ 각 dict: {"kind": "ppe_missing"|"proximity"|"fire_smoke",
+ "confidence": float 0..1,
+ "track_id": int | None,
+ "spatial": (x, y) | None, # 0..1 정규화
+ "metrics": dict} # 유형별 부가 수치
+ 프레임당 한 번 호출되며, 예외를 던지면 그 틱만 건너뛴다(파이프라인은 계속 돈다).
+"""
+
+from dataclasses import dataclass, field
+
+
+_KINDS = ("ppe_missing", "proximity", "fire_smoke")
+
+
+@dataclass
+class PendingConfig:
+ # 기본 False. 모델이 없는 채로 켜면 "감지되고 있다"는 잘못된 믿음만 만든다.
+ enabled: bool = False
+ # 유형별 최소 신뢰도. 보호구는 오탐이 곧 현장 반발이라 높게 잡는다.
+ min_confidence: dict = field(default_factory=lambda: {
+ "ppe_missing": 0.80, "proximity": 0.70, "fire_smoke": 0.85,
+ })
+ # 유형별 재발행 억제(초). 엔진 쿨다운과 별개의 2차 방어 — 보호구 미착용은 한 사람이
+ # 한 시간을 그러고 있어도 사건 하나다.
+ reemit_s: dict = field(default_factory=lambda: {
+ "ppe_missing": 600.0, "proximity": 30.0, "fire_smoke": 60.0,
+ })
+
+
+class PendingDetectors:
+ """모델이 주어지면 그 출력을 이벤트 후보로 정규화한다. 없으면 아무것도 하지 않는다."""
+
+ def __init__(self, cfg: PendingConfig | None = None, model=None, log=None):
+ self.cfg = cfg or PendingConfig()
+ self.model = model
+ self._log = log or (lambda m: None)
+ self._last: dict[tuple, float] = {}
+
+ @property
+ def active(self) -> bool:
+ """켜져 있고 실제로 판정할 수단이 있는가. 대시보드/기동 로그가 이것을 보고한다."""
+ return bool(self.cfg.enabled and self.model is not None)
+
+ def _allow(self, kind: str, track_id, now: float) -> bool:
+ key = (kind, track_id)
+ last = self._last.get(key)
+ if last is not None and now - last < float(self.cfg.reemit_s.get(kind, 60.0)):
+ return False
+ self._last[key] = now
+ return True
+
+ def forget(self, track_id: int) -> None:
+ self._last = {k: v for k, v in self._last.items() if k[1] != track_id}
+
+ def observe(self, frame, persons, now: float) -> list[tuple[str, dict, object, object]]:
+ """@return [(event_type, metrics, track_id, spatial), ...] — 보통 빈 리스트.
+
+ 모델이 없거나 비활성이면 항상 빈 리스트다. 그것이 정상 상태다.
+ """
+ if not self.active:
+ return []
+ try:
+ raw = self.model.detect(frame, persons) or []
+ except Exception as e:
+ # 한 틱의 추론 실패로 넘어짐 감지까지 멈추면 안 된다.
+ self._log(f"pending detector model failed: {type(e).__name__}: {e}")
+ return []
+
+ out = []
+ for d in raw:
+ if not isinstance(d, dict):
+ continue
+ kind = d.get("kind")
+ if kind not in _KINDS:
+ continue
+ conf = float(d.get("confidence") or 0.0)
+ if conf < float(self.cfg.min_confidence.get(kind, 1.0)):
+ continue
+ track_id = d.get("track_id")
+ if not self._allow(kind, track_id, now):
+ continue
+ metrics = dict(d.get("metrics") or {})
+ metrics["confidence"] = round(conf, 3)
+ out.append((kind, metrics, track_id, d.get("spatial")))
+ return out
diff --git a/edge/events.py b/edge/events.py
index d40c735..37b24e3 100644
--- a/edge/events.py
+++ b/edge/events.py
@@ -18,6 +18,19 @@ class EventType(str, Enum):
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
ERGO_REPORT = "ergo_report" # 자세 부담 노출 주기 리포트 (카메라 단위, 익명)
+ # --- 감시 자체의 건전성 ---------------------------------------------------------
+ # 존 판정은 카메라 화각이 고정이라는 전제 위에 서 있다. 청소하다 삼각대를 건드리면
+ # 제한구역이 엉뚱한 곳을 가리키는데, 시스템은 계속 "정상"이라고 보고한다. 이 조용한
+ # 실패를 드러내는 것이 아래 세 유형이다.
+ CAMERA_MOVED = "camera_moved" # 배경 기준 프레임 대비 화각 이동
+ CAMERA_OBSTRUCTED = "camera_obstructed" # 렌즈 가림 (분산 급락)
+ LOW_LIGHT = "low_light" # 조도 저하로 검출력 하락
+ # --- 모델 확보 후 활성화되는 자리 -----------------------------------------------
+ # 감지기 배선과 이벤트 계약은 준비되어 있고, 추론 모델만 비어 있다. AGPL 아닌
+ # 가중치를 확보하기 전까지 엣지는 이 유형을 발화하지 않는다 (edge/detectors/pending.py).
+ PPE_MISSING = "ppe_missing" # 보호구(안전모·안전대) 미착용
+ PROXIMITY = "proximity" # 중장비-사람 근접
+ FIRE_SMOKE = "fire_smoke" # 화재·연기
class Severity(str, Enum):
@@ -44,6 +57,15 @@ _SEVERITY = {
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO.
EventType.NEAR_FALL: Severity.INFO,
EventType.ERGO_REPORT: Severity.INFO,
+ # 화각이 틀어지면 그 카메라의 존 판정 전체가 무효다. 사람이 다친 사건은 아니지만
+ # "지금부터 못 보고 있다"는 뜻이라 HIGH — 정비 대기열이 아니라 당일 처리 대상이다.
+ EventType.CAMERA_MOVED: Severity.HIGH,
+ EventType.CAMERA_OBSTRUCTED: Severity.HIGH,
+ # 조도 저하는 검출력이 떨어졌다는 신호이지 실패는 아니다 — monitoring_degraded 와 같은 급.
+ EventType.LOW_LIGHT: Severity.WARNING,
+ EventType.PPE_MISSING: Severity.WARNING,
+ EventType.PROXIMITY: Severity.HIGH,
+ EventType.FIRE_SMOKE: Severity.CRITICAL,
}
diff --git a/edge/runtime.py b/edge/runtime.py
index b0e9dab..de4962c 100644
--- a/edge/runtime.py
+++ b/edge/runtime.py
@@ -5,8 +5,10 @@ from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector
+from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig
from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
+from edge.tuning import apply_thresholds
class PipelineRuntime:
@@ -37,6 +39,10 @@ class PipelineRuntime:
)
self.ring = RingBuffer()
self.snapshotter = Snapshotter(FaceBlurrer())
+ # Watches the frame itself rather than the people in it: a knocked-askew or covered
+ # camera keeps delivering healthy frames while every zone drawn on it is now wrong.
+ self.integrity = CameraIntegrityWatch(
+ getattr(config, "integrity", None) or IntegrityConfig())
def step(self, now: float) -> list:
self._tick += 1
@@ -51,7 +57,17 @@ class PipelineRuntime:
return [] if ok else None
return run_once(self.source, self.pose, self.tracker, self.pool,
self.engine, self.ring, self.snapshotter, self.sink,
- now_fn=lambda: now, wall_fn=self._wall)
+ now_fn=lambda: now, wall_fn=self._wall,
+ integrity=self.integrity)
def apply_zones(self, zones: list) -> None:
self.zone_detector.zones = list(zones)
+
+ def apply_thresholds(self, thresholds: dict, log=None) -> dict:
+ """Push server-published tunables into the live detectors.
+
+ Until this existed the server stored thresholds, /api/config delivered them and the edge
+ parsed them into a dict it then threw away — the two ends of the wire were built and the
+ middle was missing, so nothing an administrator changed ever reached a device.
+ """
+ return apply_thresholds(self, thresholds, log=log)
diff --git a/edge/tuning.py b/edge/tuning.py
new file mode 100644
index 0000000..09f1d18
--- /dev/null
+++ b/edge/tuning.py
@@ -0,0 +1,101 @@
+"""서버에서 내려온 임계값을 살아 있는 감지기에 적용한다.
+
+WHAT WAS BROKEN
+ `edge/zones.py:thresholds_from_config()` 는 정의만 되어 있고 어디에서도 호출되지 않았다.
+ 서버에는 `move_thresholds` 표가 있고, `GET /api/config` 가 그것을 성실히 실어 보내고,
+ 엣지는 그 값을 파싱한 뒤 그대로 버렸다. 즉 배선의 양 끝이 다 있는데 가운데가 비어 있어서,
+ 관리자가 무엇을 바꾸든 장비는 부팅 시 edge.env 값으로 영원히 돌았다. 이 파일이 그 가운데다.
+
+WHY VALIDATE HERE TOO
+ 서버의 ThresholdCatalog 가 이미 범위를 강제하지만, 그것은 *편집 화면* 의 방어선이다.
+ DB 를 직접 고친 값, 구버전 서버, 손상된 응답은 여전히 도달할 수 있고, `now - t >= limit`
+ 비교에 NaN 이 들어가면 감지기가 조용히 꺼지고 0 이 들어가면 영구 알람이 된다
+ (edge/config.py `_positive` 의 주석과 같은 이유). 기동 시점의 검증은 여기서 반복된다.
+
+WHY NOT CRASH
+ 기동 검증은 잘못된 값에 예외를 던진다 — 그때는 운영자가 화면 앞에 있다. 여기는 24/7 로
+ 도는 동기화 경로다. 서버가 보낸 한 값이 이상하다고 공장 안 장비를 재시작 루프에 빠뜨리면
+ 그 카메라는 그 순간부터 아무것도 못 본다. 나쁜 값은 무시하고 로그로 남기고, 나머지는
+ 적용한다.
+"""
+
+import math
+
+# 서버 키 -> (대상 객체 경로, 속성명). 하나의 키가 두 곳에 적용되는 경우도 있다
+# (motion_thr 은 fall 과 immobility 가 공유한다 — edge/config.py 가 같은 값을 둘에 넣는다).
+_TARGETS: dict[str, tuple[tuple[str, str], ...]] = {
+ "fall_prone_s": (("fall", "prone_confirm_s"),),
+ "fall_no_motion_s": (("fall", "no_motion_s"),),
+ "immobile_s": (("immobility", "immobile_s"),),
+ "ghost_reappear_s": (("ghost", "reappear_s"),),
+ "zone_debounce_s": (("zone", "debounce_s"),),
+ "cooldown_s": (("engine", "cooldown_s"),),
+ "motion_thr": (("fall", "motion_thr"), ("immobility", "motion_thr")),
+}
+
+# 정수 노브는 별도 취급: 감지기 설정이 아니라 런타임 자체의 샘플링 주기다.
+_INT_KNOBS = {"infer_every"}
+
+
+def _clean(raw) -> float | None:
+ """유한 양수만 통과. 그 외에는 None (호출부가 로그하고 무시)."""
+ try:
+ v = float(raw)
+ except (TypeError, ValueError):
+ return None
+ if not math.isfinite(v) or v <= 0:
+ return None
+ return v
+
+
+def apply_thresholds(runtime, thresholds: dict, log=None) -> dict:
+ """`runtime` 의 감지기 설정에 서버 임계값을 적용한다.
+
+ @return 실제로 적용된 {key: value} — 호출부가 로그/테스트에 쓴다.
+ """
+ log = log or (lambda m: None)
+ if not isinstance(thresholds, dict) or not thresholds:
+ return {}
+
+ engine = getattr(runtime, "engine", None)
+ if engine is None:
+ return {}
+
+ # 감지기별 설정 객체. 이름이 다른 구현이 오더라도 없는 것은 조용히 건너뛴다.
+ holders = {
+ "fall": getattr(getattr(engine, "fall", None), "cfg", None),
+ "immobility": getattr(getattr(engine, "immobility", None), "cfg", None),
+ "ghost": getattr(getattr(engine, "ghost", None), "cfg", None),
+ "zone": getattr(getattr(engine, "zone", None), "cfg", None),
+ "engine": getattr(engine, "cfg", None),
+ }
+
+ applied: dict[str, float] = {}
+ for key, raw in thresholds.items():
+ if key in _INT_KNOBS:
+ v = _clean(raw)
+ if v is None or v != int(v):
+ log(f"threshold {key}={raw!r} ignored (must be a positive integer)")
+ continue
+ # infer_every 는 런타임의 샘플링 주기. 상한을 두는 이유: 서버가 실수로 1000 을
+ # 보내면 그 카메라는 사실상 감시를 멈춘다.
+ runtime.infer_every = max(1, min(10, int(v)))
+ applied[key] = runtime.infer_every
+ continue
+
+ targets = _TARGETS.get(key)
+ if targets is None:
+ # 알 수 없는 키는 무시한다. 서버가 신버전이고 엣지가 구버전인 정상 상황이다.
+ continue
+ v = _clean(raw)
+ if v is None:
+ log(f"threshold {key}={raw!r} ignored (must be a finite positive number)")
+ continue
+ for holder_name, attr in targets:
+ holder = holders.get(holder_name)
+ if holder is None or not hasattr(holder, attr):
+ continue
+ setattr(holder, attr, v)
+ applied[key] = v
+
+ return applied
diff --git a/server/config.example.php b/server/config.example.php
index f049413..58840c5 100644
--- a/server/config.example.php
+++ b/server/config.example.php
@@ -14,4 +14,17 @@ return [
// PIPA retention: worker snapshots are purged after this many days (0 disables).
'snapshot_retention_days' => 7,
'event_retention_days' => 0, // 0 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료)
+
+ // --- 통보 (tools/dispatch_alerts.php) --------------------------------------------
+ // 대시보드의 외부 접속 주소. 알림 메시지의 사건 링크에 쓰인다. 비어 있으면 디스패처가
+ // 실행을 거부한다 — 열리지 않는 링크가 담긴 알림을 보내는 것보다 낫다.
+ 'public_base_url' => '', // 예: 'https://example.com/move_detect'
+ // 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다.
+ 'escalate_after_seconds' => 300,
+
+ // --- 보존 / 용량 ------------------------------------------------------------------
+ // 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리).
+ 'metric_retention_days' => 30,
+ // 스냅샷 볼륨 여유 공간 하한(MB). 밑돌면 device_fault 로 알린다. 0 = 검사 안 함.
+ 'storage_min_free_mb' => 200,
];
diff --git a/server/deploy/build_cafe24_package.sh b/server/deploy/build_cafe24_package.sh
index efd9494..3949708 100755
--- a/server/deploy/build_cafe24_package.sh
+++ b/server/deploy/build_cafe24_package.sh
@@ -148,6 +148,12 @@ cp "$SERVER/schema.sql" "$PRIV/schema.sql"
# running these (phpMyAdmin, date order) instead of re-importing — schema.sql is for a
# FRESH database only. Like schema.sql, delete from the host after applying.
cp -r "$SERVER/migrations" "$PRIV/migrations"
+# tools/ ships INSIDE the private folder, never under www/. These are CLI entry points the host
+# runs from cron (notification dispatch, retention) or by hand (backup, audit verification), and
+# bootstrap.php refuses to run under any SAPI but CLI — but the real guarantee is that they are
+# not web-reachable at all, because dispatch_alerts.php sends mail and backup.php reads every
+# incident record in the database.
+cp -r "$SERVER/tools" "$PRIV/tools"
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
diff --git a/server/deploy/config.template.php b/server/deploy/config.template.php
index 73bd439..47890d2 100644
--- a/server/deploy/config.template.php
+++ b/server/deploy/config.template.php
@@ -32,4 +32,19 @@ return [
// 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다.
// DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다.
'event_retention_days' => 0,
+
+ // ---- 4. 통보 (알림을 쓰려면 채우세요) -------------------------------
+ // 대시보드 주소입니다. 알림 메일/웹훅에 들어가는 사건 링크가 이 주소로 만들어집니다.
+ // 비워두면 통보 디스패처(tools/dispatch_alerts.php)가 실행을 거부합니다 —
+ // 열리지 않는 링크가 담긴 알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다.
+ 'public_base_url' => '', // 예: 'https://example.com/move_detect'
+ // 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
+ // 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
+ 'escalate_after_seconds' => 300,
+
+ // ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
+ // 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.
+ 'metric_retention_days' => 30,
+ // 스냅샷 저장 공간 여유 하한(MB). 밑돌면 대시보드에 경고가 뜹니다. 0 = 검사 안 함.
+ 'storage_min_free_mb' => 200,
];
diff --git a/server/docs/server-deploy.md b/server/docs/server-deploy.md
index 02773d7..70b3be5 100644
--- a/server/docs/server-deploy.md
+++ b/server/docs/server-deploy.md
@@ -280,3 +280,45 @@ client as a bare 500 with no body/trace, which is the intended behavior — turn
- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per
§4) are **not** reachable over HTTP — e.g. `curl -I https:///../config.php`
and `curl -I https:///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//move_detect_app/tools/dispatch_alerts.php >> /home//logs/alerts.log 2>&1
+
+# 보존기간 정리 — 스냅샷(PIPA) · 성능 시계열 · nonce.
+# 이벤트 기록은 event_retention_days 를 명시적으로 설정한 현장에서만 지워진다.
+17 3 * * * /usr/bin/php /home//move_detect_app/tools/maintain.php >> /home//logs/maintain.log 2>&1
+```
+
+**처음 켤 때는 반드시 dry-run 부터.** 잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는
+두 번 다시 이 기능을 켜지 않습니다.
+
+```bash
+php move_detect_app/tools/dispatch_alerts.php --dry-run
+php move_detect_app/tools/maintain.php --dry-run
+```
+
+`public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지 않는 링크가 담긴
+알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다 (`config.php` §4).
+
+### 그 외 도구 (수동 실행)
+
+| 도구 | 용도 |
+|------|------|
+| `tools/backup.php --out=<디렉터리>` | DB 덤프 + 스냅샷 아카이브 + 복구 안내. 출력 경로가 `www/` 안이면 거부합니다. |
+| `tools/verify_audit_chain.php` | 감사로그 해시체인 검증. 손상 시 종료 코드 1 과 함께 끊긴 지점을 출력합니다. |
+| `tools/seed_demo.php --days=45` | 시연·QA용 데모 데이터. **운영 DB 에서는 절대 실행하지 마십시오** (`--purge` 로 데모 행만 제거). |
+
+## 12. Post-deploy: 설치 점검 화면
+
+로그인 후 `/setup` 을 엽니다. 각 단계는 실제 상태를 조회해 판정하므로, 체크가 켜졌다면 그
+단계는 정말로 동작하는 것입니다. 특히 **⑤ 통보 경로** 가 비어 있으면 이 시스템은 감지 기록
+장치일 뿐입니다 — 현장 경광등(엣지 GPIO)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
+경로입니다.
diff --git a/server/migrations/2026-08-09-safety-platform.sql b/server/migrations/2026-08-09-safety-platform.sql
new file mode 100644
index 0000000..6876839
--- /dev/null
+++ b/server/migrations/2026-08-09-safety-platform.sql
@@ -0,0 +1,191 @@
+-- 안전 플랫폼 확장 — 통보·억제·증거무결성·운영지표.
+--
+-- 이 마이그레이션 하나로 다음 기능의 저장소를 연다. 기능별로 파일을 쪼개지 않은 이유는
+-- 서로를 참조하기 때문이다(억제 규칙은 통보를 막고, 통보 실패는 감사로그에 남고, 감사로그는
+-- 해시체인으로 봉인된다). 부분 적용된 중간 상태가 더 위험하다.
+--
+-- 1. move_audit_log 해시체인 — 증거 무결성 (schema.sql 이 "P3 로 연기"라 적어둔 항목)
+-- 2. move_suppressions — 정비/작업허가 중 알람 억제 (사유·기간·감사 필수)
+-- 3. move_alert_channels/_log — 통보 경로와 전달 이력 (지금까지 통보 수단이 전무했다)
+-- 4. move_shift_handovers — 교대 인수인계 기록
+-- 5. move_risk_controls — 감지 유형 ↔ 위험요인·대책 (위험성평가 연계)
+-- 6. move_device_metrics — 엣지 성능 시계열 (지금은 순간값만 있어 열화를 못 본다)
+-- 7. move_thresholds 감사 컬럼 — 편집 UI 가 생기므로 누가 언제 바꿨는지 남긴다
+-- 8. move_actions.event_id — 어떤 사건이 그 조치의 계기였는지
+-- 9. move_events CHECK 확장 — 신규 감지 유형
+--
+-- 적용 조건: 2026-08-08-actions-site.sql 이후.
+
+-- ---------------------------------------------------------------------------
+-- 1. 감사로그 해시체인
+-- 각 행은 직전 행의 entry_hash 를 prev_hash 로 물고, 자신의 내용+prev_hash 를 sha256 한
+-- 값을 entry_hash 로 갖는다. 중간 행을 지우거나 고치면 그 이후 전 구간의 사슬이 끊긴다.
+-- (삭제·수정을 "막는" 것이 아니라 "숨길 수 없게" 만드는 것이 목적이다. append-only 권한을
+-- DB 레벨에서 강제할 수 없는 공유호스팅에서는 이것이 실질적인 방어선이다.)
+-- 기존 행은 NULL 로 남는다 — 체인 이전 구간임을 검증 도구가 그대로 보고한다.
+ALTER TABLE move_audit_log
+ ADD COLUMN prev_hash CHAR(64) NULL AFTER ts,
+ ADD COLUMN entry_hash CHAR(64) NULL AFTER prev_hash,
+ ADD KEY ix_audit_log_entry_hash (entry_hash);
+
+-- ---------------------------------------------------------------------------
+-- 2. 알람 억제 (정비 모드 / 작업허가)
+-- 오탐의 최대 원인은 "계획된 작업"이다. 정비 중 제한구역 진입이 전부 알람이면 현장은
+-- 시스템을 끄고, 그 다음 진짜 사고를 놓친다. 억제는 감지를 멈추지 않는다 — 이벤트는 그대로
+-- 적재되고 통보만 막힌다. 사후 조사에서 "그때 무슨 일이 있었나"가 사라지면 안 되기 때문이다.
+CREATE TABLE move_suppressions (
+ suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
+ scope_id VARCHAR(64) NOT NULL, -- 해당 스코프의 식별자
+ site VARCHAR(64) NULL, -- 접근제어 스코프
+ reason VARCHAR(500) NOT NULL, -- 필수: 사유 없는 억제는 허용하지 않는다
+ starts_at BIGINT UNSIGNED NOT NULL,
+ ends_at BIGINT UNSIGNED NOT NULL, -- 무기한 억제 없음. 반드시 만료된다.
+ created_by VARCHAR(190) NOT NULL,
+ created_at BIGINT UNSIGNED NOT NULL,
+ revoked_by VARCHAR(190) NULL, -- 조기 해제
+ revoked_at BIGINT UNSIGNED NULL,
+ PRIMARY KEY (suppression_id),
+ KEY ix_suppressions_window (ends_at, starts_at),
+ KEY ix_suppressions_scope (scope_type, scope_id),
+ KEY ix_suppressions_site (site),
+ CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- 3. 통보 경로와 전달 이력
+-- 폐쇄망이라 FCM/Slack 을 쓸 수 없다. 사내에서 실제로 닿는 경로만 둔다:
+-- local — 엣지 노드의 경광등/부저 (GPIO). 인터넷이 필요 없는 유일한 경로이자 1순위.
+-- email — 사내 SMTP 릴레이
+-- webhook— 사내 시스템(그룹웨어·문자게이트웨이) HTTP 엔드포인트
+-- config_json 은 경로별 설정(수신자, URL, GPIO 핀 등). 비밀번호류는 넣지 않는다 —
+-- SMTP 인증정보는 config.php 에 두고 여기서는 참조만 한다.
+CREATE TABLE move_alert_channels (
+ channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ site VARCHAR(64) NULL,
+ kind VARCHAR(16) NOT NULL, -- local | email | webhook
+ name VARCHAR(190) NOT NULL,
+ config_json JSON NOT NULL,
+ min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ -- 에스컬레이션 단계. 0 = 즉시. 1 이상이면 escalate_after_s 만큼 미확인일 때만 발송한다.
+ step INT UNSIGNED NOT NULL DEFAULT 0,
+ escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
+ created_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (channel_id),
+ KEY ix_alert_channels_site (site, enabled),
+ CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
+ CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 전달 이력. (event_id, channel_id) UNIQUE 가 중복 발송을 막는 유일한 방어선이다 —
+-- 디스패처는 크론으로 돌고 겹쳐 실행될 수 있다.
+CREATE TABLE move_alert_log (
+ alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ event_id INT UNSIGNED NOT NULL,
+ channel_id INT UNSIGNED NOT NULL,
+ status VARCHAR(16) NOT NULL, -- pending | sent | failed | suppressed
+ attempts INT UNSIGNED NOT NULL DEFAULT 0,
+ last_error VARCHAR(500) NULL,
+ queued_at BIGINT UNSIGNED NOT NULL,
+ sent_at BIGINT UNSIGNED NULL,
+ PRIMARY KEY (alert_id),
+ UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
+ KEY ix_alert_log_status (status, queued_at),
+ CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
+ CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- 4. 교대 인수인계
+-- 24시간 브리프는 있으나 교대 경계와 책임 이관이 없었다. 인수자가 "무엇을 넘겨받았는지"를
+-- 명시적으로 확인한 기록이 남아야 미처리 이벤트가 교대 사이에서 증발하지 않는다.
+CREATE TABLE move_shift_handovers (
+ handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ site VARCHAR(64) NULL,
+ from_user VARCHAR(190) NULL, -- 최초 교대는 인계자가 없다
+ to_user VARCHAR(190) NOT NULL,
+ period_start BIGINT UNSIGNED NOT NULL, -- 인계 대상 구간
+ period_end BIGINT UNSIGNED NOT NULL,
+ unacked_count INT UNSIGNED NOT NULL, -- 인계 시점 스냅샷 (사후 재계산과 대조 가능)
+ severe_count INT UNSIGNED NOT NULL,
+ note VARCHAR(1000) NULL,
+ created_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (handover_id),
+ KEY ix_handovers_site_created (site, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- 5. 위험성평가 연계
+-- 감지 유형 하나하나가 위험요인 하나에 대응한다. 이 표가 있어야 "이 시스템이 무엇을
+-- 통제하고 있는가"를 서식으로 뽑을 수 있고, 개선조치가 어느 위험요인을 낮췄는지 이어진다.
+CREATE TABLE move_risk_controls (
+ control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ site VARCHAR(64) NULL,
+ event_type VARCHAR(32) NOT NULL, -- move_events.event_type 와 같은 코드
+ hazard VARCHAR(300) NOT NULL, -- 위험요인
+ control VARCHAR(500) NOT NULL, -- 현재 통제 대책
+ severity_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (강도)
+ likelihood_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (빈도)
+ reviewed_by VARCHAR(190) NULL,
+ reviewed_at BIGINT UNSIGNED NULL,
+ updated_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (control_id),
+ UNIQUE KEY uq_risk_controls_site_type (site, event_type),
+ CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
+ CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- 6. 엣지 성능 시계열
+-- move_devices 는 마지막 값만 덮어쓴다. "어제부터 추론 지연이 두 배가 됐다"를 볼 수 없어
+-- 서서히 죽는 노드를 놓친다. 하트비트마다 한 행씩 append 하고 보존기간으로 잘라낸다.
+CREATE TABLE move_device_metrics (
+ metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ device_id VARCHAR(64) NOT NULL,
+ ts BIGINT UNSIGNED NOT NULL,
+ fps DOUBLE NULL,
+ temp DOUBLE NULL,
+ throttle TINYINT(1) NULL,
+ queue_pending INT UNSIGNED NULL,
+ cameras_total INT UNSIGNED NULL,
+ cameras_erroring INT UNSIGNED NULL,
+ errors INT UNSIGNED NULL,
+ PRIMARY KEY (metric_id),
+ KEY ix_device_metrics_device_ts (device_id, ts)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- 7. 임계값 감사 컬럼 (편집 UI 도입)
+-- 지금까지 이 표는 phpMyAdmin 으로 직접 고치는 것이 유일한 방법이었다(스키마 주석 참조).
+-- 화면이 생기면 누가 언제 바꿨는지가 사고 조사에서 반드시 필요해진다.
+ALTER TABLE move_thresholds
+ ADD COLUMN updated_by VARCHAR(190) NULL,
+ ADD COLUMN updated_at BIGINT UNSIGNED NULL;
+
+-- ---------------------------------------------------------------------------
+-- 8. 조치 ↔ 계기 사건
+ALTER TABLE move_actions
+ ADD COLUMN event_id INT UNSIGNED NULL AFTER site,
+ ADD KEY ix_actions_event (event_id);
+
+-- ---------------------------------------------------------------------------
+-- 9. 신규 감지 유형
+-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
+-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
+-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
+ALTER TABLE move_events
+ DROP CHECK chk_events_type;
+ALTER TABLE move_events
+ ADD CONSTRAINT chk_events_type CHECK (event_type IN (
+ 'fall','no_motion','immobility','ghost','zone_warning',
+ 'zone_restricted','pinch','monitoring_degraded','device_fault',
+ 'lone_worker','overcapacity','overstay','near_fall','ergo_report',
+ 'camera_moved','camera_obstructed','low_light','unacked_escalation',
+ 'ppe_missing','proximity','fire_smoke'
+ ));
+
+-- 검증:
+-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
+-- php server/tools/verify_audit_chain.php -- 사슬 무결성
+-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';
diff --git a/server/public/assets/admin.js b/server/public/assets/admin.js
new file mode 100644
index 0000000..4756ecd
--- /dev/null
+++ b/server/public/assets/admin.js
@@ -0,0 +1,322 @@
+/**
+ * 시스템 설정: 통보 채널 + 감지 임계값.
+ *
+ * 이 두 화면이 없던 동안 납품된 현장은 (1) 감지되어도 아무에게도 알릴 수 없었고
+ * (2) 오탐이 나도 임계값을 고칠 방법이 phpMyAdmin 뿐이었다. 콘솔의 오탐율 패널이
+ * "이 카메라를 재튜닝하라"고 알려주는데 재튜닝할 화면이 없는 상태였다.
+ *
+ * 임계값 입력은 자유 텍스트가 아니라 서버가 내려준 카탈로그(범위·단위·설명)로 렌더한다.
+ * 범위 밖 값은 엣지가 기동 시 거부하는데, 그때는 이미 공장 안 장비가 재시작 루프에 빠진
+ * 뒤다 — 키보드 앞에서 막는 편이 낫다.
+ *
+ * XSS-safe: 모든 동적 값은 textContent. innerHTML 금지 (프로젝트 규약).
+ */
+(function () {
+ 'use strict';
+
+ var B = window.APP_BASE || '';
+ var root = document.querySelector('.admin-page');
+ if (!root) return;
+
+ var csrf = null;
+ var catalog = {};
+ var devices = [];
+
+ function el(tag, cls, text) {
+ var n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined && text !== null) n.textContent = text;
+ return n;
+ }
+
+ function loadCsrf() {
+ return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { if (d && d.token) csrf = d.token; })
+ .catch(function () { /* 저장 시 403 으로 드러난다 */ });
+ }
+
+ function post(path, body) {
+ return fetch(B + path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' },
+ body: JSON.stringify(body || {})
+ }).then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return null; }
+ return r.json().then(function (d) { return { ok: r.ok, data: d }; });
+ });
+ }
+
+ // 서버 오류 코드 -> 사람이 고칠 수 있는 문장. 코드를 그대로 보여주면 운영자는
+ // 무엇을 고쳐야 할지 알 수 없다.
+ var ERRORS = {
+ out_of_range: '허용 범위를 벗어났습니다.',
+ not_numeric: '숫자를 입력하세요.',
+ not_finite: '유효한 숫자가 아닙니다.',
+ not_integer: '정수만 입력할 수 있습니다.',
+ unknown_key: '알 수 없는 항목입니다.',
+ unknown_device: '등록되지 않은 장치입니다.',
+ invalid_kind: '채널 종류가 올바르지 않습니다.',
+ invalid_name: '이름을 입력하세요.',
+ invalid_config: '연결 설정을 확인하세요 (수신자 주소 / URL).',
+ invalid_escalate_after: '에스컬레이션 지연은 30초 이상이어야 합니다.',
+ invalid_step: '단계는 0~5 사이여야 합니다.',
+ csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.',
+ forbidden: '권한이 없습니다.',
+ not_found: '대상을 찾을 수 없습니다.'
+ };
+ function errText(code) { return ERRORS[code] || ('저장에 실패했습니다 (' + String(code) + ')'); }
+
+ // ============================================================ 통보 채널
+ var chList = document.getElementById('channel-list');
+ var chForm = document.getElementById('channel-form');
+ var chStatus = document.getElementById('ch-status');
+ var KIND_LABELS = { local: '현장 경광등', email: '메일', webhook: '웹훅' };
+ var SEV_LABELS = { critical: '위급만', high: '높음 이상', warning: '경고 이상', info: '전부' };
+
+ function field(id) { return document.getElementById(id); }
+
+ function showKind(kind) {
+ ['local', 'email', 'webhook'].forEach(function (k) {
+ var box = root.querySelector('.kind-' + k);
+ if (box) box.hidden = (k !== kind);
+ });
+ }
+
+ function renderChannels(list) {
+ chList.textContent = '';
+ if (!list.length) {
+ var empty = el('p', 'admin-empty',
+ '설정된 통보 경로가 없습니다. 지금은 감지가 되어도 화면을 보고 있는 사람에게만 전달됩니다.');
+ chList.appendChild(empty);
+ return;
+ }
+ list.forEach(function (c) {
+ var row = el('div', 'channel-row' + (c.enabled ? '' : ' is-off'));
+ var main = el('div', 'channel-main');
+ main.appendChild(el('span', 'channel-kind', KIND_LABELS[c.kind] || c.kind));
+ main.appendChild(el('span', 'channel-name', c.name));
+ var meta = el('span', 'channel-meta');
+ var bits = [SEV_LABELS[c.min_severity] || c.min_severity];
+ if (c.step > 0) bits.push('에스컬레이션 ' + Math.round(c.escalate_after_s / 60) + '분 후');
+ else bits.push('즉시');
+ if (c.kind === 'local' && c.config && c.config.gpio_pin) bits.push('GPIO ' + c.config.gpio_pin);
+ if (c.kind === 'email' && c.config && c.config.to) bits.push([].concat(c.config.to).join(', '));
+ if (c.kind === 'webhook' && c.config && c.config.url) bits.push(c.config.url);
+ if (!c.enabled) bits.push('사용 안 함');
+ meta.textContent = bits.join(' · ');
+ main.appendChild(meta);
+ row.appendChild(main);
+
+ var edit = el('button', null, '수정');
+ edit.type = 'button';
+ edit.addEventListener('click', function () { openForm(c); });
+ row.appendChild(edit);
+
+ var del = el('button', 'zone-del', '삭제');
+ del.type = 'button';
+ var armed = false, timer = null;
+ del.addEventListener('click', function () {
+ if (!armed) {
+ armed = true; del.classList.add('is-armed'); del.textContent = '정말 삭제?';
+ timer = setTimeout(function () { armed = false; del.classList.remove('is-armed'); del.textContent = '삭제'; }, 4000);
+ return;
+ }
+ clearTimeout(timer);
+ post('/api/admin/channels/' + c.channel_id + '/delete').then(function (r) {
+ if (r && r.ok) loadChannels();
+ });
+ });
+ row.appendChild(del);
+ chList.appendChild(row);
+ });
+ }
+
+ function openForm(c) {
+ chForm.hidden = false;
+ chStatus.textContent = '';
+ c = c || {};
+ var cfg = c.config || {};
+ field('ch-id').value = c.channel_id || '';
+ field('ch-kind').value = c.kind || 'local';
+ field('ch-name').value = c.name || '';
+ field('ch-severity').value = c.min_severity || 'high';
+ field('ch-step').value = c.step || 0;
+ field('ch-after').value = c.escalate_after_s || 300;
+ field('ch-enabled').checked = c.channel_id ? !!c.enabled : true;
+ field('ch-pin').value = cfg.gpio_pin || 0;
+ field('ch-mode').value = cfg.mode || 'latch';
+ field('ch-hold').value = cfg.hold_s || 30;
+ field('ch-active-high').checked = !!cfg.active_high;
+ field('ch-to').value = cfg.to ? [].concat(cfg.to).join(', ') : '';
+ field('ch-from').value = cfg.from || '';
+ field('ch-url').value = cfg.url || '';
+ // 토큰은 서버가 마스킹해서 준다. 그대로 두면 "변경 안 함"이 된다.
+ field('ch-token').value = cfg.token || '';
+ showKind(field('ch-kind').value);
+ field('ch-after-wrap').hidden = Number(field('ch-step').value) === 0;
+ field('ch-name').focus();
+ }
+
+ function collectConfig(kind) {
+ if (kind === 'email') {
+ return { to: field('ch-to').value, from: field('ch-from').value.trim() };
+ }
+ if (kind === 'webhook') {
+ var out = { url: field('ch-url').value.trim() };
+ var t = field('ch-token').value.trim();
+ if (t) out.token = t;
+ return out;
+ }
+ return {
+ gpio_pin: Number(field('ch-pin').value) || 0,
+ mode: field('ch-mode').value,
+ hold_s: Number(field('ch-hold').value) || 30,
+ active_high: field('ch-active-high').checked
+ };
+ }
+
+ if (chForm) {
+ field('ch-kind').addEventListener('change', function () { showKind(this.value); });
+ field('ch-step').addEventListener('input', function () {
+ field('ch-after-wrap').hidden = Number(this.value) === 0;
+ });
+ document.getElementById('ch-cancel').addEventListener('click', function () { chForm.hidden = true; });
+ document.getElementById('channel-new').addEventListener('click', function () { openForm(null); });
+
+ chForm.addEventListener('submit', function (ev) {
+ ev.preventDefault();
+ var kind = field('ch-kind').value;
+ var body = {
+ channel_id: Number(field('ch-id').value) || 0,
+ kind: kind,
+ name: field('ch-name').value.trim(),
+ min_severity: field('ch-severity').value,
+ step: Number(field('ch-step').value) || 0,
+ escalate_after_s: Number(field('ch-after').value) || 0,
+ enabled: field('ch-enabled').checked ? 1 : 0,
+ config: collectConfig(kind)
+ };
+ chStatus.textContent = '저장 중…';
+ post('/api/admin/channels', body).then(function (r) {
+ if (!r) return;
+ if (!r.ok) { chStatus.textContent = errText(r.data && r.data.error); return; }
+ chStatus.textContent = '저장되었습니다.';
+ chForm.hidden = true;
+ loadChannels();
+ }).catch(function () { chStatus.textContent = '저장에 실패했습니다.'; });
+ });
+ }
+
+ function loadChannels() {
+ fetch(B + '/api/admin/channels', { headers: { Accept: 'application/json' } })
+ .then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return null; }
+ return r.ok ? r.json() : null;
+ })
+ .then(function (d) { if (d) renderChannels(d.channels || []); })
+ .catch(function () { chList.textContent = '통보 경로를 불러오지 못했습니다.'; });
+ }
+
+ // ============================================================ 임계값
+ var thrDevice = document.getElementById('thr-device');
+ var thrList = document.getElementById('thr-list');
+ var thrStatus = document.getElementById('thr-status');
+ var values = {}; // device_id -> {key: value}
+
+ function renderThresholds() {
+ var dev = thrDevice.value;
+ thrList.textContent = '';
+ if (!dev) { thrList.textContent = '장치를 선택하세요.'; return; }
+ var current = values[dev] || {};
+
+ Object.keys(catalog).forEach(function (key) {
+ var spec = catalog[key];
+ var row = el('div', 'thr-row');
+
+ var head = el('div', 'thr-head');
+ head.appendChild(el('span', 'thr-label', spec.label));
+ var val = el('span', 'thr-value');
+ row.appendChild(head);
+
+ var input = document.createElement('input');
+ input.type = 'number';
+ input.min = spec.min;
+ input.max = spec.max;
+ input.step = spec.step;
+ // 값이 없으면 장비가 쓰고 있는 기본값을 보여준다 — 빈칸이면 "설정 안 됨"과
+ // "기본값과 같음"을 구분할 수 없다.
+ var isSet = Object.prototype.hasOwnProperty.call(current, key);
+ input.value = isSet ? current[key] : spec['default'];
+ head.appendChild(val);
+ val.textContent = (spec.unit ? spec.unit : '') + (isSet ? '' : ' · 기본값');
+
+ var controls = el('div', 'thr-controls');
+ controls.appendChild(input);
+ var save = el('button', 'btn-primary', '적용');
+ save.type = 'button';
+ save.addEventListener('click', function () {
+ thrStatus.textContent = '적용 중…';
+ post('/api/admin/thresholds', { device_id: dev, key: key, value: input.value })
+ .then(function (r) {
+ if (!r) return;
+ if (!r.ok) {
+ thrStatus.textContent = spec.label + ': ' + errText(r.data && r.data.error)
+ + ' (허용 ' + spec.min + '~' + spec.max + ')';
+ return;
+ }
+ thrStatus.textContent = spec.label + ' 적용됨 — 다음 동기화 때 장치에 반영됩니다.';
+ loadThresholds();
+ })
+ .catch(function () { thrStatus.textContent = '적용에 실패했습니다.'; });
+ });
+ controls.appendChild(save);
+ row.appendChild(controls);
+ row.appendChild(el('p', 'thr-help', spec.help));
+ thrList.appendChild(row);
+ });
+ }
+
+ function loadThresholds() {
+ fetch(B + '/api/admin/thresholds', { headers: { Accept: 'application/json' } })
+ .then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return null; }
+ if (r.status === 403) { thrList.textContent = '임계값 편집 권한이 없습니다.'; return null; }
+ return r.ok ? r.json() : null;
+ })
+ .then(function (d) {
+ if (!d) return;
+ catalog = d.catalog || {};
+ devices = d.devices || [];
+ values = {};
+ (d.thresholds || []).forEach(function (t) {
+ if (t.camera_id) return; // 카메라별 재정의는 이 화면의 범위 밖
+ if (!values[t.device_id]) values[t.device_id] = {};
+ values[t.device_id][t.key] = t.value;
+ });
+
+ var keep = thrDevice.value;
+ thrDevice.textContent = '';
+ var first = document.createElement('option');
+ first.value = '';
+ first.textContent = devices.length ? '장치를 선택하세요' : '등록된 장치가 없습니다';
+ thrDevice.appendChild(first);
+ devices.forEach(function (dev) {
+ var o = document.createElement('option');
+ o.value = dev.device_id;
+ o.textContent = (dev.name || dev.device_id) + ' · ' + dev.device_id;
+ thrDevice.appendChild(o);
+ });
+ if (keep) thrDevice.value = keep;
+ renderThresholds();
+ })
+ .catch(function () { thrList.textContent = '임계값을 불러오지 못했습니다.'; });
+ }
+
+ if (thrDevice) thrDevice.addEventListener('change', renderThresholds);
+
+ loadCsrf().then(function () {
+ loadChannels();
+ loadThresholds();
+ });
+})();
diff --git a/server/public/assets/app.css b/server/public/assets/app.css
index 6cd3576..9e2e641 100644
--- a/server/public/assets/app.css
+++ b/server/public/assets/app.css
@@ -173,8 +173,11 @@ label { color:var(--ink-2); font-size:.84rem; }
.rail-filters { display:flex; flex-direction:column; gap:.55rem; }
.rail-filters .field { display:flex; flex-direction:column; gap:.28rem; font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
.rail-filters select { text-transform:none; font-size:.84rem; font-weight:400; letter-spacing:0; color:var(--ink); }
-.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; }
-.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
+/* Rail: 날짜 조회 (사고 조사 진입로). */
+.rail-date { display:flex; align-items:flex-end; gap:.4rem; }
+.rail-date .field { flex:1; min-width:0; display:flex; flex-direction:column; gap:.28rem; font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
+.rail-date input { width:100%; font-size:.8rem; }
+.rail-date button { flex:none; }
/* ---- Device → camera tree (rail) --------------------------------- */
/* Devices carry the heartbeat telemetry (online/offline, fps, temp, throttle, drift); the
@@ -262,6 +265,57 @@ h1 { font-size:1.3rem; font-weight:760; letter-spacing:-.02em; margin:0; }
.watch.is-stale::before { animation:none; }
@keyframes watch-pulse { 0%{ box-shadow:0 0 0 0 rgba(21,128,61,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,128,61,0);} }
+/* Alarm mute — only present while something is actually alarming, and it says so while on.
+ A silenced console that LOOKS identical to a live one is the failure this must not create. */
+.mute-btn { font-size:.72rem; padding:.22rem .5rem; color:var(--ink-2); }
+.mute-btn[hidden] { display:none; }
+.mute-btn[aria-pressed="true"] { color:var(--crit-ink); background:var(--crit-bg); border-color:var(--crit-line); font-weight:650; }
+.mute-state { font-size:.72rem; font-weight:600; color:var(--crit-ink); }
+.mute-state[hidden] { display:none; }
+
+/* View mode — a segmented pair, deliberately NOT a checkbox: the two modes are exclusive
+ readings of the same list, and a checkbox implied "and also filter by this". */
+/* align-self, because .stream is a stretching flex column and an inline-flex child would
+ otherwise span the full stream width — a segmented control the width of the page reads as
+ a toolbar, not as a two-way switch. */
+.view-modes { display:inline-flex; align-self:flex-start; gap:2px; padding:2px; margin:0 0 .7rem;
+ background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
+.mode-btn { font-size:.78rem; font-weight:600; padding:.3rem .7rem; border:0; border-radius:7px;
+ background:transparent; color:var(--ink-3); box-shadow:none; }
+.mode-btn:hover { background:#fff; color:var(--ink-2); }
+.mode-btn.is-on { background:var(--card); color:var(--accent); box-shadow:var(--shadow-sm); }
+
+/* Day-query banner: the stream is showing a past day, not the live feed. Says so, and the
+ way back is the same control every time. */
+.query-banner { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem;
+ font-size:.79rem; color:var(--accent-deep); background:var(--accent-wash);
+ border:1px solid var(--accent-line); border-radius:8px; }
+.query-banner[hidden] { display:none; }
+.query-banner .query-text { font-weight:600; }
+.query-banner button { margin-left:auto; flex:none; }
+.query-banner.is-truncated { color:var(--warn-ink); background:var(--warn-bg); border-color:var(--warn-line); }
+
+/* Day divider — a strictly chronological list still has to make midnight visible. */
+.day-sep { display:flex; align-items:center; gap:.7rem; margin:.75rem 0 .3rem; }
+.day-sep:first-child { margin-top:0; }
+.day-sep span {
+ flex:none; font-size:.7rem; font-weight:700; letter-spacing:.05em; color:var(--ink-3);
+ font-variant-numeric:tabular-nums;
+}
+.day-sep::after { content:""; flex:1; height:1px; background:var(--line); }
+
+/* Resolution feedback. 확인/오탐 are audit-backed and not reversible here, so this is a
+ confirmation of what was recorded — never an "undo" that the server could not honour. */
+.action-toast {
+ position:fixed; left:50%; bottom:1.5rem; transform:translate(-50%, .5rem);
+ z-index:var(--z-toast); max-width:min(90vw, 30rem);
+ padding:.55rem .9rem; font-size:.82rem; font-weight:600;
+ color:#fff; background:#1b2330; border-radius:9px;
+ box-shadow:0 10px 30px -12px rgba(19,24,36,.6);
+ opacity:0; pointer-events:none; transition:opacity .18s, transform .18s;
+}
+.action-toast.is-on { opacity:1; transform:translate(-50%, 0); }
+
/* ---- KPI strip: compact unacknowledged counts -------------------- */
/* Computed client-side from the /api/events window (no extra endpoint). A tight
inline strip of pills, not four hero cards — dense, glanceable. Zero dims, never hides. */
@@ -332,7 +386,15 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.event-time .ev-at { font-family:var(--mono); color:var(--ink-3); }
.event-detail-link { color:inherit; text-decoration:none; }
.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }
-.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
+/* "확인 필요" chip — on EVERY unacknowledged row, toned by severity. A critical still shouts
+ (solid fill); lower tiers state the same fact quietly (tinted, outlined). What must never
+ happen again is a row whose handled/unhandled state is only inferable from whether a button
+ exists in a distant column. */
+.chip { font-size:.68rem; font-weight:700; padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; flex:none; }
+.chip-critical { color:#fff; background:var(--crit-ink); }
+.chip-high { color:var(--high-ink); background:var(--high-bg); box-shadow:inset 0 0 0 1px var(--high-line); }
+.chip-warning { color:var(--warn-ink); background:var(--warn-bg); box-shadow:inset 0 0 0 1px var(--warn-line); }
+.chip-info { color:var(--ink-2); background:#eef1f5; box-shadow:inset 0 0 0 1px #dde3ea; }
/* Actions column: [확인][오탐] while open; a single mono verdict line once resolved. */
.row-actions { margin-left:auto; flex:none; display:flex; align-items:center; gap:.4rem; }
@@ -342,6 +404,9 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.fp-btn { flex:none; padding:.32rem .55rem; font-size:.75rem; color:var(--ink-3);
background:none; border-color:transparent; box-shadow:none; }
.fp-btn:hover { color:var(--ink-2); border-color:var(--line); background:#fff; }
+/* Armed state: the first click asks, the second commits. 오탐 asserts the DETECTOR was wrong,
+ moves a per-camera statistic a threshold is later retuned from, and has no undo. */
+.fp-btn.is-armed { color:var(--high-ink); background:var(--high-bg); border-color:var(--high-line); font-weight:650; }
.ack-done, .fp-done { color:var(--ink-3); font-family:var(--mono); font-size:.75rem; white-space:nowrap; }
.fp-done { text-decoration:line-through; text-decoration-color:#c9d1da; }
@@ -363,6 +428,9 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.fp-what .fp-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); margin-right:.15rem; }
.fp-rate { flex:none; font-family:var(--mono); color:var(--ink-3); }
.fp-rate.is-hot { color:var(--warn-ink); font-weight:700; }
+/* Under the minimum sample there is no rate to print — only counts, and they must not
+ compete visually with a real one ("100% (1/1)" used to outrank "33% (5/15)"). */
+.fp-rate.is-thin { color:#8b93a1; font-size:.7rem; }
/* "new events arrived while paging" reload affordance — honest about a possible gap. */
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem;
@@ -389,33 +457,144 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.login-form button:hover { background:var(--accent-deep); border-color:var(--accent-deep); }
.form-error { color:var(--crit-ink); font-size:.85rem; }
+/* ---- Primary action ---------------------------------------------- */
+/* The one filled button in the app. It exists so a page's committing action cannot sit at the
+ same weight as the destructive one beside it — which is exactly how 저장 and 폴리곤 지우기
+ used to read on the zone editor. */
+.btn-primary {
+ color:var(--accent-ink); background:var(--accent); border-color:var(--accent); font-weight:650;
+}
+.btn-primary:hover { background:var(--accent-deep); border-color:var(--accent-deep); }
+
/* ---- Zone editor ------------------------------------------------- */
-.zone-controls { display:flex; gap:.6rem; flex-wrap:wrap; align-items:end; margin-bottom:.9rem; }
-.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; }
+/* Same console grammar as the events page (panel rail + stage), so the two screens read as one
+ product. The panel walks the actual task order: ① pick a camera ② draw ③ rules + save.
+ Before this the page was a bare
over three unlabelled rows of loose controls. */
+.zone-page { display:flex; flex-direction:column; }
+.zone-console { display:grid; grid-template-columns:300px minmax(0,1fr); gap:1rem; align-items:start; }
+
+.zone-panel {
+ position:sticky; top:calc(45px + 1.15rem);
+ display:flex; flex-direction:column; gap:.85rem;
+ background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
+ padding:.85rem .8rem; box-shadow:var(--shadow-sm);
+}
+.zone-step { display:flex; flex-direction:column; gap:.5rem; }
+.zone-step + .zone-step { padding-top:.85rem; border-top:1px solid var(--line-2); }
+.zone-step .rail-title { display:flex; align-items:center; gap:.45rem; }
+.step-no {
+ display:grid; place-items:center; width:16px; height:16px; flex:none;
+ border-radius:50%; font-size:.62rem; font-weight:700; letter-spacing:0;
+ color:var(--accent); background:var(--accent-wash); border:1px solid var(--accent-line);
+}
+.step-hint { margin:0; font-size:.73rem; color:var(--ink-3); }
+.zone-step .field {
+ display:flex; flex-direction:column; gap:.28rem;
+ font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase;
+}
+.zone-step .field input, .zone-step .field select {
+ text-transform:none; font-size:.83rem; font-weight:400; letter-spacing:0; color:var(--ink); width:100%;
+}
+/* Label and its unit/why note share ONE line — as separate flex children of the column they
+ stacked, and a hint on its own row reads as a second label. */
+.field-label { display:inline-flex; align-items:baseline; gap:.35rem; flex-wrap:wrap; }
+.field-hint { font-weight:600; font-size:.64rem; color:#8b93a1; letter-spacing:0; text-transform:none; }
+.step-actions { display:flex; flex-wrap:wrap; gap:.4rem; }
+.step-actions button { flex:1 1 auto; }
+.zone-overlay-tools { display:flex; align-items:flex-end; gap:.4rem; }
+.zone-overlay-tools .field { flex:0 0 5.5rem; }
+/* aria-pressed had no visual pair: the heatmap could be on with nothing on screen saying so —
+ the one place this stylesheet's own "state is never colour alone" rule was broken by there
+ being no state indication at all. */
+#heatmap-btn { flex:1; }
+#heatmap-btn[aria-pressed="true"] {
+ color:var(--accent); background:var(--accent-wash); border-color:var(--accent-line); font-weight:650;
+}
+.zone-manual { font-size:.75rem; color:var(--ink-3); }
+.zone-manual > summary { cursor:pointer; padding:.15rem 0; color:var(--accent); font-weight:600; }
+.zone-manual[open] { display:flex; flex-direction:column; gap:.45rem; padding-bottom:.2rem; }
+.zone-manual-why { margin:.25rem 0 0; font-size:.72rem; color:var(--ink-3); }
+
+.zone-stage { display:flex; flex-direction:column; gap:.6rem; min-width:0; }
#zone-canvas {
+ width:100%; height:auto; display:block;
border:1px solid var(--line); border-radius:10px;
- background:#0e1116 center / contain no-repeat; cursor:crosshair; max-width:100%;
+ background:#0e1116 center / contain no-repeat; cursor:crosshair;
box-shadow:var(--shadow);
}
-#zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; }
-#zone-list li {
- display:flex; align-items:center; justify-content:space-between; gap:.75rem;
- max-width:380px; padding:.5rem .75rem;
- background:var(--card); border:1px solid var(--line); border-radius:9px;
- font-size:.85rem; color:var(--ink-2);
+.zone-status {
+ margin:0; padding:.42rem .65rem; font-size:.79rem; color:var(--ink-2);
+ background:var(--panel-2); border:1px solid var(--line); border-radius:8px;
+}
+.zone-saved { display:flex; flex-direction:column; gap:.4rem; }
+.zone-list { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:.35rem; }
+.zone-item {
+ display:flex; align-items:center; justify-content:space-between; gap:.75rem;
+ padding:.45rem .6rem;
+ background:var(--card); border:1px solid var(--line); border-radius:9px;
+ font-size:.83rem; color:var(--ink-2);
+}
+.zone-item-label { display:inline-flex; align-items:center; gap:.5rem; min-width:0; }
+.zone-swatch { width:13px; height:13px; flex:none; border-radius:3px; border:2px solid; }
+.zone-empty { font-size:.79rem; color:var(--ink-3); padding:.3rem 0; }
+.zone-del { flex:none; font-size:.75rem; padding:.26rem .55rem; color:var(--ink-3); }
+.zone-del:hover { color:var(--crit-ink); border-color:var(--crit-line); background:var(--crit-bg); }
+/* Armed: the second click deletes, and there is no restore on the server. */
+.zone-del.is-armed { color:#fff; background:var(--crit-ink); border-color:var(--crit-ink); font-weight:650; }
+
+@media (max-width:860px) {
+ .zone-console { grid-template-columns:1fr; }
+ .zone-panel { position:static; }
}
-#zone-status { font-family:var(--mono); font-size:.82rem; color:var(--ink-3); }
/* ---- Incident detail -------------------------------------------- */
-.detail-row { margin:.35rem 0; }
-.detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; }
-#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; }
+/* The screen an accident is judged on. It was a bare label:value list with a default blue link,
+ in a 200px column inside a 1240px shell — the least finished surface in the product and the
+ one an investigator spends the most time on. */
+.detail { display:flex; flex-direction:column; gap:.9rem; }
+.detail-crumb { display:flex; align-items:center; gap:.4rem; font-size:.76rem; color:var(--ink-3); }
+.detail-crumb a { color:var(--accent); text-decoration:none; font-weight:600; }
+.detail-crumb a:hover { text-decoration:underline; text-underline-offset:3px; }
+.detail-head { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
+.detail-headline h1 { margin:0; }
+.detail-when { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.7rem; margin:.3rem 0 0; font-size:.79rem; }
+.detail-when .ev-src { font-weight:550; color:var(--ink-2); }
+.detail-when .ev-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); }
+.detail-when .ev-at { font-family:var(--mono); color:var(--ink-3); }
+.detail-actions { display:flex; align-items:center; gap:.5rem; flex:none; }
+.detail-actions[hidden] { display:none; }
+.detail-action-note { margin:0; font-size:.82rem; font-weight:600; color:var(--ink-2); }
+.detail-action-note.is-error { color:var(--crit-ink); }
+
+.detail-grid { display:grid; grid-template-columns:minmax(0,1fr) 336px; gap:.9rem; align-items:start; }
+.detail-card {
+ padding:.9rem 1rem 1rem; background:var(--card);
+ border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
+}
+.detail-card-title { margin:0 0 .75rem; font-size:.72rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); }
+.detail-empty { margin:0; font-size:.82rem; color:var(--ink-3); }
+.detail-foot { margin:.8rem 0 0; font-size:.72rem; color:var(--ink-3); }
+
+/* Meta as a definition list: the label column is a real column, so values line up. */
+#event-detail-meta { margin:0; display:flex; flex-direction:column; }
+.detail-row { display:grid; grid-template-columns:4.9rem minmax(0,1fr); gap:.55rem; align-items:baseline; padding:.32rem 0; }
+.detail-row + .detail-row { border-top:1px solid var(--line-2); }
+.detail-row dt { font-size:.73rem; font-weight:600; color:var(--ink-3); }
+.detail-row dd { margin:0; font-size:.85rem; color:var(--ink); min-width:0; overflow-wrap:anywhere; }
+.detail-row dd .mono { font-family:var(--mono); font-size:.8rem; }
+
+#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; }
/* Evidence timeline: each frame carries its moment label — the sequence reads left→right
- into the event ("이전 → 직전 → 발생 시점"). */
+ into the event ("이전 → 직전 → 발생 시점"). No fabricated second offsets: the frame
+ interval is edge configuration this server does not know. */
.detail-snap { margin:0; display:flex; flex-direction:column; gap:.3rem; flex:1 1 240px; max-width:360px; }
.detail-snap figcaption { font-size:.72rem; font-weight:700; color:var(--ink-3); font-family:var(--mono); }
.detail-snap:last-child figcaption { color:var(--ink); }
-.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:10px; background:#fff; padding:4px; box-shadow:var(--shadow); }
+.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:10px; background:#fff; padding:4px; box-shadow:var(--shadow-sm); }
+
+@media (max-width:860px) {
+ .detail-grid { grid-template-columns:1fr; }
+}
/* ---- Responsive: rail collapses above the stream ----------------- */
@media (max-width:860px) {
@@ -480,9 +659,13 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.bar-fill { height:100%; border-radius:999px; background:var(--accent); }
.bar-fill.is-hot { background:var(--crit-ink); }
-.rule-stats { display:grid; grid-template-columns:repeat(2, 1fr); gap:.6rem; }
+/* Three zone-rule counters. "미확인 잔여" used to sit here as a fourth tile of equal weight,
+ which read as a fourth rule; it is a processing state, so it moved to a line below. */
+/* minmax(0,1fr), not 1fr: a grid item's automatic minimum is its CONTENT, so "체류 상한 초과"
+ refused to shrink, blew the card out and gave the whole report a horizontal scrollbar. */
+.rule-stats { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:.6rem; }
.rule-stat {
- display:flex; flex-direction:column; gap:.1rem; padding:.6rem .7rem;
+ display:flex; flex-direction:column; gap:.1rem; padding:.6rem .7rem; min-width:0;
border:1px solid var(--line); border-radius:8px; background:var(--panel-2);
}
.rule-stat strong { font-size:1.25rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-3); }
@@ -504,6 +687,20 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.kpi-trend.is-better { color:var(--accent); }
.report-card-wide { grid-column:1 / -1; }
+.report-card[hidden] { display:none; }
+
+/* 현장별 롤업 (본사 관점). */
+.site-rows { display:flex; flex-direction:column; gap:.4rem; }
+.site-row { display:flex; align-items:baseline; justify-content:space-between; gap:.8rem;
+ padding:.5rem .65rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
+.site-row.is-worst { background:var(--crit-bg); border-color:var(--crit-line); }
+.site-name { font-weight:650; font-size:.9rem; color:var(--ink); }
+.site-nums { display:flex; align-items:baseline; gap:.35rem; font-size:.73rem; color:var(--ink-3); }
+.site-nums strong { font-size:1rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-2); margin-left:.55rem; }
+.site-nums strong:first-child { margin-left:0; }
+.site-severe.is-hot { color:var(--crit-ink); }
+.site-unacked.is-open { color:var(--high-ink); }
+.site-cams { margin-left:.8rem; font-family:var(--mono); }
/* 개선 조치: 기록 폼 + 전후 비교 행. */
.action-form { display:flex; gap:.5rem; margin-bottom:.8rem; }
@@ -521,6 +718,10 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.action-effect.is-better { color:var(--accent); font-weight:700; }
.action-effect.is-worse { color:var(--crit-ink); font-weight:700; }
.action-effect.is-pending { font-style:normal; }
+/* Below the minimum sample the row states the counts and withholds the verdict — a coloured
+ ▲33% off six events is the kind of number a safety meeting rejects, and takes the rest of
+ the card's credibility with it. */
+.action-effect.is-thin { color:#8b93a1; font-weight:400; }
.pattern { display:flex; flex-direction:column; gap:2px; overflow-x:auto; }
.pattern-row { display:flex; align-items:center; gap:2px; }
.pattern-day { flex:none; width:1.4rem; font-size:.68rem; color:var(--ink-3); font-family:var(--mono); }
@@ -543,6 +744,304 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.report-grid, .report-kpis { break-inside:avoid; }
}
+/* ==================================================================
+ 확장 화면: 시스템 설정 · 안전 관리 · 사고조사 패키지, 그리고 콘솔의
+ 일괄 처리 · 키보드 트리아지 · 저장공간 경고.
+ 기존 콘솔과 같은 문법(카드 + 소문자 캡션 + 단일 accent)을 유지한다 —
+ 새 화면이 다른 제품처럼 보이는 것이 이 프로젝트의 원래 문제였다.
+ ================================================================== */
+
+/* ---- Stream tools: 뷰모드 + 일괄 확인 + 단축키 안내 --------------- */
+.stream-tools { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:.7rem; }
+.stream-tools .view-modes { margin:0; }
+.bulk-btn { font-size:.78rem; font-weight:600; color:var(--ink-2); }
+.bulk-btn[hidden] { display:none; }
+/* 무장 상태는 붉게 — 되돌릴 수 없는 일괄 처리라는 사실을 색으로도 말한다. */
+.bulk-btn.is-armed { color:#fff; background:var(--crit-ink); border-color:var(--crit-ink); }
+.kbd-hint { margin-left:auto; font-size:.7rem; color:var(--ink-3); display:inline-flex; align-items:center; gap:.25rem; }
+.kbd-hint kbd {
+ font-family:var(--mono); font-size:.65rem; padding:.05rem .3rem; border-radius:4px;
+ background:var(--panel-2); border:1px solid var(--line); color:var(--ink-2);
+}
+/* 키보드 커서: 포커스 링과 구분되는 은은한 레일. 마우스 사용자에게는 보이지 않는다. */
+.event-row.is-cursor { box-shadow:inset 3px 0 0 0 var(--accent), var(--shadow-sm); }
+
+.storage-warn {
+ margin:0 0 .6rem; padding:.5rem .75rem; font-size:.8rem; color:var(--crit-ink);
+ background:var(--crit-bg); border:1px solid var(--crit-line); border-radius:8px;
+}
+.storage-warn[hidden] { display:none; }
+.storage-warn .warn-lead { font-weight:700; margin-right:.15rem; }
+
+/* ---- 공용 관리 화면 셸 -------------------------------------------- */
+.admin-page, .compliance-page { display:flex; flex-direction:column; gap:1rem; }
+.comp-grid { display:grid; grid-template-columns:1fr 1fr; gap:1rem; align-items:start; }
+.comp-wide { grid-column:1 / -1; }
+.admin-card {
+ padding:1rem 1.1rem 1.1rem; background:var(--card);
+ border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
+}
+.admin-card-head { display:flex; align-items:center; justify-content:space-between; gap:.8rem; flex-wrap:wrap; margin-bottom:.6rem; }
+.admin-card-head h2 { margin:0; font-size:.95rem; font-weight:750; letter-spacing:-.01em; }
+.admin-device-pick { min-width:16rem; }
+.admin-note {
+ margin:0 0 .8rem; padding:.5rem .7rem; font-size:.78rem; line-height:1.5; color:var(--ink-2);
+ background:var(--panel-2); border:1px solid var(--line-2); border-radius:8px;
+}
+.admin-note code { font-family:var(--mono); font-size:.74rem; }
+.admin-empty { margin:.4rem 0; font-size:.8rem; color:var(--ink-3); }
+.admin-status { margin:.55rem 0 0; font-size:.78rem; color:var(--ink-2); min-height:1em; }
+.comp-subhead { margin:1rem 0 .5rem; font-size:.7rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); }
+
+.form-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(11rem, 1fr)); gap:.6rem; align-items:end; }
+/* `display:grid` beats the UA's [hidden]{display:none}, so a collapsed form would render
+ open — say it explicitly for every layout class that sets display. */
+.form-grid[hidden], .detail-links[hidden] { display:none; }
+.form-grid .field { display:flex; flex-direction:column; gap:.28rem;
+ font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
+.form-grid .field input, .form-grid .field select {
+ text-transform:none; font-size:.83rem; font-weight:400; letter-spacing:0; color:var(--ink); width:100%;
+}
+.form-grid .filter-check { align-self:center; }
+.form-grid > button { align-self:end; }
+.comp-span, .sup-reason { grid-column:1 / -1; }
+.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.8rem; color:var(--ink-2); cursor:pointer; user-select:none; text-transform:none; letter-spacing:0; font-weight:400; }
+.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
+
+/* ---- 통보 채널 ---------------------------------------------------- */
+.channel-list { display:flex; flex-direction:column; gap:.4rem; margin-bottom:.8rem; }
+.channel-row {
+ display:flex; align-items:center; gap:.7rem; padding:.5rem .7rem;
+ background:var(--panel-2); border:1px solid var(--line); border-radius:9px;
+}
+.channel-row.is-off { opacity:.6; }
+.channel-main { display:flex; flex-direction:column; gap:.1rem; min-width:0; flex:1; }
+.channel-kind { font-size:.66rem; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:var(--accent); }
+.channel-name { font-size:.86rem; font-weight:650; color:var(--ink); }
+.channel-meta { font-size:.73rem; color:var(--ink-3); overflow-wrap:anywhere; }
+.channel-form { display:flex; flex-direction:column; gap:.7rem; padding-top:.8rem; border-top:1px solid var(--line-2); }
+.channel-form[hidden] { display:none; }
+
+/* ---- 임계값 ------------------------------------------------------- */
+.thr-list { display:grid; grid-template-columns:repeat(auto-fit, minmax(19rem, 1fr)); gap:.7rem; }
+.thr-row { padding:.65rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
+.thr-head { display:flex; align-items:baseline; justify-content:space-between; gap:.5rem; }
+.thr-label { font-size:.85rem; font-weight:650; color:var(--ink); }
+.thr-value { font-size:.7rem; color:var(--ink-3); font-family:var(--mono); }
+.thr-controls { display:flex; gap:.4rem; margin-top:.4rem; }
+.thr-controls input { flex:1; min-width:0; }
+.thr-help { margin:.45rem 0 0; font-size:.72rem; line-height:1.5; color:var(--ink-3); }
+
+/* ---- 교대 인수인계 ------------------------------------------------ */
+.handover-pending { padding:.7rem .8rem; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
+.handover-head { display:flex; align-items:baseline; justify-content:space-between; gap:.6rem; flex-wrap:wrap; }
+.handover-range { font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
+.handover-stats { display:flex; gap:.6rem; margin-top:.6rem; }
+.handover-stat { flex:1; display:flex; flex-direction:column; gap:.1rem; padding:.5rem .6rem;
+ background:var(--card); border:1px solid var(--line); border-radius:8px; }
+.handover-stat strong { font-size:1.3rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-3); }
+.handover-stat span { font-size:.72rem; color:var(--ink-3); }
+.handover-stat.is-open strong { color:var(--ink); }
+.handover-stat.is-severe { background:var(--crit-bg); border-color:var(--crit-line); }
+.handover-stat.is-severe strong { color:var(--crit-ink); }
+.handover-from { margin:.5rem 0 0; font-size:.75rem; color:var(--ink-3); }
+.handover-form { display:flex; align-items:flex-end; gap:.5rem; margin-top:.7rem; }
+.handover-form .field { flex:1; display:flex; flex-direction:column; gap:.28rem;
+ font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
+.handover-form input { text-transform:none; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; }
+.handover-list { display:flex; flex-direction:column; gap:.3rem; }
+.handover-row { display:flex; flex-wrap:wrap; gap:.5rem; align-items:baseline; padding:.4rem 0;
+ border-top:1px solid var(--line-2); font-size:.78rem; }
+.handover-when { font-family:var(--mono); font-size:.72rem; color:var(--ink-3); }
+.handover-who { font-weight:650; color:var(--ink); }
+.handover-count { font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
+.handover-note { color:var(--ink-2); flex-basis:100%; }
+
+/* ---- 억제 --------------------------------------------------------- */
+.suppress-list { display:flex; flex-direction:column; gap:.4rem; margin-top:.7rem; }
+.suppress-row { display:flex; align-items:center; gap:.7rem; padding:.5rem .7rem;
+ background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:9px; }
+.suppress-main { display:flex; flex-direction:column; gap:.1rem; flex:1; min-width:0; }
+.suppress-scope { font-family:var(--mono); font-size:.76rem; font-weight:650; color:var(--warn-ink); }
+.suppress-reason { font-size:.82rem; color:var(--ink); }
+.suppress-meta { font-size:.72rem; color:var(--ink-3); }
+
+/* ---- 위험성평가 --------------------------------------------------- */
+.risk-list { display:flex; flex-direction:column; gap:.4rem; }
+.risk-row { display:flex; align-items:flex-start; gap:.8rem; padding:.6rem .7rem;
+ background:var(--panel-2); border:1px solid var(--line); border-radius:9px; }
+.risk-score { flex:none; width:3.2rem; display:flex; flex-direction:column; align-items:center;
+ gap:.05rem; padding:.35rem 0; border-radius:8px; background:var(--card); border:1px solid var(--line); }
+.risk-score strong { font-size:1.2rem; font-weight:750; font-variant-numeric:tabular-nums; }
+.risk-score span { font-size:.63rem; font-weight:700; letter-spacing:.04em; }
+.risk-score.is-high { background:var(--crit-bg); border-color:var(--crit-line); color:var(--crit-ink); }
+.risk-score.is-mid { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn-ink); }
+.risk-score.is-low { color:var(--ink-3); }
+.risk-body { display:flex; flex-direction:column; gap:.12rem; flex:1; min-width:0; }
+.risk-type { font-size:.66rem; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:var(--accent); }
+.risk-hazard { font-size:.86rem; font-weight:600; color:var(--ink); }
+.risk-control { font-size:.78rem; color:var(--ink-2); }
+.risk-meta { font-size:.72rem; color:var(--ink-3); font-family:var(--mono); }
+
+/* ---- 성능 추이 ---------------------------------------------------- */
+.metric-chart { min-height:6rem; }
+.metric-svg { width:100%; height:auto; display:block; }
+.metric-svg .axis { stroke:var(--line); stroke-width:1; }
+.metric-svg .series-fps { fill:none; stroke:var(--accent); stroke-width:2; stroke-linejoin:round; }
+.metric-svg .series-throttle { fill:var(--high); opacity:.35; }
+.metric-legend { display:flex; gap:1rem; flex-wrap:wrap; margin-top:.4rem; font-size:.72rem; color:var(--ink-3); }
+.legend-fps::before { content:""; display:inline-block; width:14px; height:2px; background:var(--accent); margin-right:.3rem; vertical-align:middle; }
+.legend-throttle::before { content:""; display:inline-block; width:8px; height:10px; background:var(--high); opacity:.35; margin-right:.3rem; vertical-align:middle; }
+.legend-range { margin-left:auto; font-family:var(--mono); }
+.metric-summary { margin:.35rem 0 0; font-size:.78rem; color:var(--ink-2); font-family:var(--mono); }
+
+/* ---- 이벤트 상세: 링크 · 조치 카드 --------------------------------- */
+.detail-side { display:flex; flex-direction:column; gap:.9rem; min-width:0; }
+.detail-links { display:flex; gap:.5rem; flex-wrap:wrap; }
+.detail-link-btn {
+ font-size:.78rem; font-weight:600; text-decoration:none; padding:.32rem .65rem; border-radius:8px;
+ color:var(--accent); background:var(--accent-wash); border:1px solid var(--accent-line);
+}
+.detail-link-btn:hover { background:#d5e9ed; }
+.detail-card-hint { margin:0 0 .6rem; font-size:.74rem; line-height:1.5; color:var(--ink-3); }
+.detail-action-card .field { display:flex; flex-direction:column; gap:.28rem; margin-bottom:.5rem;
+ font-size:.68rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; }
+.detail-action-card input { text-transform:none; font-weight:400; letter-spacing:0; color:var(--ink); width:100%; }
+
+/* ---- 설치 점검 (온보딩) -------------------------------------------- */
+.setup-page { max-width:820px; margin:0 auto; }
+.setup-steps { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.6rem; }
+.setup-step {
+ display:flex; gap:.85rem; align-items:flex-start; padding:.85rem 1rem;
+ background:var(--card); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
+}
+.setup-mark {
+ display:grid; place-items:center; width:26px; height:26px; flex:none; border-radius:50%;
+ font-size:.8rem; font-weight:750;
+ color:var(--ink-3); background:var(--panel-2); border:1px solid var(--line);
+}
+.setup-step.is-ok { border-color:var(--accent-line); }
+.setup-step.is-ok .setup-mark { color:#fff; background:var(--ok); border-color:var(--ok); }
+.setup-step.is-todo { background:var(--warn-bg); border-color:var(--warn-line); }
+.setup-step.is-todo .setup-mark { color:var(--warn-ink); background:#fff; border-color:var(--warn-line); }
+/* blocked = 앞 단계가 끝나야 판정할 수 있는 단계. 실패가 아니므로 경고색을 쓰지 않는다. */
+.setup-step.is-blocked { opacity:.62; }
+.setup-step.is-unknown .setup-mark { color:var(--ink-3); }
+.setup-body { display:flex; flex-direction:column; gap:.2rem; min-width:0; }
+.setup-title { margin:0; font-size:.92rem; font-weight:700; }
+.setup-status { margin:0; font-size:.82rem; color:var(--ink-2); }
+.setup-why { margin:.25rem 0 0; font-size:.78rem; line-height:1.55; color:var(--ink-3); }
+.setup-link { align-self:flex-start; margin-top:.4rem; font-size:.78rem; font-weight:600;
+ color:var(--accent); text-decoration:none; }
+.setup-link:hover { text-decoration:underline; text-underline-offset:3px; }
+.setup-foot { margin:1rem 0 0; font-size:.76rem; color:var(--ink-3); }
+.setup-foot a { color:var(--accent); }
+
+/* ---- 사고조사 패키지 ---------------------------------------------- */
+/* 인쇄물로 제출되는 문서다. 화면과 종이에서 같은 위계로 읽혀야 한다. */
+.package-page { display:flex; flex-direction:column; gap:.9rem; }
+.package-toolbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; }
+.package {
+ max-width:860px; margin:0 auto; padding:2rem 2.2rem 2.4rem; background:var(--card);
+ border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow);
+}
+.pkg-head { padding-bottom:1rem; margin-bottom:1.2rem; border-bottom:2px solid var(--ink); }
+.pkg-doc-kind { margin:0; font-size:.7rem; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--ink-3); }
+.pkg-title { margin:.3rem 0 .2rem; font-size:1.6rem; }
+.pkg-sub { margin:0; font-size:.85rem; color:var(--ink-2); font-family:var(--mono); }
+.pkg-section { margin-bottom:1.5rem; break-inside:avoid; }
+.pkg-h2 { margin:0 0 .6rem; font-size:.72rem; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
+ color:var(--ink-3); padding-bottom:.3rem; border-bottom:1px solid var(--line); }
+.pkg-facts { margin:0; display:grid; grid-template-columns:auto 1fr; gap:.3rem 1rem; }
+.pkg-kv { display:contents; }
+.pkg-kv dt { font-size:.76rem; color:var(--ink-3); }
+.pkg-kv dd { margin:0; font-size:.84rem; color:var(--ink); overflow-wrap:anywhere; }
+.pkg-empty { margin:.3rem 0; font-size:.8rem; color:var(--ink-3); }
+.pkg-note { margin:.6rem 0 0; font-size:.72rem; color:var(--ink-3); }
+.pkg-frames { display:flex; gap:.7rem; flex-wrap:wrap; }
+.pkg-frame { margin:0; flex:1 1 220px; max-width:280px; }
+.pkg-frame img { width:100%; height:auto; display:block; border:1px solid var(--line); border-radius:8px; }
+.pkg-frame figcaption { margin-top:.25rem; font-size:.7rem; font-family:var(--mono); color:var(--ink-3); }
+.pkg-timeline { list-style:none; margin:0; padding:0; }
+.pkg-tl-row { display:flex; align-items:baseline; gap:.7rem; padding:.3rem 0; border-bottom:1px solid var(--line-2); font-size:.8rem; }
+/* 조사 대상 사건은 목록 안에서 즉시 찾을 수 있어야 한다. */
+.pkg-tl-row.is-subject { font-weight:700; background:var(--accent-wash); margin:0 -.5rem; padding:.3rem .5rem; border-radius:6px; }
+.pkg-tl-time { font-family:var(--mono); font-size:.75rem; color:var(--ink-3); flex:none; }
+.pkg-tl-sev { flex:none; font-size:.72rem; font-weight:700; min-width:2.4rem; }
+.pkg-tl-sev.sev-critical { color:var(--crit-ink); }
+.pkg-tl-sev.sev-high { color:var(--high-ink); }
+.pkg-tl-sev.sev-warning { color:var(--warn-ink); }
+.pkg-tl-sev.sev-info { color:var(--info); }
+.pkg-tl-type { flex:1; }
+.pkg-tl-state { flex:none; font-size:.73rem; color:var(--ink-3); }
+.pkg-alerts, .pkg-actions { list-style:none; margin:0; padding:0; }
+.pkg-alert, .pkg-actions li { display:flex; gap:.7rem; align-items:baseline; flex-wrap:wrap;
+ padding:.3rem 0; border-bottom:1px solid var(--line-2); font-size:.8rem; }
+.pkg-alert-ch { font-weight:650; }
+.pkg-alert-status { font-size:.75rem; }
+.pkg-alert.is-sent .pkg-alert-status { color:var(--ok); }
+.pkg-alert.is-failed .pkg-alert-status { color:var(--crit-ink); font-weight:700; }
+.pkg-alert.is-suppressed .pkg-alert-status { color:var(--warn-ink); }
+.pkg-alert-when { margin-left:auto; font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
+.pkg-alert-err { flex-basis:100%; font-size:.72rem; color:var(--crit-ink); font-family:var(--mono); }
+.pkg-act-date { font-family:var(--mono); font-size:.75rem; color:var(--ink-3); }
+.pkg-act-note { flex:1; }
+.pkg-act-by { font-size:.73rem; color:var(--ink-3); }
+.pkg-sign { display:grid; grid-template-columns:repeat(3, 1fr); gap:1.2rem; margin-top:.5rem; }
+.pkg-sign-box { display:flex; flex-direction:column; gap:1.8rem; }
+.pkg-sign-role { font-size:.74rem; color:var(--ink-3); }
+.pkg-sign-line { border-bottom:1px solid var(--ink-3); }
+.pkg-foot { margin-top:1.5rem; padding-top:.7rem; border-top:1px solid var(--line);
+ font-size:.7rem; color:var(--ink-3); line-height:1.6; }
+.package-loading { color:var(--ink-3); font-size:.85rem; }
+
+@media (max-width:860px) {
+ .comp-grid { grid-template-columns:1fr; }
+ .package { padding:1.2rem 1rem 1.6rem; }
+ .pkg-sign { grid-template-columns:1fr; }
+}
+
+/* 패키지 인쇄: 앱 크롬과 조작 버튼을 걷어내고 문서만 남긴다. */
+@media print {
+ .package-toolbar, .detail-links, .detail-action-card { display:none !important; }
+ .package { max-width:none; margin:0; padding:0; border:0; box-shadow:none; }
+ .pkg-section { page-break-inside:avoid; }
+}
+
+/* ---- 모바일: 현장 관리자는 폰으로 본다 ---------------------------- */
+/* 데스크 콘솔의 축소판이 아니라, 한 손으로 확인만 하는 화면으로 재구성한다.
+ 터치 타깃 44px, 썸네일 우선, 부수 정보는 접는다. */
+@media (max-width:560px) {
+ .stream-tools { gap:.4rem; }
+ .kbd-hint { display:none; } /* 폰에는 키보드가 없다 */
+ .view-modes { flex:1; }
+ .mode-btn { flex:1; min-height:38px; }
+ .bulk-btn { width:100%; min-height:40px; }
+
+ .event-row { flex-wrap:wrap; padding:.55rem .6rem; gap:.55rem; }
+ .event-thumb-slot { width:88px; height:52px; }
+ .event-meta { flex:1 1 0; }
+ .event-type { font-size:.92rem; }
+ /* 액션은 행 아래 전폭으로 내린다 — 44px 타깃을 확보하고, 좁은 화면에서
+ 썸네일과 버튼이 서로를 밀어내지 않게 한다. */
+ .row-actions { margin-left:0; flex-basis:100%; gap:.5rem; }
+ .ack-btn { flex:1; min-height:44px; font-size:.88rem; }
+ .fp-btn { flex:0 0 5.5rem; min-height:44px; border-color:var(--line); }
+ .day-sep span { font-size:.72rem; }
+
+ .rail { padding:.7rem .65rem; }
+ .form-grid { grid-template-columns:1fr; }
+ .handover-form { flex-direction:column; align-items:stretch; }
+ .handover-form button { min-height:44px; }
+ .thr-list { grid-template-columns:1fr; }
+ .thr-controls input, .thr-controls button { min-height:44px; }
+ .channel-row, .suppress-row, .risk-row { flex-wrap:wrap; }
+ .channel-row button, .suppress-row button, .risk-row button { min-height:40px; }
+ .detail-grid { gap:.7rem; }
+ .detail-actions { width:100%; }
+ .detail-actions button { flex:1; min-height:44px; }
+}
+
/* ---- Reduced motion ---------------------------------------------- */
@media (prefers-reduced-motion:reduce) {
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
diff --git a/server/public/assets/app.js b/server/public/assets/app.js
index 7a56b24..5e893f4 100644
--- a/server/public/assets/app.js
+++ b/server/public/assets/app.js
@@ -1,7 +1,14 @@
/**
* Events dashboard: polls GET /api/events every 5s, renders the list with
- * severity text+icon+color (never color alone, per NF9), pins unacknowledged
- * events to the top, and wires the acknowledge button to POST /events/{id}/ack.
+ * severity text+icon+color (never color alone, per NF9), and wires the acknowledge
+ * button to POST /events/{id}/ack.
+ *
+ * ORDERING (deliberate): strictly newest-first by occurred_at, ALWAYS. The list used to pin
+ * unacknowledged rows above acknowledged ones, which made the stream look like a timeline
+ * while actually being two concatenated queues — an incident's own follow-up landed pages
+ * away from it, and acknowledging a row teleported it out of view (the next click then hit a
+ * different event, because every row below had shifted up). Triage is served by the 미확인
+ * view mode instead, which FILTERS rather than re-sorts, so the chronology never lies.
*
* XSS-safe: all dynamic values are set via textContent, never innerHTML.
*/
@@ -71,6 +78,57 @@
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
+ // --- day grouping ------------------------------------------------------------------
+ // A strictly chronological stream still needs a visible day boundary: without it the only
+ // signal that the list crossed midnight is two digits inside a mono stamp, and a row from
+ // last Tuesday reads as if it happened this morning.
+ var DOW = ['일', '월', '화', '수', '목', '금', '토'];
+
+ function dayKey(occurredAt) {
+ var d = new Date(Number(occurredAt) * 1000);
+ if (isNaN(d.getTime())) return '';
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
+ }
+
+ function dayLabel(occurredAt) {
+ var d = new Date(Number(occurredAt) * 1000);
+ if (isNaN(d.getTime())) return '';
+ var today = new Date();
+ var key = dayKey(occurredAt);
+ var rel = '';
+ if (key === dayKey(today.getTime() / 1000)) rel = ' · 오늘';
+ else if (key === dayKey(today.getTime() / 1000 - 86400)) rel = ' · 어제';
+ return d.getFullYear() + '. ' + (d.getMonth() + 1) + '. ' + d.getDate()
+ + '. (' + DOW[d.getDay()] + ')' + rel;
+ }
+
+ // --- action feedback -----------------------------------------------------------------
+ // 확인/오탐 are audit-backed and NOT reversible from this console (acknowledged_by is an
+ // append-only record; a silent un-ack would be a worse property for a safety system than a
+ // visible one). So the affordance is confirmation, not undo: the row transitions in place
+ // and this line states exactly which event was just resolved, by type and camera.
+ var toastEl = null;
+ var toastTimer = null;
+
+ function showToast(text) {
+ if (!toastEl) {
+ toastEl = document.createElement('div');
+ toastEl.className = 'action-toast';
+ toastEl.setAttribute('role', 'status');
+ toastEl.setAttribute('aria-live', 'polite');
+ document.body.appendChild(toastEl);
+ }
+ toastEl.textContent = text;
+ toastEl.classList.add('is-on');
+ if (toastTimer) clearTimeout(toastTimer);
+ toastTimer = setTimeout(function () { toastEl.classList.remove('is-on'); }, 4000);
+ }
+
+ function eventSummary(ev) {
+ var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
+ return what + (ev.camera_id ? ' · ' + ev.camera_id : '');
+ }
+
function buildBadge(sev) {
var info = severityInfo(sev);
var badge = document.createElement('span');
@@ -93,6 +151,30 @@
var audioCtx = null;
var alarmTimer = null;
var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately
+ // Session-scoped mute. A repeating tone that cannot be silenced is how a safety console gets
+ // switched off entirely, so the operator gets a control — but a deliberately narrow one: it
+ // lives for this page only (no persistence), it is announced on screen while active, and a
+ // NEWLY arriving critical re-arms it, so muting a known backlog can never hide the next fall.
+ var muted = false;
+ var muteBtn = document.getElementById('alarm-mute');
+ var muteState = document.getElementById('alarm-mute-state');
+
+ function renderMute() {
+ if (muteBtn) {
+ muteBtn.setAttribute('aria-pressed', muted ? 'true' : 'false');
+ muteBtn.textContent = muted ? '음소거 해제' : '경보음 끄기';
+ }
+ if (muteState) {
+ muteState.hidden = !muted;
+ muteState.textContent = muted ? '경보음 꺼짐 — 새 위급 발생 시 자동 해제' : '';
+ }
+ }
+
+ function setMuted(on) {
+ muted = on;
+ if (muted && alarmTimer) { clearInterval(alarmTimer); alarmTimer = null; }
+ renderMute();
+ }
function unlockAudio() {
if (!audioCtx) {
@@ -103,6 +185,7 @@
}
function beep() {
+ if (muted) return;
if (!audioCtx) return;
try {
var osc = audioCtx.createOscillator();
@@ -125,15 +208,24 @@
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
if (ev.severity === 'critical' && !acked) {
hasUnackedCritical = true;
- if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); }
+ if (!beepedCritical[ev.event_id]) {
+ beepedCritical[ev.event_id] = true;
+ // A critical this session has never seen re-arms a muted console: the mute is for a
+ // known backlog, never for the next fall.
+ if (muted) setMuted(false);
+ beep();
+ }
}
});
- if (hasUnackedCritical && !alarmTimer) {
+ if (hasUnackedCritical && !alarmTimer && !muted) {
alarmTimer = setInterval(beep, 3000);
- } else if (!hasUnackedCritical && alarmTimer) {
+ } else if ((!hasUnackedCritical || muted) && alarmTimer) {
clearInterval(alarmTimer);
alarmTimer = null;
}
+ // Nothing left alarming: drop the mute so the control never sits "on" without a reason.
+ if (!hasUnackedCritical && muted) setMuted(false);
+ if (muteBtn) muteBtn.hidden = !hasUnackedCritical && !muted;
}
// No snapshot, or the file is gone (PIPA retention purge / a broken store). Both are the same
@@ -210,11 +302,14 @@
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
typeLink.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
type.appendChild(typeLink);
- // The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip —
- // secondary encoding alongside the red tint, so it never rides on color alone.
- if (!isAcked && ev.severity === 'critical') {
+ // "확인 필요" on EVERY unacknowledged row, not just criticals. The chip used to be a
+ // critical-only flourish, which left 경고/정보 rows with no textual state at all: whether
+ // they were handled could only be inferred from the presence of the action buttons in a
+ // far-right column. The chip is toned by severity so a critical still shouts and an info
+ // row only murmurs — but the FACT is always written, never inferred.
+ if (!isAcked) {
var chip = document.createElement('span');
- chip.className = 'chip';
+ chip.className = 'chip chip-' + sevRow;
chip.textContent = '확인 필요';
type.appendChild(chip);
}
@@ -283,11 +378,12 @@
})
.then(function (res) {
if (!res.ok) throw new Error('ack_failed:' + res.status);
- // Reflect the ack on a "더 보기"-loaded (older-window) row too. loadEvents() only refreshes
- // the live window, so without this an event acked from the paged tail snaps straight back
- // to unacknowledged on the next render — inviting a duplicate ack that overwrites the
- // original acknowledged_by audit record.
- patchOlderAck(eventId);
+ // Patch BOTH windows immediately. The row must resolve in place on this frame, not on
+ // the next 5s poll: a row that keeps saying 미확인 for five seconds invites a second
+ // click that overwrites the original acknowledged_by audit record.
+ var ev = patchEventLocally(eventId, false);
+ renderList();
+ showToast('확인 처리 — ' + (ev ? eventSummary(ev) : '이벤트 ' + eventId));
return loadEvents();
})
.catch(function () {
@@ -295,22 +391,49 @@
});
}
- function patchOlderAck(eventId, alsoFp) {
- for (var i = 0; i < olderEvents.length; i++) {
- var ev = olderEvents[i];
- if (ev && ev.event_id === eventId && !isAcked(ev)) {
- // New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
- var patch = { acknowledged_by: window.APP_USER || '확인' };
- if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐';
- olderEvents[i] = Object.assign({}, ev, patch);
+ /** Apply the resolution to the live window AND the paged tail, and return the patched event.
+ * loadEvents() only refreshes the live window, so without the tail patch an event resolved
+ * from a "더 보기" page snaps back to 미확인 on the next render. */
+ function patchEventLocally(eventId, alsoFp) {
+ var patch = { acknowledged_by: window.APP_USER || '확인' };
+ if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐';
+ var found = null;
+ [liveEvents, olderEvents].forEach(function (list) {
+ for (var i = 0; i < list.length; i++) {
+ var ev = list[i];
+ if (ev && ev.event_id === eventId) {
+ // New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
+ list[i] = Object.assign({}, ev, isAcked(ev) && !alsoFp ? {} : patch);
+ found = list[i];
+ }
}
- }
+ });
+ return found;
}
// The 오탐 tag: same terminality as an ack (the server also acknowledges), plus the event
// feeds the per-camera FP-rate panel. By product decision this is where the loop ENDS —
// the tag never auto-tunes edge thresholds; a human reads the rate and adjusts deliberately.
+ //
+ // Two-step on purpose: unlike 확인, this one asserts the DETECTOR was wrong. It moves a
+ // per-camera statistic that a human later re-tunes a threshold from, and there is no undo.
+ // The first click arms, a second within 4s commits; anything else disarms.
+ var fpArmTimer = null;
+
+ function disarmFp(fpBtn) {
+ if (fpArmTimer) { clearTimeout(fpArmTimer); fpArmTimer = null; }
+ if (fpBtn) { fpBtn.classList.remove('is-armed'); fpBtn.textContent = '오탐'; }
+ }
+
function markFalsePositive(eventId, fpBtn, ackBtn) {
+ if (!fpBtn.classList.contains('is-armed')) {
+ fpBtn.classList.add('is-armed');
+ fpBtn.textContent = '오탐 확정?';
+ if (fpArmTimer) clearTimeout(fpArmTimer);
+ fpArmTimer = setTimeout(function () { disarmFp(fpBtn); }, 4000);
+ return;
+ }
+ disarmFp(fpBtn);
fpBtn.disabled = true;
if (ackBtn) ackBtn.disabled = true;
fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', {
@@ -319,7 +442,9 @@
})
.then(function (res) {
if (!res.ok) throw new Error('fp_failed:' + res.status);
- patchOlderAck(eventId, true);
+ var ev = patchEventLocally(eventId, true);
+ renderList();
+ showToast('오탐 태그 — ' + (ev ? eventSummary(ev) : '이벤트 ' + eventId));
loadFpStats(); // the panel should reflect the tag immediately, not in 60s
return loadEvents();
})
@@ -344,7 +469,24 @@
return ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
}
+ // The strip's scope has to be written down. It counts the newest window (up to 200 events),
+ // which is an unbounded stretch of time — hours on a busy site, months on a quiet one. Left
+ // captioned as a bare "미확인" it sat directly above the 24h brief's own 미확인 count and the
+ // two numbers disagreed with nothing to explain why.
+ var kpiCap = document.getElementById('kpi-cap');
+
+ function renderStatusCaption(events) {
+ if (!kpiCap) return;
+ if (events.length === 0) { kpiCap.textContent = '미확인'; kpiCap.removeAttribute('title'); return; }
+ var oldest = events[events.length - 1];
+ var newest = events[0];
+ kpiCap.textContent = '미확인 · 최근 ' + events.length + '건';
+ kpiCap.title = '집계 범위: ' + formatTime(oldest.occurred_at) + ' ~ ' + formatTime(newest.occurred_at)
+ + ' (아래 배너의 "지난 24시간"과는 기준 구간이 다릅니다)';
+ }
+
function renderStatus(events) {
+ renderStatusCaption(events);
if (!statusRoot) return;
var counts = { critical: 0, high: 0, warning: 0, info: 0 };
events.forEach(function (ev) {
@@ -411,6 +553,18 @@
var filters = { severity: '', type: '', device: '', camera: '', unackedOnly: false };
var moreBtn = document.getElementById('load-more');
+ // --- day-query mode ----------------------------------------------------------------
+ // Accident investigation asks "what happened on the 24th", and the live console could only
+ // answer it by pressing 더 보기 until that day scrolled past. The keyset cursor is
+ // ":", so a specific day is reachable directly: seed the cursor at the
+ // end of that day and page backwards until the window covers it. While a query is active the
+ // stream shows ONLY that day; the poll (and therefore the KPI strip and the alarm) keeps
+ // running against the live window underneath, so investigating never blinds the console.
+ var QUERY_MAX_PAGES = 6;
+ var query = null; // null = live stream; else {sinceTs, untilTs, label, events, loading, truncated}
+
+ function inQueryMode() { return query !== null; }
+
function matchesFilters(ev) {
if (filters.severity && ev.severity !== filters.severity) return false;
if (filters.type && ev.event_type !== filters.type) return false;
@@ -434,14 +588,21 @@
renderStatus(liveEvents); // counts always from the full live window, never the filtered view
updateAlarm(liveEvents); // alarm too — before any early return
renderTree(); // rail: camera stats + the current selection
+ renderQueryBanner();
- var all = combinedEvents();
+ // First paint waits for /api/devices to resolve (success OR failure, and at most
+ // DEVICE_GATE_MS). Rows print the device NAME; painting before the names are known showed
+ // raw ids that swapped a beat later — the operator's first read of the board was of values
+ // that then changed under them.
+ if (!devicesResolved && !deviceGateExpired) return;
+ var all = inQueryMode() ? query.events : combinedEvents();
+
+ // Strictly newest-first. See the ordering note at the top of this file: acknowledgement
+ // state must never reorder a chronology.
var shown = all.filter(matchesFilters).sort(function (a, b) {
- var aA = isAcked(a), bA = isAcked(b);
- if (aA !== bA) return aA ? 1 : -1; // unacknowledged first
var at = Number(a.occurred_at) || 0, bt = Number(b.occurred_at) || 0;
- if (at !== bt) return bt - at; // then newest first
+ if (at !== bt) return bt - at;
return (b.event_id || 0) - (a.event_id || 0);
});
@@ -453,7 +614,8 @@
var sig = shown.map(function (ev) {
return [ev.event_id, ev.acknowledged_by || '', ev.false_positive_by || '',
ev.thumb_uuid || ''].join('|');
- }).join(',') + '#' + String(moreCursor === null) + '#' + String(!!anyFilter);
+ }).join(',') + '#' + String(moreCursor === null) + '#' + String(!!anyFilter)
+ + '#' + (inQueryMode() ? query.label + (query.loading ? ':loading' : '') : 'live');
if (sig === lastListSig) return;
lastListSig = sig;
@@ -466,23 +628,156 @@
if (shown.length === 0) {
var empty = document.createElement('div');
empty.className = 'stream-empty';
- empty.textContent = anyFilter
- ? '조건에 맞는 이벤트가 없습니다. 왼쪽에서 필터를 해제하면 전체 스트림으로 돌아갑니다.'
- : '표시할 이벤트가 없습니다.';
+ if (inQueryMode()) {
+ empty.textContent = query.loading
+ ? '해당 날짜를 조회하는 중…'
+ : query.label + '에는 ' + (anyFilter ? '조건에 맞는 ' : '') + '감지 이벤트가 없습니다.';
+ } else if (anyFilter) {
+ empty.textContent = '조건에 맞는 이벤트가 없습니다. 왼쪽에서 필터를 해제하면 전체 스트림으로 돌아갑니다.';
+ } else if (devicesLoaded && deviceList.length === 0) {
+ // 갓 설치된 시스템. "표시할 이벤트가 없습니다"는 여기서 최악의 문장이다 — 조용하다는
+ // 뜻으로 읽히지만 실제로는 아무것도 감시하고 있지 않다.
+ empty.textContent = '등록된 장치가 없습니다. 이 시스템은 아직 아무것도 감시하고 있지 않습니다. ';
+ var setup = document.createElement('a');
+ setup.href = B + '/setup';
+ setup.textContent = '설치 점검 →';
+ empty.appendChild(setup);
+ } else {
+ empty.textContent = '표시할 이벤트가 없습니다.';
+ }
root.appendChild(empty);
} else {
+ // Day dividers: emitted whenever the calendar day changes going down the (strictly
+ // chronological) list, so crossing midnight is a visible event and not two digits
+ // buried in a mono timestamp.
var frag = document.createDocumentFragment();
- shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
+ var lastDay = null;
+ shown.forEach(function (ev) {
+ var key = dayKey(ev.occurred_at);
+ if (key && key !== lastDay) {
+ lastDay = key;
+ var sep = document.createElement('div');
+ sep.className = 'day-sep';
+ sep.setAttribute('role', 'separator');
+ var lbl = document.createElement('span');
+ lbl.textContent = dayLabel(ev.occurred_at);
+ sep.appendChild(lbl);
+ frag.appendChild(sep);
+ }
+ frag.appendChild(buildRow(ev));
+ });
root.appendChild(frag);
}
if (refocus) {
var back = root.querySelector('[data-focus="' + refocus + '"]');
if (back) back.focus();
}
- if (moreBtn) moreBtn.hidden = (moreCursor === null);
+ renderBulk();
+ // The keyboard cursor is index-based and the list was just rebuilt; clamp it rather than
+ // leaving it pointing past the end (where A would silently do nothing).
+ var rowCount = root.querySelectorAll('.event-row').length;
+ if (cursor >= rowCount) cursor = rowCount - 1;
+ if (cursor >= 0) {
+ var rows = [].slice.call(root.querySelectorAll('.event-row'));
+ rows.forEach(function (r, i) { r.classList.toggle('is-cursor', i === cursor); });
+ }
+ // "더 보기" pages the LIVE stream only: in day-query mode the range is already fully
+ // fetched, so offering it there would page past the day the operator asked for.
+ if (moreBtn) moreBtn.hidden = inQueryMode() || (moreCursor === null);
updatePageGapHint();
}
+ // --- day query: fetch + banner -------------------------------------------------------
+ var queryBanner = null;
+
+ function renderQueryBanner() {
+ if (!queryBanner) {
+ queryBanner = document.createElement('div');
+ queryBanner.className = 'query-banner';
+ var text = document.createElement('span');
+ text.className = 'query-text';
+ var back = document.createElement('button');
+ back.type = 'button';
+ back.textContent = '실시간 스트림으로';
+ back.addEventListener('click', exitQuery);
+ queryBanner.appendChild(text);
+ queryBanner.appendChild(back);
+ if (root.parentNode) root.parentNode.insertBefore(queryBanner, root);
+ }
+ queryBanner.hidden = !inQueryMode();
+ if (!inQueryMode()) return;
+ var t = queryBanner.querySelector('.query-text');
+ if (query.loading) {
+ t.textContent = query.label + ' 조회 중…';
+ } else {
+ t.textContent = query.label + ' 조회 · ' + query.events.length + '건'
+ + (query.truncated ? ' (표시 상한에 도달해 이 날의 가장 이른 구간이 잘렸습니다)' : '');
+ }
+ queryBanner.classList.toggle('is-truncated', !!query.truncated);
+ }
+
+ function exitQuery() {
+ query = null;
+ lastListSig = null;
+ if (dateInput) dateInput.value = '';
+ renderList();
+ }
+
+ function runDayQuery(dateStr) {
+ var parts = String(dateStr || '').split('-');
+ if (parts.length !== 3) return;
+ var start = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]), 0, 0, 0, 0);
+ if (isNaN(start.getTime())) return;
+ var sinceTs = Math.floor(start.getTime() / 1000);
+ var untilTs = sinceTs + 86400;
+ query = {
+ sinceTs: sinceTs, untilTs: untilTs,
+ label: dayLabel(sinceTs + 1),
+ events: [], loading: true, truncated: false
+ };
+ lastListSig = null;
+ renderList();
+
+ // Page backwards from the end of the requested day until the window passes its start.
+ var page = 0;
+ function fetchPage(cursor) {
+ page += 1;
+ return fetch(B + '/api/events?limit=200&cursor=' + encodeURIComponent(cursor),
+ { headers: { Accept: 'application/json' } })
+ .then(function (res) {
+ if (res.status === 401) { window.location.href = B + '/login'; return null; }
+ if (!res.ok) throw new Error('query_failed:' + res.status);
+ return res.json();
+ })
+ .then(function (data) {
+ if (!data || !query || query.sinceTs !== sinceTs) return; // superseded/cancelled
+ var rows = data.events || [];
+ var reachedStart = false;
+ rows.forEach(function (ev) {
+ var at = Number(ev.occurred_at) || 0;
+ if (at < sinceTs) { reachedStart = true; return; }
+ if (at < untilTs) query.events.push(ev);
+ });
+ if (!reachedStart && data.next_cursor && page < QUERY_MAX_PAGES) {
+ return fetchPage(data.next_cursor);
+ }
+ query.truncated = !reachedStart && !!data.next_cursor;
+ query.loading = false;
+ lastListSig = null;
+ renderList();
+ });
+ }
+
+ fetchPage(untilTs + ':2147483647').catch(function () {
+ if (!query) return;
+ query.loading = false;
+ query.truncated = false;
+ lastListSig = null;
+ renderList();
+ showToast('해당 날짜를 불러오지 못했습니다.');
+ });
+ }
+
function updatePageGapHint() {
// A gap exists once new events have arrived since the operator paged: some events have slid out
// of the live window into a band that was never fetched. Surface it honestly with a reload,
@@ -596,6 +891,18 @@
var devicesLoaded = false; // false until the first success, so "empty" ≠ "not known yet"
var devicesStale = false; // last poll failed: show the old state, but say that it is old
var deviceLabelSig = ''; // device_id=name pairs last painted into the stream rows
+ // First-paint gate (see renderList): resolved on the first /api/devices outcome, success or
+ // failure. The timer is the backstop — a hung devices endpoint must delay the stream by a
+ // moment, never withhold it, because the events are the safety-critical half of this page.
+ var devicesResolved = false;
+ var deviceGateExpired = false;
+ var DEVICE_GATE_MS = 2500;
+ setTimeout(function () {
+ if (devicesResolved) return;
+ deviceGateExpired = true;
+ lastListSig = null;
+ renderList();
+ }, DEVICE_GATE_MS);
var SEV_RANK = { info: 0, warning: 1, high: 2, critical: 3 };
var CAM_SEP = '\u001f'; // (device, camera) key separator — a unit separator, never in an id
@@ -672,10 +979,12 @@
return res.json();
})
.then(function (data) {
+ if (data && data.storage) renderStorage(data.storage);
if (data && Array.isArray(data.devices)) {
deviceList = data.devices;
devicesLoaded = true;
devicesStale = false;
+ devicesResolved = true;
// The stream rows print the device's NAME, not its id, so a changed name set — the
// first load above all, which lands after the first event render — has to repaint the
// list too. Unchanged names (the steady state) only repaint the rail.
@@ -689,16 +998,145 @@
// an empty one: a camera that dies afterwards would keep showing "온라인". Keep the last
// state, but flag it. Cleared automatically by the next successful poll.
devicesStale = true;
- renderTree();
+ // A failed devices poll still RESOLVES the first-paint gate: the stream must not be
+ // held hostage to the rail. Rows then print the raw device_id, which is the honest
+ // answer while no name is known.
+ if (!devicesResolved) { devicesResolved = true; lastListSig = null; renderList(); }
+ else renderTree();
});
}
+ // --- storage headroom --------------------------------------------------------------
+ // A full snapshot volume does not announce itself: image writes start failing, ingest logs
+ // and carries on (an event without its picture still beats no event), and the board stays
+ // green while the evidence quietly stops being kept. Say it out loud instead.
+ var storageWarn = document.getElementById('storage-warn');
+
+ function renderStorage(s) {
+ if (!storageWarn) return;
+ if (!s || !s.low) { storageWarn.hidden = true; return; }
+ storageWarn.hidden = false;
+ storageWarn.textContent = '';
+ storageWarn.appendChild(spanWith('warn-lead', '저장 공간 부족'));
+ storageWarn.appendChild(document.createTextNode(
+ ' — 스냅샷 볼륨 여유 ' + s.free_mb + 'MB (기준 ' + s.min_free_mb + 'MB). '
+ + '이 상태가 지속되면 새 사건의 증거 이미지가 저장되지 않습니다. '
+ + '보존기간 설정을 줄이거나 용량을 확보하세요.'));
+ }
+
+ // --- bulk acknowledge ----------------------------------------------------------------
+ // For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall
+ // is exactly the row a human must look at, and a control that can clear one in a sweep is a
+ // control that will eventually clear one nobody read.
+ var bulkBtn = document.getElementById('bulk-ack');
+ var bulkArmed = false;
+ var bulkTimer = null;
+
+ function bulkTargets() {
+ if (!filters.unackedOnly) return [];
+ var src = inQueryMode() ? query.events : combinedEvents();
+ return src.filter(function (ev) {
+ return matchesFilters(ev) && !isAcked(ev) && ev.severity !== 'critical';
+ });
+ }
+
+ function renderBulk() {
+ if (!bulkBtn) return;
+ var n = bulkTargets().length;
+ // Only offered in the triage view; in the chronological view a sweep has no clear scope.
+ bulkBtn.hidden = !filters.unackedOnly || n === 0;
+ if (bulkBtn.hidden) { disarmBulk(); return; }
+ if (!bulkArmed) bulkBtn.textContent = '위급 제외 ' + n + '건 일괄 확인';
+ }
+
+ function disarmBulk() {
+ if (bulkTimer) { clearTimeout(bulkTimer); bulkTimer = null; }
+ bulkArmed = false;
+ if (bulkBtn) bulkBtn.classList.remove('is-armed');
+ }
+
+ if (bulkBtn) {
+ bulkBtn.addEventListener('click', function () {
+ var targets = bulkTargets();
+ if (!targets.length) return;
+ if (!bulkArmed) {
+ bulkArmed = true;
+ bulkBtn.classList.add('is-armed');
+ bulkBtn.textContent = targets.length + '건을 확인 처리합니다. 한 번 더 누르세요';
+ bulkTimer = setTimeout(function () { disarmBulk(); renderBulk(); }, 5000);
+ return;
+ }
+ disarmBulk();
+ bulkBtn.disabled = true;
+ var done = 0;
+ Promise.all(targets.map(function (ev) {
+ return fetch(B + '/events/' + encodeURIComponent(ev.event_id) + '/ack', {
+ method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' }
+ }).then(function (res) {
+ if (res.ok) { patchEventLocally(ev.event_id, false); done++; }
+ }).catch(function () {});
+ })).then(function () {
+ bulkBtn.disabled = false;
+ lastListSig = null;
+ renderList();
+ showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)');
+ loadEvents();
+ });
+ });
+ }
+
+ // --- keyboard triage -----------------------------------------------------------------
+ // A desk operator working a backlog should not have to move a mouse between rows. The
+ // shortcuts are deliberately non-destructive except for A: F still arms the two-step 오탐.
+ var cursor = -1;
+
+ function rowsOnScreen() {
+ return [].slice.call(root.querySelectorAll('.event-row'));
+ }
+
+ function moveCursor(delta) {
+ var rows = rowsOnScreen();
+ if (!rows.length) return;
+ cursor = Math.max(0, Math.min(rows.length - 1, cursor < 0 ? 0 : cursor + delta));
+ rows.forEach(function (r, i) { r.classList.toggle('is-cursor', i === cursor); });
+ rows[cursor].scrollIntoView({ block: 'nearest' });
+ }
+
+ function cursorRow() {
+ var rows = rowsOnScreen();
+ return cursor >= 0 && cursor < rows.length ? rows[cursor] : null;
+ }
+
+ document.addEventListener('keydown', function (e) {
+ // Never steal a key from a field, and never from a modified chord (browser shortcuts).
+ var t = e.target;
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return;
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
+
+ var key = e.key.toLowerCase();
+ if (key === 'j') { e.preventDefault(); moveCursor(1); return; }
+ if (key === 'k') { e.preventDefault(); moveCursor(-1); return; }
+ var row = cursorRow();
+ if (!row) return;
+ if (key === 'a') {
+ var ack = row.querySelector('.ack-btn');
+ if (ack) { e.preventDefault(); ack.click(); }
+ } else if (key === 'f') {
+ var fp = row.querySelector('.fp-btn');
+ if (fp) { e.preventDefault(); fp.click(); }
+ } else if (e.key === 'Enter') {
+ var link = row.querySelector('.event-detail-link');
+ if (link) { e.preventDefault(); window.location.href = link.href; }
+ }
+ });
+
// --- rail: FP-rate panel ----------------------------------------------------------
// Per-(camera, type) false-positive rates over 30 days. Refreshed on its own slow cadence
// (60s) — tag volume changes slowly — plus immediately after the operator tags one.
var fpStatsRoot = document.getElementById('fp-stats-root');
var FP_STATS_INTERVAL_MS = 60000;
var FP_STATS_SHOWN = 5;
+ var FP_MIN_SAMPLE = 5; // below this a ratio is noise, not a rate — see renderFpStats
function renderFpStats(stats) {
if (!fpStatsRoot) return;
@@ -722,9 +1160,19 @@
row.appendChild(what);
var rate = document.createElement('span');
var pct = Math.round((s.fp / Math.max(1, s.total)) * 100);
- rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : '');
- rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')';
- // 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
+ // Sample gate. "100% (1/1)" is not a false-positive RATE, it is one tagged event — and
+ // printed in the same column as "33% (5/15)" it outranked it. Below the minimum sample
+ // the raw counts are shown instead, with no percentage and no hot styling, so nothing
+ // reads as a tuning signal until there is enough evidence to be one.
+ if (s.total < FP_MIN_SAMPLE) {
+ rate.className = 'fp-rate is-thin';
+ rate.textContent = s.fp + '/' + s.total + ' (표본 부족)';
+ rate.title = '오탐율은 ' + FP_MIN_SAMPLE + '건 이상 감지된 카메라·유형에만 표시합니다.';
+ } else {
+ // 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
+ rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : '');
+ rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')';
+ }
row.appendChild(rate);
fpStatsRoot.appendChild(row);
});
@@ -764,11 +1212,14 @@
if (t.events === 0) {
parts.push('감지 이벤트 없음');
} else {
- parts.push('감지 ' + t.events + '건' + (severe > 0 ? ' (중대 ' + severe + ')' : ''));
+ // "중대" is this product's word for 위급+높음; spell the two tiers out inline so the
+ // reader never has to guess how it relates to the 위급/높음 tiles right above.
+ parts.push('감지 ' + t.events + '건'
+ + (severe > 0 ? ' (중대 ' + severe + ' = 위급 ' + t.critical + ' + 높음 ' + t.high + ')' : ''));
if (r.near_miss.near_fall > 0) parts.push('니어미스 ' + r.near_miss.near_fall);
var avg = briefDuration(r.tta.avg_s);
if (avg) parts.push('평균 대응 ' + avg);
- parts.push(t.unacked > 0 ? '미확인 ' + t.unacked + '건 남음' : '전부 확인됨');
+ parts.push(t.unacked > 0 ? '이 중 미확인 ' + t.unacked + '건' : '전부 확인됨');
}
dayBrief.appendChild(document.createTextNode(' — ' + parts.join(' · ') + ' '));
var link = document.createElement('a');
@@ -793,10 +1244,43 @@
var typeSel = document.getElementById('type-filter');
if (typeSel) typeSel.addEventListener('change', function () { filters.type = typeSel.value; renderList(); });
if (cameraReset) cameraReset.addEventListener('click', clearCamera);
- var unackedChk = document.getElementById('unacked-only');
- if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); });
if (moreBtn) moreBtn.addEventListener('click', loadMore);
+ // View mode — a FILTER, never a re-sort. "전체" is the chronology; "미확인" is the triage
+ // queue over the same chronology. Swapping between them can add or remove rows but can
+ // never move one relative to another, so the operator's mental map of the stream survives.
+ var modeBtns = [].slice.call(document.querySelectorAll('[data-view-mode]'));
+ function setViewMode(mode) {
+ filters.unackedOnly = (mode === 'unacked');
+ modeBtns.forEach(function (b) {
+ var on = b.getAttribute('data-view-mode') === mode;
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
+ b.classList.toggle('is-on', on);
+ });
+ renderList();
+ }
+ modeBtns.forEach(function (b) {
+ b.addEventListener('click', function () { setViewMode(b.getAttribute('data-view-mode')); });
+ });
+ if (modeBtns.length) setViewMode('all');
+
+ // 날짜 조회 (see runDayQuery)
+ var dateInput = document.getElementById('date-jump');
+ var dateGo = document.getElementById('date-jump-go');
+ if (dateGo && dateInput) {
+ dateGo.addEventListener('click', function () {
+ if (dateInput.value) runDayQuery(dateInput.value);
+ });
+ dateInput.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && dateInput.value) { e.preventDefault(); runDayQuery(dateInput.value); }
+ });
+ dateInput.max = dayKey(Date.now() / 1000);
+ }
+
+ if (muteBtn) muteBtn.addEventListener('click', function () { setMuted(!muted); });
+ renderMute();
+ if (muteBtn) muteBtn.hidden = true;
+
loadCsrfToken();
loadEvents();
loadDevices();
diff --git a/server/public/assets/compliance.js b/server/public/assets/compliance.js
new file mode 100644
index 0000000..33a8e0a
--- /dev/null
+++ b/server/public/assets/compliance.js
@@ -0,0 +1,420 @@
+/**
+ * 안전 관리: 교대 인수인계 · 정비 억제 · 위험성평가 · 설비 성능 추이.
+ *
+ * XSS-safe: 모든 동적 값은 textContent. 그래프는 인라인 SVG 를 DOM API 로 조립한다
+ * (문자열 innerHTML 금지 — 프로젝트 규약이자, 카메라 이름 같은 값이 마크업에 섞이는 것을 막는다).
+ */
+(function () {
+ 'use strict';
+
+ var B = window.APP_BASE || '';
+ var root = document.querySelector('.compliance-page');
+ if (!root) return;
+
+ var csrf = null;
+ var TYPE_LABELS = {
+ fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
+ ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
+ pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
+ lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
+ near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
+ camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
+ unacked_escalation: '미확인 경보 에스컬레이션',
+ ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
+ };
+
+ function el(tag, cls, text) {
+ var n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined && text !== null) n.textContent = text;
+ return n;
+ }
+ function pad2(n) { return n < 10 ? '0' + n : String(n); }
+ function fmtDate(ts) {
+ var d = new Date(Number(ts) * 1000);
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
+ }
+ function fmtStamp(ts) {
+ var d = new Date(Number(ts) * 1000);
+ return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
+ }
+ function fmtLeft(seconds) {
+ if (seconds <= 0) return '만료됨';
+ if (seconds < 3600) return Math.ceil(seconds / 60) + '분 남음';
+ return Math.floor(seconds / 3600) + '시간 ' + Math.round((seconds % 3600) / 60) + '분 남음';
+ }
+
+ function loadCsrf() {
+ return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { if (d && d.token) csrf = d.token; })
+ .catch(function () {});
+ }
+ function post(path, body) {
+ return fetch(B + path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' },
+ body: JSON.stringify(body || {})
+ }).then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return null; }
+ return r.json().then(function (d) { return { ok: r.ok, data: d }; });
+ });
+ }
+ function getJson(path) {
+ return fetch(B + path, { headers: { Accept: 'application/json' } })
+ .then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return null; }
+ return r.ok ? r.json() : null;
+ });
+ }
+
+ var ERRORS = {
+ reason_required: '사유를 입력하세요 — 사유 없는 억제는 나중에 아무도 해석할 수 없습니다.',
+ invalid_window: '기간은 1분 이상 8시간(480분) 이하여야 합니다.',
+ invalid_scope_id: '대상을 선택하세요.',
+ unknown_scope_id: '등록되지 않은 대상입니다.',
+ invalid_rating: '강도·빈도는 1~5 사이여야 합니다.',
+ invalid_hazard: '위험요인을 입력하세요.',
+ invalid_control: '현재 대책을 입력하세요.',
+ forbidden: '권한이 없습니다.',
+ csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.'
+ };
+ function errText(c) { return ERRORS[c] || ('처리에 실패했습니다 (' + String(c) + ')'); }
+
+ // ============================================================ 교대 인수인계
+ var pendingBox = document.getElementById('handover-pending');
+ var handoverList = document.getElementById('handover-list');
+
+ function renderHandovers(d) {
+ var p = d.pending || {};
+ pendingBox.textContent = '';
+ var head = el('div', 'handover-head');
+ head.appendChild(el('strong', null, '인수 대상 구간'));
+ head.appendChild(el('span', 'handover-range',
+ fmtStamp(p.period_start) + ' ~ ' + fmtStamp(p.period_end)));
+ pendingBox.appendChild(head);
+
+ var stats = el('div', 'handover-stats');
+ var a = el('div', 'handover-stat' + (p.unacked_count > 0 ? ' is-open' : ''));
+ a.appendChild(el('strong', null, String(p.unacked_count || 0)));
+ a.appendChild(el('span', null, '미확인'));
+ stats.appendChild(a);
+ var b = el('div', 'handover-stat' + (p.severe_count > 0 ? ' is-severe' : ''));
+ b.appendChild(el('strong', null, String(p.severe_count || 0)));
+ b.appendChild(el('span', null, '이 중 중대'));
+ stats.appendChild(b);
+ pendingBox.appendChild(stats);
+ if (p.from_user) {
+ pendingBox.appendChild(el('p', 'handover-from', '인계자: ' + p.from_user));
+ }
+
+ handoverList.textContent = '';
+ var list = d.handovers || [];
+ if (!list.length) {
+ handoverList.appendChild(el('p', 'admin-empty', '기록된 인수인계가 없습니다.'));
+ return;
+ }
+ list.forEach(function (h) {
+ var row = el('div', 'handover-row');
+ row.appendChild(el('span', 'handover-when', fmtStamp(h.created_at)));
+ row.appendChild(el('span', 'handover-who',
+ (h.from_user ? h.from_user + ' → ' : '') + h.to_user));
+ row.appendChild(el('span', 'handover-count',
+ '미확인 ' + h.unacked_count + ' (중대 ' + h.severe_count + ')'));
+ if (h.note) row.appendChild(el('span', 'handover-note', h.note));
+ handoverList.appendChild(row);
+ });
+ }
+
+ function loadHandovers() {
+ getJson('/api/handovers').then(function (d) { if (d) renderHandovers(d); })
+ .catch(function () { pendingBox.textContent = '인수인계 정보를 불러오지 못했습니다.'; });
+ }
+
+ var handoverForm = document.getElementById('handover-form');
+ if (handoverForm) {
+ handoverForm.addEventListener('submit', function (ev) {
+ ev.preventDefault();
+ var note = document.getElementById('handover-note');
+ post('/api/handovers', { note: note.value.trim() }).then(function (r) {
+ if (!r) return;
+ if (r.ok) { note.value = ''; loadHandovers(); }
+ });
+ });
+ }
+
+ // ============================================================ 억제
+ var supList = document.getElementById('suppress-list');
+ var supStatus = document.getElementById('sup-status');
+ var supType = document.getElementById('sup-scope-type');
+ var supId = document.getElementById('sup-scope-id');
+ var deviceRows = [];
+ var cameraRows = [];
+
+ function fillScopeOptions() {
+ var rows = supType.value === 'device' ? deviceRows : cameraRows;
+ supId.textContent = '';
+ if (!rows.length) {
+ supId.appendChild(new Option(supType.value === 'device' ? '등록된 장치가 없습니다' : '관측된 카메라가 없습니다', ''));
+ return;
+ }
+ supId.appendChild(new Option('선택하세요', ''));
+ rows.forEach(function (r) { supId.appendChild(new Option(r.label, r.id)); });
+ }
+
+ function renderSuppressions(d) {
+ var now = d.now || Math.floor(Date.now() / 1000);
+ supList.textContent = '';
+ var list = d.suppressions || [];
+ if (!list.length) {
+ supList.appendChild(el('p', 'admin-empty', '현재 억제 중인 대상이 없습니다.'));
+ return;
+ }
+ list.forEach(function (s) {
+ var row = el('div', 'suppress-row');
+ var main = el('div', 'suppress-main');
+ main.appendChild(el('span', 'suppress-scope',
+ (s.scope_type === 'device' ? '장치' : s.scope_type === 'camera' ? '카메라' : '구역') + ' ' + s.scope_id));
+ main.appendChild(el('span', 'suppress-reason', s.reason));
+ main.appendChild(el('span', 'suppress-meta',
+ s.created_by + ' · ' + fmtStamp(s.starts_at) + ' 시작 · ' + fmtLeft(s.ends_at - now)));
+ row.appendChild(main);
+ var stop = el('button', null, '해제');
+ stop.type = 'button';
+ stop.addEventListener('click', function () {
+ post('/api/suppressions/' + s.suppression_id + '/revoke').then(function (r) {
+ if (r && r.ok) loadSuppressions();
+ });
+ });
+ row.appendChild(stop);
+ supList.appendChild(row);
+ });
+ }
+
+ function loadSuppressions() {
+ getJson('/api/suppressions').then(function (d) { if (d) renderSuppressions(d); })
+ .catch(function () { supList.textContent = '억제 목록을 불러오지 못했습니다.'; });
+ }
+
+ var supForm = document.getElementById('suppress-form');
+ if (supForm) {
+ supType.addEventListener('change', fillScopeOptions);
+ supForm.addEventListener('submit', function (ev) {
+ ev.preventDefault();
+ supStatus.textContent = '';
+ post('/api/suppressions', {
+ scope_type: supType.value,
+ scope_id: supId.value,
+ minutes: Number(document.getElementById('sup-minutes').value) || 0,
+ reason: document.getElementById('sup-reason').value.trim()
+ }).then(function (r) {
+ if (!r) return;
+ if (!r.ok) { supStatus.textContent = errText(r.data && r.data.error); return; }
+ supStatus.textContent = '억제를 시작했습니다. ' + fmtStamp(r.data.ends_at) + ' 에 자동 해제됩니다.';
+ document.getElementById('sup-reason').value = '';
+ loadSuppressions();
+ });
+ });
+ }
+
+ // ============================================================ 위험성평가
+ var riskList = document.getElementById('risk-list');
+ var riskForm = document.getElementById('risk-form');
+ var riskStatus = document.getElementById('risk-status');
+
+ function riskBand(score) {
+ if (score >= 15) return { cls: 'is-high', text: '높음' };
+ if (score >= 8) return { cls: 'is-mid', text: '보통' };
+ return { cls: 'is-low', text: '낮음' };
+ }
+
+ function renderRisks(list) {
+ riskList.textContent = '';
+ if (!list.length) {
+ riskList.appendChild(el('p', 'admin-empty',
+ '등록된 위험요인이 없습니다. 감지 유형별로 무엇을 통제하고 있는지 기록해 두면 사고조사 패키지에 함께 실립니다.'));
+ return;
+ }
+ list.forEach(function (r) {
+ var band = riskBand(r.risk_score);
+ var row = el('div', 'risk-row');
+ var score = el('div', 'risk-score ' + band.cls);
+ score.appendChild(el('strong', null, String(r.risk_score)));
+ score.appendChild(el('span', null, band.text));
+ row.appendChild(score);
+
+ var body = el('div', 'risk-body');
+ body.appendChild(el('span', 'risk-type', TYPE_LABELS[r.event_type] || r.event_type));
+ body.appendChild(el('span', 'risk-hazard', r.hazard));
+ body.appendChild(el('span', 'risk-control', '대책: ' + r.control));
+ var meta = '강도 ' + r.severity_rating + ' × 빈도 ' + r.likelihood_rating;
+ if (r.reviewed_by) meta += ' · ' + r.reviewed_by + ' ' + fmtDate(r.reviewed_at);
+ body.appendChild(el('span', 'risk-meta', meta));
+ row.appendChild(body);
+
+ var edit = el('button', null, '수정');
+ edit.type = 'button';
+ edit.addEventListener('click', function () { openRisk(r); });
+ row.appendChild(edit);
+ riskList.appendChild(row);
+ });
+ }
+
+ function openRisk(r) {
+ riskForm.hidden = false;
+ riskStatus.textContent = '';
+ var sel = document.getElementById('risk-type');
+ if (!sel.options.length) {
+ Object.keys(TYPE_LABELS).forEach(function (k) { sel.appendChild(new Option(TYPE_LABELS[k], k)); });
+ }
+ r = r || {};
+ sel.value = r.event_type || 'fall';
+ document.getElementById('risk-hazard').value = r.hazard || '';
+ document.getElementById('risk-control').value = r.control || '';
+ document.getElementById('risk-severity').value = r.severity_rating || 3;
+ document.getElementById('risk-likelihood').value = r.likelihood_rating || 3;
+ document.getElementById('risk-hazard').focus();
+ }
+
+ if (riskForm) {
+ document.getElementById('risk-new').addEventListener('click', function () { openRisk(null); });
+ document.getElementById('risk-cancel').addEventListener('click', function () { riskForm.hidden = true; });
+ riskForm.addEventListener('submit', function (ev) {
+ ev.preventDefault();
+ post('/api/risks', {
+ event_type: document.getElementById('risk-type').value,
+ hazard: document.getElementById('risk-hazard').value.trim(),
+ control: document.getElementById('risk-control').value.trim(),
+ severity_rating: Number(document.getElementById('risk-severity').value),
+ likelihood_rating: Number(document.getElementById('risk-likelihood').value)
+ }).then(function (r) {
+ if (!r) return;
+ if (!r.ok) { riskStatus.textContent = errText(r.data && r.data.error); return; }
+ riskStatus.textContent = '저장되었습니다.';
+ riskForm.hidden = true;
+ loadRisks();
+ });
+ });
+ }
+
+ function loadRisks() {
+ getJson('/api/risks').then(function (d) { if (d) renderRisks(d.controls || []); })
+ .catch(function () { riskList.textContent = '위험성평가를 불러오지 못했습니다.'; });
+ }
+
+ // ============================================================ 성능 추이
+ var metricDevice = document.getElementById('metric-device');
+ var metricChart = document.getElementById('metric-chart');
+ var SVG_NS = 'http://www.w3.org/2000/svg';
+
+ function svg(tag, attrs) {
+ var n = document.createElementNS(SVG_NS, tag);
+ Object.keys(attrs || {}).forEach(function (k) { n.setAttribute(k, String(attrs[k])); });
+ return n;
+ }
+
+ /** 값이 없는 구간은 선을 잇지 않는다 — 없는 데이터를 직선으로 메우면 그래프가 거짓말을 한다. */
+ function polylines(points, key, x, y) {
+ var segs = [], cur = [];
+ points.forEach(function (p, i) {
+ var v = p[key];
+ if (v === null || v === undefined) { if (cur.length > 1) segs.push(cur); cur = []; return; }
+ cur.push(x(i) + ',' + y(v));
+ });
+ if (cur.length > 1) segs.push(cur);
+ return segs;
+ }
+
+ function renderMetrics(d) {
+ metricChart.textContent = '';
+ var pts = d.points || [];
+ if (pts.length < 2) {
+ metricChart.appendChild(el('p', 'admin-empty',
+ '이 장치의 성능 기록이 아직 충분하지 않습니다. 하트비트가 쌓이면 추이가 그려집니다.'));
+ return;
+ }
+ var W = 720, H = 160, PAD = 28;
+ var fpsVals = pts.map(function (p) { return p.fps; }).filter(function (v) { return v !== null; });
+ var maxFps = Math.max(30, Math.ceil(Math.max.apply(null, fpsVals.length ? fpsVals : [30])));
+ var x = function (i) { return PAD + (i / (pts.length - 1)) * (W - PAD * 2); };
+ var yFps = function (v) { return H - PAD - (v / maxFps) * (H - PAD * 2); };
+
+ var chart = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, class: 'metric-svg',
+ role: 'img', 'aria-label': '프레임률 추이' });
+ chart.appendChild(svg('line', { x1: PAD, y1: H - PAD, x2: W - PAD, y2: H - PAD, class: 'axis' }));
+ chart.appendChild(svg('line', { x1: PAD, y1: PAD, x2: PAD, y2: H - PAD, class: 'axis' }));
+
+ polylines(pts, 'fps', x, yFps).forEach(function (seg) {
+ chart.appendChild(svg('polyline', { points: seg.join(' '), class: 'series-fps' }));
+ });
+
+ // 스로틀 구간은 배경 띠로 — 프레임률 하락의 원인을 같은 그림에서 읽을 수 있어야 한다.
+ pts.forEach(function (p, i) {
+ if (!p.throttle) return;
+ chart.appendChild(svg('rect', { x: x(i) - 1, y: PAD, width: 3, height: H - PAD * 2,
+ class: 'series-throttle' }));
+ });
+
+ metricChart.appendChild(chart);
+
+ var legend = el('div', 'metric-legend');
+ legend.appendChild(el('span', 'legend-fps', '프레임률 (최대 ' + maxFps + ')'));
+ legend.appendChild(el('span', 'legend-throttle', '과열 스로틀 구간'));
+ legend.appendChild(el('span', 'legend-range',
+ fmtStamp(pts[0].ts) + ' ~ ' + fmtStamp(pts[pts.length - 1].ts) + ' · ' + pts.length + '점'));
+ metricChart.appendChild(legend);
+
+ var last = pts[pts.length - 1];
+ var summary = el('p', 'metric-summary');
+ var bits = [];
+ if (last.fps !== null) bits.push('현재 ' + last.fps.toFixed(1) + ' fps');
+ if (last.temp !== null) bits.push(last.temp.toFixed(1) + '°C');
+ if (last.queue_pending !== null) bits.push('전송 대기 ' + last.queue_pending + '건');
+ if (last.cameras_erroring) bits.push('오류 카메라 ' + last.cameras_erroring + '대');
+ summary.textContent = bits.join(' · ');
+ metricChart.appendChild(summary);
+ }
+
+ function loadMetrics() {
+ var dev = metricDevice.value;
+ if (!dev) { metricChart.textContent = '장치를 선택하세요.'; return; }
+ metricChart.textContent = '불러오는 중…';
+ getJson('/api/devices/' + encodeURIComponent(dev) + '/metrics?hours=24')
+ .then(function (d) { if (d) renderMetrics(d); })
+ .catch(function () { metricChart.textContent = '성능 기록을 불러오지 못했습니다.'; });
+ }
+
+ if (metricDevice) metricDevice.addEventListener('change', loadMetrics);
+
+ // ---------------------------------------------------------------- 공통 목록
+ function loadTargets() {
+ return Promise.all([getJson('/api/devices'), getJson('/api/events?limit=200')])
+ .then(function (res) {
+ var devs = (res[0] && res[0].devices) || [];
+ deviceRows = devs.map(function (d) {
+ return { id: d.device_id, label: (d.name || d.device_id) + ' · ' + d.device_id };
+ });
+ var seen = {};
+ cameraRows = [];
+ ((res[1] && res[1].events) || []).forEach(function (e) {
+ if (!e.camera_id || seen[e.camera_id]) return;
+ seen[e.camera_id] = true;
+ cameraRows.push({ id: e.camera_id, label: e.camera_id });
+ });
+ cameraRows.sort(function (a, b) { return a.id < b.id ? -1 : 1; });
+ fillScopeOptions();
+
+ metricDevice.textContent = '';
+ metricDevice.appendChild(new Option(devs.length ? '장치를 선택하세요' : '등록된 장치가 없습니다', ''));
+ deviceRows.forEach(function (d) { metricDevice.appendChild(new Option(d.label, d.id)); });
+ })
+ .catch(function () {});
+ }
+
+ loadCsrf().then(function () {
+ loadTargets();
+ loadHandovers();
+ loadSuppressions();
+ loadRisks();
+ });
+})();
diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js
index ab0e6b1..f4967a8 100644
--- a/server/public/assets/detail.js
+++ b/server/public/assets/detail.js
@@ -3,6 +3,11 @@
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
* plus each snapshot as an .
*
+ * This is where an incident is JUDGED, so it is also where it is RESOLVED: 확인/오탐 post to
+ * the same routes the stream uses. Previously the operator had to read the evidence here,
+ * navigate back, and find the row again — the deepest screen had no way to act on what it
+ * showed, which is exactly backwards.
+ *
* XSS-safe: every dynamic value is written via textContent or a validated URL
* attribute, never innerHTML. Severity is shown as icon + text (never color
* alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
@@ -32,8 +37,11 @@
};
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo
- // report's whole value IS its metrics). Unknown keys fall through as the raw key.
+ // report's whole value IS its metrics). Unknown keys fall through as the raw key — which is
+ // why `confidence` had been rendering as a bare uppercase "CONFIDENCE" in an otherwise
+ // Korean column. Anything the edge can attach belongs in this table.
var METRIC_LABELS = {
+ confidence: '검출 신뢰도', score: '검출 점수',
observed_s: '관측 시간', trunk_high_s: '허리 고위험(60°+) 노출',
trunk_mod_s: '허리 중위험(20~60°) 노출', arm_high_s: '팔 거상(90°+) 노출',
squat_s: '웅크림 노출', prone_s: '엎드림 지속', immobility_s: '부동 지속',
@@ -43,16 +51,26 @@
last_state: '소실 시 상태'
};
+ var STATE_LABELS = { FALLING: '넘어지는 중', FALLEN: '넘어짐', NO_MOTION: '미동 없음', UPRIGHT: '기립' };
+
function metricValue(key, v) {
if (/_s$/.test(key) && typeof v === 'number') {
if (v >= 90) return Math.round(v / 60) + '분 ' + Math.round(v % 60) + '초';
return v + '초';
}
+ // A 0..1 confidence reads as a percentage everywhere else a human meets one.
+ if ((key === 'confidence' || key === 'score') && typeof v === 'number' && v <= 1) {
+ return Math.round(v * 100) + '%';
+ }
+ if (key === 'last_state') return STATE_LABELS[v] || String(v);
return String(v);
}
var meta = document.getElementById('event-detail-meta');
var snapBox = document.getElementById('event-detail-snapshots');
+ var actionsBox = document.getElementById('detail-actions');
+ var titleEl = document.getElementById('detail-title');
+ var whenEl = document.getElementById('detail-when');
if (!meta) return;
var m = location.pathname.match(/\/events\/(\d+)\/view/);
@@ -62,16 +80,34 @@
return;
}
var id = m[1];
+ var currentEvent = null;
+ var csrfToken = null;
+ var deviceNames = {};
function severityInfo(sev) {
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
}
+ function pad2(n) { return n < 10 ? '0' + n : String(n); }
+
+ // One stamp format across the product. This page used to print toLocaleString('ko-KR')
+ // ("2026. 8. 8. 오후 4:01:50") while the stream printed "08-08 16:01:50" for the same
+ // instant — the same event looked like two different records depending on the screen.
+ var DOW = ['일', '월', '화', '수', '목', '금', '토'];
function formatTime(occurredAt) {
if (!occurredAt && occurredAt !== 0) return '-';
var d = new Date(Number(occurredAt) * 1000);
if (isNaN(d.getTime())) return '-';
- return d.toLocaleString('ko-KR');
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ + ' (' + DOW[d.getDay()] + ') '
+ + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
+ }
+
+ function fmtDuration(s) {
+ var v = Number(s);
+ if (!isFinite(v)) return '-';
+ if (v < 60) return (Math.round(v * 10) / 10) + '초';
+ return Math.floor(v / 60) + '분 ' + Math.round(v % 60) + '초';
}
function buildBadge(sev) {
@@ -90,8 +126,9 @@
return badge;
}
- function textNode(value) {
+ function textNode(value, cls) {
var span = document.createElement('span');
+ if (cls) span.className = cls;
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
return span;
}
@@ -99,46 +136,159 @@
function addRow(label, valueNode) {
var row = document.createElement('div');
row.className = 'detail-row';
- var k = document.createElement('span');
- k.className = 'detail-label';
- k.textContent = label + ': ';
+ var k = document.createElement('dt');
+ k.textContent = label;
+ var v = document.createElement('dd');
+ v.appendChild(valueNode);
row.appendChild(k);
- row.appendChild(valueNode);
+ row.appendChild(v);
meta.appendChild(row);
}
- function render(data) {
- var ev = data.event || {};
+ // --- resolution actions --------------------------------------------------------------
+ function loadCsrf() {
+ return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
+ .then(function (res) { return res.ok ? res.json() : null; })
+ .then(function (d) { if (d && d.token) csrfToken = d.token; })
+ .catch(function () { /* the POST surfaces it as a 403 */ });
+ }
+
+ function post(path) {
+ return fetch(B + '/events/' + encodeURIComponent(id) + path, {
+ method: 'POST',
+ headers: { 'X-Csrf-Token': csrfToken || '' }
+ }).then(function (res) {
+ if (res.status === 401) { window.location.href = B + '/login'; return null; }
+ if (!res.ok) throw new Error('action_failed:' + res.status);
+ return res;
+ });
+ }
+
+ function setNote(text, isError) {
+ var note = document.createElement('p');
+ note.className = 'detail-action-note' + (isError ? ' is-error' : '');
+ note.textContent = text;
+ actionsBox.appendChild(note);
+ }
+
+ // 오탐 is two-step here for the same reason as in the stream: it asserts the detector was
+ // wrong, it moves a per-camera statistic, and there is no undo.
+ var fpArmed = false;
+
+ function renderActions() {
+ if (!actionsBox || !currentEvent) return;
+ actionsBox.hidden = false;
+ actionsBox.textContent = '';
+ fpArmed = false;
+
+ if (currentEvent.false_positive_by) {
+ setNote('오탐으로 판정됨 · ' + currentEvent.false_positive_by);
+ return;
+ }
+ if (currentEvent.acknowledged_by) {
+ setNote('확인 완료 · ' + currentEvent.acknowledged_by);
+ return;
+ }
+
+ var ack = document.createElement('button');
+ ack.type = 'button';
+ ack.className = 'btn-primary';
+ ack.textContent = '확인 처리';
+ ack.addEventListener('click', function () {
+ ack.disabled = true;
+ post('/ack')
+ .then(function (res) {
+ if (!res) return;
+ currentEvent.acknowledged_by = window.APP_USER || '확인';
+ renderActions();
+ renderMeta();
+ })
+ .catch(function () { ack.disabled = false; setNote('확인 처리에 실패했습니다.', true); });
+ });
+ actionsBox.appendChild(ack);
+
+ var fp = document.createElement('button');
+ fp.type = 'button';
+ fp.className = 'fp-btn';
+ fp.textContent = '오탐';
+ fp.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
+ fp.addEventListener('click', function () {
+ if (!fpArmed) {
+ fpArmed = true;
+ fp.classList.add('is-armed');
+ fp.textContent = '오탐 확정?';
+ setTimeout(function () {
+ if (!fpArmed) return;
+ fpArmed = false; fp.classList.remove('is-armed'); fp.textContent = '오탐';
+ }, 4000);
+ return;
+ }
+ fp.disabled = true; ack.disabled = true;
+ post('/false-positive')
+ .then(function (res) {
+ if (!res) return;
+ currentEvent.acknowledged_by = window.APP_USER || '확인';
+ currentEvent.false_positive_by = window.APP_USER || '오탐';
+ renderActions();
+ renderMeta();
+ })
+ .catch(function () { fp.disabled = false; ack.disabled = false; setNote('오탐 처리에 실패했습니다.', true); });
+ });
+ actionsBox.appendChild(fp);
+ }
+
+ function renderMeta() {
+ var ev = currentEvent || {};
meta.textContent = '';
meta.removeAttribute('aria-busy');
addRow('심각도', buildBadge(ev.severity));
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
- addRow('카메라', textNode(ev.camera_id));
- addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
- addRow('지속(초)', textNode(ev.duration_s));
- addRow('확인', textNode(ev.acknowledged_by || '미확인'));
- if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그'));
+ addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
+ addRow('카메라', textNode(ev.camera_id, 'mono'));
+ addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono'));
+ addRow('지속', textNode(fmtDuration(ev.duration_s)));
+ if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
+ if (ev.zone_id) addRow('구역', textNode(ev.zone_id, 'mono'));
+
+ var status = ev.false_positive_by
+ ? '오탐 (' + ev.false_positive_by + ')'
+ : (ev.acknowledged_by ? '확인됨 (' + ev.acknowledged_by + ')' : '미확인');
+ addRow('처리 상태', textNode(status));
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
var metrics = ev.metrics || {};
Object.keys(metrics).forEach(function (key) {
addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[key])));
});
+ }
+ function renderHead() {
+ var ev = currentEvent || {};
+ if (titleEl) titleEl.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '이벤트 상세');
+ if (whenEl) {
+ whenEl.textContent = '';
+ whenEl.appendChild(textNode(deviceNames[ev.device_id] || ev.device_id, 'ev-src'));
+ whenEl.appendChild(textNode(ev.camera_id, 'ev-cam'));
+ whenEl.appendChild(textNode(formatTime(ev.occurred_at), 'ev-at'));
+ }
+ }
+
+ function renderSnapshots(snaps) {
if (!snapBox) return;
snapBox.textContent = '';
- var snaps = data.snapshots || [];
- if (snaps.length === 0) {
+ var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; });
+ if (kept.length === 0) {
var none = document.createElement('p');
- none.textContent = '스냅샷이 없습니다.';
+ none.className = 'detail-empty';
+ none.textContent = '이 이벤트에는 저장된 스냅샷이 없습니다 (미촬영이거나 PIPA 보존기간이 지나 삭제됨).';
snapBox.appendChild(none);
return;
}
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by
- // position from the end — exact offsets are edge config the server doesn't know.
+ // position from the end — exact offsets are edge config the server doesn't know, and
+ // inventing seconds here would be a fabricated measurement on an accident record.
var CAPTIONS = ['이전', '직전', '발생 시점'];
- var kept = snaps.filter(function (s) { return s && s.file_uuid; });
kept.forEach(function (s, i) {
var fig = document.createElement('figure');
fig.className = 'detail-snap';
@@ -157,15 +307,86 @@
});
}
- fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
- .then(function (res) {
- if (res.status === 401) { window.location.href = B + '/login'; return null; }
- if (!res.ok) throw new Error('detail_failed:' + res.status);
- return res.json();
- })
- .then(function (data) { if (data) render(data); })
- .catch(function () {
- meta.textContent = '불러오기 실패';
- meta.removeAttribute('aria-busy');
+ // --- outbound links + improvement action ---------------------------------------------
+ function renderLinks() {
+ var ev = currentEvent || {};
+ var pkg = document.getElementById('detail-package');
+ if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
+ var zones = document.getElementById('detail-zones');
+ if (zones) {
+ // Deep-link the zone editor straight at this camera. Reading an incident and then having
+ // to re-select the camera by hand on the next screen is the same "the system knows this
+ // and asks you anyway" problem the editor's own picker exists to remove.
+ zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
+ + '&camera=' + encodeURIComponent(ev.camera_id || '');
+ zones.hidden = !ev.camera_id;
+ }
+ }
+
+ function wireActionForm() {
+ var form = document.getElementById('detail-action-form');
+ var note = document.getElementById('detail-action-note');
+ var status = document.getElementById('detail-action-status');
+ if (!form) return;
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ var text = (note.value || '').trim();
+ if (!text || !currentEvent) return;
+ status.textContent = '기록 중…';
+ fetch(B + '/api/actions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
+ body: JSON.stringify({
+ camera_id: currentEvent.camera_id,
+ note: text,
+ event_id: currentEvent.event_id
+ })
+ })
+ .then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return; }
+ if (!r.ok) throw new Error('action_failed');
+ note.value = '';
+ status.textContent = '기록되었습니다. 안전 리포트의 [개선 조치 효과]에서 전후 비교를 볼 수 있습니다.';
+ })
+ .catch(function () { status.textContent = '기록에 실패했습니다. 권한을 확인하세요.'; });
});
+ }
+
+ function render(data) {
+ currentEvent = data.event || {};
+ renderHead();
+ renderMeta();
+ renderActions();
+ renderLinks();
+ renderSnapshots(data.snapshots);
+ }
+
+ // Device NAMES, so the incident record says "1공장 프레스라인" and not "pi-a1" — the same
+ // provenance the stream row shows. A failure here is non-fatal: the raw id stands in.
+ function loadDevices() {
+ return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
+ .then(function (res) { return res.ok ? res.json() : null; })
+ .then(function (d) {
+ if (!d || !Array.isArray(d.devices)) return;
+ d.devices.forEach(function (dev) { if (dev.name) deviceNames[dev.device_id] = dev.name; });
+ })
+ .catch(function () { /* raw id stands in */ });
+ }
+
+ loadCsrf();
+ wireActionForm();
+ // Names first so the record never paints an id and then swap it for a name a beat later.
+ loadDevices().then(function () {
+ return fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
+ .then(function (res) {
+ if (res.status === 401) { window.location.href = B + '/login'; return null; }
+ if (!res.ok) throw new Error('detail_failed:' + res.status);
+ return res.json();
+ })
+ .then(function (data) { if (data) render(data); })
+ .catch(function () {
+ meta.textContent = '불러오기 실패';
+ meta.removeAttribute('aria-busy');
+ });
+ });
})();
diff --git a/server/public/assets/package.js b/server/public/assets/package.js
new file mode 100644
index 0000000..2049cd3
--- /dev/null
+++ b/server/public/assets/package.js
@@ -0,0 +1,240 @@
+/**
+ * 사고조사 패키지.
+ *
+ * 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다:
+ * 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지,
+ * 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면
+ * 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다.
+ *
+ * 인쇄 CSS 가 앱 크롬을 걷어내므로 이 화면 그대로 PDF 로 저장해 제출할 수 있다.
+ *
+ * XSS-safe: 모든 동적 값은 textContent.
+ */
+(function () {
+ 'use strict';
+
+ var B = window.APP_BASE || '';
+ var root = document.getElementById('package-root');
+ if (!root) return;
+
+ var m = location.pathname.match(/\/events\/(\d+)\/package/);
+ if (!m) { root.textContent = '잘못된 주소입니다.'; return; }
+ var id = m[1];
+
+ var TYPE_LABELS = {
+ fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
+ ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
+ pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
+ lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
+ near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
+ camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
+ unacked_escalation: '미확인 경보 에스컬레이션',
+ ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
+ };
+ var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
+ var ALERT_STATUS = {
+ sent: '발송됨', pending: '발송 대기', failed: '발송 실패', suppressed: '억제됨 (정비 중)'
+ };
+
+ function el(tag, cls, text) {
+ var n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined && text !== null) n.textContent = text;
+ return n;
+ }
+ function pad2(n) { return n < 10 ? '0' + n : String(n); }
+ function stamp(ts) {
+ var d = new Date(Number(ts) * 1000);
+ if (isNaN(d.getTime())) return '-';
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
+ }
+ function clockOnly(ts) {
+ var d = new Date(Number(ts) * 1000);
+ return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
+ }
+
+ function section(title) {
+ var s = el('section', 'pkg-section');
+ s.appendChild(el('h2', 'pkg-h2', title));
+ return s;
+ }
+ function kv(parent, label, value) {
+ var row = el('div', 'pkg-kv');
+ row.appendChild(el('dt', null, label));
+ row.appendChild(el('dd', null, value === null || value === undefined || value === '' ? '-' : String(value)));
+ parent.appendChild(row);
+ }
+
+ function render(d) {
+ var ev = d.event || {};
+ root.textContent = '';
+
+ // ---- 표지 -----------------------------------------------------------------
+ var head = el('header', 'pkg-head');
+ head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
+ head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
+ var sub = el('p', 'pkg-sub');
+ sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, ev.camera_id, stamp(ev.occurred_at)]
+ .filter(Boolean).join(' · ');
+ head.appendChild(sub);
+ root.appendChild(head);
+
+ // ---- 사건 개요 -------------------------------------------------------------
+ var facts = section('사건 개요');
+ var dl = el('dl', 'pkg-facts');
+ kv(dl, '사건 번호', ev.event_id);
+ kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
+ kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
+ kv(dl, '장치', ev.device_id);
+ kv(dl, '카메라', ev.camera_id);
+ kv(dl, '현장', ev.site);
+ kv(dl, '발생 시각', stamp(ev.occurred_at));
+ kv(dl, '지속', (ev.duration_s || 0) + '초');
+ kv(dl, '처리 상태', ev.false_positive_by ? ('오탐 판정 · ' + ev.false_positive_by)
+ : (ev.acknowledged_by ? ('확인 · ' + ev.acknowledged_by + ' · ' + stamp(ev.acknowledged_at))
+ : '미확인'));
+ // 대응 시간은 조사에서 가장 먼저 묻는 숫자다.
+ if (ev.acknowledged_at) {
+ var tta = Math.max(0, Math.round(ev.acknowledged_at - ev.occurred_at));
+ kv(dl, '대응 소요', tta < 60 ? tta + '초' : Math.floor(tta / 60) + '분 ' + (tta % 60) + '초');
+ }
+ facts.appendChild(dl);
+ root.appendChild(facts);
+
+ // ---- 증거 프레임 -----------------------------------------------------------
+ var evidence = section('증거 프레임');
+ var snaps = (d.snapshots || []).filter(function (s) { return s && s.file_uuid; });
+ if (!snaps.length) {
+ evidence.appendChild(el('p', 'pkg-empty',
+ '저장된 스냅샷이 없습니다 (미촬영이거나 개인정보 보존기간이 지나 삭제됨).'));
+ } else {
+ var strip = el('div', 'pkg-frames');
+ var CAPTIONS = ['이전', '직전', '발생 시점'];
+ snaps.forEach(function (s, i) {
+ var fig = el('figure', 'pkg-frame');
+ var img = document.createElement('img');
+ img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
+ img.alt = '사고 스냅샷';
+ img.loading = 'lazy';
+ fig.appendChild(img);
+ fig.appendChild(el('figcaption', null,
+ snaps.length > 1 ? CAPTIONS[Math.max(0, CAPTIONS.length - snaps.length + i)] : '발생 시점'));
+ strip.appendChild(fig);
+ });
+ evidence.appendChild(strip);
+ evidence.appendChild(el('p', 'pkg-note',
+ '얼굴은 개인정보 보호를 위해 촬영 시점에 블러 처리되어 저장됩니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.'));
+ }
+ root.appendChild(evidence);
+
+ // ---- 전후 타임라인 ---------------------------------------------------------
+ var win = Math.round((d.context_window_s || 1800) / 60);
+ var tl = section('같은 카메라의 전후 ' + win + '분');
+ var rows = d.timeline || [];
+ if (!rows.length) {
+ tl.appendChild(el('p', 'pkg-empty', '해당 구간에 다른 감지가 없습니다.'));
+ } else {
+ var list = el('ol', 'pkg-timeline');
+ rows.forEach(function (r) {
+ var li = el('li', 'pkg-tl-row' + (r.event_id === ev.event_id ? ' is-subject' : ''));
+ li.appendChild(el('span', 'pkg-tl-time', clockOnly(r.occurred_at)));
+ li.appendChild(el('span', 'pkg-tl-sev sev-' + r.severity, SEV_LABELS[r.severity] || r.severity));
+ li.appendChild(el('span', 'pkg-tl-type', TYPE_LABELS[r.event_type] || r.event_type));
+ li.appendChild(el('span', 'pkg-tl-state',
+ r.false_positive_by ? '오탐' : (r.acknowledged_by ? '확인됨' : '미확인')));
+ list.appendChild(li);
+ });
+ tl.appendChild(list);
+ }
+ root.appendChild(tl);
+
+ // ---- 통보 이력 -------------------------------------------------------------
+ // 시스템이 "봤다"는 것만 증명하고 사람에게 "닿았는가"를 비워두면 조사의 절반이 빈다.
+ var notif = section('통보 이력');
+ var alerts = d.notifications || [];
+ if (!alerts.length) {
+ notif.appendChild(el('p', 'pkg-empty',
+ '이 사건에 대한 통보 기록이 없습니다. 통보 경로가 설정되지 않았거나, 심각도가 발송 기준에 못 미쳤습니다.'));
+ } else {
+ var nt = el('ul', 'pkg-alerts');
+ alerts.forEach(function (a) {
+ var li = el('li', 'pkg-alert is-' + a.status);
+ li.appendChild(el('span', 'pkg-alert-ch', (a.channel_name || '삭제된 채널')));
+ li.appendChild(el('span', 'pkg-alert-status', ALERT_STATUS[a.status] || a.status));
+ li.appendChild(el('span', 'pkg-alert-when',
+ a.sent_at ? stamp(a.sent_at) : ('접수 ' + stamp(a.queued_at))));
+ if (a.last_error) li.appendChild(el('span', 'pkg-alert-err', a.last_error));
+ nt.appendChild(li);
+ });
+ notif.appendChild(nt);
+ }
+ root.appendChild(notif);
+
+ // ---- 위험성평가 -------------------------------------------------------------
+ var risk = section('해당 위험요인 · 통제 대책');
+ if (!d.risk_control) {
+ risk.appendChild(el('p', 'pkg-empty',
+ '이 감지 유형에 등록된 위험요인이 없습니다. [안전 관리] 화면에서 등록하면 다음 조사부터 함께 실립니다.'));
+ } else {
+ var rc = d.risk_control;
+ var rdl = el('dl', 'pkg-facts');
+ kv(rdl, '위험요인', rc.hazard);
+ kv(rdl, '현재 대책', rc.control);
+ kv(rdl, '위험도', rc.risk_score + ' (강도 ' + rc.severity_rating + ' × 빈도 ' + rc.likelihood_rating + ')');
+ kv(rdl, '최종 검토', rc.reviewed_by ? (rc.reviewed_by + ' · ' + stamp(rc.reviewed_at)) : '-');
+ risk.appendChild(rdl);
+ }
+ root.appendChild(risk);
+
+ // ---- 조치 이력 -------------------------------------------------------------
+ var acts = section('이 카메라의 개선 조치 이력');
+ var actions = d.actions || [];
+ if (!actions.length) {
+ acts.appendChild(el('p', 'pkg-empty', '기록된 조치가 없습니다.'));
+ } else {
+ var al = el('ul', 'pkg-actions');
+ actions.forEach(function (a) {
+ var li = el('li', null);
+ li.appendChild(el('span', 'pkg-act-date', stamp(a.taken_at).slice(0, 10)));
+ li.appendChild(el('span', 'pkg-act-note', a.note));
+ li.appendChild(el('span', 'pkg-act-by', a.created_by));
+ al.appendChild(li);
+ });
+ acts.appendChild(al);
+ }
+ root.appendChild(acts);
+
+ // ---- 서명 -----------------------------------------------------------------
+ var sign = section('확인');
+ var grid = el('div', 'pkg-sign');
+ ['조사자', '안전관리자', '사업주 · 대리인'].forEach(function (role) {
+ var box = el('div', 'pkg-sign-box');
+ box.appendChild(el('span', 'pkg-sign-role', role));
+ box.appendChild(el('span', 'pkg-sign-line', ''));
+ grid.appendChild(box);
+ });
+ sign.appendChild(grid);
+ root.appendChild(sign);
+
+ var foot = el('footer', 'pkg-foot');
+ foot.textContent = '생성 ' + stamp(d.generated_at) + ' · 작성 ' + (d.generated_by || '-')
+ + ' · 본 문서는 AI 감지 기록에 근거한 보조 자료이며 공식 재해조사 결과를 대체하지 않습니다.';
+ root.appendChild(foot);
+
+ document.title = '사고조사 #' + ev.event_id + ' · AI SAFETY CCTV';
+ }
+
+ var printBtn = document.getElementById('package-print');
+ if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
+
+ fetch(B + '/api/events/' + encodeURIComponent(id) + '/package', { headers: { Accept: 'application/json' } })
+ .then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return null; }
+ if (r.status === 404) { root.textContent = '해당 사건을 찾을 수 없습니다.'; return null; }
+ if (!r.ok) throw new Error('package_failed');
+ return r.json();
+ })
+ .then(function (d) { if (d) render(d); })
+ .catch(function () { root.textContent = '패키지를 불러오지 못했습니다.'; });
+})();
diff --git a/server/public/assets/report.js b/server/public/assets/report.js
index 0b3e890..baecddb 100644
--- a/server/public/assets/report.js
+++ b/server/public/assets/report.js
@@ -19,6 +19,10 @@
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
};
+ // 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
+ var PATTERN_MIN_PEAK = 4; // 요일×시간 격자에서 최다 칸이 이보다 적으면 무늬는 우연
+ var ACTION_MIN_BEFORE = 5; // 개선조치 전후 비교의 최소 '전' 표본
+
var root = document.getElementById('report-kpis');
if (!root) return;
@@ -51,6 +55,21 @@
return (s / 3600).toFixed(1) + '시간';
}
+ // One stamp format across the product (app.js / detail.js use the same shape). This page
+ // printed toLocaleDateString('ko-KR') — "2026. 8. 2." — while the stream printed "08-02",
+ // so the same day looked like two different notations depending on the screen.
+ function pad2(n) { return n < 10 ? '0' + n : String(n); }
+ function fmtDate(ts) {
+ var d = new Date(Number(ts) * 1000);
+ if (isNaN(d.getTime())) return '-';
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
+ }
+ function fmtStamp(ts) {
+ var d = new Date(Number(ts) * 1000);
+ if (isNaN(d.getTime())) return '-';
+ return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
+ }
+
// KPI tile: the headline number plus the one-line reading of what it means.
function kpi(label, value, note, cls) {
var t = el('div', 'kpi' + (cls ? ' ' + cls : ''));
@@ -94,8 +113,7 @@
var severe = t.critical + t.high;
rangeEl.textContent = '최근 ' + r.days + '일 · '
- + new Date((r.generated_at - r.days * 86400) * 1000).toLocaleDateString('ko-KR')
- + ' ~ ' + new Date(r.generated_at * 1000).toLocaleDateString('ko-KR');
+ + fmtDate(r.generated_at - r.days * 86400) + ' ~ ' + fmtDate(r.generated_at);
// 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다.
var alertEl = document.getElementById('report-alert');
@@ -136,6 +154,38 @@
trendLine(kFp, prevFpPct, fpPct, function (v) { return v + '%'; });
root.appendChild(kFp);
+ // 현장별 롤업 (본사 관점). 스코프 운영자에게는 서버가 null 을 주고 카드는 숨은 채로 남는다.
+ var sitesCard = document.getElementById('report-sites-card');
+ var sitesEl = document.getElementById('report-sites');
+ if (sitesCard && sitesEl) {
+ var sites = r.by_site;
+ // 현장이 하나뿐인 설치에서 한 줄짜리 표는 정보가 아니라 잡음이다.
+ var show = Array.isArray(sites) && sites.length > 1;
+ sitesCard.hidden = !show;
+ if (show) {
+ sitesEl.textContent = '';
+ var table = el('div', 'site-rows');
+ var maxSevere = sites.reduce(function (m, s) { return Math.max(m, s.critical_high); }, 0);
+ sites.forEach(function (s) {
+ var row = el('div', 'site-row' + (s.critical_high > 0 && s.critical_high === maxSevere ? ' is-worst' : ''));
+ row.appendChild(el('span', 'site-name', s.site || '현장 미지정'));
+ var nums = el('span', 'site-nums');
+ nums.appendChild(el('strong', 'site-severe' + (s.critical_high > 0 ? ' is-hot' : ''), String(s.critical_high)));
+ nums.appendChild(el('span', null, '중대'));
+ nums.appendChild(el('strong', null, String(s.count)));
+ nums.appendChild(el('span', null, '전체'));
+ nums.appendChild(el('strong', 'site-unacked' + (s.unacked > 0 ? ' is-open' : ''), String(s.unacked)));
+ nums.appendChild(el('span', null, '미확인'));
+ nums.appendChild(el('span', 'site-cams', '카메라 ' + s.cameras));
+ row.appendChild(nums);
+ table.appendChild(row);
+ });
+ sitesEl.appendChild(table);
+ sitesEl.appendChild(el('p', 'report-empty',
+ '현장별 수치는 접근 권한이 전역인 계정에만 표시됩니다. 중대(위급+높음)가 가장 많은 현장을 강조했습니다.'));
+ }
+ }
+
typesEl.textContent = '';
var typeKeys = Object.keys(r.by_type);
if (typeKeys.length === 0) typesEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
@@ -147,10 +197,19 @@
camerasEl.textContent = '';
if (r.by_camera.length === 0) camerasEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
var maxCam = r.by_camera.reduce(function (m, c) { return Math.max(m, c.count); }, 0);
+ // "위험 순위"인데 중대가 1건이라도 있으면 전부 빨강이었다 — 실제 현장에서는 거의 모든
+ // 카메라가 그 조건을 만족하므로 순위가 순위 노릇을 못 했다. 최상위 카메라와, 그에 준하는
+ // (중대 밀도가 최상위의 60% 이상인) 카메라만 강조해 '먼저 볼 곳'을 실제로 좁힌다.
+ var maxSevere = r.by_camera.reduce(function (m, c) { return Math.max(m, c.critical_high); }, 0);
+ var hotFloor = Math.max(1, Math.ceil(maxSevere * 0.6));
r.by_camera.forEach(function (c) {
camerasEl.appendChild(barRow(c.camera_id, c.count, maxCam,
- c.count + '건 · 중대 ' + c.critical_high, c.critical_high > 0));
+ c.count + '건 · 중대 ' + c.critical_high, maxSevere > 0 && c.critical_high >= hotFloor));
});
+ if (maxSevere > 0) {
+ camerasEl.appendChild(el('p', 'report-empty',
+ '중대(위급+높음)가 ' + hotFloor + '건 이상인 카메라를 강조했습니다.'));
+ }
// 인체공학: 관측 시간 대비 각 밴드 노출 — 절대시간과 비율을 함께.
ergoEl.textContent = '';
@@ -174,14 +233,18 @@
rulesEl.textContent = '';
var oc = r.occupancy;
var rules = el('div', 'rule-stats');
- [['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay],
- ['미확인 잔여', t.unacked]].forEach(function (pair) {
- var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : ''));
- box.appendChild(el('strong', null, String(pair[1])));
- box.appendChild(el('span', null, pair[0]));
- rules.appendChild(box);
- });
+ // '미확인 잔여'는 존 규칙 위반이 아니다. 같은 2×2 격자에 같은 무게로 놓여 있어서
+ // 네 번째 규칙처럼 읽혔다 — 처리 현황은 격자 밖 한 줄로 분리한다.
+ [['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay]]
+ .forEach(function (pair) {
+ var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : ''));
+ box.appendChild(el('strong', null, String(pair[1])));
+ box.appendChild(el('span', null, pair[0]));
+ rules.appendChild(box);
+ });
rulesEl.appendChild(rules);
+ rulesEl.appendChild(el('p', 'report-empty',
+ '처리 현황 — 이 기간 감지 ' + t.events + '건 중 미확인 ' + t.unacked + '건.'));
// 시간 패턴: 요일×시간 발생 밀도 — "언제가 위험한가"를 한 장에.
var patternEl = document.getElementById('report-pattern');
@@ -190,6 +253,12 @@
var pt = r.pattern || { grid: [], max: 0 };
if (!pt.max) {
patternEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
+ } else if (pt.max < PATTERN_MIN_PEAK) {
+ // 168칸에 최대 2~3건이면 무늬는 우연이다. 7일 창에서는 거의 항상 이 상태이므로,
+ // 전폭 카드가 '패턴을 찾았다'고 말하지 않도록 게이트를 둔다.
+ patternEl.appendChild(el('p', 'report-empty',
+ '아직 시간대 패턴을 말할 만큼 감지가 쌓이지 않았습니다 (가장 많은 칸이 ' + pt.max
+ + '건). 기간을 30일 이상으로 넓히면 분포가 드러납니다.'));
} else {
var DAYS = ['월', '화', '수', '목', '금', '토', '일'];
var table = el('div', 'pattern');
@@ -217,7 +286,7 @@
}
}
- footEl.textContent = '생성 ' + new Date(r.generated_at * 1000).toLocaleString('ko-KR')
+ footEl.textContent = '생성 ' + fmtStamp(r.generated_at)
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
}
@@ -235,16 +304,32 @@
.catch(function () { /* 기록 시 403으로 드러난다 */ });
}
+ // 이 줄은 안전회의에 그대로 올라간다. 표본 6건에서 나온 "▲33%"를 확정 색으로 칠하면
+ // 회의에서 바로 반박당하고, 그 다음부터는 이 카드 전체가 신뢰를 잃는다. 표본이 임계 미만이면
+ // 원시 수치만 중립 톤으로 적고 판정을 미룬다. 창 길이도 함께 적어 서로 다른 창의 두 행이
+ // 같은 잣대로 읽히지 않게 한다.
function actionEffect(a) {
if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)');
var e = a.effect;
- var txt = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)';
- if (e.before > 0 && e.after !== e.before) {
- var pct = Math.round((e.after - e.before) / e.before * 100);
- txt += ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%';
+ var base = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)';
+ if (e.before < ACTION_MIN_BEFORE) {
+ var thin = el('span', 'action-effect is-thin', base + ' · 표본 부족, 판정 보류');
+ thin.title = '조치 전 감지가 ' + ACTION_MIN_BEFORE + '건 이상일 때만 증감률을 표시합니다.';
+ return thin;
}
- var cls = 'action-effect' + (e.after < e.before ? ' is-better' : (e.after > e.before ? ' is-worse' : ''));
- return el('span', cls, txt);
+ // 카운트 데이터의 자연 변동폭(√before, 푸아송 표준편차)을 넘지 못하는 차이는 조치의 효과가
+ // 아니라 잡음이다. 6건→8건을 "▲33% 악화"로 칠하면 그 카드는 회의에서 한 번 반박당하고
+ // 끝난다. 노이즈 범위 안이면 방향도 색도 주장하지 않는다.
+ var diff = e.after - e.before;
+ if (Math.abs(diff) <= Math.sqrt(e.before)) {
+ var flat = el('span', 'action-effect is-thin', base + ' · 유의미한 변화 아님 (통계적 잡음 범위)');
+ flat.title = '카운트 데이터의 자연 변동폭(±' + (Math.round(Math.sqrt(e.before) * 10) / 10)
+ + '건) 안이라 조치 효과로 볼 수 없습니다.';
+ return flat;
+ }
+ var pct = Math.round(diff / e.before * 100);
+ var txt = base + ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%';
+ return el('span', 'action-effect' + (diff < 0 ? ' is-better' : ' is-worse'), txt);
}
function renderActions(list) {
@@ -258,8 +343,7 @@
list.forEach(function (a) {
var row = el('div', 'action-row');
var what = el('div', 'action-what');
- what.appendChild(el('span', 'action-date',
- new Date(a.taken_at * 1000).toLocaleDateString('ko-KR')));
+ what.appendChild(el('span', 'action-date', fmtDate(a.taken_at)));
what.appendChild(el('span', 'action-cam', a.camera_id));
what.appendChild(el('span', 'action-note', a.note));
row.appendChild(what);
diff --git a/server/public/assets/setup.js b/server/public/assets/setup.js
new file mode 100644
index 0000000..3a2e4b0
--- /dev/null
+++ b/server/public/assets/setup.js
@@ -0,0 +1,168 @@
+/**
+ * 설치 점검 (온보딩).
+ *
+ * 납품 직후가 이 제품에서 가장 위험한 구간이다. 대시보드는 멀쩡히 뜨고, 로그인도 되고,
+ * 화면 어디에도 오류가 없는데 — 장치가 등록되지 않았거나, 존을 안 그렸거나, 통보 경로가
+ * 비어 있으면 시스템은 "감시 중"으로 보이면서 실제로는 아무것도 하지 않는다. 그 상태가
+ * 몇 주씩 유지되다가 사고가 나서야 발견되는 것이 최악의 시나리오다.
+ *
+ * 그래서 각 단계는 "설정했다고 표시"가 아니라 **실제 데이터를 조회해** 판정한다. 체크가
+ * 켜졌다면 그 단계는 정말로 동작하고 있다.
+ *
+ * XSS-safe: 모든 동적 값은 textContent.
+ */
+(function () {
+ 'use strict';
+
+ var B = window.APP_BASE || '';
+ var listEl = document.getElementById('setup-steps');
+ if (!listEl) return;
+
+ function el(tag, cls, text) {
+ var n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined && text !== null) n.textContent = text;
+ return n;
+ }
+
+ function getJson(path) {
+ return fetch(B + path, { headers: { Accept: 'application/json' } })
+ .then(function (r) {
+ if (r.status === 401) { window.location.href = B + '/login'; return null; }
+ // 403 은 "권한 없음"이지 "미설정"이 아니다. 단계는 판정 보류로 남긴다.
+ if (r.status === 403) return { _forbidden: true };
+ return r.ok ? r.json() : null;
+ })
+ .catch(function () { return null; });
+ }
+
+ function render(steps) {
+ listEl.textContent = '';
+ steps.forEach(function (s, i) {
+ var li = el('li', 'setup-step is-' + s.state);
+ var mark = el('span', 'setup-mark');
+ mark.textContent = s.state === 'ok' ? '✓' : (s.state === 'unknown' ? '?' : String(i + 1));
+ mark.setAttribute('aria-hidden', 'true');
+ li.appendChild(mark);
+
+ var body = el('div', 'setup-body');
+ body.appendChild(el('h2', 'setup-title', s.title));
+ body.appendChild(el('p', 'setup-status', s.status));
+ // 이 단계가 안 되어 있으면 무엇이 벌어지는가 — 체크리스트를 지시가 아니라 이유로 만든다.
+ if (s.state !== 'ok' && s.why) body.appendChild(el('p', 'setup-why', s.why));
+ if (s.href) {
+ var a = el('a', 'setup-link', s.linkText || '설정하러 가기 →');
+ a.href = B + s.href;
+ body.appendChild(a);
+ }
+ li.appendChild(body);
+ listEl.appendChild(li);
+ });
+ }
+
+ Promise.all([
+ getJson('/api/devices'),
+ getJson('/api/events?limit=200'),
+ getJson('/api/admin/channels')
+ ]).then(function (res) {
+ var devicesRes = res[0] || {};
+ var eventsRes = res[1] || {};
+ var channelsRes = res[2] || {};
+
+ var devices = devicesRes.devices || [];
+ var online = devices.filter(function (d) { return d.online; });
+ var events = eventsRes.events || [];
+ var cameras = {};
+ events.forEach(function (e) { if (e.camera_id) cameras[e.camera_id] = true; });
+ var cameraCount = Object.keys(cameras).length;
+
+ var steps = [];
+
+ // 1. 장치 등록
+ steps.push(devices.length === 0 ? {
+ state: 'todo', title: '① 장치 등록',
+ status: '등록된 엣지 장치가 없습니다.',
+ why: '장치 행이 없으면 현장 노드가 보내는 이벤트는 인증에서 거부됩니다. 장치별 비밀키는 관리자가 DB에 직접 발급합니다 (dist/PROVISION_SECRETS.md).',
+ } : {
+ state: 'ok', title: '① 장치 등록',
+ status: devices.length + '대 등록됨 · 현재 온라인 ' + online.length + '대'
+ });
+
+ // 2. 장치 통신 (하트비트)
+ steps.push(online.length === 0 ? {
+ state: devices.length ? 'todo' : 'blocked', title: '② 장치 통신',
+ status: devices.length ? '하트비트를 보내는 장치가 없습니다.' : '장치 등록 후 확인할 수 있습니다.',
+ why: '장치가 서버에 닿지 못하면 감지는 노드 안에서만 일어나고 대시보드는 영원히 비어 있습니다. 노드의 CAFE24_URL·DEVICE_SECRET 과 방화벽을 확인하세요.',
+ } : {
+ state: 'ok', title: '② 장치 통신', status: online.length + '대가 하트비트를 보내고 있습니다.'
+ });
+
+ // 3. 카메라 감지
+ steps.push(cameraCount === 0 ? {
+ state: online.length ? 'todo' : 'blocked', title: '③ 카메라 감지',
+ status: online.length ? '아직 어떤 카메라도 이벤트를 보낸 적이 없습니다.' : '장치 통신 후 확인할 수 있습니다.',
+ why: '카메라가 붙어 있어도 이벤트가 한 번도 없으면 실제로 사람을 검출하고 있는지 알 수 없습니다. 감시 구역에서 한 번 앉았다 일어나 보고, 그 사건이 스트림에 뜨는지 확인하세요.',
+ href: '/', linkText: '감지 스트림 보기 →'
+ } : {
+ state: 'ok', title: '③ 카메라 감지', status: cameraCount + '대의 카메라가 이벤트를 보냈습니다.'
+ });
+
+ // 4. 위험구역
+ getJson('/api/zones/' + encodeURIComponent(Object.keys(cameras)[0] || '_none_'))
+ .then(function (zoneRes) {
+ var zones = (zoneRes && zoneRes.zones) || [];
+ steps.push(zones.length === 0 ? {
+ state: cameraCount ? 'todo' : 'blocked', title: '④ 위험구역 설정',
+ status: cameraCount ? '첫 카메라에 저장된 구역이 없습니다.' : '카메라 감지 후 설정할 수 있습니다.',
+ why: '구역이 없으면 넘어짐·부동 감지는 동작하지만 제한구역 침입·협착·2인1조 같은 규칙은 전부 비활성입니다.',
+ href: '/zones', linkText: '구역 설정으로 →'
+ } : {
+ state: 'ok', title: '④ 위험구역 설정',
+ status: '첫 카메라에 구역 ' + zones.length + '개가 설정되어 있습니다.'
+ });
+
+ // 5. 통보 경로 — 이 제품에서 가장 자주 비어 있는 칸이다.
+ if (channelsRes._forbidden) {
+ steps.push({
+ state: 'unknown', title: '⑤ 통보 경로',
+ status: '이 계정에는 통보 설정 권한이 없습니다. 관리자 계정으로 확인하세요.'
+ });
+ } else {
+ var chans = (channelsRes.channels || []).filter(function (c) { return c.enabled; });
+ var local = chans.filter(function (c) { return c.kind === 'local'; });
+ steps.push(chans.length === 0 ? {
+ state: 'todo', title: '⑤ 통보 경로',
+ status: '사용 중인 통보 경로가 없습니다.',
+ why: '지금 상태에서는 감지가 되어도 브라우저를 보고 있는 사람에게만 전달됩니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다. 현장 경광등(엣지 GPIO)부터 설정하세요 — 인터넷이 끊겨도 동작합니다.',
+ href: '/admin', linkText: '통보 경로 설정 →'
+ } : {
+ state: local.length ? 'ok' : 'warn', title: '⑤ 통보 경로',
+ status: chans.length + '개 사용 중' + (local.length ? ' (현장 경광등 포함)' : ' — 현장 경광등 없음'),
+ why: local.length ? null : '메일·웹훅은 서버와 네트워크가 살아 있을 때만 닿습니다. 정전이나 회선 장애 때도 울릴 경로가 하나는 있어야 합니다.',
+ href: '/admin', linkText: '통보 경로 보기 →'
+ });
+ }
+
+ // 6. 통보 디스패처 — 서버 상태로는 알 수 없다. 정직하게 '확인 필요'로 남긴다.
+ steps.push({
+ state: 'unknown', title: '⑥ 통보 디스패처 (크론)',
+ status: '이 화면에서는 확인할 수 없습니다.',
+ why: '메일·웹훅 통보와 미확인 에스컬레이션은 tools/dispatch_alerts.php 가 크론으로 돌아야 동작합니다. 호스팅 관리자 화면의 크론 설정에서 1분 주기로 등록되어 있는지 직접 확인하세요. (--dry-run 으로 먼저 시험할 수 있습니다.)'
+ });
+
+ // 7. 위험성평가
+ getJson('/api/risks').then(function (riskRes) {
+ var controls = (riskRes && riskRes.controls) || [];
+ steps.push(controls.length === 0 ? {
+ state: 'todo', title: '⑦ 위험성평가 연계',
+ status: '등록된 위험요인이 없습니다.',
+ why: '없어도 감지는 동작합니다. 다만 사고조사 패키지와 관리체계 증빙에서 "무엇을 통제하고 있었나" 칸이 비게 됩니다.',
+ href: '/compliance', linkText: '안전 관리로 →'
+ } : {
+ state: 'ok', title: '⑦ 위험성평가 연계', status: controls.length + '개 항목이 등록되어 있습니다.'
+ });
+ render(steps);
+ });
+ });
+ });
+})();
diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js
index 850924b..81a2877 100644
--- a/server/public/assets/zone-draw.js
+++ b/server/public/assets/zone-draw.js
@@ -39,11 +39,17 @@
var heatmapBtn = document.getElementById('heatmap-btn');
var heatmapDaysInput = document.getElementById('heatmap-days');
+ // Zone styling stays inside the app's severity ramp (app.css --warn/--crit/--high). The old
+ // table used a raw #a855f7 for 협착 — a purple that appears nowhere else in the product — and
+ // drew every zone as a bare 2px outline, which disappears against a real factory frame. Each
+ // level now carries a translucent FILL plus a stroke, and 협착 is separated by a dashed
+ // stroke rather than by an alien hue: shape does the work colour was being stretched to do.
var LEVEL_STYLE = {
- warning: { stroke: '#eab308', label: '경고' },
- restricted: { stroke: '#dc2626', label: '제한' },
- pinch: { stroke: '#a855f7', label: '협착' }
+ warning: { stroke: '#b7820a', fill: 'rgba(183,130,10,.16)', label: '경고', dash: null },
+ restricted: { stroke: '#dc2626', fill: 'rgba(220,38,38,.16)', label: '제한', dash: null },
+ pinch: { stroke: '#e2560c', fill: 'rgba(226,86,12,.18)', label: '협착', dash: [9, 5] }
};
+ var DRAFT_STYLE = { stroke: '#0b6b7a', fill: 'rgba(11,107,122,.14)', label: '편집중', dash: [5, 4] };
var backgroundImage = null;
var existingZones = []; // [{zone_id, level, points: [[x,y],...]}]
@@ -80,25 +86,59 @@
}
}
- function drawPolygon(points, strokeStyle, label, closed) {
+ function drawPolygon(points, style, label, closed) {
if (points.length === 0) return;
- ctx.strokeStyle = strokeStyle;
- ctx.fillStyle = strokeStyle;
- ctx.lineWidth = 2;
+ ctx.save();
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
if (closed && points.length > 2) ctx.closePath();
+ // Fill first so the stroke sits on top of its own tint.
+ if (closed && points.length > 2 && style.fill) {
+ ctx.fillStyle = style.fill;
+ ctx.fill();
+ }
+ ctx.strokeStyle = style.stroke;
+ ctx.lineWidth = 2.5;
+ ctx.lineJoin = 'round';
+ if (style.dash) ctx.setLineDash(style.dash);
ctx.stroke();
+ ctx.setLineDash([]);
+ // Vertex handles: white-cored so they stay visible on both a bright floor and a dark shadow.
points.forEach(function (p) {
ctx.beginPath();
- ctx.arc(p[0], p[1], 3, 0, Math.PI * 2);
+ ctx.arc(p[0], p[1], 4, 0, Math.PI * 2);
+ ctx.fillStyle = '#ffffff';
ctx.fill();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = style.stroke;
+ ctx.stroke();
});
- if (label) {
- ctx.font = '14px sans-serif';
- ctx.fillText(label, points[0][0] + 6, points[0][1] - 6);
+ if (label) drawLabelChip(label, points[0][0], points[0][1], style.stroke);
+ ctx.restore();
+ }
+
+ // A plain fillText label vanished into whatever the camera happened to be looking at.
+ // Draw it on its own solid chip so the level is legible over any frame.
+ function drawLabelChip(text, x, y, bg) {
+ ctx.save();
+ ctx.font = '600 13px system-ui, sans-serif';
+ ctx.textBaseline = 'middle';
+ var padX = 7, h = 20;
+ var w = ctx.measureText(text).width + padX * 2;
+ var cx = Math.min(Math.max(x + 8, 2), canvas.width - w - 2);
+ var cy = Math.min(Math.max(y - 14, h / 2 + 2), canvas.height - h / 2 - 2);
+ ctx.fillStyle = bg;
+ if (ctx.roundRect) {
+ ctx.beginPath();
+ ctx.roundRect(cx, cy - h / 2, w, h, 5);
+ ctx.fill();
+ } else {
+ ctx.fillRect(cx, cy - h / 2, w, h);
}
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(text, cx + padX, cy);
+ ctx.restore();
}
// ---- 위험 히트맵 오버레이 -------------------------------------------------------
@@ -133,7 +173,7 @@
function loadHeatmap() {
var camera = cameraInput.value.trim();
- if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
+ if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
var days = Math.max(1, Math.min(90, parseInt(heatmapDaysInput && heatmapDaysInput.value, 10) || 30));
setStatus('히트맵을 불러오는 중...');
fetch(B + '/api/heatmap/' + encodeURIComponent(camera) + '?days=' + days,
@@ -171,11 +211,24 @@
ctx.drawImage(heatmapLayer, 0, 0);
}
existingZones.forEach(function (z) {
- var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level };
- drawPolygon(z.points, style.stroke, style.label, true);
+ var style = LEVEL_STYLE[z.level] || { stroke: '#626a79', fill: 'rgba(98,106,121,.14)', label: z.level, dash: null };
+ drawPolygon(z.points, style, style.label, true);
});
- var current = LEVEL_STYLE[levelSelect.value] || { stroke: '#38bdf8', label: '' };
- drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', false);
+ drawPolygon(currentPoints, DRAFT_STYLE, currentPoints.length ? DRAFT_STYLE.label : '', false);
+ if (!backgroundImage && !existingZones.length && !currentPoints.length) drawCanvasHint();
+ }
+
+ // An empty black rectangle told the operator nothing about what to do with it.
+ function drawCanvasHint() {
+ ctx.save();
+ ctx.fillStyle = 'rgba(255,255,255,.5)';
+ ctx.font = '500 15px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('① 왼쪽에서 카메라를 고르고 "카메라 화면을 배경으로"를 누르세요.',
+ canvas.width / 2, canvas.height / 2 - 10);
+ ctx.fillText('② 그다음 이 위를 클릭해 구역 폴리곤을 그립니다.',
+ canvas.width / 2, canvas.height / 2 + 16);
+ ctx.restore();
}
// Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire).
@@ -203,24 +256,130 @@
function renderZoneList() {
listEl.textContent = '';
+ if (existingZones.length === 0) {
+ var none = document.createElement('li');
+ none.className = 'zone-empty';
+ none.textContent = '이 카메라에 저장된 구역이 없습니다.';
+ listEl.appendChild(none);
+ return;
+ }
existingZones.forEach(function (z) {
var li = document.createElement('li');
- var style = LEVEL_STYLE[z.level] || { label: z.level };
+ li.className = 'zone-item lv-' + z.level;
var label = document.createElement('span');
- label.textContent = style.label + ' (' + z.points.length + '점)' + rulesLabel(z);
+ label.className = 'zone-item-label';
+ var swatch = document.createElement('span');
+ swatch.className = 'zone-swatch';
+ swatch.setAttribute('aria-hidden', 'true');
+ // Inline colour is the ONE place a raw hex belongs: it must match what the canvas drew.
+ swatch.style.background = (LEVEL_STYLE[z.level] || {}).fill || 'transparent';
+ swatch.style.borderColor = (LEVEL_STYLE[z.level] || {}).stroke || '#626a79';
+ label.appendChild(swatch);
+ label.appendChild(document.createTextNode(
+ (LEVEL_STYLE[z.level] || { label: z.level }).label + ' (' + z.points.length + '점)' + rulesLabel(z)));
li.appendChild(label);
+ // Two-step delete. A zone is what makes 침입 alarms fire; it takes minutes to draw and
+ // the server has no restore. A single unconfirmed click was the least reversible action
+ // in the product and also the cheapest one to trigger by accident.
var del = document.createElement('button');
del.type = 'button';
+ del.className = 'zone-del';
del.textContent = '삭제';
- del.addEventListener('click', function () { deleteZone(z.zone_id); });
+ var armed = false, armTimer = null;
+ del.addEventListener('click', function () {
+ if (!armed) {
+ armed = true;
+ del.classList.add('is-armed');
+ del.textContent = '정말 삭제?';
+ setStatus('한 번 더 누르면 이 구역이 삭제됩니다. 되돌릴 수 없습니다.');
+ armTimer = setTimeout(function () {
+ armed = false; del.classList.remove('is-armed'); del.textContent = '삭제';
+ }, 4000);
+ return;
+ }
+ if (armTimer) clearTimeout(armTimer);
+ deleteZone(z.zone_id);
+ });
li.appendChild(del);
listEl.appendChild(li);
});
}
+ // --- 장치 · 카메라 선택기 ---------------------------------------------------------
+ // The editor used to demand two free-text ids the operator had to remember, while the
+ // events console already knew both. Devices come from /api/devices; the cameras under each
+ // device are derived from the events they have reported (there is no camera registry).
+ // A camera that has never sent an event cannot appear here, which is what the manual
+ // fallback in the view is for.
+ var deviceSelect = document.getElementById('device-select');
+ var cameraSelect = document.getElementById('camera-select');
+ var manualBox = document.getElementById('zone-manual');
+ var camerasByDevice = {};
+
+ function option(value, text) {
+ var o = document.createElement('option');
+ o.value = value;
+ o.textContent = text;
+ return o;
+ }
+
+ function fillCameraSelect(deviceId) {
+ if (!cameraSelect) return;
+ cameraSelect.textContent = '';
+ var cams = camerasByDevice[deviceId] || [];
+ if (!deviceId) { cameraSelect.appendChild(option('', '장치를 먼저 선택하세요')); return; }
+ if (cams.length === 0) {
+ cameraSelect.appendChild(option('', '이 장치가 보고한 카메라 없음'));
+ return;
+ }
+ cameraSelect.appendChild(option('', '카메라를 선택하세요'));
+ cams.forEach(function (c) { cameraSelect.appendChild(option(c, c)); });
+ }
+
+ function applySelection() {
+ if (deviceSelect) deviceInput.value = deviceSelect.value;
+ if (cameraSelect) cameraInput.value = cameraSelect.value;
+ }
+
+ function loadPickers() {
+ var devices = fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; });
+ var events = fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; });
+ return Promise.all([devices, events]).then(function (res) {
+ var devs = (res[0] && res[0].devices) || [];
+ var evs = (res[1] && res[1].events) || [];
+ evs.forEach(function (ev) {
+ if (!ev.camera_id) return;
+ var d = String(ev.device_id || '');
+ if (!camerasByDevice[d]) camerasByDevice[d] = [];
+ if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id);
+ });
+ Object.keys(camerasByDevice).forEach(function (d) { camerasByDevice[d].sort(); });
+
+ if (!deviceSelect) return;
+ deviceSelect.textContent = '';
+ if (devs.length === 0) {
+ deviceSelect.appendChild(option('', '등록된 장치가 없습니다'));
+ if (manualBox) manualBox.open = true;
+ return;
+ }
+ deviceSelect.appendChild(option('', '장치를 선택하세요'));
+ devs.forEach(function (d) {
+ deviceSelect.appendChild(option(d.device_id, (d.name || d.device_id) + ' · ' + d.device_id));
+ });
+ // Honour a deep link (?device=&camera=) or a value already typed into the manual fields.
+ if (deviceInput.value) {
+ deviceSelect.value = deviceInput.value;
+ fillCameraSelect(deviceInput.value);
+ if (cameraInput.value) cameraSelect.value = cameraInput.value;
+ }
+ });
+ }
+
function loadBackground(uuid) {
if (!uuid) { backgroundImage = null; redraw(); return; }
var img = new Image();
@@ -233,7 +392,7 @@
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
function loadLatestFrame() {
var camera = cameraInput.value.trim();
- if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
+ if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
setStatus('현재 화면을 불러오는 중...');
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
.then(function (r) {
@@ -245,9 +404,9 @@
.catch(function (e) { setStatus(e.message); });
}
- function loadZones() {
+ function loadZones(doneMsg) {
var camera = cameraInput.value.trim();
- if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
+ if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
setStatus('불러오는 중...');
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
.then(function (res) {
@@ -263,7 +422,9 @@
currentPoints = [];
renderZoneList();
redraw();
- setStatus('구역 ' + existingZones.length + '개를 불러왔습니다.');
+ setStatus(doneMsg || (existingZones.length
+ ? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.'
+ : '이 카메라에는 저장된 구역이 없습니다. 캔버스를 클릭해 새로 그리세요.'));
})
.catch(function () { setStatus('불러오기에 실패했습니다.'); });
}
@@ -271,7 +432,7 @@
function saveZone() {
var camera = cameraInput.value.trim();
var device = deviceInput.value.trim();
- if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
+ if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
setStatus('저장 중...');
fetch(B + '/api/zones', {
@@ -297,8 +458,7 @@
.then(function (data) {
if (!data) return;
currentPoints = [];
- setStatus('저장되었습니다.');
- loadZones();
+ loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.');
})
.catch(function () { setStatus('저장에 실패했습니다.'); });
}
@@ -311,7 +471,7 @@
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return; }
if (!res.ok) throw new Error('delete_failed:' + res.status);
- loadZones();
+ loadZones('구역을 삭제했습니다.');
})
.catch(function () { setStatus('삭제에 실패했습니다.'); });
}
@@ -327,17 +487,49 @@
});
levelSelect.addEventListener('change', redraw);
- loadBtn.addEventListener('click', loadZones);
- if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
- if (heatmapBtn) heatmapBtn.addEventListener('click', toggleHeatmap);
+ loadBtn.addEventListener('click', function () { applySelection(); loadZones(); });
+ if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); });
+ if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); });
if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); });
- clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
- saveBtn.addEventListener('click', saveZone);
+ clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('그리던 폴리곤을 지웠습니다.'); });
+ saveBtn.addEventListener('click', function () { applySelection(); saveZone(); });
+
+ if (deviceSelect) {
+ deviceSelect.addEventListener('change', function () {
+ deviceInput.value = deviceSelect.value;
+ fillCameraSelect(deviceSelect.value);
+ cameraInput.value = '';
+ setStatus(deviceSelect.value ? '카메라를 선택하세요.' : '장치를 선택하세요.');
+ });
+ }
+ if (cameraSelect) {
+ // Selecting a camera is the operator's intent to work on it: load its zones and its latest
+ // frame at once, instead of making them press two more buttons to see anything at all.
+ cameraSelect.addEventListener('change', function () {
+ cameraInput.value = cameraSelect.value;
+ if (!cameraSelect.value) return;
+ currentPoints = [];
+ loadZones();
+ loadLatestFrame();
+ });
+ }
+ // Typing into the manual fields wins over the selects (and clears them, so the panel never
+ // shows a camera other than the one being edited).
+ [deviceInput, cameraInput].forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ if (deviceSelect && deviceSelect.value !== deviceInput.value) deviceSelect.value = '';
+ if (cameraSelect && cameraSelect.value !== cameraInput.value) cameraSelect.value = '';
+ });
+ });
var params = new URLSearchParams(window.location.search);
if (params.get('camera')) cameraInput.value = params.get('camera');
if (params.get('device')) deviceInput.value = params.get('device');
+ if (params.get('camera') || params.get('device')) { if (manualBox) manualBox.open = false; }
loadBackground(params.get('snapshot'));
loadCsrfToken();
- if (cameraInput.value) loadZones();
+ loadPickers();
+ renderZoneList(); // paint the "저장된 구역 없음" placeholder before any camera is chosen
+ redraw();
+ if (cameraInput.value) { loadZones(); loadLatestFrame(); }
})();
diff --git a/server/public/index.php b/server/public/index.php
index f0b0ca6..5c96ffd 100644
--- a/server/public/index.php
+++ b/server/public/index.php
@@ -41,13 +41,15 @@ use App\Db\Database;
use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
- PdoThresholdRepository, PdoAuditRepository, PdoActionRepository};
+ PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
+ PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
DeviceStatusController, HeatmapController, FpStatsController, ReportController,
- ExportController, ActionsController};
+ ExportController, ActionsController, AdminConfigController, SuppressionController,
+ ComplianceController};
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
@@ -85,22 +87,31 @@ $users = new PdoUserRepository($pdo);
$zones = new PdoZoneRepository($pdo);
$thr = new PdoThresholdRepository($pdo);
$audit = new PdoAuditRepository($pdo);
+$suppressions = new PdoSuppressionRepository($pdo);
+$alerts = new PdoAlertRepository($pdo);
+$handovers = new PdoHandoverRepository($pdo);
+$risks = new PdoRiskControlRepository($pdo);
+$metrics = new PdoDeviceMetricRepository($pdo);
+$actions = new PdoActionRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
-$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate);
-$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
+$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
+$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate, $metrics);
$auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController();
$list = new EventListController($events, $snaps, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
-$deviceStatus = new DeviceStatusController($devices, $sess);
+$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg);
$heatmap = new HeatmapController($events, $sess);
$fpStats = new FpStatsController($events, $sess);
$report = new ReportController($events, $sess);
$export = new ExportController($events, $snaps, $sess);
-$actionsCtl = new ActionsController(new PdoActionRepository($pdo), $events, $sess);
+$actionsCtl = new ActionsController($actions, $events, $sess);
+$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess);
+$suppressCtl = new SuppressionController($suppressions, $events, $devices, $audit, $sess);
+$complianceCtl = new ComplianceController($events, $snaps, $actions, $alerts, $risks, $handovers, $audit, $sess);
$render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
@@ -133,10 +144,31 @@ $r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p)
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
+// --- 성능 추이 · 통보 · 억제 · 규정 --------------------------------------------------
+$r->add('GET', '/api/devices/{id}/metrics', fn(Request $q, array $p) => $deviceStatus->metrics($q, $p, $now));
+$r->add('GET', '/api/admin/thresholds', fn(Request $q) => $adminCtl->thresholds($q));
+$r->add('POST', '/api/admin/thresholds', fn(Request $q) => $adminCtl->saveThreshold($q));
+$r->add('POST', '/api/admin/thresholds/{id}/delete', fn(Request $q, array $p) => $adminCtl->deleteThreshold($q, $p));
+$r->add('GET', '/api/admin/channels', fn(Request $q) => $adminCtl->channels($q));
+$r->add('POST', '/api/admin/channels', fn(Request $q) => $adminCtl->saveChannel($q));
+$r->add('POST', '/api/admin/channels/{id}/delete', fn(Request $q, array $p) => $adminCtl->deleteChannel($q, $p));
+$r->add('GET', '/api/suppressions', fn(Request $q) => $suppressCtl->json($q));
+$r->add('POST', '/api/suppressions', fn(Request $q) => $suppressCtl->create($q));
+$r->add('POST', '/api/suppressions/{id}/revoke', fn(Request $q, array $p) => $suppressCtl->revoke($q, $p));
+$r->add('GET', '/api/risks', fn(Request $q) => $complianceCtl->risks($q));
+$r->add('POST', '/api/risks', fn(Request $q) => $complianceCtl->saveRisk($q));
+$r->add('POST', '/api/risks/{id}/delete', fn(Request $q, array $p) => $complianceCtl->deleteRisk($q, $p));
+$r->add('GET', '/api/handovers', fn(Request $q) => $complianceCtl->handovers($q));
+$r->add('POST', '/api/handovers', fn(Request $q) => $complianceCtl->acceptHandover($q));
+$r->add('GET', '/api/events/{id}/package', fn(Request $q, array $p) => $complianceCtl->package($q, $p));
$r->add('GET', '/', fn() => $render('events'));
$r->add('GET', '/login', fn() => $render('login'));
$r->add('GET', '/zones', fn() => $render('zones'));
$r->add('GET', '/report', fn() => $render('report'));
+$r->add('GET', '/admin', fn() => $render('admin'));
+$r->add('GET', '/compliance', fn() => $render('compliance'));
+$r->add('GET', '/setup', fn() => $render('setup'));
$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail'));
+$r->add('GET', '/events/{id}/package/view', fn(Request $q, array $p) => $render('incident_package'));
$r->dispatch(Request::fromGlobals())->send();
diff --git a/server/schema.sql b/server/schema.sql
index 7068d69..f5d4ad5 100644
--- a/server/schema.sql
+++ b/server/schema.sql
@@ -102,13 +102,15 @@ CREATE TABLE move_actions (
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
camera_id VARCHAR(64) NOT NULL,
site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert
+ event_id INT UNSIGNED NULL, -- 이 조치의 계기가 된 사건 (상세 화면에서 바로 기록할 때)
note VARCHAR(500) NOT NULL,
taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s)
created_by VARCHAR(190) NOT NULL,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (action_id),
KEY ix_actions_taken (taken_at),
- KEY ix_actions_site_taken (site, taken_at)
+ KEY ix_actions_site_taken (site, taken_at),
+ KEY ix_actions_event (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE move_zones (
@@ -163,7 +165,13 @@ CREATE TABLE move_events (
CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault',
- 'lone_worker','overcapacity','overstay','near_fall','ergo_report'
+ 'lone_worker','overcapacity','overstay','near_fall','ergo_report',
+ -- 감시 자체의 건전성: 카메라가 틀어지거나 가려지면 존 판정이 조용히 무의미해진다.
+ 'camera_moved','camera_obstructed','low_light',
+ -- 아무도 확인하지 않았다는 사실 자체를 사건으로 만든다 (에스컬레이션 디스패처가 발행).
+ 'unacked_escalation',
+ -- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다.
+ 'ppe_missing','proximity','fire_smoke'
)),
CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -204,6 +212,8 @@ CREATE TABLE move_thresholds (
zone_id CHAR(32) NULL,
`key` VARCHAR(64) NOT NULL,
`value` VARCHAR(255) NOT NULL,
+ updated_by VARCHAR(190) NULL, -- 편집 UI(관리자 전용)가 남기는 감사 흔적
+ updated_at BIGINT UNSIGNED NULL,
PRIMARY KEY (id),
KEY ix_thresholds_device (device_id),
UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`)
@@ -242,9 +252,15 @@ CREATE TABLE move_rate_limits (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
--- audit_log: append-only action log (acknowledge / zone save+delete / login).
--- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the
--- Task 16 brief self-review -- this table is the plain append-only record only.
+-- audit_log: append-only action log (acknowledge / zone save+delete / login /
+-- threshold edit / suppression / handover).
+--
+-- TAMPER-EVIDENT: each row carries prev_hash (the previous row's entry_hash) and
+-- entry_hash = sha256(id|actor|action|target|before|after|ts|prev_hash). Editing or
+-- deleting a row breaks every hash after it, so a doctored log is DETECTABLE even
+-- though shared hosting cannot grant append-only rights at the DB level. Verified by
+-- `php tools/verify_audit_chain.php`. Rows written before the chain existed keep NULL
+-- hashes and the verifier reports them as the pre-chain segment rather than as damage.
-- ---------------------------------------------------------------------------
CREATE TABLE move_audit_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -254,9 +270,148 @@ CREATE TABLE move_audit_log (
before_json JSON NOT NULL,
after_json JSON NOT NULL,
ts BIGINT UNSIGNED NOT NULL,
+ prev_hash CHAR(64) NULL,
+ entry_hash CHAR(64) NULL,
PRIMARY KEY (id),
KEY ix_audit_log_ts (ts),
- KEY ix_audit_log_actor (actor)
+ KEY ix_audit_log_actor (actor),
+ KEY ix_audit_log_entry_hash (entry_hash)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- suppressions: planned-work alarm suppression (정비 모드 / 작업허가).
+-- Detection is NOT paused -- events are still ingested and still appear in the console
+-- and in reports. Only NOTIFICATION is withheld, and every suppression carries a
+-- mandatory reason, a hard expiry (no open-ended suppression) and an audit entry.
+-- Rationale: the single largest source of false alarms is planned maintenance, and a
+-- console that cries wolf during every shutdown is a console the site switches off.
+-- ---------------------------------------------------------------------------
+CREATE TABLE move_suppressions (
+ suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
+ scope_id VARCHAR(64) NOT NULL,
+ site VARCHAR(64) NULL,
+ reason VARCHAR(500) NOT NULL,
+ starts_at BIGINT UNSIGNED NOT NULL,
+ ends_at BIGINT UNSIGNED NOT NULL,
+ created_by VARCHAR(190) NOT NULL,
+ created_at BIGINT UNSIGNED NOT NULL,
+ revoked_by VARCHAR(190) NULL,
+ revoked_at BIGINT UNSIGNED NULL,
+ PRIMARY KEY (suppression_id),
+ KEY ix_suppressions_window (ends_at, starts_at),
+ KEY ix_suppressions_scope (scope_type, scope_id),
+ KEY ix_suppressions_site (site),
+ CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- alert_channels / alert_log: how a detection reaches a human.
+-- Air-gapped sites cannot use FCM/Slack, so the channels are the ones a closed network
+-- actually has: `local` (the edge node's own beacon/buzzer over GPIO -- the only path
+-- that needs no network at all), `email` (in-house SMTP relay) and `webhook` (in-house
+-- groupware / SMS gateway). step>0 channels fire only after escalate_after_s of the
+-- event still being unacknowledged, which is what turns "we detected it" into "someone
+-- was made to look at it".
+-- ---------------------------------------------------------------------------
+CREATE TABLE move_alert_channels (
+ channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ site VARCHAR(64) NULL,
+ kind VARCHAR(16) NOT NULL,
+ name VARCHAR(190) NOT NULL,
+ config_json JSON NOT NULL, -- recipients / url / gpio pin. NEVER credentials.
+ min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ step INT UNSIGNED NOT NULL DEFAULT 0,
+ escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
+ created_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (channel_id),
+ KEY ix_alert_channels_site (site, enabled),
+ CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
+ CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE move_alert_log (
+ alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ event_id INT UNSIGNED NOT NULL,
+ channel_id INT UNSIGNED NOT NULL,
+ status VARCHAR(16) NOT NULL,
+ attempts INT UNSIGNED NOT NULL DEFAULT 0,
+ last_error VARCHAR(500) NULL,
+ queued_at BIGINT UNSIGNED NOT NULL,
+ sent_at BIGINT UNSIGNED NULL,
+ PRIMARY KEY (alert_id),
+ -- The dispatcher runs from cron and overlapping runs are expected; this unique key is
+ -- the only thing standing between a site and the same fall being phoned in five times.
+ UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
+ KEY ix_alert_log_status (status, queued_at),
+ CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
+ CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- shift_handovers: who took responsibility for what, and when.
+-- The 24h brief tells an operator what happened; it does not record that anyone
+-- accepted the backlog. Without that, unacknowledged events evaporate across a shift
+-- change with nobody accountable for them.
+-- ---------------------------------------------------------------------------
+CREATE TABLE move_shift_handovers (
+ handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ site VARCHAR(64) NULL,
+ from_user VARCHAR(190) NULL,
+ to_user VARCHAR(190) NOT NULL,
+ period_start BIGINT UNSIGNED NOT NULL,
+ period_end BIGINT UNSIGNED NOT NULL,
+ unacked_count INT UNSIGNED NOT NULL,
+ severe_count INT UNSIGNED NOT NULL,
+ note VARCHAR(1000) NULL,
+ created_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (handover_id),
+ KEY ix_handovers_site_created (site, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- risk_controls: 위험성평가 연계. One row per (site, detection type): the hazard it
+-- stands for, the control currently in place, and the 5x5 rating. This is what lets the
+-- system print "what are we controlling and how well", which is the artefact a safety
+-- management system is actually audited on.
+-- ---------------------------------------------------------------------------
+CREATE TABLE move_risk_controls (
+ control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ site VARCHAR(64) NULL,
+ event_type VARCHAR(32) NOT NULL,
+ hazard VARCHAR(300) NOT NULL,
+ control VARCHAR(500) NOT NULL,
+ severity_rating TINYINT UNSIGNED NOT NULL,
+ likelihood_rating TINYINT UNSIGNED NOT NULL,
+ reviewed_by VARCHAR(190) NULL,
+ reviewed_at BIGINT UNSIGNED NULL,
+ updated_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (control_id),
+ UNIQUE KEY uq_risk_controls_site_type (site, event_type),
+ CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
+ CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------------
+-- device_metrics: heartbeat telemetry as a TIME SERIES.
+-- move_devices holds only the latest value, so a node whose inference latency doubled
+-- overnight looks identical to a healthy one. Appending each heartbeat is what makes
+-- slow degradation visible before it becomes a monitoring outage.
+-- ---------------------------------------------------------------------------
+CREATE TABLE move_device_metrics (
+ metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ device_id VARCHAR(64) NOT NULL,
+ ts BIGINT UNSIGNED NOT NULL,
+ fps DOUBLE NULL,
+ temp DOUBLE NULL,
+ throttle TINYINT(1) NULL,
+ queue_pending INT UNSIGNED NULL,
+ cameras_total INT UNSIGNED NULL,
+ cameras_erroring INT UNSIGNED NULL,
+ errors INT UNSIGNED NULL,
+ PRIMARY KEY (metric_id),
+ KEY ix_device_metrics_device_ts (device_id, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===========================================================================
diff --git a/server/src/Alert/AlertDispatcher.php b/server/src/Alert/AlertDispatcher.php
new file mode 100644
index 0000000..42c385d
--- /dev/null
+++ b/server/src/Alert/AlertDispatcher.php
@@ -0,0 +1,130 @@
+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 deliveries to attempt
+ */
+ public function planFor(array $event, array $channels, int $now): array
+ {
+ // Resolved events stop the chain. Note this covers the escalation steps too: an event
+ // acknowledged 30 seconds in never reaches the 5-minute escalation channel.
+ if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) return [];
+
+ $rank = self::SEVERITY_RANK[$event['severity']] ?? 0;
+ $age = $now - (int)$event['occurred_at'];
+
+ // One suppression lookup per event covers all of its scopes. Zone is included because a
+ // permit is usually issued for an area, not for a camera.
+ $scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]];
+ if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']];
+ $suppressed = $this->suppressions->activeFor($scopes, $now) !== [];
+
+ $out = [];
+ foreach ($channels as $ch) {
+ if (empty($ch['enabled'])) continue;
+ // `local` beacons are fired by the edge node itself the moment its own detector
+ // produces the event — no server round trip, and no dependency on this job running
+ // at all. Their rows exist only to carry the GPIO configuration down through
+ // GET /api/config, so the server has nothing to deliver and must not claim a
+ // delivery row for them (which would show up as a permanent failure).
+ if ($ch['kind'] === 'local') continue;
+ if ($rank < (self::SEVERITY_RANK[$ch['min_severity']] ?? 0)) continue;
+ // A site-scoped channel only serves its own site; a NULL-site channel is global.
+ if ($ch['site'] !== null && $ch['site'] !== ($event['site'] ?? null)) continue;
+ // Not yet due: a later dispatcher run will pick it up once the event has aged.
+ if ((int)$ch['step'] > 0 && $age < (int)$ch['escalate_after_s']) continue;
+ $out[] = ['channel' => $ch, 'suppressed' => $suppressed];
+ }
+ return $out;
+ }
+
+ /**
+ * Run one dispatch pass over the recent window.
+ *
+ * @param callable(array $event, array $channel): void $send throws on failure
+ * @return array{considered:int,sent:int,suppressed:int,failed:int,skipped:int}
+ */
+ public function run(int $now, int $windowSeconds, int $limit, callable $send): array
+ {
+ $stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0];
+ $events = $this->events->forNotification((float)($now - $windowSeconds), $limit);
+ // One channel read per site, not per event: a busy window is hundreds of events over a
+ // handful of sites, and this job runs on shared hosting where every query costs.
+ $channelsBySite = [];
+
+ foreach ($events as $event) {
+ $stats['considered']++;
+ $site = $event['site'] ?? null;
+ $key = $site ?? "\x00global";
+ if (!array_key_exists($key, $channelsBySite)) {
+ $channelsBySite[$key] = $this->alerts->channels($site);
+ }
+ foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
+ $channelId = (int)$delivery['channel']['channel_id'];
+ $eventId = (int)$event['event_id'];
+ // claim() is the duplicate guard. Losing it means another (possibly overlapping)
+ // dispatcher run owns this delivery — skip, do not send.
+ if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; }
+ if ($delivery['suppressed']) {
+ $this->alerts->markSuppressed($eventId, $channelId, $now);
+ $stats['suppressed']++;
+ continue;
+ }
+ try {
+ $send($event, $delivery['channel']);
+ $this->alerts->markSent($eventId, $channelId, $now);
+ $stats['sent']++;
+ } catch (\Throwable $e) {
+ // Never fatal: one unreachable SMTP relay must not stop the webhook that
+ // would have reached somebody. The row keeps the error for the retry pass.
+ $this->alerts->markFailed($eventId, $channelId, $e->getMessage(), $now);
+ $stats['failed']++;
+ }
+ }
+ }
+ return $stats;
+ }
+}
diff --git a/server/src/Alert/Channels.php b/server/src/Alert/Channels.php
new file mode 100644
index 0000000..9c4df9d
--- /dev/null
+++ b/server/src/Alert/Channels.php
@@ -0,0 +1,185 @@
+ '넘어짐', 'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동',
+ 'ghost' => '위험 상태 소실', 'zone_warning' => '경고구역 진입', 'zone_restricted' => '제한구역 침입',
+ 'pinch' => '협착구역 침입', 'monitoring_degraded' => '감시 성능 저하', 'device_fault' => '장치 이상',
+ 'lone_worker' => '위험구역 단독 작업', 'overcapacity' => '정원 초과', 'overstay' => '체류 상한 초과',
+ 'near_fall' => '넘어질 뻔함 (니어미스)', 'ergo_report' => '작업자세 부담 리포트',
+ 'camera_moved' => '카메라 화각 변경', 'camera_obstructed' => '카메라 가림', 'low_light' => '조도 저하',
+ 'unacked_escalation' => '미확인 경보 에스컬레이션',
+ 'ppe_missing' => '보호구 미착용', 'proximity' => '중장비 근접', 'fire_smoke' => '화재·연기',
+ ];
+ private const SEVERITY_LABELS = [
+ 'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
+ ];
+
+ /** @param string $baseUrl absolute dashboard URL, for the incident link. */
+ public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {}
+
+ public function send(array $event, array $channel): void
+ {
+ $config = json_decode($channel['config_json'] ?? '{}', true);
+ if (!is_array($config)) $config = [];
+ match ($channel['kind']) {
+ 'email' => $this->sendEmail($event, $config),
+ 'webhook' => $this->sendWebhook($event, $channel, $config),
+ // `local` is delivered by the edge itself (see the class comment). Recording it as
+ // sent here would be a lie; recording it as failed would bury the real failures.
+ 'local' => throw new \LogicException('local channels are delivered by the edge node, not the server'),
+ default => throw new \RuntimeException('unknown channel kind: ' . (string)$channel['kind']),
+ };
+ }
+
+ public function subject(array $event): string
+ {
+ $sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
+ $type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
+ return "[{$sev}] {$type} — " . (string)($event['camera_id'] ?? '');
+ }
+
+ public function body(array $event): string
+ {
+ $type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
+ $sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
+ $when = date('Y-m-d H:i:s', (int)$event['occurred_at']);
+ $link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
+ return implode("\n", [
+ "작업장 안전 감지 알림",
+ "",
+ "유형 : {$type}",
+ "심각도 : {$sev}",
+ "장치 : " . (string)($event['device_id'] ?? '-'),
+ "카메라 : " . (string)($event['camera_id'] ?? '-'),
+ "발생 시각: {$when}",
+ "지속 : " . (string)($event['duration_s'] ?? 0) . "초",
+ "",
+ "확인 및 증거 프레임: {$link}",
+ "",
+ "이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.",
+ "이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.",
+ ]);
+ }
+
+ private function sendEmail(array $event, array $config): void
+ {
+ $to = $config['to'] ?? null;
+ $recipients = is_array($to) ? $to : (is_string($to) ? array_map('trim', explode(',', $to)) : []);
+ $recipients = array_values(array_filter($recipients, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL)));
+ if ($recipients === []) throw new \RuntimeException('email channel has no valid recipient');
+
+ $from = $config['from'] ?? null;
+ $headers = "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n";
+ if (is_string($from) && filter_var($from, FILTER_VALIDATE_EMAIL)) {
+ $headers .= 'From: ' . $from . "\r\n";
+ }
+ // Encode the subject: a raw Korean Subject header is mangled by most relays.
+ $subject = '=?UTF-8?B?' . base64_encode($this->subject($event)) . '?=';
+ // BCC-style: one call per recipient so one bad address cannot drop the whole batch, and
+ // so recipients do not see each other's addresses.
+ $failed = [];
+ foreach ($recipients as $rcpt) {
+ if (!@mail($rcpt, $subject, $this->body($event), $headers)) $failed[] = $rcpt;
+ }
+ if (count($failed) === count($recipients)) {
+ throw new \RuntimeException('mail() rejected every recipient: ' . implode(', ', $failed));
+ }
+ }
+
+ private function sendWebhook(array $event, array $channel, array $config): void
+ {
+ $url = $config['url'] ?? '';
+ if (!is_string($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
+ throw new \RuntimeException('webhook channel has no valid url');
+ }
+ // http/https only. Without this a stored config could name file:// or gopher:// and turn
+ // the dispatcher into a local-file reader.
+ $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
+ if (!in_array($scheme, ['http', 'https'], true)) {
+ throw new \RuntimeException('webhook url must be http(s)');
+ }
+
+ $payload = json_encode([
+ 'event_id' => (int)$event['event_id'],
+ 'event_type' => $event['event_type'],
+ 'severity' => $event['severity'],
+ 'device_id' => $event['device_id'] ?? null,
+ 'camera_id' => $event['camera_id'] ?? null,
+ 'site' => $event['site'] ?? null,
+ 'occurred_at' => (int)$event['occurred_at'],
+ 'duration_s' => (float)($event['duration_s'] ?? 0),
+ 'subject' => $this->subject($event),
+ 'text' => $this->body($event),
+ 'url' => rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view',
+ ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+
+ $headers = ['Content-Type: application/json'];
+ // An optional shared token, so the receiving system can reject anything that is not us.
+ if (!empty($config['token']) && is_string($config['token'])) {
+ $headers[] = 'X-Alert-Token: ' . $config['token'];
+ }
+
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $this->timeoutSeconds,
+ CURLOPT_CONNECTTIMEOUT => min(5, $this->timeoutSeconds),
+ // No redirects: a 302 from a compromised endpoint must not be able to walk this
+ // POST — with its token header — to another host.
+ CURLOPT_FOLLOWLOCATION => false,
+ ]);
+ $body = curl_exec($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $err = curl_error($ch);
+ curl_close($ch);
+ if ($body === false) throw new \RuntimeException('webhook transport error: ' . $err);
+ if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status);
+ return;
+ }
+
+ $ctx = stream_context_create(['http' => [
+ 'method' => 'POST',
+ 'header' => implode("\r\n", $headers),
+ 'content' => $payload,
+ 'timeout' => $this->timeoutSeconds,
+ 'ignore_errors' => true,
+ 'follow_location' => 0,
+ ]]);
+ $res = @file_get_contents($url, false, $ctx);
+ if ($res === false) throw new \RuntimeException('webhook transport error (stream)');
+ $status = 0;
+ foreach ($http_response_header ?? [] as $h) {
+ if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int)$m[1];
+ }
+ if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status);
+ }
+}
diff --git a/server/src/Alert/EscalationWatch.php b/server/src/Alert/EscalationWatch.php
new file mode 100644
index 0000000..0e99994
--- /dev/null
+++ b/server/src/Alert/EscalationWatch.php
@@ -0,0 +1,91 @@
+ origin event ids that were escalated in this pass
+ */
+ public function run(int $now, int $afterSeconds, int $windowSeconds, int $limit): array
+ {
+ $escalated = [];
+ foreach ($this->events->forNotification((float)($now - $windowSeconds), $limit) as $ev) {
+ if (!in_array($ev['severity'], self::ESCALATING_SEVERITIES, true)) continue;
+ if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue;
+ // An escalation must never escalate itself — that is an infinite ladder.
+ if ($ev['event_type'] === 'unacked_escalation') continue;
+ if (($now - (int)$ev['occurred_at']) < $afterSeconds) continue;
+
+ $originId = (int)$ev['event_id'];
+ $uuid = self::uuidFor($originId);
+ if ($this->events->idByUuid($uuid) !== null) continue; // already escalated
+
+ try {
+ $this->events->insert([
+ 'event_uuid' => $uuid,
+ 'device_id' => $ev['device_id'],
+ 'site' => $ev['site'] ?? null,
+ 'camera_id' => $ev['camera_id'],
+ 'event_type' => 'unacked_escalation',
+ // The escalation inherits the severity it is escalating: a critical left
+ // unattended is still a critical, and downgrading it here would let it fall
+ // below the very channel floors that exist to wake someone.
+ 'severity' => $ev['severity'],
+ 'track_id' => $ev['track_id'] ?? null,
+ 'zone_id' => $ev['zone_id'] ?? null,
+ 'occurred_at' => (float)$now,
+ 'duration_s' => (float)($now - (int)$ev['occurred_at']),
+ 'metrics' => [
+ 'origin_event_id' => $originId,
+ 'origin_event_type' => $ev['event_type'],
+ 'unacked_s' => $now - (int)$ev['occurred_at'],
+ ],
+ 'spatial_x' => $ev['spatial_x'] ?? null,
+ 'spatial_y' => $ev['spatial_y'] ?? null,
+ ]);
+ $escalated[] = $originId;
+ } catch (\Throwable $e) {
+ // A unique-key collision means a concurrent run won the race; anything else is
+ // worth surfacing but must not stop the remaining events from escalating.
+ if (!str_contains($e->getMessage(), 'uq_events_uuid')
+ && !str_contains($e->getMessage(), 'UNIQUE')
+ && !str_contains($e->getMessage(), 'Duplicate')) {
+ throw $e;
+ }
+ }
+ }
+ return $escalated;
+ }
+}
diff --git a/server/src/Api/ConfigController.php b/server/src/Api/ConfigController.php
index 0c46646..97923f1 100644
--- a/server/src/Api/ConfigController.php
+++ b/server/src/Api/ConfigController.php
@@ -5,7 +5,8 @@ namespace App\Api;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
-use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
+use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository,
+ RateLimiterRepository, AlertRepository};
final class ConfigController
{
@@ -13,6 +14,7 @@ final class ConfigController
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
private NonceRepository $nonces, private Config $cfg,
private ?RateLimiterRepository $rateLimiter = null,
+ private ?AlertRepository $alerts = null,
) {}
public function get(Request $req, int $now, string $clientIp = ''): Response
@@ -40,6 +42,33 @@ final class ConfigController
'version' => $this->devices->configVersion($deviceId),
'zones' => $this->zones->forDevice($deviceId),
'thresholds' => $this->thresholds->forDevice($deviceId),
+ // Local beacon wiring (GPIO pin, latch/pulse, hold time). The node fires its own
+ // beacon the instant its detector produces a qualifying event — no server round trip,
+ // so it still works with the uplink down, which is the whole point of having it.
+ // Carried here because /api/config is the only authenticated channel the device has.
+ 'beacons' => $this->beaconsFor($deviceId),
], 200);
}
+
+ /** @return array local-kind channels applicable to this device's site. */
+ private function beaconsFor(string $deviceId): array
+ {
+ if ($this->alerts === null) return [];
+ $site = $this->devices->siteFor($deviceId);
+ $out = [];
+ foreach ($this->alerts->channels($site) as $ch) {
+ if ($ch['kind'] !== 'local') continue;
+ $config = json_decode($ch['config_json'], true);
+ if (!is_array($config)) continue;
+ $out[] = [
+ 'name' => $ch['name'],
+ 'min_severity' => $ch['min_severity'],
+ 'gpio_pin' => (int)($config['gpio_pin'] ?? 0),
+ 'mode' => (string)($config['mode'] ?? 'latch'),
+ 'hold_s' => (float)($config['hold_s'] ?? 30.0),
+ 'active_high' => (bool)($config['active_high'] ?? false),
+ ];
+ }
+ return $out;
+ }
}
diff --git a/server/src/Api/HeartbeatController.php b/server/src/Api/HeartbeatController.php
index 1df5577..c1f82a0 100644
--- a/server/src/Api/HeartbeatController.php
+++ b/server/src/Api/HeartbeatController.php
@@ -5,12 +5,13 @@ namespace App\Api;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
-use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository};
+use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, DeviceMetricRepository};
final class HeartbeatController
{
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces,
- private ?RateLimiterRepository $rateLimiter = null) {}
+ private ?RateLimiterRepository $rateLimiter = null,
+ private ?DeviceMetricRepository $metrics = null) {}
public function post(Request $req, int $now, string $clientIp = ''): Response
{
@@ -46,6 +47,25 @@ final class HeartbeatController
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
'at' => $now,
]);
+
+ // Append the same beat to the time series. move_devices only ever holds the LATEST
+ // reading, which cannot answer "is this node getting worse?" — a device whose fps has
+ // been sliding for a week looks identical to a healthy one right up until it stops.
+ // Optional dependency so the many tests that construct this controller with four
+ // arguments keep working, and so a site that has not migrated yet still ingests.
+ //
+ // Absent readings stay absent: only keys the device actually sent are forwarded, because
+ // the repository stores a missing metric as NULL and coercing one to 0 here would make a
+ // device that cannot read its temperature look like a device sitting at 0°C.
+ if ($this->metrics !== null) {
+ $series = [];
+ foreach (['fps', 'temp', 'throttle', 'queue_pending', 'cameras_total',
+ 'cameras_erroring', 'errors'] as $k) {
+ if (array_key_exists($k, $b)) $series[$k] = $b[$k];
+ }
+ if ($series !== []) $this->metrics->append($deviceId, $series, $now);
+ }
+
return Response::json(['ok' => true], 200);
}
}
diff --git a/server/src/Api/ThresholdCatalog.php b/server/src/Api/ThresholdCatalog.php
new file mode 100644
index 0000000..653f1e0
--- /dev/null
+++ b/server/src/Api/ThresholdCatalog.php
@@ -0,0 +1,101 @@
+
+ */
+ public static function all(): array
+ {
+ return [
+ 'fall_prone_s' => [
+ 'label' => '넘어짐 확정 대기', 'unit' => '초',
+ 'min' => 1.0, 'max' => 30.0, 'default' => 3.0, 'step' => 0.5, 'integer' => false,
+ 'help' => '쓰러진 자세가 이 시간 이상 지속되어야 넘어짐으로 확정합니다. 낮추면 반응이 빨라지지만 앉거나 웅크린 자세를 오탐할 수 있습니다.',
+ ],
+ 'fall_no_motion_s' => [
+ 'label' => '넘어짐 후 미동 판정', 'unit' => '초',
+ 'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false,
+ 'help' => '넘어진 뒤 이 시간 동안 움직임이 없으면 응급 상황(미동 없음)으로 올립니다.',
+ ],
+ 'immobile_s' => [
+ 'label' => '장시간 부동 판정', 'unit' => '초',
+ 'min' => 30.0, 'max' => 1800.0, 'default' => 120.0, 'step' => 10.0, 'integer' => false,
+ 'help' => '서 있든 앉아 있든 이 시간 동안 움직임이 없으면 이상으로 봅니다. 정적인 작업이 많은 공정에서는 올리세요.',
+ ],
+ 'ghost_reappear_s' => [
+ 'label' => '소실 후 재출현 대기', 'unit' => '초',
+ 'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false,
+ 'help' => '위험 상태였던 사람이 화면에서 사라진 뒤 이 시간 안에 다시 나타나지 않으면 경보합니다.',
+ ],
+ 'zone_debounce_s' => [
+ 'label' => '구역 진입 디바운스', 'unit' => '초',
+ 'min' => 0.2, 'max' => 10.0, 'default' => 0.75, 'step' => 0.05, 'integer' => false,
+ 'help' => '경계선을 스치듯 지나가는 것을 침입으로 세지 않기 위한 최소 체류 시간입니다.',
+ ],
+ 'cooldown_s' => [
+ 'label' => '동일 이벤트 재발행 억제', 'unit' => '초',
+ 'min' => 5.0, 'max' => 600.0, 'default' => 30.0, 'step' => 5.0, 'integer' => false,
+ 'help' => '같은 사람·같은 유형의 이벤트를 이 시간 안에는 다시 보내지 않습니다. 너무 낮추면 한 사건이 수십 건으로 쪼개집니다.',
+ ],
+ 'motion_thr' => [
+ 'label' => '움직임 임계', 'unit' => '',
+ 'min' => 0.05, 'max' => 2.0, 'default' => 0.25, 'step' => 0.05, 'integer' => false,
+ 'help' => '이 값보다 작은 키포인트 변화는 "정지"로 봅니다. 카메라가 흔들리는 현장은 올리고, 미세한 움직임까지 살리려면 내리세요.',
+ ],
+ 'infer_every' => [
+ 'label' => '추론 주기', 'unit' => '틱마다 1회',
+ 'min' => 1.0, 'max' => 10.0, 'default' => 1.0, 'step' => 1.0, 'integer' => true,
+ 'help' => '장비가 전 카메라를 full-rate 로 처리하지 못할 때만 올립니다. 반응 정밀도를 내주고 모든 카메라를 살리는 절충입니다.',
+ ],
+ ];
+ }
+
+ public static function has(string $key): bool
+ {
+ return array_key_exists($key, self::all());
+ }
+
+ /**
+ * Validate a submitted value against the catalogue.
+ *
+ * @return array{0:bool,1:string} [ok, normalized-or-error-code]
+ */
+ public static function normalize(string $key, mixed $raw): array
+ {
+ $spec = self::all()[$key] ?? null;
+ if ($spec === null) return [false, 'unknown_key'];
+ if (!is_numeric($raw)) return [false, 'not_numeric'];
+ $v = (float)$raw;
+ // NaN/INF slip past is_numeric for the strings "nan"/"inf" on some platforms, and both
+ // are exactly the values edge/config.py exists to reject.
+ if (!is_finite($v)) return [false, 'not_finite'];
+ if ($v < $spec['min'] || $v > $spec['max']) return [false, 'out_of_range'];
+ if ($spec['integer']) {
+ if (floor($v) !== $v) return [false, 'not_integer'];
+ return [true, (string)(int)$v];
+ }
+ // Normalize the textual form so the same value always stores identically (the column is
+ // a VARCHAR and "3" vs "3.0" would otherwise read as two different settings).
+ return [true, rtrim(rtrim(number_format($v, 3, '.', ''), '0'), '.') ?: '0'];
+ }
+}
diff --git a/server/src/Config.php b/server/src/Config.php
index b249fcc..a851400 100644
--- a/server/src/Config.php
+++ b/server/src/Config.php
@@ -26,4 +26,34 @@ final class Config
* A site with a DB quota can opt in by setting this.
*/
public function eventRetentionDays(): int { return (int)($this->a['event_retention_days'] ?? 0); }
+
+ /**
+ * Absolute, externally reachable base URL of the dashboard ("https://example.com/move_detect").
+ * Notifications need it because they are read outside the browser session that produced them,
+ * so SCRIPT_NAME — which is how every in-app link is built — is not available. Empty by
+ * default and the dispatcher refuses to run without it, rather than mailing a broken link.
+ */
+ public function publicBaseUrl(): string { return rtrim((string)($this->a['public_base_url'] ?? ''), '/'); }
+
+ /**
+ * How long an unresolved critical/high event may sit before it becomes an
+ * `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by
+ * default: long enough that a present operator handles it first, short enough that a fall
+ * at 03:00 does not wait for the morning shift.
+ */
+ public function escalateAfterSeconds(): int { return (int)($this->a['escalate_after_seconds'] ?? 300); }
+
+ /**
+ * Retention for the heartbeat time series (move_device_metrics). At a 30s heartbeat each
+ * device writes ~2,880 rows/day, so on a shared-hosting quota this table is the one most
+ * likely to grow until it breaks ingest. 30 days keeps a month of degradation history.
+ */
+ public function metricRetentionDays(): int { return (int)($this->a['metric_retention_days'] ?? 30); }
+
+ /**
+ * Free-space floor for the snapshot volume, in megabytes. Below it the system raises a
+ * `device_fault` notice instead of discovering the problem as silently-failing image writes.
+ * 0 disables the check (a host where disk_free_space() is not meaningful).
+ */
+ public function storageMinFreeMb(): int { return (int)($this->a['storage_min_free_mb'] ?? 200); }
}
diff --git a/server/src/Dashboard/ActionsController.php b/server/src/Dashboard/ActionsController.php
index 44c2d59..9131028 100644
--- a/server/src/Dashboard/ActionsController.php
+++ b/server/src/Dashboard/ActionsController.php
@@ -58,8 +58,23 @@ final class ActionsController
if (!SiteScope::allows(is_string($scope) ? $scope : null, $site)) {
return Response::json(['error' => 'not_found'], 404);
}
+ // Optional link back to the incident that prompted the fix. Recorded from the event
+ // detail page, so the report can later say WHICH accident a control came out of instead
+ // of leaving the chain of cause broken at the camera.
+ $eventId = null;
+ if (isset($b['event_id']) && is_numeric($b['event_id'])) {
+ $ev = $this->events->find((int)$b['event_id']);
+ // Silently dropping a bad id would record an action whose stated origin is a lie.
+ if ($ev === null || !SiteScope::allows(is_string($scope) ? $scope : null, $ev['site'] ?? null)) {
+ return Response::json(['error' => 'unknown_event'], 400);
+ }
+ if (($ev['camera_id'] ?? '') !== $camera) return Response::json(['error' => 'event_camera_mismatch'], 400);
+ $eventId = (int)$b['event_id'];
+ }
+
$id = $this->actions->insert([
- 'camera_id' => $camera, 'site' => $site, 'note' => $note, 'taken_at' => $takenAt,
+ 'camera_id' => $camera, 'site' => $site, 'event_id' => $eventId,
+ 'note' => $note, 'taken_at' => $takenAt,
'created_by' => (string)$this->sess->get('username'),
]);
return Response::json(['action_id' => $id], 201);
diff --git a/server/src/Dashboard/AdminConfigController.php b/server/src/Dashboard/AdminConfigController.php
new file mode 100644
index 0000000..717bc94
--- /dev/null
+++ b/server/src/Dashboard/AdminConfigController.php
@@ -0,0 +1,247 @@
+sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ if (!Rbac::can((string)$this->sess->get('role'), 'manage_thresholds')) {
+ return Response::json(['error' => 'forbidden'], 403);
+ }
+ if ($write && !Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+ return null;
+ }
+
+ private function site(): ?string
+ {
+ $scope = $this->sess->get('site_scope');
+ return is_string($scope) ? $scope : null;
+ }
+
+ // ---------------------------------------------------------------- thresholds
+ public function thresholds(Request $req): Response
+ {
+ if ($r = $this->guard($req, false)) return $r;
+ return Response::json([
+ // The catalogue travels with the values so the editor can render a bounded control
+ // (a slider with the real min/max and the real explanation) instead of a free-text
+ // key/value box that invites a typo the device will silently ignore.
+ 'catalog' => ThresholdCatalog::all(),
+ 'thresholds' => $this->thresholds->listAll($this->site()),
+ 'devices' => $this->devices->listStatus($this->site()),
+ ], 200);
+ }
+
+ public function saveThreshold(Request $req): Response
+ {
+ if ($r = $this->guard($req, true)) return $r;
+ $b = $req->json();
+ $deviceId = trim((string)($b['device_id'] ?? ''));
+ $key = trim((string)($b['key'] ?? ''));
+ $cameraId = trim((string)($b['camera_id'] ?? ''));
+ $cameraId = $cameraId === '' ? null : $cameraId;
+
+ if ($deviceId === '' || mb_strlen($deviceId) > 64) return Response::json(['error' => 'invalid_device'], 400);
+ if ($cameraId !== null && mb_strlen($cameraId) > 64) return Response::json(['error' => 'invalid_camera'], 400);
+ [$ok, $value] = ThresholdCatalog::normalize($key, $b['value'] ?? null);
+ if (!$ok) return Response::json(['error' => $value], 400);
+
+ // Scope BEFORE the write: a scoped admin must not retune another site's device.
+ if (!$this->devices->exists($deviceId)) return Response::json(['error' => 'unknown_device'], 400);
+ $site = $this->site();
+ if ($site !== null && $this->devices->siteFor($deviceId) !== $site) {
+ return Response::json(['error' => 'not_found'], 404);
+ }
+
+ $now = time();
+ $actor = (string)$this->sess->get('username');
+ // Capture the old value first — the audit entry is worthless without it.
+ $before = $this->thresholds->forDevice($deviceId)[$key] ?? null;
+ $id = $this->thresholds->set($deviceId, $cameraId, $key, $value, $actor, $now);
+ $this->audit->append($actor, 'threshold_set', "threshold:$deviceId:$key",
+ ['value' => $before], ['value' => $value, 'camera_id' => $cameraId]);
+
+ // Bumping the device's config version is what makes the change actually reach the node:
+ // without it the daemon's next sync sees the same version and the new value sits in the
+ // database looking applied. This is the failure the config-drift indicator exists for.
+ $this->devices->bumpConfigVersion($deviceId);
+
+ return Response::json(['id' => $id, 'value' => $value], 200);
+ }
+
+ public function deleteThreshold(Request $req, array $params): Response
+ {
+ if ($r = $this->guard($req, true)) return $r;
+ $id = (int)($params['id'] ?? 0);
+ $row = $this->thresholds->find($id);
+ if ($row === null) return Response::json(['error' => 'not_found'], 404);
+ $site = $this->site();
+ if ($site !== null && ($row['site'] ?? null) !== $site) return Response::json(['error' => 'not_found'], 404);
+
+ if (!$this->thresholds->remove($id, $site)) return Response::json(['error' => 'not_found'], 404);
+ $actor = (string)$this->sess->get('username');
+ $this->audit->append($actor, 'threshold_delete', 'threshold:' . $row['device_id'] . ':' . $row['key'],
+ ['value' => $row['value']], []);
+ $this->devices->bumpConfigVersion($row['device_id']);
+ return Response::json(['ok' => true], 200);
+ }
+
+ // ---------------------------------------------------------------- alert channels
+ public function channels(Request $req): Response
+ {
+ if ($r = $this->guard($req, false)) return $r;
+ $out = [];
+ foreach ($this->alerts->allChannels($this->site()) as $c) {
+ $config = json_decode($c['config_json'], true);
+ if (!is_array($config)) $config = [];
+ // Never echo a shared secret back to the browser. It is write-only from the UI's
+ // point of view: the form can set a new token, but the page can never display the
+ // current one (a screen-shared config page must not leak the integration key).
+ if (isset($config['token'])) $config['token'] = '••••••••';
+ $c['config'] = $config;
+ unset($c['config_json']);
+ $out[] = $c;
+ }
+ return Response::json(['channels' => $out], 200);
+ }
+
+ public function saveChannel(Request $req): Response
+ {
+ if ($r = $this->guard($req, true)) return $r;
+ $b = $req->json();
+ $kind = (string)($b['kind'] ?? '');
+ $name = trim((string)($b['name'] ?? ''));
+ $minSeverity = (string)($b['min_severity'] ?? 'high');
+ $step = (int)($b['step'] ?? 0);
+ $after = (int)($b['escalate_after_s'] ?? 0);
+
+ if (!in_array($kind, ['local', 'email', 'webhook'], true)) return Response::json(['error' => 'invalid_kind'], 400);
+ if ($name === '' || mb_strlen($name) > self::MAX_NAME) return Response::json(['error' => 'invalid_name'], 400);
+ if (!in_array($minSeverity, ['critical', 'high', 'warning', 'info'], true)) {
+ return Response::json(['error' => 'invalid_severity'], 400);
+ }
+ if ($step < 0 || $step > 5) return Response::json(['error' => 'invalid_step'], 400);
+ // An escalation step with no delay would fire together with the immediate channels and
+ // wake the second person for every event — the opposite of an escalation.
+ if ($step > 0 && ($after < 30 || $after > 86400)) return Response::json(['error' => 'invalid_escalate_after'], 400);
+
+ $config = $this->normalizeChannelConfig($kind, is_array($b['config'] ?? null) ? $b['config'] : []);
+ if ($config === null) return Response::json(['error' => 'invalid_config'], 400);
+
+ $id = (int)($b['channel_id'] ?? 0);
+ if ($id > 0) {
+ $existing = null;
+ foreach ($this->alerts->allChannels($this->site()) as $c) {
+ if ((int)$c['channel_id'] === $id) { $existing = $c; break; }
+ }
+ if ($existing === null) return Response::json(['error' => 'not_found'], 404);
+ // A masked token means "leave it alone" — the GET never revealed the real one.
+ if (($config['token'] ?? null) === '••••••••') {
+ $old = json_decode($existing['config_json'], true);
+ if (is_array($old) && isset($old['token'])) $config['token'] = $old['token'];
+ else unset($config['token']);
+ }
+ }
+
+ $newId = $this->alerts->saveChannel([
+ 'channel_id' => $id > 0 ? $id : null,
+ 'site' => $this->site(),
+ 'kind' => $kind,
+ 'name' => $name,
+ 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
+ 'min_severity' => $minSeverity,
+ 'enabled' => !empty($b['enabled']) ? 1 : 0,
+ 'step' => $step,
+ 'escalate_after_s' => $step > 0 ? $after : 0,
+ 'created_at' => time(),
+ ]);
+ $actor = (string)$this->sess->get('username');
+ // The audit entry records the shape, never the token.
+ $this->audit->append($actor, $id > 0 ? 'alert_channel_update' : 'alert_channel_create',
+ "alert_channel:$newId", [], ['kind' => $kind, 'name' => $name, 'step' => $step,
+ 'min_severity' => $minSeverity]);
+ return Response::json(['channel_id' => $newId], $id > 0 ? 200 : 201);
+ }
+
+ public function deleteChannel(Request $req, array $params): Response
+ {
+ if ($r = $this->guard($req, true)) return $r;
+ $id = (int)($params['id'] ?? 0);
+ if (!$this->alerts->deleteChannel($id, $this->site())) return Response::json(['error' => 'not_found'], 404);
+ $this->audit->append((string)$this->sess->get('username'), 'alert_channel_delete', "alert_channel:$id", [], []);
+ return Response::json(['ok' => true], 200);
+ }
+
+ /** @return array|null null = invalid for this kind. */
+ private function normalizeChannelConfig(string $kind, array $in): ?array
+ {
+ if ($kind === 'email') {
+ $raw = $in['to'] ?? '';
+ $list = is_array($raw) ? $raw : array_map('trim', explode(',', (string)$raw));
+ $list = array_values(array_filter($list, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL)));
+ if ($list === []) return null;
+ $out = ['to' => $list];
+ if (!empty($in['from']) && filter_var((string)$in['from'], FILTER_VALIDATE_EMAIL)) {
+ $out['from'] = (string)$in['from'];
+ }
+ return $out;
+ }
+ if ($kind === 'webhook') {
+ $url = (string)($in['url'] ?? '');
+ if (!filter_var($url, FILTER_VALIDATE_URL)) return null;
+ // http(s) only — a stored file:// or gopher:// would turn the dispatcher into a
+ // local file reader running as the web user.
+ if (!in_array(strtolower((string)parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) return null;
+ $out = ['url' => $url];
+ if (!empty($in['token']) && is_string($in['token'])) $out['token'] = $in['token'];
+ return $out;
+ }
+ // local: the edge fires this itself; the config is the GPIO wiring it should use.
+ $pin = isset($in['gpio_pin']) ? (int)$in['gpio_pin'] : 0;
+ if ($pin < 0 || $pin > 40) return null; // Pi header is 40 pins; 0 = unset/disabled
+ $mode = (string)($in['mode'] ?? 'latch');
+ if (!in_array($mode, ['latch', 'pulse'], true)) return null;
+ $out = ['gpio_pin' => $pin, 'mode' => $mode];
+ // How long a pulse lasts / how long a latch holds before auto-release. A latch that
+ // never releases needs somebody to walk to the node, which is not always possible.
+ $hold = isset($in['hold_s']) ? (float)$in['hold_s'] : 30.0;
+ if ($hold <= 0 || $hold > 3600) return null;
+ $out['hold_s'] = $hold;
+ $out['active_high'] = !empty($in['active_high']);
+ return $out;
+ }
+}
diff --git a/server/src/Dashboard/ComplianceController.php b/server/src/Dashboard/ComplianceController.php
new file mode 100644
index 0000000..b27bf57
--- /dev/null
+++ b/server/src/Dashboard/ComplianceController.php
@@ -0,0 +1,225 @@
+sess->get('site_scope');
+ return is_string($scope) ? $scope : null;
+ }
+
+ // ---------------------------------------------------------------- incident package
+ public function package(Request $req, array $params): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ $id = (int)($params['id'] ?? 0);
+ $event = $this->events->find($id);
+ // 404 (not 403) for an out-of-scope id, so a cross-site id is indistinguishable from a
+ // non-existent one — the same rule the rest of the dashboard follows.
+ if ($event === null || !SiteScope::allows($this->site(), $event['site'] ?? null)) {
+ return Response::json(['error' => 'not_found'], 404);
+ }
+
+ $at = (float)$event['occurred_at'];
+ $site = $this->site();
+ $timeline = $this->events->windowForCamera(
+ (string)$event['camera_id'], $at - self::CONTEXT_WINDOW_S, $at + self::CONTEXT_WINDOW_S,
+ $site, self::CONTEXT_LIMIT
+ );
+
+ // Improvement actions recorded against this camera, so the package shows whether the
+ // hazard had already been raised before the incident — the first question asked.
+ $cameraActions = [];
+ foreach ($this->actions->recent(50, $site) as $a) {
+ if (($a['camera_id'] ?? '') === $event['camera_id']) $cameraActions[] = $a;
+ }
+
+ $risk = null;
+ foreach ($this->risks->listAll($site) as $r) {
+ if ($r['event_type'] === $event['event_type']) { $risk = $r; break; }
+ }
+
+ return Response::json([
+ 'event' => $event,
+ 'snapshots' => $this->snaps->forEvent($id),
+ 'timeline' => $timeline,
+ // Whether anyone was actually told. A package that only proves the system SAW the
+ // incident, without showing whether it reached a human, answers half the question.
+ 'notifications' => $this->alerts->forEvent($id),
+ 'actions' => $cameraActions,
+ 'risk_control' => $risk,
+ 'context_window_s' => self::CONTEXT_WINDOW_S,
+ 'generated_at' => time(),
+ 'generated_by' => (string)$this->sess->get('username'),
+ ], 200);
+ }
+
+ // ---------------------------------------------------------------- risk controls
+ public function risks(Request $req): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ return Response::json(['controls' => $this->risks->listAll($this->site())], 200);
+ }
+
+ public function saveRisk(Request $req): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
+ return Response::json(['error' => 'forbidden'], 403);
+ }
+ if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+ $b = $req->json();
+ $type = trim((string)($b['event_type'] ?? ''));
+ $hazard = trim((string)($b['hazard'] ?? ''));
+ $control = trim((string)($b['control'] ?? ''));
+ $sev = (int)($b['severity_rating'] ?? 0);
+ $like = (int)($b['likelihood_rating'] ?? 0);
+
+ if ($type === '' || mb_strlen($type) > 32) return Response::json(['error' => 'invalid_type'], 400);
+ if ($hazard === '' || mb_strlen($hazard) > 300) return Response::json(['error' => 'invalid_hazard'], 400);
+ if ($control === '' || mb_strlen($control) > 500) return Response::json(['error' => 'invalid_control'], 400);
+ if ($sev < 1 || $sev > 5 || $like < 1 || $like > 5) return Response::json(['error' => 'invalid_rating'], 400);
+
+ $now = time();
+ $actor = (string)$this->sess->get('username');
+ $this->risks->upsert([
+ 'site' => $this->site(), 'event_type' => $type, 'hazard' => $hazard, 'control' => $control,
+ 'severity_rating' => $sev, 'likelihood_rating' => $like,
+ 'reviewed_by' => $actor, 'reviewed_at' => $now, 'updated_at' => $now,
+ ]);
+ $this->audit->append($actor, 'risk_control_save', "risk:$type", [],
+ ['severity' => $sev, 'likelihood' => $like]);
+ return Response::json(['ok' => true], 200);
+ }
+
+ public function deleteRisk(Request $req, array $params): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
+ return Response::json(['error' => 'forbidden'], 403);
+ }
+ if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+ $id = (int)($params['id'] ?? 0);
+ if (!$this->risks->delete($id, $this->site())) return Response::json(['error' => 'not_found'], 404);
+ $this->audit->append((string)$this->sess->get('username'), 'risk_control_delete', "risk:$id", [], []);
+ return Response::json(['ok' => true], 200);
+ }
+
+ // ---------------------------------------------------------------- shift handover
+ public function handovers(Request $req): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ return Response::json([
+ 'handovers' => $this->handovers->recent($this->site(), self::HANDOVER_LIST),
+ 'pending' => $this->pendingHandover(),
+ ], 200);
+ }
+
+ /**
+ * What the next handover would certify. Computed here and used by BOTH the read endpoint and
+ * the accept endpoint, so the number that is displayed is by construction the number that
+ * gets recorded — a browser-supplied count could understate the very backlog the record
+ * exists to pin down.
+ */
+ private function pendingHandover(): array
+ {
+ $site = $this->site();
+ $last = $this->handovers->latest($site);
+ $now = time();
+ // The period runs from the last handover to now; on the very first one fall back to 12
+ // hours (one shift) rather than to the beginning of time.
+ $periodStart = $last !== null ? (int)$last['created_at'] : $now - 12 * 3600;
+
+ $unacked = 0;
+ $severe = 0;
+ foreach ($this->events->page(200, null, null, null, $site) as $ev) {
+ if ((float)$ev['occurred_at'] < $periodStart) continue;
+ if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue;
+ $unacked++;
+ if (in_array($ev['severity'], ['critical', 'high'], true)) $severe++;
+ }
+ return [
+ 'period_start' => $periodStart,
+ 'period_end' => $now,
+ 'unacked_count' => $unacked,
+ 'severe_count' => $severe,
+ 'from_user' => $last['to_user'] ?? null,
+ ];
+ }
+
+ public function acceptHandover(Request $req): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) {
+ return Response::json(['error' => 'forbidden'], 403);
+ }
+ if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+ $b = $req->json();
+ $note = trim((string)($b['note'] ?? ''));
+ if (mb_strlen($note) > self::MAX_NOTE) return Response::json(['error' => 'invalid_note'], 400);
+
+ // Recomputed server-side, never taken from the request body (see pendingHandover).
+ $p = $this->pendingHandover();
+
+ $actor = (string)$this->sess->get('username');
+ $now = time();
+ $id = $this->handovers->create([
+ 'site' => $this->site(),
+ 'from_user' => $p['from_user'] ?? null,
+ 'to_user' => $actor,
+ 'period_start' => (int)$p['period_start'],
+ 'period_end' => (int)$p['period_end'],
+ 'unacked_count' => (int)$p['unacked_count'],
+ 'severe_count' => (int)$p['severe_count'],
+ 'note' => $note !== '' ? $note : null,
+ 'created_at' => $now,
+ ]);
+ $this->audit->append($actor, 'shift_handover', "handover:$id", [],
+ ['unacked' => (int)$p['unacked_count'], 'severe' => (int)$p['severe_count']]);
+ return Response::json(['handover_id' => $id], 201);
+ }
+}
diff --git a/server/src/Dashboard/DeviceStatusController.php b/server/src/Dashboard/DeviceStatusController.php
index b29207b..e0e4178 100644
--- a/server/src/Dashboard/DeviceStatusController.php
+++ b/server/src/Dashboard/DeviceStatusController.php
@@ -2,9 +2,11 @@
declare(strict_types=1);
namespace App\Dashboard;
+use App\Config;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
-use App\Repository\DeviceRepository;
+use App\Security\SiteScope;
+use App\Repository\{DeviceRepository, DeviceMetricRepository};
/**
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
@@ -25,7 +27,14 @@ final class DeviceStatusController
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
private const OFFLINE_AFTER_S = 120;
- public function __construct(private DeviceRepository $devices, private SessionStore $sess) {}
+ /** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */
+ private const SERIES_WINDOW_S = 86400;
+ private const SERIES_LIMIT = 3000;
+
+ public function __construct(
+ private DeviceRepository $devices, private SessionStore $sess,
+ private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null
+ ) {}
public function json(Request $req, int $now): Response
{
@@ -54,6 +63,63 @@ final class DeviceStatusController
];
}, $this->devices->listStatus($site));
- return Response::json(['devices' => $devices], 200);
+ return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200);
+ }
+
+ /**
+ * GET /api/devices/{id}/metrics — the heartbeat time series for one device.
+ *
+ * The status panel answers "is it alive right now"; this answers "has it been getting
+ * worse", which is the question that catches a node before it dies. A thermally throttling
+ * Pi loses fps for days before it stops.
+ */
+ public function metrics(Request $req, array $params, int $now): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ if ($this->metrics === null) return Response::json(['error' => 'not_available'], 404);
+
+ $deviceId = (string)($params['id'] ?? '');
+ $scope = $this->sess->get('site_scope');
+ $site = is_string($scope) ? $scope : null;
+ // Scope before reading: telemetry reveals that a device exists and how it is doing.
+ if (!$this->devices->exists($deviceId)
+ || !SiteScope::allows($site, $this->devices->siteFor($deviceId))) {
+ return Response::json(['error' => 'not_found'], 404);
+ }
+
+ $hours = (int)($req->query('hours') ?? 24);
+ $window = max(3600, min(self::SERIES_WINDOW_S * 7, $hours * 3600));
+ return Response::json([
+ 'device_id' => $deviceId,
+ 'from' => $now - $window,
+ 'points' => $this->metrics->series($deviceId, $now - $window, self::SERIES_LIMIT),
+ ], 200);
+ }
+
+ /**
+ * Free space on the snapshot volume.
+ *
+ * On shared hosting a full disk does not announce itself: image writes start failing, the
+ * ingest path logs and moves on (by design — an event without its picture still beats no
+ * event), and the board keeps looking healthy while the evidence quietly stops being kept.
+ * Surfacing the number is what turns that into something somebody notices.
+ */
+ private function storage(): ?array
+ {
+ if ($this->cfg === null) return null;
+ $minFreeMb = $this->cfg->storageMinFreeMb();
+ if ($minFreeMb <= 0) return null; // explicitly disabled for this host
+ $dir = $this->cfg->snapshotDir();
+ if (!is_dir($dir)) return null;
+ $free = @disk_free_space($dir);
+ $total = @disk_total_space($dir);
+ if ($free === false || $free === null) return null; // not meaningful on this host
+ $freeMb = (int)floor($free / 1048576);
+ return [
+ 'free_mb' => $freeMb,
+ 'total_mb' => ($total !== false && $total !== null) ? (int)floor($total / 1048576) : null,
+ 'min_free_mb' => $minFreeMb,
+ 'low' => $freeMb < $minFreeMb,
+ ];
}
}
diff --git a/server/src/Dashboard/ReportController.php b/server/src/Dashboard/ReportController.php
index 2b51068..1dbcffd 100644
--- a/server/src/Dashboard/ReportController.php
+++ b/server/src/Dashboard/ReportController.php
@@ -115,6 +115,9 @@ final class ReportController
],
'pattern' => $pattern,
'precursor_alert' => $spike,
+ // 현장별 롤업은 전역 운영자(본사)에게만 의미가 있다. 스코프 운영자에게는 정의상
+ // 자기 현장 한 줄뿐이라 표를 만들 이유가 없고, 다른 현장 수치가 새면 안 된다.
+ 'by_site' => $site === null ? $this->events->siteSummary($since) : null,
], 200);
}
diff --git a/server/src/Dashboard/SuppressionController.php b/server/src/Dashboard/SuppressionController.php
new file mode 100644
index 0000000..ae517f9
--- /dev/null
+++ b/server/src/Dashboard/SuppressionController.php
@@ -0,0 +1,136 @@
+sess->get('site_scope');
+ return is_string($scope) ? $scope : null;
+ }
+
+ public function json(Request $req): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ $now = time();
+ $includeExpired = $req->query('all') === '1';
+ return Response::json([
+ 'now' => $now,
+ 'suppressions' => $this->suppressions->listAll($this->site(), $now, $includeExpired),
+ ], 200);
+ }
+
+ public function create(Request $req): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ // Same permission as zone editing: issuing a maintenance window is an operational act by
+ // somebody who is on site, not an administrative one.
+ if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403);
+ if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+
+ $b = $req->json();
+ $scopeType = (string)($b['scope_type'] ?? '');
+ $scopeId = trim((string)($b['scope_id'] ?? ''));
+ $reason = trim((string)($b['reason'] ?? ''));
+ $minutes = (int)($b['minutes'] ?? 0);
+ $startsAt = isset($b['starts_at']) && is_numeric($b['starts_at']) ? (int)$b['starts_at'] : time();
+
+ if (!in_array($scopeType, ['device', 'camera', 'zone'], true)) return Response::json(['error' => 'invalid_scope'], 400);
+ if ($scopeId === '' || mb_strlen($scopeId) > 64) return Response::json(['error' => 'invalid_scope_id'], 400);
+ if ($reason === '' || mb_strlen($reason) > self::MAX_REASON) return Response::json(['error' => 'reason_required'], 400);
+ if ($minutes < 1 || $minutes * 60 > self::MAX_WINDOW_S) return Response::json(['error' => 'invalid_window'], 400);
+
+ $now = time();
+ if ($startsAt < $now - 3600 || $startsAt > $now + self::MAX_LEAD_S) {
+ return Response::json(['error' => 'invalid_start'], 400);
+ }
+
+ // Resolve the site from the scoped object, and refuse anything outside the caller's own
+ // scope BEFORE writing — a suppression is a licence to stop alarming, and it must not be
+ // possible to issue one for another site by guessing a camera name.
+ $site = $this->resolveSite($scopeType, $scopeId);
+ if ($site === false) return Response::json(['error' => 'unknown_scope_id'], 400);
+ if (!SiteScope::allows($this->site(), $site)) return Response::json(['error' => 'not_found'], 404);
+
+ $actor = (string)$this->sess->get('username');
+ $id = $this->suppressions->create([
+ 'scope_type' => $scopeType, 'scope_id' => $scopeId, 'site' => $site,
+ 'reason' => $reason, 'starts_at' => $startsAt, 'ends_at' => $startsAt + $minutes * 60,
+ 'created_by' => $actor, 'created_at' => $now,
+ ]);
+ $this->audit->append($actor, 'suppression_create', "suppression:$id", [],
+ ['scope' => "$scopeType:$scopeId", 'minutes' => $minutes, 'reason' => $reason]);
+ return Response::json(['suppression_id' => $id, 'ends_at' => $startsAt + $minutes * 60], 201);
+ }
+
+ public function revoke(Request $req, array $params): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403);
+ if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+ $id = (int)($params['id'] ?? 0);
+ $now = time();
+ if (!$this->suppressions->revoke($id, (string)$this->sess->get('username'), $now, $this->site())) {
+ return Response::json(['error' => 'not_found'], 404);
+ }
+ $this->audit->append((string)$this->sess->get('username'), 'suppression_revoke', "suppression:$id", [], []);
+ return Response::json(['ok' => true], 200);
+ }
+
+ /** @return string|null|false site, or false when the scope target does not exist. */
+ private function resolveSite(string $scopeType, string $scopeId): string|null|false
+ {
+ if ($scopeType === 'device') {
+ if (!$this->devices->exists($scopeId)) return false;
+ return $this->devices->siteFor($scopeId);
+ }
+ if ($scopeType === 'camera') {
+ // Cameras have no registry (see schema.sql); their events are the ownership record.
+ if (!$this->events->cameraExists($scopeId)) return false;
+ return $this->events->siteForCamera($scopeId);
+ }
+ // A zone id is opaque here; scope is inherited from the caller so a scoped operator can
+ // only ever create one inside their own site, and a global operator may suppress any.
+ return $this->site();
+ }
+}
diff --git a/server/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php
index 83bb20e..80e5a74 100644
--- a/server/src/Dashboard/ZoneConfigController.php
+++ b/server/src/Dashboard/ZoneConfigController.php
@@ -147,6 +147,11 @@ final class ZoneConfigController
return Response::json(['error' => 'invalid_zone'], 400);
}
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
+ // Bump the device's config version so the drift indicator becomes meaningful: it now
+ // reads "edited, not yet picked up" until the node's next sync echoes the new version
+ // back. Nothing ever incremented it before, so the indicator could never light up and an
+ // edit that failed to reach a device was indistinguishable from one that arrived.
+ $this->devices->bumpConfigVersion($deviceId);
return Response::json(['zone_id' => $zoneId], 201);
}
@@ -165,6 +170,7 @@ final class ZoneConfigController
}
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
+ $this->devices->bumpConfigVersion((string)($existing['device_id'] ?? ''));
return Response::json(['ok' => true], 200);
}
}
diff --git a/server/src/Repository/ActionRepository.php b/server/src/Repository/ActionRepository.php
index b0163a3..38eaf62 100644
--- a/server/src/Repository/ActionRepository.php
+++ b/server/src/Repository/ActionRepository.php
@@ -4,7 +4,7 @@ namespace App\Repository;
interface ActionRepository
{
- /** {camera_id, site, note, taken_at, created_by} -> action_id */
+ /** {camera_id, site, note, taken_at, created_by, event_id?} -> action_id */
public function insert(array $action): int;
/**
* Most recent actions, newest taken_at first, capped at $limit.
diff --git a/server/src/Repository/AlertRepository.php b/server/src/Repository/AlertRepository.php
new file mode 100644
index 0000000..b223eed
--- /dev/null
+++ b/server/src/Repository/AlertRepository.php
@@ -0,0 +1,34 @@
+ enabled channels, ordered by step then id. */
+ public function channels(?string $site): array;
+
+ public function allChannels(?string $site): array;
+
+ public function saveChannel(array $row): int;
+
+ public function deleteChannel(int $id, ?string $site): bool;
+
+ /**
+ * Claim (event, channel) for delivery. Returns false when it was already claimed —
+ * that is the duplicate-send guard, enforced by a UNIQUE key rather than by a read.
+ */
+ public function claim(int $eventId, int $channelId, int $now): bool;
+
+ public function markSent(int $eventId, int $channelId, int $now): void;
+
+ public function markFailed(int $eventId, int $channelId, string $error, int $now): void;
+
+ public function markSuppressed(int $eventId, int $channelId, int $now): void;
+
+ /** Delivery rows for one event, for the incident record. */
+ public function forEvent(int $eventId): array;
+
+ /** Rows still worth retrying (status=failed, attempts under the cap). */
+ public function retryable(int $maxAttempts, int $limit): array;
+}
diff --git a/server/src/Repository/DeviceMetricRepository.php b/server/src/Repository/DeviceMetricRepository.php
new file mode 100644
index 0000000..1a386bb
--- /dev/null
+++ b/server/src/Repository/DeviceMetricRepository.php
@@ -0,0 +1,15 @@
+ ascending by ts. */
+ public function series(string $deviceId, int $sinceTs, int $limit): array;
+
+ /** Retention: drop points older than $before. Returns rows removed. */
+ public function prune(int $before): int;
+}
diff --git a/server/src/Repository/DeviceRepository.php b/server/src/Repository/DeviceRepository.php
index 39bd372..642875c 100644
--- a/server/src/Repository/DeviceRepository.php
+++ b/server/src/Repository/DeviceRepository.php
@@ -13,6 +13,14 @@ interface DeviceRepository
public function siteFor(string $deviceId): ?string;
public function touchHeartbeat(string $deviceId, array $meta): void;
public function configVersion(string $deviceId): int;
+ /**
+ * Increment config_version so the device's next sync sees a NEW version and applies what
+ * changed. Without this a zone or threshold edit sits in the database looking applied while
+ * the node keeps running the old value — the exact drift the dashboard's config-drift
+ * indicator exists to expose, except nothing would ever have caused it to light up.
+ * Returns the new version (or 0 when the device does not exist).
+ */
+ public function bumpConfigVersion(string $deviceId): int;
/** All devices with their health telemetry, for the dashboard's device-status panel.
* Site-scoped: a non-null $site returns only that site's devices (a scoped operator);
diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php
index d3f4478..5eb376d 100644
--- a/server/src/Repository/EventRepository.php
+++ b/server/src/Repository/EventRepository.php
@@ -57,8 +57,29 @@ interface EventRepository
public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array;
/** Full mapped event rows in the window, newest first, capped — the label-dataset export. */
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array;
+ /**
+ * Candidate rows for the alert dispatcher: every event in the window, OLDEST first, with the
+ * fields a notification needs. Severity thresholds, escalation timing and suppression are
+ * decided in PHP rather than in this query — the window is minutes wide so the row count is
+ * small, and keeping the decision out of SQL is what makes it unit-testable without a
+ * database (App\Alert\AlertDispatcher). Oldest-first so the earliest unhandled emergency is
+ * notified first when a backlog has built up behind a failed cron run.
+ */
+ public function forNotification(float $sinceOccurredAt, int $limit): array;
+ /**
+ * Per-site rollup for the head-office view: [{site, count, critical_high, unacked, cameras}].
+ * Only meaningful for a GLOBAL operator (site_scope null) — a scoped one sees one site by
+ * definition, and the report hides the card for them rather than showing a table of one row.
+ */
+ public function siteSummary(float $sinceOccurredAt, ?float $untilOccurredAt = null): array;
/** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int;
+ /**
+ * Full rows for one camera in [from, to), oldest first — the incident package's surrounding
+ * timeline. Reconstructing an accident means seeing what the same camera reported in the
+ * minutes either side, not just the one row somebody clicked.
+ */
+ public function windowForCamera(string $cameraId, float $from, float $to, ?string $site, int $limit): array;
/**
* True if this camera has ever reported an event — the closest thing to "does this camera
* exist", playing the role DeviceRepository::exists() plays for devices.
diff --git a/server/src/Repository/HandoverRepository.php b/server/src/Repository/HandoverRepository.php
new file mode 100644
index 0000000..95a3b6a
--- /dev/null
+++ b/server/src/Repository/HandoverRepository.php
@@ -0,0 +1,13 @@
+pdo->prepare(
- 'INSERT INTO move_actions (camera_id, site, note, taken_at, created_by, created_at)
- VALUES (?, ?, ?, ?, ?, ?)'
+ 'INSERT INTO move_actions (camera_id, site, event_id, note, taken_at, created_by, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$st->execute([
(string)$action['camera_id'],
$action['site'] ?? null, // denormalized from the camera's events by ActionsController
+ // The incident that prompted this fix, when it was recorded from an event's detail
+ // page. Nullable because an action can also be entered standalone from the report.
+ isset($action['event_id']) ? (int)$action['event_id'] : null,
(string)$action['note'],
(int)$action['taken_at'],
(string)$action['created_by'],
@@ -28,7 +31,7 @@ final class PdoActionRepository implements ActionRepository
public function recent(int $limit, ?string $site): array
{
- $sql = 'SELECT action_id, camera_id, site, note, taken_at, created_by FROM move_actions';
+ $sql = 'SELECT action_id, camera_id, site, event_id, note, taken_at, created_by FROM move_actions';
$binds = [];
if ($site !== null) {
// Fail-closed like every other scoped list: an empty-string scope matches nothing,
@@ -46,6 +49,7 @@ final class PdoActionRepository implements ActionRepository
'action_id' => (int)$r['action_id'],
'camera_id' => $r['camera_id'],
'site' => $r['site'],
+ 'event_id' => $r['event_id'] !== null ? (int)$r['event_id'] : null,
'note' => $r['note'],
'taken_at' => (int)$r['taken_at'],
'created_by' => $r['created_by'],
diff --git a/server/src/Repository/Pdo/PdoAlertRepository.php b/server/src/Repository/Pdo/PdoAlertRepository.php
new file mode 100644
index 0000000..5f58faa
--- /dev/null
+++ b/server/src/Repository/Pdo/PdoAlertRepository.php
@@ -0,0 +1,184 @@
+pdo->prepare($sql);
+ $st->execute($args);
+ return array_map([self::class, 'mapChannel'], $st->fetchAll());
+ }
+
+ public function allChannels(?string $site): array
+ {
+ $sql = 'SELECT * FROM move_alert_channels';
+ $args = [];
+ if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; }
+ $sql .= ' ORDER BY step ASC, channel_id ASC';
+ $st = $this->pdo->prepare($sql);
+ $st->execute($args);
+ return array_map([self::class, 'mapChannel'], $st->fetchAll());
+ }
+
+ public function saveChannel(array $row): int
+ {
+ if (!empty($row['channel_id'])) {
+ $st = $this->pdo->prepare(
+ 'UPDATE move_alert_channels
+ SET site = ?, kind = ?, name = ?, config_json = ?, min_severity = ?,
+ enabled = ?, step = ?, escalate_after_s = ?
+ WHERE channel_id = ?'
+ );
+ $st->execute([
+ $row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'],
+ $row['min_severity'], (int)$row['enabled'], (int)$row['step'],
+ (int)$row['escalate_after_s'], (int)$row['channel_id'],
+ ]);
+ return (int)$row['channel_id'];
+ }
+ $st = $this->pdo->prepare(
+ 'INSERT INTO move_alert_channels (site, kind, name, config_json, min_severity, enabled, step, escalate_after_s, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ );
+ $st->execute([
+ $row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'],
+ $row['min_severity'], (int)$row['enabled'], (int)$row['step'],
+ (int)$row['escalate_after_s'], $row['created_at'],
+ ]);
+ return (int)$this->pdo->lastInsertId();
+ }
+
+ public function deleteChannel(int $id, ?string $site): bool
+ {
+ $sql = 'DELETE FROM move_alert_channels WHERE channel_id = ?';
+ $args = [$id];
+ if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
+ $st = $this->pdo->prepare($sql);
+ $st->execute($args);
+ return $st->rowCount() > 0;
+ }
+
+ public function claim(int $eventId, int $channelId, int $now): bool
+ {
+ try {
+ $st = $this->pdo->prepare(
+ 'INSERT INTO move_alert_log (event_id, channel_id, status, attempts, queued_at)
+ VALUES (?, ?, ?, 0, ?)'
+ );
+ $st->execute([$eventId, $channelId, 'pending', $now]);
+ return true;
+ } catch (PDOException $e) {
+ // Duplicate key = another dispatcher run already owns this delivery. That is the
+ // designed outcome, not an error: losing the race means someone else is sending.
+ if (self::isDuplicate($e)) return false;
+ throw $e;
+ }
+ }
+
+ public function markSent(int $eventId, int $channelId, int $now): void
+ {
+ $st = $this->pdo->prepare(
+ 'UPDATE move_alert_log SET status = ?, sent_at = ?, attempts = attempts + 1, last_error = NULL
+ WHERE event_id = ? AND channel_id = ?'
+ );
+ $st->execute(['sent', $now, $eventId, $channelId]);
+ }
+
+ public function markFailed(int $eventId, int $channelId, string $error, int $now): void
+ {
+ $st = $this->pdo->prepare(
+ 'UPDATE move_alert_log SET status = ?, attempts = attempts + 1, last_error = ?
+ WHERE event_id = ? AND channel_id = ?'
+ );
+ // Truncate: a curl error body can be long and the column is not the place to store it.
+ $st->execute(['failed', mb_substr($error, 0, 500), $eventId, $channelId]);
+ }
+
+ public function markSuppressed(int $eventId, int $channelId, int $now): void
+ {
+ $st = $this->pdo->prepare(
+ 'UPDATE move_alert_log SET status = ?, sent_at = ? WHERE event_id = ? AND channel_id = ?'
+ );
+ $st->execute(['suppressed', $now, $eventId, $channelId]);
+ }
+
+ public function forEvent(int $eventId): array
+ {
+ $st = $this->pdo->prepare(
+ 'SELECT l.*, c.name AS channel_name, c.kind
+ FROM move_alert_log l
+ LEFT JOIN move_alert_channels c ON c.channel_id = l.channel_id
+ WHERE l.event_id = ? ORDER BY l.alert_id ASC'
+ );
+ $st->execute([$eventId]);
+ return array_map(static fn (array $r): array => [
+ 'channel_id' => (int)$r['channel_id'],
+ 'channel_name' => $r['channel_name'] !== null ? (string)$r['channel_name'] : null,
+ 'kind' => $r['kind'] !== null ? (string)$r['kind'] : null,
+ 'status' => (string)$r['status'],
+ 'attempts' => (int)$r['attempts'],
+ 'last_error' => $r['last_error'] !== null ? (string)$r['last_error'] : null,
+ 'queued_at' => (int)$r['queued_at'],
+ 'sent_at' => $r['sent_at'] !== null ? (int)$r['sent_at'] : null,
+ ], $st->fetchAll());
+ }
+
+ public function retryable(int $maxAttempts, int $limit): array
+ {
+ $st = $this->pdo->prepare(
+ 'SELECT event_id, channel_id, attempts FROM move_alert_log
+ WHERE status = ? AND attempts < ? ORDER BY queued_at ASC LIMIT ' . max(1, min(500, $limit))
+ );
+ $st->execute(['failed', $maxAttempts]);
+ return array_map(static fn (array $r): array => [
+ 'event_id' => (int)$r['event_id'],
+ 'channel_id' => (int)$r['channel_id'],
+ 'attempts' => (int)$r['attempts'],
+ ], $st->fetchAll());
+ }
+
+ private static function isDuplicate(PDOException $e): bool
+ {
+ // MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class
+ // so this holds for both drivers the project runs (MySQL in production, sqlite in tests).
+ return ($e->getCode() === '23000') || str_contains($e->getMessage(), 'UNIQUE constraint failed');
+ }
+
+ private static function mapChannel(array $r): array
+ {
+ return [
+ 'channel_id' => (int)$r['channel_id'],
+ 'site' => $r['site'] !== null ? (string)$r['site'] : null,
+ 'kind' => (string)$r['kind'],
+ 'name' => (string)$r['name'],
+ 'config_json' => (string)$r['config_json'],
+ 'min_severity' => (string)$r['min_severity'],
+ 'enabled' => (int)$r['enabled'],
+ 'step' => (int)$r['step'],
+ 'escalate_after_s' => (int)$r['escalate_after_s'],
+ ];
+ }
+}
diff --git a/server/src/Repository/Pdo/PdoAuditRepository.php b/server/src/Repository/Pdo/PdoAuditRepository.php
index a2d6e95..84045a7 100644
--- a/server/src/Repository/Pdo/PdoAuditRepository.php
+++ b/server/src/Repository/Pdo/PdoAuditRepository.php
@@ -5,23 +5,79 @@ namespace App\Repository\Pdo;
use App\Repository\AuditRepository;
use PDO;
+/**
+ * Tamper-EVIDENT audit log.
+ *
+ * Shared hosting cannot give the app an append-only table: the same DB user that inserts rows
+ * can also UPDATE and DELETE them, and there is no way to drop that privilege from inside the
+ * application. So the goal is not to make tampering impossible — it is to make it impossible to
+ * do QUIETLY. Each row carries the previous row's hash, and its own hash covers its contents
+ * plus that link. Change one row, or remove one, and every hash after it stops matching; the
+ * verifier (tools/verify_audit_chain.php) prints exactly where the chain forks.
+ *
+ * `id` is deliberately NOT part of the hash. Excluding it lets the whole row be written in a
+ * single INSERT (the hash is computed before the id exists), which keeps the append atomic;
+ * deletion is still detected, because the row after the hole carries a prev_hash that matches
+ * nothing that remains.
+ */
final class PdoAuditRepository implements AuditRepository
{
public function __construct(private PDO $pdo) {}
public function append(string $actor, string $action, string $target, array $before, array $after): void
{
- $st = $this->pdo->prepare(
- 'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts)
- VALUES (?, ?, ?, ?, ?, ?)'
- );
- $st->execute([
- $actor,
- $action,
- $target,
- json_encode($before, JSON_THROW_ON_ERROR),
- json_encode($after, JSON_THROW_ON_ERROR),
- time(),
- ]);
+ $beforeJson = json_encode($before, JSON_THROW_ON_ERROR);
+ $afterJson = json_encode($after, JSON_THROW_ON_ERROR);
+ $ts = time();
+
+ // The read of the tail and the write of the new row must not interleave with another
+ // append, or two rows would claim the same predecessor and the chain would fork. When
+ // this repository owns the transaction it also takes a row lock (MySQL); sqlite's
+ // single-writer model makes the lock unnecessary and it has no FOR UPDATE.
+ $ownTransaction = !$this->pdo->inTransaction();
+ if ($ownTransaction) $this->pdo->beginTransaction();
+ try {
+ $prev = $this->tailHash();
+ $entry = self::hash($actor, $action, $target, $beforeJson, $afterJson, $ts, $prev);
+ $st = $this->pdo->prepare(
+ 'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts, prev_hash, entry_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
+ );
+ $st->execute([$actor, $action, $target, $beforeJson, $afterJson, $ts, $prev, $entry]);
+ if ($ownTransaction) $this->pdo->commit();
+ } catch (\Throwable $e) {
+ if ($ownTransaction && $this->pdo->inTransaction()) $this->pdo->rollBack();
+ throw $e;
+ }
+ }
+
+ private function tailHash(): ?string
+ {
+ $sql = 'SELECT entry_hash FROM move_audit_log ORDER BY id DESC LIMIT 1';
+ if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
+ $sql .= ' FOR UPDATE';
+ }
+ $row = $this->pdo->query($sql)->fetch();
+ if ($row === false) return null; // first entry ever: chain genesis
+ $hash = $row['entry_hash'] ?? null;
+ // NULL here means the tail predates the chain (rows written before the migration).
+ // Linking to NULL restarts the chain from this row, and the verifier reports the
+ // pre-chain rows as an unverifiable prefix rather than as tampering.
+ return is_string($hash) && $hash !== '' ? $hash : null;
+ }
+
+ /**
+ * The hashed record. Field separator is a unit separator (0x1F), not a printable character:
+ * with a plain '|' an actor literally named "a|b" could be crafted to collide with a
+ * different (actor, action) split, and forging an audit entry is exactly the attack this
+ * chain exists to catch.
+ */
+ public static function hash(
+ string $actor, string $action, string $target,
+ string $beforeJson, string $afterJson, int $ts, ?string $prev
+ ): string {
+ return hash('sha256', implode("\x1f", [
+ $actor, $action, $target, $beforeJson, $afterJson, (string)$ts, $prev ?? '',
+ ]));
}
}
diff --git a/server/src/Repository/Pdo/PdoDeviceMetricRepository.php b/server/src/Repository/Pdo/PdoDeviceMetricRepository.php
new file mode 100644
index 0000000..56c5f59
--- /dev/null
+++ b/server/src/Repository/Pdo/PdoDeviceMetricRepository.php
@@ -0,0 +1,65 @@
+pdo->prepare(
+ 'INSERT INTO move_device_metrics (device_id, ts, fps, temp, throttle, queue_pending,
+ cameras_total, cameras_erroring, errors)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ );
+ // A missing metric stays NULL. Never coerce an absent reading to 0: a device that cannot
+ // read its temperature would then look like a device sitting at 0°C.
+ $num = static fn (string $k) => isset($metrics[$k]) && is_numeric($metrics[$k]) ? $metrics[$k] + 0 : null;
+ $st->execute([
+ $deviceId, $now,
+ $num('fps'), $num('temp'),
+ isset($metrics['throttle']) ? (int)(bool)$metrics['throttle'] : null,
+ $num('queue_pending'), $num('cameras_total'), $num('cameras_erroring'), $num('errors'),
+ ]);
+ }
+
+ public function series(string $deviceId, int $sinceTs, int $limit): array
+ {
+ $st = $this->pdo->prepare(
+ 'SELECT ts, fps, temp, throttle, queue_pending, cameras_total, cameras_erroring, errors
+ FROM move_device_metrics
+ WHERE device_id = ? AND ts >= ?
+ ORDER BY ts ASC LIMIT ' . max(1, min(5000, $limit))
+ );
+ $st->execute([$deviceId, $sinceTs]);
+ return array_map(static fn (array $r): array => [
+ 'ts' => (int)$r['ts'],
+ 'fps' => $r['fps'] !== null ? (float)$r['fps'] : null,
+ 'temp' => $r['temp'] !== null ? (float)$r['temp'] : null,
+ 'throttle' => $r['throttle'] !== null ? (bool)$r['throttle'] : null,
+ 'queue_pending' => $r['queue_pending'] !== null ? (int)$r['queue_pending'] : null,
+ 'cameras_total' => $r['cameras_total'] !== null ? (int)$r['cameras_total'] : null,
+ 'cameras_erroring' => $r['cameras_erroring'] !== null ? (int)$r['cameras_erroring'] : null,
+ 'errors' => $r['errors'] !== null ? (int)$r['errors'] : null,
+ ], $st->fetchAll());
+ }
+
+ public function prune(int $before): int
+ {
+ $st = $this->pdo->prepare('DELETE FROM move_device_metrics WHERE ts < ?');
+ $st->execute([$before]);
+ return $st->rowCount();
+ }
+}
diff --git a/server/src/Repository/Pdo/PdoDeviceRepository.php b/server/src/Repository/Pdo/PdoDeviceRepository.php
index fca4fc6..e93cf25 100644
--- a/server/src/Repository/Pdo/PdoDeviceRepository.php
+++ b/server/src/Repository/Pdo/PdoDeviceRepository.php
@@ -67,6 +67,18 @@ final class PdoDeviceRepository implements DeviceRepository
return $version === false ? 1 : (int)$version;
}
+ public function bumpConfigVersion(string $deviceId): int
+ {
+ // Increment in SQL, not read-modify-write: two admins saving different zones at the same
+ // moment would otherwise both write version+1 and one edit would never reach the device.
+ $st = $this->pdo->prepare(
+ 'UPDATE move_devices SET config_version = config_version + 1 WHERE device_id = ?'
+ );
+ $st->execute([$deviceId]);
+ if ($st->rowCount() === 0) return 0;
+ return $this->configVersion($deviceId);
+ }
+
public function listStatus(?string $site): array
{
// Site-scope access control, same rule as the event list: a non-null scope filters to
diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php
index 72d2ea9..c00d96b 100644
--- a/server/src/Repository/Pdo/PdoEventRepository.php
+++ b/server/src/Repository/Pdo/PdoEventRepository.php
@@ -321,6 +321,68 @@ final class PdoEventRepository implements EventRepository
return array_map(self::mapRow(...), $st->fetchAll());
}
+ public function forNotification(float $sinceOccurredAt, int $limit): array
+ {
+ // No site filter: the dispatcher is a server-side job, not a logged-in operator, and it
+ // must be able to notify every site the installation serves. Channel rows carry their
+ // own site, which is where the scoping actually belongs.
+ $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events
+ WHERE occurred_at >= ? ORDER BY occurred_at ASC, event_id ASC LIMIT ?';
+ $st = $this->pdo->prepare($sql);
+ $st->bindValue(1, sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR);
+ $st->bindValue(2, $limit, PDO::PARAM_INT);
+ $st->execute();
+ return array_map(self::mapRow(...), $st->fetchAll());
+ }
+
+ public function siteSummary(float $sinceOccurredAt, ?float $untilOccurredAt = null): array
+ {
+ // COUNT(DISTINCT camera_id) alongside the totals: "three sites, one of which is watching
+ // with a single camera" is a different picture from "three comparable sites", and the
+ // head-office view is exactly where that distinction gets lost.
+ $sql = "SELECT site,
+ COUNT(*) AS count,
+ SUM(CASE WHEN severity IN ('critical','high') THEN 1 ELSE 0 END) AS critical_high,
+ SUM(CASE WHEN acknowledged_by IS NULL THEN 1 ELSE 0 END) AS unacked,
+ COUNT(DISTINCT camera_id) AS cameras
+ FROM move_events
+ WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
+ $binds = [sprintf('%.6f', $sinceOccurredAt)];
+ if ($untilOccurredAt !== null) {
+ $sql .= ' AND occurred_at < ?';
+ $binds[] = sprintf('%.6f', $untilOccurredAt);
+ }
+ $sql .= ' GROUP BY site ORDER BY critical_high DESC, count DESC';
+ $st = $this->pdo->prepare($sql);
+ $st->execute($binds);
+ return array_map(static fn (array $r): array => [
+ 'site' => $r['site'] !== null ? (string)$r['site'] : null,
+ 'count' => (int)$r['count'],
+ 'critical_high' => (int)$r['critical_high'],
+ 'unacked' => (int)$r['unacked'],
+ 'cameras' => (int)$r['cameras'],
+ ], $st->fetchAll());
+ }
+
+ public function windowForCamera(string $cameraId, float $from, float $to, ?string $site, int $limit): array
+ {
+ $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events
+ WHERE camera_id = ? AND occurred_at >= ? AND occurred_at < ?';
+ $binds = [[$cameraId, PDO::PARAM_STR], [sprintf('%.6f', $from), PDO::PARAM_STR],
+ [sprintf('%.6f', $to), PDO::PARAM_STR]];
+ if ($site !== null) {
+ $sql .= ' AND site = ?';
+ $binds[] = [$site, PDO::PARAM_STR];
+ }
+ $sql .= ' ORDER BY occurred_at ASC, event_id ASC LIMIT ?';
+ $binds[] = [$limit, PDO::PARAM_INT];
+ $st = $this->pdo->prepare($sql);
+ $pos = 1;
+ foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
+ $st->execute();
+ return array_map(self::mapRow(...), $st->fetchAll());
+ }
+
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int
{
$sql = "SELECT COUNT(*) FROM move_events
diff --git a/server/src/Repository/Pdo/PdoHandoverRepository.php b/server/src/Repository/Pdo/PdoHandoverRepository.php
new file mode 100644
index 0000000..28ef32e
--- /dev/null
+++ b/server/src/Repository/Pdo/PdoHandoverRepository.php
@@ -0,0 +1,66 @@
+pdo->prepare($sql);
+ $st->execute($args);
+ return array_map([self::class, 'mapRow'], $st->fetchAll());
+ }
+
+ public function latest(?string $site): ?array
+ {
+ return $this->recent($site, 1)[0] ?? null;
+ }
+
+ public function create(array $row): int
+ {
+ $st = $this->pdo->prepare(
+ 'INSERT INTO move_shift_handovers (site, from_user, to_user, period_start, period_end,
+ unacked_count, severe_count, note, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ );
+ $st->execute([
+ $row['site'] ?? null, $row['from_user'] ?? null, $row['to_user'],
+ $row['period_start'], $row['period_end'], $row['unacked_count'], $row['severe_count'],
+ $row['note'] ?? null, $row['created_at'],
+ ]);
+ return (int)$this->pdo->lastInsertId();
+ }
+
+ private static function mapRow(array $r): array
+ {
+ return [
+ 'handover_id' => (int)$r['handover_id'],
+ 'site' => $r['site'] !== null ? (string)$r['site'] : null,
+ 'from_user' => $r['from_user'] !== null ? (string)$r['from_user'] : null,
+ 'to_user' => (string)$r['to_user'],
+ 'period_start' => (int)$r['period_start'],
+ 'period_end' => (int)$r['period_end'],
+ 'unacked_count' => (int)$r['unacked_count'],
+ 'severe_count' => (int)$r['severe_count'],
+ 'note' => $r['note'] !== null ? (string)$r['note'] : null,
+ 'created_at' => (int)$r['created_at'],
+ ];
+ }
+}
diff --git a/server/src/Repository/Pdo/PdoRiskControlRepository.php b/server/src/Repository/Pdo/PdoRiskControlRepository.php
new file mode 100644
index 0000000..586f3d5
--- /dev/null
+++ b/server/src/Repository/Pdo/PdoRiskControlRepository.php
@@ -0,0 +1,92 @@
+pdo->prepare($sql);
+ $st->execute($args);
+ return array_map([self::class, 'mapRow'], $st->fetchAll());
+ }
+
+ public function upsert(array $row): void
+ {
+ $site = $row['site'] ?? null;
+ $sql = 'SELECT control_id FROM move_risk_controls WHERE event_type = ? AND site '
+ . ($site === null ? 'IS NULL' : '= ?');
+ $st = $this->pdo->prepare($sql);
+ $st->execute($site === null ? [$row['event_type']] : [$row['event_type'], $site]);
+ $id = $st->fetchColumn();
+
+ if ($id !== false) {
+ $up = $this->pdo->prepare(
+ 'UPDATE move_risk_controls
+ SET hazard = ?, control = ?, severity_rating = ?, likelihood_rating = ?,
+ reviewed_by = ?, reviewed_at = ?, updated_at = ?
+ WHERE control_id = ?'
+ );
+ $up->execute([
+ $row['hazard'], $row['control'], (int)$row['severity_rating'], (int)$row['likelihood_rating'],
+ $row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'], (int)$id,
+ ]);
+ return;
+ }
+ $ins = $this->pdo->prepare(
+ 'INSERT INTO move_risk_controls (site, event_type, hazard, control, severity_rating,
+ likelihood_rating, reviewed_by, reviewed_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ );
+ $ins->execute([
+ $site, $row['event_type'], $row['hazard'], $row['control'],
+ (int)$row['severity_rating'], (int)$row['likelihood_rating'],
+ $row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'],
+ ]);
+ }
+
+ public function delete(int $id, ?string $site): bool
+ {
+ $sql = 'DELETE FROM move_risk_controls WHERE control_id = ?';
+ $args = [$id];
+ if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
+ $st = $this->pdo->prepare($sql);
+ $st->execute($args);
+ return $st->rowCount() > 0;
+ }
+
+ private static function mapRow(array $r): array
+ {
+ return [
+ 'control_id' => (int)$r['control_id'],
+ 'site' => $r['site'] !== null ? (string)$r['site'] : null,
+ 'event_type' => (string)$r['event_type'],
+ 'hazard' => (string)$r['hazard'],
+ 'control' => (string)$r['control'],
+ 'severity_rating' => (int)$r['severity_rating'],
+ 'likelihood_rating' => (int)$r['likelihood_rating'],
+ 'risk_score' => (int)$r['severity_rating'] * (int)$r['likelihood_rating'],
+ 'reviewed_by' => $r['reviewed_by'] !== null ? (string)$r['reviewed_by'] : null,
+ 'reviewed_at' => $r['reviewed_at'] !== null ? (int)$r['reviewed_at'] : null,
+ 'updated_at' => (int)$r['updated_at'],
+ ];
+ }
+}
diff --git a/server/src/Repository/Pdo/PdoSuppressionRepository.php b/server/src/Repository/Pdo/PdoSuppressionRepository.php
new file mode 100644
index 0000000..75b2535
--- /dev/null
+++ b/server/src/Repository/Pdo/PdoSuppressionRepository.php
@@ -0,0 +1,109 @@
+ $scopes [[type, id], ...] */
+ public function activeFor(array $scopes, int $now): array
+ {
+ if ($scopes === []) return [];
+ $where = [];
+ $args = [];
+ foreach ($scopes as [$type, $id]) {
+ $where[] = '(scope_type = ? AND scope_id = ?)';
+ $args[] = $type;
+ $args[] = (string)$id;
+ }
+ $sql = 'SELECT * FROM move_suppressions
+ WHERE revoked_at IS NULL AND starts_at <= ? AND ends_at > ?
+ AND (' . implode(' OR ', $where) . ')';
+ $st = $this->pdo->prepare($sql);
+ $st->execute(array_merge([$now, $now], $args));
+ return array_map([self::class, 'mapRow'], $st->fetchAll());
+ }
+
+ public function listAll(?string $site, int $now, bool $includeExpired = false): array
+ {
+ $sql = 'SELECT * FROM move_suppressions WHERE 1=1';
+ $args = [];
+ if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
+ if (!$includeExpired) {
+ $sql .= ' AND revoked_at IS NULL AND ends_at > ?';
+ $args[] = $now;
+ }
+ $sql .= ' ORDER BY created_at DESC LIMIT 200';
+ $st = $this->pdo->prepare($sql);
+ $st->execute($args);
+ return array_map([self::class, 'mapRow'], $st->fetchAll());
+ }
+
+ public function create(array $row): int
+ {
+ $st = $this->pdo->prepare(
+ 'INSERT INTO move_suppressions (scope_type, scope_id, site, reason, starts_at, ends_at, created_by, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
+ );
+ $st->execute([
+ $row['scope_type'], $row['scope_id'], $row['site'] ?? null, $row['reason'],
+ $row['starts_at'], $row['ends_at'], $row['created_by'], $row['created_at'],
+ ]);
+ return (int)$this->pdo->lastInsertId();
+ }
+
+ public function revoke(int $id, string $user, int $now, ?string $site): bool
+ {
+ // The site predicate is part of the UPDATE, not a prior read: a scoped operator must not
+ // be able to lift another site's suppression by guessing an id, and a check-then-write
+ // would leave a window between the two.
+ $sql = 'UPDATE move_suppressions SET revoked_by = ?, revoked_at = ?
+ WHERE suppression_id = ? AND revoked_at IS NULL';
+ $args = [$user, $now, $id];
+ if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
+ $st = $this->pdo->prepare($sql);
+ $st->execute($args);
+ return $st->rowCount() > 0;
+ }
+
+ public function find(int $id): ?array
+ {
+ $st = $this->pdo->prepare('SELECT * FROM move_suppressions WHERE suppression_id = ?');
+ $st->execute([$id]);
+ $r = $st->fetch();
+ return $r === false ? null : self::mapRow($r);
+ }
+
+ private static function mapRow(array $r): array
+ {
+ return [
+ 'suppression_id' => (int)$r['suppression_id'],
+ 'scope_type' => (string)$r['scope_type'],
+ 'scope_id' => (string)$r['scope_id'],
+ 'site' => $r['site'] !== null ? (string)$r['site'] : null,
+ 'reason' => (string)$r['reason'],
+ 'starts_at' => (int)$r['starts_at'],
+ 'ends_at' => (int)$r['ends_at'],
+ 'created_by' => (string)$r['created_by'],
+ 'created_at' => (int)$r['created_at'],
+ 'revoked_by' => $r['revoked_by'] !== null ? (string)$r['revoked_by'] : null,
+ 'revoked_at' => $r['revoked_at'] !== null ? (int)$r['revoked_at'] : null,
+ ];
+ }
+}
diff --git a/server/src/Repository/Pdo/PdoThresholdRepository.php b/server/src/Repository/Pdo/PdoThresholdRepository.php
index 779faba..9012a02 100644
--- a/server/src/Repository/Pdo/PdoThresholdRepository.php
+++ b/server/src/Repository/Pdo/PdoThresholdRepository.php
@@ -5,13 +5,31 @@ namespace App\Repository\Pdo;
use App\Repository\ThresholdRepository;
use PDO;
+/**
+ * Per-device (optionally per-camera) tunables, served to the edge by GET /api/config.
+ *
+ * These rows used to be editable only by hand in phpMyAdmin — which meant that after delivery
+ * the customer had no way to retune a camera at all, even though the console's false-positive
+ * panel exists precisely to tell them which camera needs it. The write methods below back the
+ * admin editor; every write records who made it, because a threshold change is a change to what
+ * the system will and will not detect, and an incident investigation has to be able to ask when
+ * it happened.
+ *
+ * Site scope is resolved through move_devices: a scoped operator must never see, let alone
+ * retune, another site's device.
+ */
final class PdoThresholdRepository implements ThresholdRepository
{
public function __construct(private PDO $pdo) {}
public function forDevice(string $deviceId): array
{
- $st = $this->pdo->prepare('SELECT `key`, `value` FROM move_thresholds WHERE device_id = ?');
+ // Device-wide rows only (camera_id IS NULL). Per-camera overrides are addressed by the
+ // edge through its own camera id and are not flattened into this map, which the daemon
+ // applies globally — merging them here would silently apply one camera's tuning to all.
+ $st = $this->pdo->prepare(
+ 'SELECT `key`, `value` FROM move_thresholds WHERE device_id = ? AND camera_id IS NULL'
+ );
$st->execute([$deviceId]);
$out = [];
@@ -20,4 +38,92 @@ final class PdoThresholdRepository implements ThresholdRepository
}
return $out;
}
+
+ public function listAll(?string $site): array
+ {
+ $sql = 'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at,
+ d.name AS device_name, d.site
+ FROM move_thresholds t
+ LEFT JOIN move_devices d ON d.device_id = t.device_id';
+ $args = [];
+ if ($site !== null) { $sql .= ' WHERE d.site = ?'; $args[] = $site; }
+ $sql .= ' ORDER BY t.device_id, t.camera_id, t.`key`';
+ $st = $this->pdo->prepare($sql);
+ $st->execute($args);
+ return array_map(static fn (array $r): array => [
+ 'id' => (int)$r['id'],
+ 'device_id' => (string)$r['device_id'],
+ 'device_name' => $r['device_name'] !== null ? (string)$r['device_name'] : null,
+ 'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null,
+ 'key' => (string)$r['key'],
+ 'value' => (string)$r['value'],
+ 'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null,
+ 'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null,
+ ], $st->fetchAll());
+ }
+
+ public function set(string $deviceId, ?string $cameraId, string $key, string $value, string $actor, int $now): int
+ {
+ // The unique key is (device_id, camera_id, zone_id, key), and in MySQL a UNIQUE index
+ // treats NULLs as distinct — so "ON DUPLICATE KEY" would NOT collide for the common
+ // camera_id IS NULL row and every save would append a duplicate. Look it up explicitly.
+ $existing = $this->findScoped($deviceId, $cameraId, $key);
+ if ($existing !== null) {
+ $st = $this->pdo->prepare(
+ 'UPDATE move_thresholds SET `value` = ?, updated_by = ?, updated_at = ? WHERE id = ?'
+ );
+ $st->execute([$value, $actor, $now, $existing]);
+ return $existing;
+ }
+ $st = $this->pdo->prepare(
+ 'INSERT INTO move_thresholds (device_id, camera_id, zone_id, `key`, `value`, updated_by, updated_at)
+ VALUES (?, ?, NULL, ?, ?, ?, ?)'
+ );
+ $st->execute([$deviceId, $cameraId, $key, $value, $actor, $now]);
+ return (int)$this->pdo->lastInsertId();
+ }
+
+ private function findScoped(string $deviceId, ?string $cameraId, string $key): ?int
+ {
+ $sql = 'SELECT id FROM move_thresholds WHERE device_id = ? AND `key` = ? AND camera_id '
+ . ($cameraId === null ? 'IS NULL' : '= ?');
+ $args = $cameraId === null ? [$deviceId, $key] : [$deviceId, $key, $cameraId];
+ $st = $this->pdo->prepare($sql);
+ $st->execute($args);
+ $id = $st->fetchColumn();
+ return $id === false ? null : (int)$id;
+ }
+
+ public function remove(int $id, ?string $site): bool
+ {
+ $row = $this->find($id);
+ if ($row === null) return false;
+ if ($site !== null && ($row['site'] ?? null) !== $site) return false;
+ $st = $this->pdo->prepare('DELETE FROM move_thresholds WHERE id = ?');
+ $st->execute([$id]);
+ return $st->rowCount() > 0;
+ }
+
+ public function find(int $id): ?array
+ {
+ $st = $this->pdo->prepare(
+ 'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at, d.site
+ FROM move_thresholds t
+ LEFT JOIN move_devices d ON d.device_id = t.device_id
+ WHERE t.id = ?'
+ );
+ $st->execute([$id]);
+ $r = $st->fetch();
+ if ($r === false) return null;
+ return [
+ 'id' => (int)$r['id'],
+ 'device_id' => (string)$r['device_id'],
+ 'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null,
+ 'key' => (string)$r['key'],
+ 'value' => (string)$r['value'],
+ 'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null,
+ 'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null,
+ 'site' => $r['site'] !== null ? (string)$r['site'] : null,
+ ];
+ }
}
diff --git a/server/src/Repository/RiskControlRepository.php b/server/src/Repository/RiskControlRepository.php
new file mode 100644
index 0000000..436387c
--- /dev/null
+++ b/server/src/Repository/RiskControlRepository.php
@@ -0,0 +1,13 @@
+ newest first; $site null = all sites (global operator). */
+ public function listAll(?string $site, int $now, bool $includeExpired = false): array;
+
+ public function create(array $row): int;
+
+ /** @return bool false when the id does not exist or is already revoked. */
+ public function revoke(int $id, string $user, int $now, ?string $site): bool;
+
+ public function find(int $id): ?array;
+}
diff --git a/server/src/Repository/ThresholdRepository.php b/server/src/Repository/ThresholdRepository.php
index 9acb517..3894616 100644
--- a/server/src/Repository/ThresholdRepository.php
+++ b/server/src/Repository/ThresholdRepository.php
@@ -4,5 +4,17 @@ namespace App\Repository;
interface ThresholdRepository
{
+ /** key => value, flattened for GET /api/config. Device-scoped rows only. */
public function forDevice(string $deviceId): array;
+
+ /** Full rows (id, scope, key, value, updated_by/at) for the admin editor. */
+ public function listAll(?string $site): array;
+
+ /** Insert or update one tunable. Returns the row id. */
+ public function set(string $deviceId, ?string $cameraId, string $key, string $value, string $actor, int $now): int;
+
+ /** @return bool false when the id does not exist (or is out of the caller's site scope). */
+ public function remove(int $id, ?string $site): bool;
+
+ public function find(int $id): ?array;
}
diff --git a/server/src/View/admin.php b/server/src/View/admin.php
new file mode 100644
index 0000000..6fb9a79
--- /dev/null
+++ b/server/src/View/admin.php
@@ -0,0 +1,110 @@
+
+
+
+
시스템 설정
+
감지 임계값과 통보 경로. 두 가지 모두 이 화면이 생기기 전에는 DB를 직접 고치는 것 외에 방법이 없었습니다. 모든 변경은 감사 기록에 남습니다.