Compare commits

..

No commits in common. "5c7c65351d4f4da55c6ff26e8d7f47b5f83a028e" and "9d25e0a2d127c639854f83bb1f4bf95d3118b16b" have entirely different histories.

153 changed files with 537 additions and 14714 deletions

View file

@ -1,44 +0,0 @@
# The repo had no CI at all, which meant PdoIntegrationTest — the only test that runs the
# delivered SQL rather than an in-memory double — had almost certainly never executed anywhere:
# it skips itself unless pdo_sqlite is loaded, and no machine was loading it.
#
# Same two commands as the local `verify` in CLAUDE.md. Keep them identical; a CI that runs
# something else is a CI that proves something else.
name: verify
on:
push:
pull_request:
jobs:
edge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11' # Pi OS Bookworm ships CPython 3.11; match the target
- run: pip install -e ".[dev]" # deps + pytest come from pyproject.toml
- run: python -m pytest tests -q
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2' # Cafe24's floor (see server/docs/server-deploy.md)
# pdo_sqlite is what un-skips the DB integration test; gd/sodium mirror the host's
# image + Argon2id requirements so a missing-extension failure shows up here, not
# on the customer's shared hosting.
extensions: pdo_sqlite, gd, sodium
- run: composer install --no-interaction --prefer-dist
working-directory: server
- run: php vendor/bin/phpunit
working-directory: server
- name: fail if any test skipped
working-directory: server
# A skip is how the DB coverage silently disappeared in the first place. In CI every
# test must actually run, so a re-introduced skip gate fails the build instead of
# quietly shrinking coverage back down.
run: php vendor/bin/phpunit --do-not-cache-result | tee out.txt; ! grep -q "Skipped:" out.txt

View file

@ -1,28 +0,0 @@
# move_detect — 작업장 안전 AI CCTV
Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보드(`server/`).
납품·폐쇄망 대상 프로젝트 — `~/.claude/rules/common/build-deployment.md` 적용.
## 시작
- 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신.
- 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가.
- 감사 상태: `docs/delivery-audit.md` (43/43 — 실장비 OFF-HW 검증만 잔여).
## 검증 (verify)
```
python -m pytest tests -q # edge (247+)
cd server && php -d extension=pdo_sqlite vendor/bin/phpunit # server (162+, skip 0)
```
`-d extension=pdo_sqlite` 없이 돌리면 PDO 통합 테스트(납품되는 실제 SQL)가 통째로 스킵된다.
CI(`.forgejo/workflows/verify.yml`)는 같은 명령을 쓰고 스킵이 하나라도 있으면 실패시킨다.
## 제약
- ultralytics(AGPL) 금지 — Hailo HEF/MoveNet만. 라이브뷰·영상 전송 금지(스냅샷만, 얼굴 블러).
- 감지기 타이머는 monotonic `now`, 이벤트 스탬프만 wall clock.
- DB 테이블 prefix `move_`. 프런트 JS는 textContent만(innerHTML 금지), 바닐라(빌드 없음).
- UI는 라이트·고밀도 운영 콘솔 방향 확정 (메모리 참조; 다크·미니멀 반려됨).
- 원격: Forgejo(`tea` CLI, gh 금지).

View file

@ -1 +0,0 @@
0.9.0

View file

@ -6,13 +6,11 @@ performs **zero network fetches** at install or run time. The only runtime
network use is the server POST / config-poll / heartbeat, and even that tolerates network use is the server POST / config-poll / heartbeat, and even that tolerates
being offline (events buffer in the local sqlite queue and flush on reconnect). being offline (events buffer in the local sqlite queue and flush on reconnect).
> Scope: the delivered bundle is **Hailo-8 + `yolov8s_pose.hef`**, which > Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
> `build_offline_bundle.sh` fetches (sha256-pinned) and stages into `models/`. > daemon wires today (`edge/daemon.py:main`) and the one verified end-to-end.
> The CPU MoveNet fallback is **not** bundled: its upstream ONNX URL 404s, so > Hailo-8 acceleration (`edge/adapters/hailo_pose.py`) and the CSI camera
> `install_offline.sh` configures the unit for whatever backend is actually > (`edge/adapters/picamera_source.py`) are separate device-validated paths — see
> present and refuses to install if none is. Camera side: USB (UVC) and CSI are > `docs/hardware-bringup.md`.
> both wired; CSI needs `hailo-all`/`picamera2` from the Pi OS image.
> Device validation for both is `docs/hardware-bringup.md` (still OFF-HW).
## Files ## Files
@ -107,18 +105,17 @@ sudo systemctl enable --now move-detect-edge
journalctl -u move-detect-edge -f journalctl -u move-detect-edge -f
``` ```
`DEVICE_ID`/`DEVICE_SECRET` must match a `move_devices` row in the server (see `DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
`server/docs/server-deploy.md` §6). `HAILO_HEF` points at the bundled model `server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
(`/opt/move-detect/models/yolov8s_pose.hef`); the installer sets it for you and model (`/opt/move-detect/models/movenet_lightning.onnx`).
only falls back to `MOVENET_ONNX` if a MoveNet bundle was built.
## 4. Verify the air gap (do this before trusting a build) ## 4. Verify the air gap (do this before trusting a build)
- [ ] On a host with **networking disabled**, `install_offline.sh` completes — - [ ] On a host with **networking disabled**, `install_offline.sh` completes —
the `--no-index` installs prove nothing is fetched. If pip tries to reach the `--no-index` installs prove nothing is fetched. If pip tries to reach
PyPI, a wheel is missing from the bundle: rebuild (step 1) natively. PyPI, a wheel is missing from the bundle: rebuild (step 1) natively.
- [ ] Step [4/6] of the installer prints `edge import OK` (cv2 + numpy + requests - [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
import from the venv; onnxruntime only if a MoveNet bundle was built). numpy + requests all import from the venv).
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows - [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
frames processed and (if the server is reachable) heartbeats. frames processed and (if the server is reachable) heartbeats.
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore - [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
@ -169,10 +166,6 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the server
## Not bundled (by design) ## Not bundled (by design)
- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`. - **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`.
- **HailoRT + picamera2** — Hailo's runtime/driver and the CSI camera stack are Pi - **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
system packages, installed out-of-band (above). Note the HEF itself **is** bundled HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
(`models/yolov8s_pose.hef`); only the driver is not. the default CPU MoveNet + USB path needs none of them.
- **`python3-venv`** — `install_offline.sh` runs `python3 -m venv`, which Pi OS Lite
does not always include. Install it with the Hailo packages, before going air-gapped.
- **MoveNet ONNX (CPU fallback)** — its upstream URL 404s, so the bundle is Hailo-only.
If you need the CPU path, supply the ONNX to the build machine and rebuild.

View file

@ -155,19 +155,8 @@ sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock"
# air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX. # air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX.
HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes
SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
# Provenance. Without this, a bundle is unidentifiable once it leaves the build machine: the
# SHA256SUMS inside a STALE bundle is perfectly self-consistent, so integrity checks pass while
# the contents are months behind the source. version+commit is how a site reports what it runs.
BUNDLE_VERSION="$(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
GIT_REV="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
GIT_DIRTY=""
(cd "$REPO_ROOT" && git diff --quiet HEAD -- edge deploy 2>/dev/null) || GIT_DIRTY=" (uncommitted changes)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{ {
echo "bundle: move-detect-edge" echo "bundle: move-detect-edge"
echo "version: $BUNDLE_VERSION"
echo "git_commit: ${GIT_REV}${GIT_DIRTY}"
echo "built_at: $BUILD_DATE"
echo "built_mode: $MODE" echo "built_mode: $MODE"
echo "python: $PYVER ($PYABI)" echo "python: $PYVER ($PYABI)"
echo "target_arch: $TARGET_ARCH" echo "target_arch: $TARGET_ARCH"

View file

@ -19,15 +19,12 @@ CAFE24_URL=https://your-domain.example/move_detect
CAMERA_ID=cam-01 CAMERA_ID=cam-01
# --- pose backend / capture --- # --- pose backend / capture ---
# POSE_BACKEND: "hailo" (Pi 5 + AI HAT+, needs HAILO_HEF) or "movenet" (CPU, needs MOVENET_ONNX). # POSE_BACKEND: "movenet" (CPU, needs MOVENET_ONNX) or "hailo" (needs HAILO_HEF).
# The delivered bundle carries the HEF only — the MoveNet ONNX has no reachable upstream, so a POSE_BACKEND=movenet
# movenet unit would point at a file that is not there and fail to start. install_offline.sh # MOVENET_ONNX: required when POSE_BACKEND=movenet.
# rewrites these to match whatever the bundle actually contains; edit them only if you know the MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
# file exists on this device. # HAILO_HEF: required when POSE_BACKEND=hailo (uncomment and set).
POSE_BACKEND=hailo #HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
# MOVENET_ONNX: required only when POSE_BACKEND=movenet (CPU fallback; not bundled by default).
#MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
# CAMERA_BACKEND: "uvc" (USB webcam, uses UVC_DEVICE) or "csi" (Pi CSI camera, uses CSI_DEVICE). # CAMERA_BACKEND: "uvc" (USB webcam, uses UVC_DEVICE) or "csi" (Pi CSI camera, uses CSI_DEVICE).
CAMERA_BACKEND=uvc CAMERA_BACKEND=uvc
# UVC_DEVICE: OpenCV VideoCapture index for the USB (UVC) webcam. # UVC_DEVICE: OpenCV VideoCapture index for the USB (UVC) webcam.

View file

@ -43,21 +43,6 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge
Restart=always Restart=always
RestartSec=5 RestartSec=5
# Catch the failure Restart=always CANNOT: alive but wedged. run_loop pings WATCHDOG=1 at the
# top of every tick, so a blocked camera read / accelerator call / sqlite lock stops the pings
# and systemd restarts the unit instead of leaving an unattended site silently unmonitored.
# 120s, not a tight value: one tick may legitimately include an offline-queue flush slice whose
# POSTs each carry a 5s timeout. Too tight would restart a HEALTHY daemon mid-recovery.
WatchdogSec=120
# Memory ceiling. The evidence ring buffers ~90 frames per camera, so a mis-set capture size or
# a leak grows RSS until the kernel OOM killer fires — and the kernel may kill something else
# entirely, leaving the Pi in an unpredictable state. Let systemd kill THIS unit instead, where
# Restart=always brings it straight back. MemoryHigh throttles first to ride out a brief spike.
MemoryHigh=1200M
MemoryMax=1500M
OOMPolicy=restart
# Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it). # Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it).
# systemd creates/owns /var/lib/move-detect for the service user. # systemd creates/owns /var/lib/move-detect for the service user.
StateDirectory=move-detect StateDirectory=move-detect

View file

@ -1,115 +0,0 @@
# INDEX — 작업 기록 (완료 결과만)
- **2026-08-09** 안전 플랫폼 확장 — `e00ad69`. 로드맵 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) — `e00ad69`
(위 안전 플랫폼 확장과 같은 커밋: 후자가 전자에서 고친 화면 위에 얹혀 있다).
**동선**: ① 스트림 정렬을 '미확인 우선'에서 **엄격 시간순**으로 되돌림 — 확인 상태가 연대기를
뒤집어 사고 재구성이 불가능했고, 확인 시 행이 목록 밖으로 사라져 아래 행이 밀려 올라오며
오클릭으로 다른 이벤트가 확인 처리될 수 있었다. 확인은 제자리 전환 + 확정 토스트로 변경.
② 처리 큐는 정렬이 아니라 뷰모드(전체/미확인만) 필터로 분리. ③ 이벤트 상세에 확인/오탐 액션
신설(판정 화면에 판정 도구가 없던 문제). ④ 오탐·구역삭제 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 배치 상한(정전 복구 시 감지 정지
해소), 타이머 env 전수 NaN/음수 검증, T2 프레임 스킵(`INFER_EVERY`, 스킵 틱은 링버퍼 미투입
=미블러 얼굴 유출 차단), systemd 워치독(sd_notify)+메모리 상한, UVC 해상도·버퍼 고정.
**테스트**: PDO 통합 테스트 스킵 해소(`-d extension=pdo_sqlite`, 리포트·히트맵·익스포트
집계 SQL 최초 실행), 얼굴 블러 테스트를 실제 증거로 교체(무동작 퇴화 감지), index.php 라우트
테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트, CI 신설
(`.forgejo/workflows/verify.yml`, 스킵 발생 시 실패). **프런트**: 이벤트 목록 시그니처 기반
재렌더 스킵+포커스 복원(5초마다 포커스 소실·전체 재낭독 해소), 폴링 중첩 가드, 콜드스타트
실패 표시. **문서**: deploy/README·env.example을 Hailo 단독 번들 실제와 일치, bringup의
미존재 설정 키를 실제 env 노브로 교체. 납품물에 version/git_commit/built_at 스탬프.
pytest 251 / PHPUnit 162 (스킵 0).
- **2026-08-08** 프로젝트 8개 차원 감사(종합 82/100) 후 상위 3건 수정. ①개선 조치 site 스코프
누락(스코프 운영자가 타 현장 조치 기록 열람·기록 가능) → `move_actions.site` 추가
(`2026-08-08-actions-site.sql`, 이벤트에서 카메라 소유 현장 해석, 오타 카메라 400·타 현장
404). ②README-UPLOAD 업그레이드 절차가 마이그레이션 1/7만 지시 → 순서·적용조건 표로 교체
(`move_` 접두 리네임 누락 시 전 요청 500). ③두 납품 번들 재생성(4커밋 스테일 해소 —
Report/Export/Actions 컨트롤러·마이그레이션 누락 복구, 엣지 소스 패리티 회복).
문서 테이블 수·프리픽스 표기 정정. pytest 235 / PHPUnit 147.
- **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간
추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후
효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장
이후(사이트 스코프 구조가 이미 수용). PHPUnit 145 + Chrome 실렌더.
- **2026-08-07** 증거 타임라인 + 24시간 캐치업: 이벤트 스냅샷이 링버퍼 기반 과거→발생
3장(프레임별 자기 시점 얼굴 블러, P1-B 잔여 종결), 상세뷰 이전/직전/발생 캡션. 콘솔
첫 진입 '지난 24시간' 요약 배너(/api/report?days=1 재사용, 리포트 링크). pytest 235/
PHPUnit 138 + Chrome 실렌더.
- **2026-08-07** 안전 운영 리포트 `/report` (주간 안전회의용 인쇄 가능 한 장: 중대 이벤트·
TTA(FP 제외)·니어미스 피라미드·오탐율·유형/카메라 순위·인체공학 노출·존 규칙 위반) +
이벤트 스트림 유형 필터. PHPUnit 138 + Chrome 실렌더(KPI/바리스트/타일/필터).
- **2026-08-06** 인체공학 자세부담 스코어링 (`ergo_report`/INFO, 카메라 단위 익명·스냅샷
미부착): REBA 밴드 노출시간(허리 20~60°/60°+·팔 90°+·웅크림<110°), 사고 상태 트랙 제외,
검출 공백 dt 상한. 상세뷰 지표 한글 렌더 — `b358822`. pytest 232/PHPUnit 135 + 실렌더.
- **2026-08-06** 납품 번들 재생성: `dist/1_cafe24_upload` + `move-detect-cafe24.zip`
(마이그레이션 5종 동봉 — 빌드 스크립트에 staging 추가) · `dist/2_pi_install` --cross 재빌드
(aarch64/cp311 휠 11종 + HEF 다이제스트 검증 + SHA256SUMS).
**FLAG:** MoveNet ONNX 업스트림 URL 404 → 이번 Pi 번들은 Hailo 단독(CPU 폴백 모델 미포함).
`edge/tools/fetch_movenet.py`의 소스 URL 갱신 필요.
- **2026-08-06** 오탐 피드백 (결정 a: 통계만, 자동 튜닝 없음): 이벤트 오탐 태그
(`POST /events/{id}/false-positive`, ack 겸함, 최초 태거 보존) + `GET /api/fp-stats` +
콘솔 오탐율 패널(30일, 30%+ 강조). PHPUnit 135 + Chrome 실렌더(태그→행 전환→패널 즉시 갱신).
- **2026-08-06** 대시보드 장치→카메라 트리 레일 + (device,camera) 쌍 카메라 필터 —
`eaff176`. PHPUnit 119 통과 + Chrome 실렌더 검증(트리 상태·필터·접기·콘솔 무오류).
- **2026-08-06** 감사 잔여 P2-6/7/8 종결 (ID 스왑 게이트 · sustained-prone FALLING ·
stale 트랙 no-data) — `32f2237`. TDD(RED 확인), pytest 203 통과/2 skip.
→ delivery-audit 오프하드웨어 항목 43/43 완료. 잔여는 실장비(OFF-HW) 검증뿐.
- **2026-08-06** 연속성 문서 신설: `CLAUDE.md` · `docs/PROJECT_MAP.md` · 본 INDEX.
- **2026-08-06** 니어미스 히트맵: `near_fall`(FALLING→확정 전 회복, INFO) + 전 트랙 이벤트에
정규화 발좌표(spatial) 부착 → `move_events.spatial_x/y` 적재 → `GET /api/heatmap/{camera}` →
존 편집기 '위험 히트맵' 오버레이. **직전 커밋의 chk_events_type 제약 누락(신규 타입 INSERT
거부) 마이그레이션으로 수정.** pytest 222/PHPUnit 130 + Chrome 실렌더.
- **2026-08-06** 존 점유 규칙 엔진 (2인1조 `lone_worker`/HIGH · 정원 `overcapacity` ·
체류상한 `overstay`): edge `occupancy.py` + `move_zones.rules_json` + 존 편집기 규칙 입력 +
이벤트 유형 한글 라벨. pytest 216/PHPUnit 123 통과 + Chrome 실렌더(편집기 저장·목록·콘솔 라벨).

View file

@ -1,89 +0,0 @@
# PROJECT_MAP — move_detect
> 작업 전 이 문서로 위치를 찾고 필요한 파일만 연다 (전체 재스캔 금지).
> 새 파일·라우트·스키마 변경 시 해당 섹션을 갱신할 것.
## 개요
작업장 안전 AI CCTV. Raspberry Pi 5 + Hailo-8 엣지 추론(Python) → HMAC POST → Cafe24
웹호스팅 PHP+MySQL 대시보드. 감지: 넘어짐 · 넘어짐 후 미동 · 장시간 부동 · 위험구역
침입(경고/절대금지/협착) · 유령사고(위험 상태로 소실). 스냅샷만 전송, 라이브뷰·영상 없음.
## edge/ (Python — Pi에서 구동)
| 파일 | 역할 |
|------|------|
| `app.py` | `run_once` 파이프라인 (프레임→추론→추적→감지→스냅샷→전송). ambiguous 리셋·stale 필터 |
| `engine.py` | `EventEngine` — 감지기 오케스트레이션, 쿨다운 dedup, 트랙 forget |
| `tracking.py` | `IouTracker` — 전역 그리디 IoU 매칭, `ambiguous()` (교차 경합 트랙) |
| `detectors/` | `fall.py`(FALLING→FALLEN→NO_MOTION, sustained prone) · `immobility.py` · `ghost.py` · `zone.py` · `occupancy.py`(존 규칙: 2인1조/정원/체류상한 — Zone.rules 소비) |
| `smoothing.py` | 트랙별 키포인트 스무딩 풀 |
| `snapshot.py` | RingBuffer · FaceBlurrer(PIPA) · Snapshotter |
| `alert.py` / `serverclient.py` | HMAC+nonce POST, 커스텀 UA, no-redirect |
| `queue.py` | 오프라인 전송 큐 |
| `config.py` | env → Config (빈 CAFE24_URL/SECRET는 기동 실패) |
| `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,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`(개선 조치·전후 효과) ·
`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/Alert/` 통보 결정·발송·에스컬레이션 · `src/Repository/` 14종 (인터페이스+PDO)
- 감사로그는 **해시체인**(prev_hash/entry_hash). 수정·삭제·우회삽입이 전부 탐지된다 —
`tools/verify_audit_chain.php`, 손상 시 종료 코드 1
- 프런트: `public/assets/app.js`(이벤트 콘솔 — 엄격 시간순 정렬 · 뷰모드(전체/미확인만) ·
날짜 조회(커서 시딩) · 확인 제자리 전환+토스트 · 오탐 2단계 · 경보음 음소거 · 날짜 구분선) ·
`device-tree.js`(장치→카메라 트리 레일) · `zone-draw.js`(장치·카메라 선택기 · 존 채움/라벨칩 ·
삭제 2단계) · `detail.js`(상세 + 확인/오탐 판정) · `report.js`(표본·유의성 게이트) · `app.css`
- UI 불변식: 스트림 정렬은 **항상 occurred_at 내림차순** — 확인 상태가 순서를 바꾸면 안 됨
(연대기 = 사고 재구성 근거). 처리 큐는 정렬이 아니라 필터(`filters.unackedOnly`)로만 만든다.
- 페이지 명칭은 nav 라벨 · `<h1>` · `<title>` 세 곳이 동일 문자열 (`이벤트`/`구역 설정`/`안전 리포트`).
날짜 표기는 `YYYY-MM-DD` 로 3개 화면 통일 (각 파일의 로컬 `formatTime`/`fmtDate`).
- DB: `schema.sql` (테이블 prefix `move_`), 마이그레이션 `migrations/`.
존 행의 `rules_json`(선택) = 점유 규칙 {min_workers,max_workers,max_dwell_s} — 편집기 저장 시
ZoneConfigController가 검증, /api/config로 엣지 전달
- 배포: `server/deploy/build_cafe24_package.sh` → zip, `server/docs/server-deploy.md`
## deploy/ · dist/ (납품)
- `deploy/`: Pi 설치 — `setup_online.sh`, `bundle/build_offline_bundle.sh`(폐쇄망),
`systemd/move-detect-edge.service`, `device.env`(사전 프로비저닝)
- `dist/`: `1_cafe24_upload/` · `2_pi_install/` · `PROVISION_SECRETS.md`
## tests/
- Python: `tests/` 31파일 (pytest) — 하드웨어 필요 테스트는 `tests/hardware/` + skip 마커
- PHP: `server/tests/` 19파일 (phpunit, in-memory repo; PDO 통합은 skip 게이트)
## docs/
`delivery-audit.md`(감사 43건, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` ·
`hardware-bringup.md` · `INDEX.md`(작업 기록)

View file

@ -95,23 +95,13 @@ misconfig/degradation fail LOUD.
normalize by elapsed time. normalize by elapsed time.
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning. FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
- [x] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross, - [ ] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches; transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
reset per-track safety state on a low-confidence match. reset per-track safety state on a low-confidence match.
DONE — global greedy (all pairs sorted by IoU desc) replaces track-order greedy; any track - [ ] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
in a contended match (person↔2 tracks or track↔2 persons) is reported via
`tracker.ambiguous()` and run_once resets its engine + smoother state. A still-prone worker
re-confirms within prone_confirm_s. test_tracking.py + test_app.py lock both.
- [x] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone. arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
DONE — STANDING→FALLING now requires sustained prone (`falling_confirm_s`, default 1.0s, - [ ] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
dataclass-only, no env knob). FALLEN/no_motion timing unchanged (keyed off prone_since).
test_fall.py locks it.
- [x] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data. frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
DONE — run_once feeds the engine (and smoothers) only tracks re-detected THIS tick
(`latest.t == now`); a stale track is no-data until re-detected or lost. Re-detection still
confirms the fall (dropout pauses evidence, doesn't erase it). test_app.py locks both.
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no - [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:** confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
confidence-gate the ankles, fall back to bbox-bottom-center. confidence-gate the ankles, fall back to bbox-bottom-center.

View file

@ -65,15 +65,8 @@ Measure frame processing under three operational modes:
| **T2: Detection** | Inference every 2–3 frames | Trade-off: skip frames to reduce Hailo load. | ≥ 8 fps | For additional context cameras; acceptable for non-critical zones. | | **T2: Detection** | Inference every 2–3 frames | Trade-off: skip frames to reduce Hailo load. | ≥ 8 fps | For additional context cameras; acceptable for non-critical zones. |
| **T3: Motion** | Hailo only on motion keyframes | Inference triggered by motion (v4l2 MJPEG keyframe or frame diff). | ≥ 5 fps avg | For background monitoring; many cameras, low overhead. | | **T3: Motion** | Hailo only on motion keyframes | Inference triggered by motion (v4l2 MJPEG keyframe or frame diff). | ≥ 5 fps avg | For background monitoring; many cameras, low overhead. |
1. Tier selection is `INFER_EVERY` in `/etc/move-detect/edge.env` (per device): 1. Configure edge detection core to support tier selection (via thresholds file or env var).
`1` = T1 (every tick, the default), `2`–`3` = T2. On a skipped tick the frame is still read 2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2/T3 on perimeter).
and discarded — the camera buffer stays current — but no pose runs and nothing enters the
evidence ring, so no unblurred frame can reach a snapshot.
**T3 (motion-gated inference) is deliberately NOT implemented.** A motion gate that misjudges
would stop inferring on a *motionless fallen worker* — the exact case this product exists to
catch — and its threshold cannot be tuned without real footage. Decide it during bring-up
with measured video, not in advance.
2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2 on perimeter).
3. Log FPS, E2E latency (frame capture → alert), CPU, Hailo temp for 30 min per tier. 3. Log FPS, E2E latency (frame capture → alert), CPU, Hailo temp for 30 min per tier.
4. Confirm T1 ≥ 12 fps for the target scene (well-lit, medium activity). 4. Confirm T1 ≥ 12 fps for the target scene (well-lit, medium activity).
@ -104,23 +97,14 @@ Validate sustained operation and resource cleanup:
After benchmark completion, update configuration: After benchmark completion, update configuration:
Every knob below lives in `/etc/move-detect/edge.env` (read by `edge/config.py::load_config`), 1. **`edge/config.py` thresholds:**
**not** in a code file — no redeploy is needed to retune a site. All are validated at startup: - Adjust `motion.cooldown_ms`, `motion.min_duration_ms` based on measured T2/T3 FPS.
a non-finite or non-positive value refuses to boot rather than silently disabling a detector. - Tune `fall.confidence_threshold` if fall recall vs. precision trade-off differs on real video.
The full list is in `docs/pilot-runbook.md` §3.
1. **Detector thresholds:** 2. **`edge/config.py` camera roles:**
- `MOTION_THR` (default 0.25) — per-second stillness threshold shared by fall no-motion and - Assign `role="fall_primary"` to high-fps T1 cam (≥ 12 fps).
immobility. This is the primary re-tuning knob on real video: it is frame-rate normalised, - Assign `role="context"` to T2 cameras (8–10 fps).
so a device running at a different measured fps needs it re-checked. - Assign `role="perimeter"` to T3 motion-only cameras.
- `FALL_PRONE_S` (3.0) / `FALL_NO_MOTION_S` (10.0) — how long prone before a fall confirms,
and before it escalates to the 위급 no-motion event. Raise if real footage over-triggers.
- `IMMOBILE_S` (120.0), `GHOST_REAPPEAR_S` (10.0), `ZONE_DEBOUNCE_S` (0.75), `COOLDOWN_S` (30.0).
2. **Per-camera load:**
- `INFER_EVERY` (1) — 1 for the fall-critical camera, 2–3 for context cameras that missed
the T1 target. Set per device; each camera on a device shares it today.
- `TARGET_FPS` (12) — loop pacing. It only slows the loop down; it cannot buy throughput.
3. **Benchmark report:** 3. **Benchmark report:**
- Document actual Pi hardware, kernel version, HailoRT version, Hailo FW version. - Document actual Pi hardware, kernel version, HailoRT version, Hailo FW version.

View file

@ -58,15 +58,15 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은 로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만. [server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (11개 테이블, 전부 `move_` 접두사). 1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로). (`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. 3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
- `INSERT INTO move_devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` - `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
- `INSERT INTO move_users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` - `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
4. 기동: `php -S localhost:8000 -t server/public` 4. 기동: `php -S localhost:8000 -t server/public`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조. `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.

View file

@ -4,17 +4,11 @@ import cv2
class UvcSource: class UvcSource:
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
size: tuple[int, int] = (640, 480), _open=None):
# _open lets tests inject a fake capture; production uses cv2.VideoCapture. # _open lets tests inject a fake capture; production uses cv2.VideoCapture.
self._dev = dev self._dev = dev
self._mjpeg = mjpeg self._mjpeg = mjpeg
self._reopen_after = reopen_after self._reopen_after = reopen_after
# Pin the capture size to the CSI default. Left unset, a UVC cam opens at ITS default —
# a 1080p webcam yields 6.2MB frames, and the 90-frame evidence ring then costs ~560MB
# per camera (vs ~80MB at 640x480), which is an OOM on a 4-camera Pi. Memory must be a
# property of OUR config, not of whichever webcam the site happened to plug in.
self._size = size
self._open = _open or (lambda d: cv2.VideoCapture(d)) self._open = _open or (lambda d: cv2.VideoCapture(d))
self._fail_streak = 0 self._fail_streak = 0
self.cap = self._open_cap() self.cap = self._open_cap()
@ -28,14 +22,6 @@ class UvcSource:
raise RuntimeError(f"UVC device {self._dev!r} failed to open") raise RuntimeError(f"UVC device {self._dev!r} failed to open")
if self._mjpeg: if self._mjpeg:
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
w, h = self._size
cap.set(cv2.CAP_PROP_FRAME_WIDTH, w)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h)
# Ask v4l2 to keep the shallowest queue it will honour. When the loop runs slower than
# the camera's fps, a deep driver queue hands back frames from seconds ago — a fall
# would be detected long after it happened. Not all backends honour this; harmless if
# ignored (the drain-only tick in PipelineRuntime covers the rest).
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
return cap return cap
def read(self): def read(self):

View file

@ -8,11 +8,6 @@ from edge.detectors.zone import ZoneIntrusionDetector
from edge.engine import EventEngine from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.models import BBox from edge.models import BBox
from edge.events import 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.
SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)
def _head_boxes(persons: list) -> list: def _head_boxes(persons: list) -> list:
@ -34,8 +29,7 @@ def _head_boxes(persons: list) -> list:
return boxes return boxes
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None, def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
integrity=None):
ok, frame = source.read() ok, frame = source.read()
if not ok: if not ok:
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no # No frame this tick. Return None (not []) so the daemon can tell "camera produced no
@ -47,58 +41,21 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
persons = pose.infer(frame) persons = pose.infer(frame)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
lost = tracker.lost() lost = tracker.lost()
# Head boxes are computed EVERY tick and stored with the frame (not only when an event for tr in tracks:
# fires): a past frame pulled from the ring later must be blurred with the head positions
# of its own moment, not the current tick's.
head_boxes = _head_boxes(persons)
# A contended (crossing) match may be an id swap — FALLEN state, frozen baseline, or an
# immobility timer would ride along to the wrong person. Reset that track's safety state;
# a genuinely fallen worker still prone re-confirms within prone_confirm_s.
for tid in tracker.ambiguous():
engine.forget(tid)
pool.forget(tid)
# A track not re-detected this tick still sits in the tracker (< max_age) with a frozen
# latest frame. Feeding it downstream would re-smooth the same frame and let time-based
# detector timers (prone confirm / no-motion / immobility) accrue on stale data — treat
# it as no-data until it is re-detected or ages into lost().
fresh = [tr for tr in tracks if tr.latest.t == now]
for tr in fresh:
f = tr.latest f = tr.latest
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now) f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame, head_boxes) ring.push(now, frame)
h, w = frame.shape[:2] h, w = frame.shape[:2]
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the # `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before. # daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
wall_now = wall_fn() if wall_fn is not None else None wall_now = wall_fn() if wall_fn is not None else None
events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now) events = engine.process(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
# Camera-integrity findings are about the FRAME, not about any track, so they are produced
# here (where the frame is) rather than inside the engine (which only sees tracks). They
# matter because a camera that has been knocked askew keeps producing perfectly healthy
# frames while every zone drawn on it now points somewhere else — the failure that used to
# be invisible until after an incident.
if integrity is not None:
stamp = wall_now if wall_now is not None else now
for etype, metrics in integrity.observe(frame, now):
et = EventType(etype)
events.append(Event(et, severity_for(et), engine.cfg.camera_id, None, None,
stamp, 0.0, metrics, None))
for tr in lost: for tr in lost:
pool.forget(tr.track_id) pool.forget(tr.track_id)
if events: if events:
# ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame snaps = snapshotter.capture([frame], [_head_boxes(persons)])
# would turn a statistic into surveillance imagery. Capture only when a real incident
# needs evidence, and never attach snapshots to the ergo report itself.
evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT]
snaps = []
if evidential:
# Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end
# state (a fall's value is in the seconds before the person hit the floor).
# Right after startup the ring is short and fewer frames go out — never zero,
# since the current frame was pushed above. Each frame blurs with its own boxes.
frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S)
snaps = snapshotter.capture(frames, boxes)
for e in events: for e in events:
sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps) sink.try_send(e, snaps)
return events return events

View file

@ -1,176 +0,0 @@
"""현장 경광등·부저 — 엣지 노드가 스스로 울리는 유일한 통보 경로.
WHY LOCAL
폐쇄망 현장에서는 FCM 도 Slack 도 쓸 수 없고, 사내 SMTP·웹훅조차 없는 곳이 있다. 게다가
서버 경유 통보는 업링크가 살아 있을 때만 동작한다 — 정전이나 스위치 장애로 회선이 끊긴
순간은 사고 위험이 오히려 높아지는 때다. GPIO 출력은 그 모든 것과 무관하게 동작한다:
감지기가 이벤트를 만든 그 자리에서, 네트워크 없이, 수십 밀리초 안에.
그래서 이것이 어떤 사이트든 **가장 먼저 설정해야 할 통보 경로**다. 서버측 채널
(App\\Alert\\Channels)은 이 위에 얹는 보조 수단이다.
설정 전달
서버 /api/config 의 `beacons` 배열로 내려온다 (핀 번호, latch/pulse, 유지 시간,
active_high). 관리 화면에서 켜고 끄므로 현장에서 edge.env 를 고칠 필요가 없다.
하드웨어 부재
gpiozero / RPi.GPIO 가 없거나(개발 PC, CI) 핀 열기에 실패하면 NullBeacon 으로 조용히
떨어진다. 경광등이 없다고 감지가 멈추면 안 된다 — 통보는 감지의 부가 기능이지 전제가
아니다. 다만 그 사실은 로그로 남긴다: 아무도 모르게 경광등이 죽어 있는 것이 최악이다.
실장비 검증
핀이 실제로 떨어지는지는 이 파일의 단위 테스트로 증명할 수 없다. tests/hardware/ 의
OFF-HW 항목으로 남긴다 (docs/hardware-bringup.md).
"""
import time
_SEVERITY_RANK = {"info": 0, "warning": 1, "high": 2, "critical": 3}
class NullBeacon:
"""하드웨어가 없을 때의 무동작 구현. 인터페이스는 동일하다."""
available = False
def fire(self, severity: str, now: float) -> bool:
return False
def tick(self, now: float) -> None:
pass
def release(self) -> None:
pass
class GpioBeacon:
"""핀 하나를 latch(고정) 또는 pulse(일정 시간 후 해제) 로 구동한다.
`latch` 는 사람이 확인할 때까지 계속 울리는 대신 hold_s 뒤 자동 해제한다. 무한 latch 는
현장에 사람이 갈 수 없는 시간대(야간·주말)에 아무도 끌 수 없는 소음이 되고, 그러면 다음
주에 배선이 뽑혀 있다.
"""
available = True
def __init__(self, pin: int, *, mode: str = "latch", hold_s: float = 30.0,
active_high: bool = False, min_severity: str = "high",
backend=None, log=None):
self.pin = int(pin)
self.mode = mode if mode in ("latch", "pulse") else "latch"
self.hold_s = max(0.5, float(hold_s))
self.active_high = bool(active_high)
self.min_rank = _SEVERITY_RANK.get(min_severity, 2)
self._on_until: float | None = None
self._log = log or (lambda m: None)
self._backend = backend if backend is not None else _open_pin(self.pin, self.active_high)
def _write(self, on: bool) -> None:
# active_high=False 는 릴레이 모듈의 통상 배선(LOW 에서 도통)이다. 반대로 물린
# 현장에서 경광등이 상시 켜져 있는 것을 설정으로 뒤집을 수 있어야 한다.
self._backend.set(on if self.active_high else not on)
def fire(self, severity: str, now: float) -> bool:
if _SEVERITY_RANK.get(severity, 0) < self.min_rank:
return False
self._write(True)
# 이미 울리는 중에 새 이벤트가 오면 유지 시간을 연장한다 — 사고가 이어지는 동안
# 경광등이 중간에 꺼졌다 켜지면 사람이 "끝났다"로 읽는다.
self._on_until = now + self.hold_s
return True
def tick(self, now: float) -> None:
"""루프가 매 틱 호출한다. 유지 시간이 지나면 해제."""
if self._on_until is not None and now >= self._on_until:
self._write(False)
self._on_until = None
def release(self) -> None:
try:
self._write(False)
finally:
close = getattr(self._backend, "close", None)
if close:
close()
class _PinBackend:
"""gpiozero / RPi.GPIO 중 있는 쪽에 얇게 붙는다."""
def __init__(self, device=None, rpi=None, pin: int = 0):
self._device = device
self._rpi = rpi
self._pin = pin
def set(self, high: bool) -> None:
if self._device is not None:
if high:
self._device.on()
else:
self._device.off()
elif self._rpi is not None:
self._rpi.output(self._pin, self._rpi.HIGH if high else self._rpi.LOW)
def close(self) -> None:
if self._device is not None:
self._device.close()
elif self._rpi is not None:
self._rpi.cleanup(self._pin)
def _open_pin(pin: int, active_high: bool): # pragma: no cover (hardware)
try:
from gpiozero import DigitalOutputDevice
# initial_value=False 는 "비활성" 을 뜻한다. active_high 해석은 _write 가 담당하므로
# 여기서는 항상 raw 출력으로 열고, 부팅 순간 경광등이 번쩍이지 않도록 초기값을 준다.
dev = DigitalOutputDevice(pin, active_high=True, initial_value=not active_high)
return _PinBackend(device=dev, pin=pin)
except Exception:
pass
try:
import RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(pin, GPIO.OUT, initial=GPIO.LOW if active_high else GPIO.HIGH)
return _PinBackend(rpi=GPIO, pin=pin)
except Exception:
raise RuntimeError("no usable GPIO backend")
def build_beacons(config: dict, log=None) -> list:
"""/api/config 의 `beacons` 배열 -> 구동 가능한 비콘 목록.
핀 하나가 열리지 않아도 나머지는 살린다. 전부 실패하면 빈 목록이고, 그때도 감지·전송은
그대로 돈다.
"""
log = log or (lambda m: None)
out = []
for b in (config or {}).get("beacons") or []:
if not isinstance(b, dict):
continue
pin = int(b.get("gpio_pin") or 0)
if pin <= 0:
continue # 0 = 미배선 (관리 화면에서 핀을 아직 안 정한 상태)
try:
out.append(GpioBeacon(
pin,
mode=str(b.get("mode") or "latch"),
hold_s=float(b.get("hold_s") or 30.0),
active_high=bool(b.get("active_high")),
min_severity=str(b.get("min_severity") or "high"),
log=log,
))
except Exception as e:
# 조용히 넘어가면 아무도 경광등이 죽은 줄 모른다.
log(f"beacon pin {pin} unavailable ({type(e).__name__}: {e}); alerts on this node "
f"will not sound locally")
return out
def fire_all(beacons, events, now: float | None = None) -> int:
"""이벤트 목록 중 가장 높은 심각도로 비콘을 울린다. @return 실제로 울린 비콘 수."""
if not beacons or not events:
return 0
now = time.monotonic() if now is None else now
worst = max((getattr(e.severity, "value", e.severity) for e in events),
key=lambda s: _SEVERITY_RANK.get(s, 0))
return sum(1 for b in beacons if b.fire(worst, now))

View file

@ -19,32 +19,12 @@ class Config:
ghost: GhostConfig ghost: GhostConfig
zone: ZoneConfig zone: ZoneConfig
engine: EngineConfig engine: EngineConfig
# Tier-2 degradation knob: run pose on every Nth tick (1 = every tick, the T1 target).
# Raise this when a device cannot sustain full-rate inference across all its cameras —
# it trades reaction granularity for keeping every camera online, which beats going blind
# on one. See docs/hardware-bringup.md.
infer_every: int = 1
def _f(env: dict, key: str, default: float) -> float: def _f(env: dict, key: str, default: float) -> float:
return float(env[key]) if key in env else default return float(env[key]) if key in env else default
def _positive(env: dict, key: str, default: float) -> float:
"""A timer/threshold knob that must be a finite positive number.
Every one of these feeds a `now - started >= limit` comparison. NaN makes that comparison
ALWAYS False and 0/negative makes it ALWAYS True, so a typo in edge.env does not crash —
it silently turns a detector off, or turns it into a permanent alarm. COOLDOWN_S=nan is the
worst case: dedup stops suppressing and every tick re-fires the same event. Reject at
startup, where the operator still sees the message, rather than in the field.
"""
val = _f(env, key, default)
if not math.isfinite(val) or val <= 0:
raise ValueError(f"{key} must be a finite positive number, got {val!r}")
return val
def frame_interval_from_env(env: dict) -> float: def frame_interval_from_env(env: dict) -> float:
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a """Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
non-numeric value (ValueError) would otherwise crash at every startup into an infinite non-numeric value (ValueError) would otherwise crash at every startup into an infinite
@ -118,20 +98,20 @@ def load_config(env: dict) -> Config:
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose # track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive # it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion). # value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
motion_thr = _positive(env, "MOTION_THR", 0.25) motion_thr = _f(env, "MOTION_THR", 0.25)
if not math.isfinite(motion_thr) or motion_thr <= 0:
raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}")
fall = FallConfig( fall = FallConfig(
prone_confirm_s=_positive(env, "FALL_PRONE_S", 3.0), prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
no_motion_s=_positive(env, "FALL_NO_MOTION_S", 10.0), no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
motion_thr=motion_thr, motion_thr=motion_thr,
) )
immobility = ImmobilityConfig(immobile_s=_positive(env, "IMMOBILE_S", 120.0), immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
motion_thr=motion_thr) motion_thr=motion_thr)
ghost = GhostConfig(reappear_s=_positive(env, "GHOST_REAPPEAR_S", 10.0)) ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_positive(env, "ZONE_DEBOUNCE_S", 0.75)) zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_positive(env, "COOLDOWN_S", 30.0)) engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))
infer_every = int(_positive(env, "INFER_EVERY", 1.0))
return Config( return Config(
infer_every=infer_every,
device_id=env.get("DEVICE_ID", "pi-unknown"), device_id=env.get("DEVICE_ID", "pi-unknown"),
camera_id=camera_id, camera_id=camera_id,
cafe24_url=env.get("CAFE24_URL", ""), cafe24_url=env.get("CAFE24_URL", ""),

View file

@ -7,14 +7,6 @@ import os
_LOG_EVERY = 300 _LOG_EVERY = 300
def _beacon_signature(beacons):
"""Identity of a beacon set, so a sync only re-opens GPIO when the wiring actually changed.
Rebuilding unconditionally would blink every strobe on every sync (every ~150 ticks)."""
return tuple(sorted((getattr(b, "pin", 0), getattr(b, "mode", ""), getattr(b, "hold_s", 0.0),
getattr(b, "active_high", False), getattr(b, "min_rank", 0))
for b in (beacons or [])))
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper) def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
import sys import sys
import traceback import traceback
@ -26,8 +18,7 @@ def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
sync_every=150, heartbeat_every=900, flush_every=300, sync_every=150, heartbeat_every=900, flush_every=300,
frame_interval=0.0, zones_for=None, log=None, frame_interval=0.0, zones_for=None, log=None,
heartbeat_interval_s=None, health_fn=None, configured_cameras=None, heartbeat_interval_s=None, health_fn=None, configured_cameras=None):
notify=None, beacons=None, build_beacons_fn=None):
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All # `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
# cameras share this loop, one shared clock read per tick, and the periodic # cameras share this loop, one shared clock read per tick, and the periodic
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged, # sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
@ -42,22 +33,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
if health_fn is None: if health_fn is None:
from edge.health import read_health from edge.health import read_health
health_fn = read_health health_fn = read_health
if notify is None: counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
from edge.watchdog import ping as notify
# 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.
# Local beacons (siren / strobe on the node's own GPIO). This is the ONE notification path
# that survives a dead uplink, so it fires here — the instant this loop sees the event —
# rather than waiting for the server-side dispatcher. Configured from /api/config, so the
# list is rebuilt on sync; None means "not built yet", [] means "built, nothing wired".
if beacons is None:
beacons = []
if build_beacons_fn is None:
from edge.beacon import build_beacons as build_beacons_fn
from edge.beacon import fire_all as _fire_beacons
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0,
"beacon_fires": 0, "tuned": 0}
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
def note(key, label, exc=None): def note(key, label, exc=None):
@ -76,10 +52,6 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
i = 0 i = 0
while iterations is None or i < iterations: while iterations is None or i < iterations:
i += 1 i += 1
try:
notify()
except Exception as e:
note("watchdog", "watchdog ping failed", e)
now = clock() # one timestamp per tick, shared across cameras now = clock() # one timestamp per tick, shared across cameras
if beat_anchor is None: if beat_anchor is None:
beat_anchor = (now, 0) beat_anchor = (now, 0)
@ -103,48 +75,12 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
else: else:
counters["frames"] += 1 counters["frames"] += 1
streak.pop(id(rt), None) # recovered — reset the streak streak.pop(id(rt), None) # recovered — reset the streak
# Sound the local beacon on this tick's events. Wrapped: a wiring fault must
# never take down the detection loop — a node that stopped watching because
# its strobe failed would be the worst possible trade.
if produced and beacons:
try:
counters["beacon_fires"] += _fire_beacons(beacons, produced, now)
except Exception as e:
note("beacon", "beacon fire failed", e)
# Release latched beacons whose hold time has elapsed.
for b in beacons:
try:
b.tick(now)
except Exception as e:
note("beacon", "beacon tick failed", e)
try: try:
if sync_every and i % sync_every == 0: if sync_every and i % sync_every == 0:
cfg = client.fetch_config() cfg = client.fetch_config()
if cfg is not None: if cfg is not None:
for rt in runtimes: for rt in runtimes:
rt.apply_zones(zones_for(cfg, rt)) rt.apply_zones(zones_for(cfg, rt))
# Thresholds too. The server has always SENT these; nothing ever applied
# them, so an administrator's retuning never reached the device.
thresholds = cfg.get("thresholds") if isinstance(cfg, dict) else None
if isinstance(thresholds, dict) and thresholds:
for rt in runtimes:
applier = getattr(rt, "apply_thresholds", None)
if applier is None:
continue
applied = applier(thresholds, log=lambda m: log(m, None))
counters["tuned"] += len(applied)
# Rebuild the local beacons from the published wiring. Doing it on sync
# (not only at boot) is what lets an administrator turn a site's strobe on
# without anyone walking to the node.
try:
rebuilt = build_beacons_fn(cfg if isinstance(cfg, dict) else {},
log=lambda m: log(m, None))
if _beacon_signature(rebuilt) != _beacon_signature(beacons):
for b in beacons:
b.release()
beacons = rebuilt
except Exception as e:
note("beacon", "beacon rebuild failed", e)
# Remember the config version so heartbeats can echo it back — the server's # Remember the config version so heartbeats can echo it back — the server's
# config-drift indicator is meaningless until the device reports what it runs. # config-drift indicator is meaningless until the device reports what it runs.
if isinstance(cfg, dict) and cfg.get("version") is not None: if isinstance(cfg, dict) and cfg.get("version") is not None:

View file

@ -1,70 +0,0 @@
from dataclasses import dataclass
from edge.tracking import Track
from edge.geometry import torso_angle_deg, arm_elevation_deg, knee_flexion_deg, core_visible
@dataclass
class ErgoConfig:
"""자세 부담(인체공학) 노출 집계. REBA의 밴드 개념을 2D 단안 관절에 정직하게 축소한
것 — 정밀 REBA 점수가 아니라 '고위험 자세에 얼마나 오래 노출됐나'를 잰다."""
report_interval_s: float = 3600.0 # 카메라당 리포트 주기(노출이 있었던 경우만)
min_conf: float = 0.5
trunk_mod_deg: float = 20.0 # REBA trunk 밴드: 20~60° 중위험
trunk_high_deg: float = 60.0 # 60°+ 고위험
arm_high_deg: float = 90.0 # 어깨 위 거상
knee_flex_deg: float = 110.0 # 이보다 작은 내부각 = 깊은 웅크림
max_frame_gap_s: float = 2.0 # 검출 공백은 노출로 세지 않는다 (dt 상한)
class ErgoMonitor:
"""트랙별 프레임 dt로 자세 밴드 노출 시간을 누적하고, 주기마다 카메라 단위의 익명
합계를 리포트로 내보낸다. 개인 식별 없음: 트랙·좌표는 합산 과정에서 소거된다.
사고 상태(FALLING/FALLEN/NO_MOTION)의 트랙은 엔진이 걸러 넘긴다 — 쓰러진 사람의
90° 몸통은 인체공학 노출이 아니라 사고다."""
def __init__(self, cfg: ErgoConfig):
self.cfg = cfg
self._period_start: float | None = None
self._acc = self._zero()
@staticmethod
def _zero() -> dict:
return {"observed_s": 0.0, "trunk_mod_s": 0.0, "trunk_high_s": 0.0,
"arm_high_s": 0.0, "squat_s": 0.0}
def update(self, track: Track, now: float) -> None:
if self._period_start is None:
self._period_start = now
if len(track.frames) < 2:
return
dt = track.latest.t - track.frames[-2].t
if dt <= 0:
return
dt = min(dt, self.cfg.max_frame_gap_s)
kps = track.latest.keypoints
# 몸통 각도는 어깨·엉덩이 4점이 신뢰될 때만 — 저신뢰 프레임의 각도는 노이즈다.
if not core_visible(kps, self.cfg.min_conf):
return
self._acc["observed_s"] += dt
trunk = torso_angle_deg(kps)
if trunk >= self.cfg.trunk_high_deg:
self._acc["trunk_high_s"] += dt
elif trunk >= self.cfg.trunk_mod_deg:
self._acc["trunk_mod_s"] += dt
arm = arm_elevation_deg(kps, self.cfg.min_conf)
if arm is not None and arm >= self.cfg.arm_high_deg:
self._acc["arm_high_s"] += dt
knee = knee_flexion_deg(kps, self.cfg.min_conf)
if knee is not None and knee < self.cfg.knee_flex_deg:
self._acc["squat_s"] += dt
def report(self, now: float) -> dict | None:
"""주기가 찼고 실제 노출 관측이 있었으면 합계를 내보내고 다음 주기를 시작한다."""
if self._period_start is None or now - self._period_start < self.cfg.report_interval_s:
return None
acc, self._acc = self._acc, self._zero()
self._period_start = now
if acc["observed_s"] <= 0:
return None # 빈 주기: 사람이 없던 시간의 리포트는 소음이다
return {k: round(v, 1) for k, v in acc.items()}

View file

@ -14,7 +14,6 @@ class FallConfig:
aspect_ratio: float = 1.25 aspect_ratio: float = 1.25
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴) height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
prone_confirm_s: float = 3.0 prone_confirm_s: float = 3.0
falling_confirm_s: float = 1.0 # STANDING→FALLING에 요구되는 지속 prone (P2-7)
no_motion_s: float = 10.0 no_motion_s: float = 10.0
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
min_conf: float = 0.5 min_conf: float = 0.5
@ -95,13 +94,7 @@ class FallDetector:
st.not_prone_since = now st.not_prone_since = now
return None, {} return None, {}
if now - st.not_prone_since >= self.cfg.recover_grace_s: if now - st.not_prone_since >= self.cfg.recover_grace_s:
# FALLING에서 확정(FALLEN) 전에 회복 = 니어미스. 사고 알람은 아니지만
# 히트맵/통계의 원료라 신호로 구분해 올린다 (엔진이 INFO로 발보).
was_near_miss = not st.fall_emitted
prone_s = (st.not_prone_since - st.prone_since) if st.prone_since is not None else 0.0
self._states[track.track_id] = _State(baseline_height=bbox.height) self._states[track.track_id] = _State(baseline_height=bbox.height)
if was_near_miss:
return "near_fall", {"prone_s": round(prone_s, 1)}
return "recover", {} return "recover", {}
return None, {} return None, {}
st.prone_since = None st.prone_since = None
@ -111,9 +104,6 @@ class FallDetector:
st.not_prone_since = None st.not_prone_since = None
if st.prone_since is None: if st.prone_since is None:
st.prone_since = now st.prone_since = now
# FALLING은 위험 상태다: 이 상태로 트랙이 사라지면 ghost가 CRITICAL을 무장한다.
# 단 한 프레임의 prone(굽힘·bbox 튐)으로 들어가지 않도록 지속 prone을 요구한다.
if st.name == "STANDING" and now - st.prone_since >= self.cfg.falling_confirm_s:
st.name = "FALLING" st.name = "FALLING"
if now - st.prone_since >= self.cfg.prone_confirm_s and not st.fall_emitted: if now - st.prone_since >= self.cfg.prone_confirm_s and not st.fall_emitted:
st.name = "FALLEN" st.name = "FALLEN"

View file

@ -1,151 +0,0 @@
"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하.
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}

View file

@ -1,95 +0,0 @@
from dataclasses import dataclass
@dataclass
class OccupancyConfig:
lone_confirm_s: float = 30.0 # 단독 작업이 이 시간 지속돼야 발보 (동료의 잠깐 이탈 흡수)
over_confirm_s: float = 10.0 # 정원 초과 지속 요구 (경계 통과 순간의 겹침 흡수)
dwell_absence_grace_s: float = 5.0 # 이 시간 안의 미검출은 체류 연속으로 간주 (검출 드롭아웃 흡수)
class OccupancyWatchdog:
"""존 규칙(2인 1조 · 정원 · 체류 상한) 감시.
존 멤버십은 ZoneIntrusionDetector가 이미 계산한 것을 엔진이 존별 인원으로 뒤집어
넘겨준다 — 여기서는 기하 계산이 없다. 규칙은 Zone.rules(대시보드 존 편집기에서
설정, /api/config로 동기)에서 읽으므로 존 목록 교체(apply_zones)에 자동으로 따라간다.
발보는 조건이 해소될 때까지 존(또는 트랙·존)당 1회 — 재발보는 엔진 쿨다운이 아니라
조건 해소 후 재충족으로만 일어난다.
"""
def __init__(self, cfg: OccupancyConfig):
self.cfg = cfg
self._lone_since: dict[str, float] = {}
self._lone_fired: set[str] = set()
self._over_since: dict[str, float] = {}
self._over_fired: set[str] = set()
self._dwell: dict[tuple[int, str], list] = {} # (tid, zone_id) -> [since, last_seen]
self._dwell_fired: set[tuple[int, str]] = set()
def forget(self, track_id: int) -> None:
self._dwell = {k: v for k, v in self._dwell.items() if k[0] != track_id}
self._dwell_fired = {k for k in self._dwell_fired if k[0] != track_id}
def update(self, zones: list, zone_tracks: dict, now: float) -> list[dict]:
out: list[dict] = []
known: set[str] = set()
for zone in zones:
zid = zone.zone_id
known.add(zid)
rules = zone.rules or {}
tids = zone_tracks.get(zid, set())
n = len(tids)
# 2인 1조: 0 < 인원 < 최소 인원이 지속되면 발보. 빈 존은 위반이 아니다.
min_workers = rules.get("min_workers")
if min_workers and 0 < n < min_workers:
start = self._lone_since.setdefault(zid, now)
if now - start >= self.cfg.lone_confirm_s and zid not in self._lone_fired:
self._lone_fired.add(zid)
out.append({"kind": "lone_worker", "zone_id": zid,
"track_id": next(iter(tids)) if n == 1 else None,
"metrics": {"workers": n, "required": min_workers,
"alone_s": round(now - start, 1)}})
else:
self._lone_since.pop(zid, None)
self._lone_fired.discard(zid)
# 정원 초과
max_workers = rules.get("max_workers")
if max_workers and n > max_workers:
start = self._over_since.setdefault(zid, now)
if now - start >= self.cfg.over_confirm_s and zid not in self._over_fired:
self._over_fired.add(zid)
out.append({"kind": "overcapacity", "zone_id": zid, "track_id": None,
"metrics": {"workers": n, "limit": max_workers}})
else:
self._over_since.pop(zid, None)
self._over_fired.discard(zid)
# 체류 상한 (트랙별 연속 체류). 미검출 1~2틱(run_once의 stale 필터)으로 타이머가
# 리셋되면 10분 규칙이 사실상 무력화되므로, grace 안의 부재는 연속으로 간주한다.
max_dwell = rules.get("max_dwell_s")
if max_dwell:
for tid in tids:
rec = self._dwell.setdefault((tid, zid), [now, now])
rec[1] = now
if now - rec[0] >= max_dwell and (tid, zid) not in self._dwell_fired:
self._dwell_fired.add((tid, zid))
out.append({"kind": "overstay", "zone_id": zid, "track_id": tid,
"metrics": {"dwell_s": round(now - rec[0], 1),
"limit_s": max_dwell}})
# grace를 넘겨 존을 떠난(또는 트랙이 죽은) 체류 기록 정리 — 재진입은 새 체류다.
for key, rec in list(self._dwell.items()):
if now - rec[1] > self.cfg.dwell_absence_grace_s:
self._dwell.pop(key)
self._dwell_fired.discard(key)
# 삭제된 존의 잔여 상태 정리 (24/7 장치 메모리 누수 방지).
for d, fired in ((self._lone_since, self._lone_fired), (self._over_since, self._over_fired)):
for zid in list(d):
if zid not in known:
d.pop(zid, None)
fired.intersection_update(known)
return out

View file

@ -1,103 +0,0 @@
"""모델 대기 감지기 — 보호구 미착용 · 중장비 근접 · 화재/연기.
STATUS: 배선 완료, 모델 없음. 기본 비활성이며 이 상태에서는 어떤 이벤트도 발화하지 않는다.
왜 모델 없이 이 파일이 있는가
이 세 가지는 현장에서 가장 자주 요구되는 항목이고(특히 보호구), 나중에 급하게 끼워
넣으면 이벤트 계약·심각도·쿨다운·스냅샷 정책을 그때그때 즉흥적으로 정하게 된다. 계약을
지금 고정해 두면 모델이 확보되는 날 바꿀 것은 `Model` 구현 하나뿐이다.
무엇이 막고 있는가 (정직하게)
ultralytics(YOLO) 계열은 AGPL 이라 이 프로젝트에서 사용 금지다(CLAUDE.md). 따라서
- 보호구/중장비: AGPL 이 아닌 라이선스의 가중치 + Hailo HEF 컴파일이 선행되어야 한다.
- 화재/연기: 위와 같거나, 열화상 센서 같은 다른 감지 방식을 택해야 한다.
확보 전까지 `enabled=False` 이며, 켜더라도 `model=None` 이면 아무 일도 하지 않는다.
"곧 됩니다" 상태로 켜 두는 것이 가장 위험하다 — 현장은 감지되고 있다고 믿는다.
모델 인터페이스 (구현체가 만족해야 하는 계약)
detect(frame, persons) -> list[dict]
각 dict: {"kind": "ppe_missing"|"proximity"|"fire_smoke",
"confidence": float 0..1,
"track_id": int | None,
"spatial": (x, y) | None, # 0..1 정규화
"metrics": dict} # 유형별 부가 수치
프레임당 한 번 호출되며, 예외를 던지면 그 틱만 건너뛴다(파이프라인은 계속 돈다).
"""
from dataclasses import dataclass, field
_KINDS = ("ppe_missing", "proximity", "fire_smoke")
@dataclass
class PendingConfig:
# 기본 False. 모델이 없는 채로 켜면 "감지되고 있다"는 잘못된 믿음만 만든다.
enabled: bool = False
# 유형별 최소 신뢰도. 보호구는 오탐이 곧 현장 반발이라 높게 잡는다.
min_confidence: dict = field(default_factory=lambda: {
"ppe_missing": 0.80, "proximity": 0.70, "fire_smoke": 0.85,
})
# 유형별 재발행 억제(초). 엔진 쿨다운과 별개의 2차 방어 — 보호구 미착용은 한 사람이
# 한 시간을 그러고 있어도 사건 하나다.
reemit_s: dict = field(default_factory=lambda: {
"ppe_missing": 600.0, "proximity": 30.0, "fire_smoke": 60.0,
})
class PendingDetectors:
"""모델이 주어지면 그 출력을 이벤트 후보로 정규화한다. 없으면 아무것도 하지 않는다."""
def __init__(self, cfg: PendingConfig | None = None, model=None, log=None):
self.cfg = cfg or PendingConfig()
self.model = model
self._log = log or (lambda m: None)
self._last: dict[tuple, float] = {}
@property
def active(self) -> bool:
"""켜져 있고 실제로 판정할 수단이 있는가. 대시보드/기동 로그가 이것을 보고한다."""
return bool(self.cfg.enabled and self.model is not None)
def _allow(self, kind: str, track_id, now: float) -> bool:
key = (kind, track_id)
last = self._last.get(key)
if last is not None and now - last < float(self.cfg.reemit_s.get(kind, 60.0)):
return False
self._last[key] = now
return True
def forget(self, track_id: int) -> None:
self._last = {k: v for k, v in self._last.items() if k[1] != track_id}
def observe(self, frame, persons, now: float) -> list[tuple[str, dict, object, object]]:
"""@return [(event_type, metrics, track_id, spatial), ...] — 보통 빈 리스트.
모델이 없거나 비활성이면 항상 빈 리스트다. 그것이 정상 상태다.
"""
if not self.active:
return []
try:
raw = self.model.detect(frame, persons) or []
except Exception as e:
# 한 틱의 추론 실패로 넘어짐 감지까지 멈추면 안 된다.
self._log(f"pending detector model failed: {type(e).__name__}: {e}")
return []
out = []
for d in raw:
if not isinstance(d, dict):
continue
kind = d.get("kind")
if kind not in _KINDS:
continue
conf = float(d.get("confidence") or 0.0)
if conf < float(self.cfg.min_confidence.get(kind, 1.0)):
continue
track_id = d.get("track_id")
if not self._allow(kind, track_id, now):
continue
metrics = dict(d.get("metrics") or {})
metrics["confidence"] = round(conf, 3)
out.append((kind, metrics, track_id, d.get("spatial")))
return out

View file

@ -25,9 +25,6 @@ class Zone:
zone_id: str zone_id: str
level: str # "warning" | "restricted" level: str # "warning" | "restricted"
polygon: list # list[tuple[float,float]] polygon: list # list[tuple[float,float]]
# 선택적 존 규칙 (occupancy.py가 소비): {"min_workers": 2, "max_workers": 4,
# "max_dwell_s": 600} — 키가 없으면 그 규칙은 꺼진 것. None = 규칙 없음.
rules: dict | None = None
@dataclass @dataclass

View file

@ -6,8 +6,6 @@ from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
@dataclass @dataclass
@ -16,23 +14,14 @@ class EngineConfig:
cooldown_s: float = 30.0 cooldown_s: float = 30.0
_OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
"overcapacity": EventType.OVERCAPACITY,
"overstay": EventType.OVERSTAY}
class EventEngine: class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None, cfg: EngineConfig):
ergonomics: ErgoMonitor | None = None):
self.fall = fall self.fall = fall
self.immobility = immobility self.immobility = immobility
self.ghost = ghost self.ghost = ghost
self.zone = zone self.zone = zone
# Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working.
self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig())
self.ergonomics = ergonomics if ergonomics is not None else ErgoMonitor(ErgoConfig())
self.cfg = cfg self.cfg = cfg
self._last_emit: dict[tuple, float] = {} self._last_emit: dict[tuple, float] = {}
@ -48,15 +37,13 @@ class EventEngine:
self.fall.forget(track_id) self.fall.forget(track_id)
self.immobility.forget(track_id) self.immobility.forget(track_id)
self.zone.forget(track_id) self.zone.forget(track_id)
self.occupancy.forget(track_id)
self._last_emit = {k: v for k, v in self._last_emit.items() self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)} if not (len(k) >= 2 and k[1] == track_id)}
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None, def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
metrics=None, spatial=None) -> Event: metrics=None, spatial=None) -> Event:
m = metrics or {} m = metrics or {}
duration_s = m.get("immobility_s") or m.get("waited_s") or m.get("dwell_s") \ duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0
or m.get("alone_s") or 0.0
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id, return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
zone_id, occurred_at, duration_s, m, spatial) zone_id, occurred_at, duration_s, m, spatial)
@ -76,32 +63,18 @@ class EventEngine:
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size. # space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh) zone_hits = self.zone.update(tr, now, frame_wh)
in_zone = bool(self.zone.active_zone_ids(tr.track_id)) in_zone = bool(self.zone.active_zone_ids(tr.track_id))
# 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미
# 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다.
foot = self.zone._foot(tr, frame_wh)
sig, meta = self.fall.update(tr, now) sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id) state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING") self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
# 자세 부담 노출은 사고 상태가 아닌 트랙만 — 쓰러진 사람의 90° 몸통은
# 인체공학 노출이 아니라 이미 사고로 처리 중인 상태다.
if state is None or state.name == "STANDING":
self.ergonomics.update(tr, now)
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now): if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, metrics=meta))
metrics=meta, spatial=foot))
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now): elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta))
metrics=meta, spatial=foot))
elif sig == "near_fall" and self._allow((EventType.NEAR_FALL, tr.track_id), now):
# 니어미스(FALLING까지 갔다 확정 전 회복): 알람용이 아니라 히트맵/통계 재료.
events.append(self._mk(EventType.NEAR_FALL, occurred_at, tr.track_id,
metrics=meta, spatial=foot))
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone) fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now): if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, metrics=imeta))
metrics=imeta, spatial=foot))
for hit in zone_hits: for hit in zone_hits:
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit # 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
@ -111,30 +84,7 @@ class EventEngine:
"restricted": EventType.ZONE_RESTRICTED}.get( "restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING) hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now): if self._allow((etype, tr.track_id, hit["zone_id"]), now):
events.append(self._mk(etype, occurred_at, tr.track_id, events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
zone_id=hit["zone_id"], spatial=foot))
# 존 규칙(2인 1조 · 정원 · 체류): 위 per-track 루프에서 zone.update가 갱신한 멤버십을
# 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로
# 여기 인원수는 이번 틱에 실제로 검출된 사람만 센다.
zone_tracks: dict[str, set[int]] = {}
for tr in active_tracks:
for zid in self.zone.active_zone_ids(tr.track_id):
zone_tracks.setdefault(zid, set()).add(tr.track_id)
for o in self.occupancy.update(self.zone.zones, zone_tracks, now):
etype = _OCCUPANCY_TYPES[o["kind"]]
# 쿨다운 키에 zone_id 포함: 존 A의 위반이 존 B의 발보를 막지 않는다. track_id가
# None인 존 단위 이벤트의 키는 forget()이 지우지 못하지만, 존 수에 비례하는
# 소량이라 누수로 보지 않는다 (ghost와 달리 키 공간이 유한).
if self._allow((etype, o["track_id"], o["zone_id"]), now):
events.append(self._mk(etype, occurred_at, track_id=o["track_id"],
zone_id=o["zone_id"], metrics=o["metrics"]))
# 자세 부담 주기 리포트 — 간격이 자체 게이트라 쿨다운(_allow)을 타지 않는다.
# 카메라 단위 익명 합계: track_id·spatial 없음 (개인 좌표를 남기지 않는다).
ergo = self.ergonomics.report(now)
if ergo is not None:
events.append(self._mk(EventType.ERGO_REPORT, occurred_at, metrics=ergo))
for g in self.ghost.update(active_tracks, now): for g in self.ghost.update(active_tracks, now):
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it # No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it
@ -142,16 +92,8 @@ class EventEngine:
# Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for # Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for
# a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that # a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that
# forget() can never reclaim (the track never appears in lost_tracks again). # forget() can never reclaim (the track never appears in lost_tracks again).
# Ghost's loss point is camera-native pixels (bbox centre at loss time). Normalize to
# the same 0..1 space every other event's spatial uses so the heatmap can overlay them;
# frame_wh=None (unit tests) keeps raw coords, matching the zone detector's convention.
gs = g["spatial"]
if frame_wh and gs is not None:
w, h = frame_wh
if w > 0 and h > 0:
gs = (gs[0] / w, gs[1] / h)
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"], events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
spatial=gs, spatial=g["spatial"],
metrics={"last_state": g["last_state"], metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]})) "waited_s": g["waited_s"]}))

View file

@ -13,24 +13,6 @@ class EventType(str, Enum):
PINCH = "pinch" PINCH = "pinch"
MONITORING_DEGRADED = "monitoring_degraded" MONITORING_DEGRADED = "monitoring_degraded"
DEVICE_FAULT = "device_fault" DEVICE_FAULT = "device_fault"
LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속
OVERCAPACITY = "overcapacity" # 존 정원 초과 지속
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): class Severity(str, Enum):
@ -50,22 +32,6 @@ _SEVERITY = {
EventType.ZONE_WARNING: Severity.WARNING, EventType.ZONE_WARNING: Severity.WARNING,
EventType.MONITORING_DEGRADED: Severity.WARNING, EventType.MONITORING_DEGRADED: Severity.WARNING,
EventType.DEVICE_FAULT: Severity.WARNING, EventType.DEVICE_FAULT: Severity.WARNING,
# 단독 작업은 규정 위반이자 사고 시 구조 지연 요인 — 정원/체류보다 한 단계 높게.
EventType.LONE_WORKER: Severity.HIGH,
EventType.OVERCAPACITY: Severity.WARNING,
EventType.OVERSTAY: Severity.WARNING,
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 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,
} }
@ -90,7 +56,7 @@ class Event:
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex) event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
def to_payload(self, device_id: str) -> dict: def to_payload(self, device_id: str) -> dict:
payload = { return {
"device_id": device_id, "device_id": device_id,
"event_uuid": self.event_uuid, "event_uuid": self.event_uuid,
"camera_id": self.camera_id, "camera_id": self.camera_id,
@ -102,8 +68,3 @@ class Event:
"duration_s": round(self.duration_s, 2), "duration_s": round(self.duration_s, 2),
"metrics": self.metrics, "metrics": self.metrics,
} }
# 정규화 발/기준 좌표(0..1) — 서버 히트맵의 원료. None이면 키 자체를 생략해
# 구서버(스키마에 spatial 없음)와의 하위호환을 지킨다.
if self.spatial is not None:
payload["spatial"] = [round(float(self.spatial[0]), 4), round(float(self.spatial[1]), 4)]
return payload

View file

@ -1,8 +1,7 @@
import math import math
from edge.models import ( from edge.models import (
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_ELBOW, R_ELBOW, Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS,
L_HIP, R_HIP, L_KNEE, R_KNEE, L_ANKLE, R_ANKLE, CORE_JOINTS,
) )
@ -46,44 +45,6 @@ def head_below_hips(keypoints: list[Keypoint], nose_thr: float = 0.2) -> bool:
return nose.y > hy return nose.y > hy
def arm_elevation_deg(keypoints: list[Keypoint], min_conf: float = 0.5) -> float | None:
"""어깨→팔꿈치 벡터가 '아래 수직'과 이루는 각의 좌우 최대값. 0=팔 내림, 90=수평 거상,
180=머리 위. 신뢰 가능한 쪽 팔만 사용; 양쪽 다 불가하면 None (측정 불가 ≠ 0°)."""
best = None
for s_i, e_i in ((L_SHOULDER, L_ELBOW), (R_SHOULDER, R_ELBOW)):
s, e = keypoints[s_i], keypoints[e_i]
if s.conf < min_conf or e.conf < min_conf:
continue
vx, vy = e.x - s.x, e.y - s.y
if not (vx or vy):
continue
# 이미지 y축은 아래로 증가: (0, +1)이 '팔 내림' 기준축.
ang = math.degrees(math.atan2(abs(vx), vy)) if vy >= 0 else \
180.0 - math.degrees(math.atan2(abs(vx), -vy))
best = ang if best is None else max(best, ang)
return best
def knee_flexion_deg(keypoints: list[Keypoint], min_conf: float = 0.5) -> float | None:
"""무릎 관절의 내부 각(엉덩이-무릎-발목)의 좌우 최소값. 180=곧게 섬, 작을수록 깊은
굴곡(웅크림). 신뢰 가능한 쪽 다리만 사용; 양쪽 다 불가하면 None."""
best = None
for h_i, k_i, a_i in ((L_HIP, L_KNEE, L_ANKLE), (R_HIP, R_KNEE, R_ANKLE)):
h, k, a = keypoints[h_i], keypoints[k_i], keypoints[a_i]
if h.conf < min_conf or k.conf < min_conf or a.conf < min_conf:
continue
v1 = (h.x - k.x, h.y - k.y)
v2 = (a.x - k.x, a.y - k.y)
n1 = math.hypot(*v1)
n2 = math.hypot(*v2)
if not n1 or not n2:
continue
cos = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
ang = math.degrees(math.acos(cos))
best = ang if best is None else min(best, ang)
return best
def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int: def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int:
return sum(1 for k in keypoints if k.conf >= thr) return sum(1 for k in keypoints if k.conf >= thr)

View file

@ -8,17 +8,10 @@ from edge.events import Event, EventType, Severity
class OfflineQueue: class OfflineQueue:
def __init__(self, db_path: str, client, max_rows: int = 2000, def __init__(self, db_path: str, client, max_rows: int = 2000,
max_send_attempts: int = 20, max_flush_rows: int = 20): max_send_attempts: int = 20):
self.client = client self.client = client
self.max_rows = max_rows # SD-card guard: evict oldest beyond this self.max_rows = max_rows # SD-card guard: evict oldest beyond this
self.max_send_attempts = max_send_attempts # drop a poison event after this many tries self.max_send_attempts = max_send_attempts # drop a poison event after this many tries
# Detection-gap guard. flush() runs INSIDE the single-threaded detection loop, so every
# row it sends is time the camera is not being processed. Draining a full max_rows queue
# in one call (2000 rows x up to 5s POST timeout) would stall fall detection for minutes
# right after an outage recovers — exactly when the backlog proves the site was
# unmonitored. Drain a slice per tick instead: the queue still empties, but the loop keeps
# detecting between slices.
self.max_flush_rows = max_flush_rows
self.db = sqlite3.connect(db_path) self.db = sqlite3.connect(db_path)
self.db.execute( self.db.execute(
"CREATE TABLE IF NOT EXISTS queue (" "CREATE TABLE IF NOT EXISTS queue ("
@ -67,11 +60,8 @@ class OfflineQueue:
return False return False
def flush(self) -> int: def flush(self) -> int:
# LIMIT bounds BOTH the stall above and peak memory: each row carries up to 3 base64
# JPEGs, so fetching the whole queue would also spike RSS on the Pi.
rows = self.db.execute( rows = self.db.execute(
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id LIMIT ?", "SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
(self.max_flush_rows,)).fetchall()
sent = 0 sent = 0
for rid, payload_json, snaps_json, attempts in rows: for rid, payload_json, snaps_json, attempts in rows:
try: try:

View file

@ -5,10 +5,8 @@ from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig
from edge.engine import EventEngine from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.tuning import apply_thresholds
class PipelineRuntime: class PipelineRuntime:
@ -18,12 +16,6 @@ class PipelineRuntime:
self.source = source self.source = source
self.pose = pose self.pose = pose
self.sink = sink self.sink = sink
# Tier-2 fallback (hardware-bringup.md): when the device cannot sustain full-rate pose
# for every camera, infer on every Nth tick instead of dropping cameras. Detector timers
# are wall/monotonic-based, not frame-count-based, so they stay correct at a lower
# sampling rate — only the reaction granularity coarsens.
self.infer_every = max(1, int(getattr(config, "infer_every", 1)))
self._tick = 0
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None # Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests. # off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
self._wall = wall_clock self._wall = wall_clock
@ -39,35 +31,11 @@ class PipelineRuntime:
) )
self.ring = RingBuffer() self.ring = RingBuffer()
self.snapshotter = Snapshotter(FaceBlurrer()) self.snapshotter = Snapshotter(FaceBlurrer())
# Watches the frame itself rather than the people in it: a knocked-askew or covered
# camera keeps delivering healthy frames while every zone drawn on it is now wrong.
self.integrity = CameraIntegrityWatch(
getattr(config, "integrity", None) or IntegrityConfig())
def step(self, now: float) -> list: def step(self, now: float) -> list:
self._tick += 1
if self.infer_every > 1 and self._tick % self.infer_every != 0:
# Drain-only tick. Pull the frame so the v4l2/CSI buffer stays current — a backed-up
# buffer hands back seconds-old frames and delays every alert — then DISCARD it.
# It must NOT reach the ring buffer: head boxes come from inference, so an
# un-analyzed frame carries no blur regions and would ship an identifiable face in
# the evidence timeline (PIPA). Returning [] (not None) keeps this a healthy tick
# for the daemon's camera-health accounting; a dead camera still reports None below.
ok, _ = self.source.read()
return [] if ok else None
return run_once(self.source, self.pose, self.tracker, self.pool, return run_once(self.source, self.pose, self.tracker, self.pool,
self.engine, self.ring, self.snapshotter, self.sink, self.engine, self.ring, self.snapshotter, self.sink,
now_fn=lambda: now, wall_fn=self._wall, now_fn=lambda: now, wall_fn=self._wall)
integrity=self.integrity)
def apply_zones(self, zones: list) -> None: def apply_zones(self, zones: list) -> None:
self.zone_detector.zones = list(zones) self.zone_detector.zones = list(zones)
def apply_thresholds(self, thresholds: dict, log=None) -> dict:
"""Push server-published tunables into the live detectors.
Until this existed the server stored thresholds, /api/config delivered them and the edge
parsed them into a dict it then threw away — the two ends of the wire were built and the
middle was missing, so nothing an administrator changed ever reached a device.
"""
return apply_thresholds(self, thresholds, log=log)

View file

@ -7,34 +7,15 @@ from edge.models import BBox
class RingBuffer: class RingBuffer:
def __init__(self, maxlen: int = 90): def __init__(self, maxlen: int = 90):
self.frames: deque = deque(maxlen=maxlen) # (t, frame, head_boxes) self.frames: deque = deque(maxlen=maxlen)
def push(self, t: float, frame, head_boxes: list | None = None) -> None: def push(self, t: float, frame) -> None:
# head_boxes ride along per frame: a PAST frame must be blurred with the head self.frames.append((t, frame))
# positions of ITS OWN moment — blurring history with the current tick's boxes
# would leave yesterday's face exactly where it actually was (PIPA).
self.frames.append((t, frame, head_boxes or []))
def window(self, pre: int, post: int, index: int) -> list: def window(self, pre: int, post: int, index: int) -> list:
lo = max(0, index - pre) lo = max(0, index - pre)
hi = min(len(self.frames), index + post + 1) hi = min(len(self.frames), index + post + 1)
return [f for (_t, f, _b) in list(self.frames)[lo:hi]] return [f for (_t, f) in list(self.frames)[lo:hi]]
def timeline(self, now: float, offsets_s: tuple) -> tuple[list, list]:
"""각 목표 시각(now - offset)에 가장 가까운 프레임을 골라 (frames, head_boxes)를
과거→현재 순으로 반환. 버퍼가 짧으면(기동 직후) 있는 만큼만 — 같은 프레임이 여러
목표에 걸리면 한 번만 들어간다."""
items = list(self.frames)
if not items:
return [], []
idxs: list[int] = []
for off in sorted(offsets_s, reverse=True):
target = now - off
i = min(range(len(items)), key=lambda k: abs(items[k][0] - target))
if i not in idxs:
idxs.append(i)
idxs.sort()
return [items[i][1] for i in idxs], [items[i][2] for i in idxs]
class FaceBlurrer: class FaceBlurrer:

View file

@ -40,46 +40,24 @@ class IouTracker:
self._tracks: dict[int, Track] = {} self._tracks: dict[int, Track] = {}
self._last_seen: dict[int, float] = {} self._last_seen: dict[int, float] = {}
self._lost: list[Track] = [] self._lost: list[Track] = []
self._ambiguous: set[int] = set()
self._next_id = 1 self._next_id = 1
def update(self, persons: list[Person], t: float) -> list[Track]: def update(self, persons: list[Person], t: float) -> list[Track]:
self._lost = [] self._lost = []
self._ambiguous = set() unmatched = list(persons)
# 전역 그리디: 모든 (트랙, 사람) 쌍을 IoU 내림차순으로 배정. 트랙 순회 순서 우선의 # 기존 트랙과 그리디 IoU 매칭
# 로컬 그리디는 교차 시 먼저 온 트랙이 남의 박스를 가로채(0.48이 0.74를 이김) FALLEN for tid, track in list(self._tracks.items()):
# 상태·frozen baseline이 다른 사람에게 넘어갔다. best, best_iou = None, self.iou_thr
pairs = [] for p in unmatched:
track_cands: dict[int, int] = {}
person_cands: dict[int, int] = {}
for tid, track in self._tracks.items():
for i, p in enumerate(persons):
iou = _iou(track.latest.bbox, p.bbox) iou = _iou(track.latest.bbox, p.bbox)
if iou >= self.iou_thr: if iou >= best_iou:
pairs.append((iou, tid, i)) best, best_iou = p, iou
track_cands[tid] = track_cands.get(tid, 0) + 1 if best is not None:
person_cands[i] = person_cands.get(i, 0) + 1 unmatched.remove(best)
pairs.sort(key=lambda pr: pr[0], reverse=True) track.frames.append(TrackFrame(t, best.bbox, best.keypoints))
used_tracks: set[int] = set() self._last_seen[tid] = t
used_persons: set[int] = set()
for _iou_v, tid, i in pairs:
if tid in used_tracks or i in used_persons:
continue
used_tracks.add(tid)
used_persons.add(i)
p = persons[i]
self._tracks[tid].frames.append(TrackFrame(t, p.bbox, p.keypoints))
self._last_seen[tid] = t
# 경합 = 저신뢰 매칭. 한 사람이 두 트랙과, 또는 한 트랙이 두 사람과 임계 이상 겹치면
# (교차) 어느 배정이든 스왑일 수 있다 — 연루된 모든 트랙을 표시해 호출자가 트랙별
# 안전 상태(FALLEN·baseline·부동 타이머)를 리셋하게 한다.
for _iou_v, tid, i in pairs:
if track_cands[tid] > 1 or person_cands[i] > 1:
self._ambiguous.add(tid)
# 신규 트랙 # 신규 트랙
for i, p in enumerate(persons): for p in unmatched:
if i in used_persons:
continue
tid = self._next_id tid = self._next_id
self._next_id += 1 self._next_id += 1
self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)], self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)],
@ -94,7 +72,3 @@ class IouTracker:
def lost(self) -> list[Track]: def lost(self) -> list[Track]:
return list(self._lost) return list(self._lost)
def ambiguous(self) -> set[int]:
"""직전 update에서 겹침 경합에 연루된(매칭이 스왑일 수 있는) 트랙 id."""
return set(self._ambiguous)

View file

@ -1,101 +0,0 @@
"""서버에서 내려온 임계값을 살아 있는 감지기에 적용한다.
WHAT WAS BROKEN
`edge/zones.py:thresholds_from_config()` 는 정의만 되어 있고 어디에서도 호출되지 않았다.
서버에는 `move_thresholds` 표가 있고, `GET /api/config` 가 그것을 성실히 실어 보내고,
엣지는 그 값을 파싱한 뒤 그대로 버렸다. 즉 배선의 양 끝이 다 있는데 가운데가 비어 있어서,
관리자가 무엇을 바꾸든 장비는 부팅 시 edge.env 값으로 영원히 돌았다. 이 파일이 그 가운데다.
WHY VALIDATE HERE TOO
서버의 ThresholdCatalog 가 이미 범위를 강제하지만, 그것은 *편집 화면* 의 방어선이다.
DB 를 직접 고친 값, 구버전 서버, 손상된 응답은 여전히 도달할 수 있고, `now - t >= limit`
비교에 NaN 이 들어가면 감지기가 조용히 꺼지고 0 이 들어가면 영구 알람이 된다
(edge/config.py `_positive` 의 주석과 같은 이유). 기동 시점의 검증은 여기서 반복된다.
WHY NOT CRASH
기동 검증은 잘못된 값에 예외를 던진다 — 그때는 운영자가 화면 앞에 있다. 여기는 24/7 로
도는 동기화 경로다. 서버가 보낸 한 값이 이상하다고 공장 안 장비를 재시작 루프에 빠뜨리면
그 카메라는 그 순간부터 아무것도 못 본다. 나쁜 값은 무시하고 로그로 남기고, 나머지는
적용한다.
"""
import math
# 서버 키 -> (대상 객체 경로, 속성명). 하나의 키가 두 곳에 적용되는 경우도 있다
# (motion_thr 은 fall 과 immobility 가 공유한다 — edge/config.py 가 같은 값을 둘에 넣는다).
_TARGETS: dict[str, tuple[tuple[str, str], ...]] = {
"fall_prone_s": (("fall", "prone_confirm_s"),),
"fall_no_motion_s": (("fall", "no_motion_s"),),
"immobile_s": (("immobility", "immobile_s"),),
"ghost_reappear_s": (("ghost", "reappear_s"),),
"zone_debounce_s": (("zone", "debounce_s"),),
"cooldown_s": (("engine", "cooldown_s"),),
"motion_thr": (("fall", "motion_thr"), ("immobility", "motion_thr")),
}
# 정수 노브는 별도 취급: 감지기 설정이 아니라 런타임 자체의 샘플링 주기다.
_INT_KNOBS = {"infer_every"}
def _clean(raw) -> float | None:
"""유한 양수만 통과. 그 외에는 None (호출부가 로그하고 무시)."""
try:
v = float(raw)
except (TypeError, ValueError):
return None
if not math.isfinite(v) or v <= 0:
return None
return v
def apply_thresholds(runtime, thresholds: dict, log=None) -> dict:
"""`runtime` 의 감지기 설정에 서버 임계값을 적용한다.
@return 실제로 적용된 {key: value} — 호출부가 로그/테스트에 쓴다.
"""
log = log or (lambda m: None)
if not isinstance(thresholds, dict) or not thresholds:
return {}
engine = getattr(runtime, "engine", None)
if engine is None:
return {}
# 감지기별 설정 객체. 이름이 다른 구현이 오더라도 없는 것은 조용히 건너뛴다.
holders = {
"fall": getattr(getattr(engine, "fall", None), "cfg", None),
"immobility": getattr(getattr(engine, "immobility", None), "cfg", None),
"ghost": getattr(getattr(engine, "ghost", None), "cfg", None),
"zone": getattr(getattr(engine, "zone", None), "cfg", None),
"engine": getattr(engine, "cfg", None),
}
applied: dict[str, float] = {}
for key, raw in thresholds.items():
if key in _INT_KNOBS:
v = _clean(raw)
if v is None or v != int(v):
log(f"threshold {key}={raw!r} ignored (must be a positive integer)")
continue
# infer_every 는 런타임의 샘플링 주기. 상한을 두는 이유: 서버가 실수로 1000 을
# 보내면 그 카메라는 사실상 감시를 멈춘다.
runtime.infer_every = max(1, min(10, int(v)))
applied[key] = runtime.infer_every
continue
targets = _TARGETS.get(key)
if targets is None:
# 알 수 없는 키는 무시한다. 서버가 신버전이고 엣지가 구버전인 정상 상황이다.
continue
v = _clean(raw)
if v is None:
log(f"threshold {key}={raw!r} ignored (must be a finite positive number)")
continue
for holder_name, attr in targets:
holder = holders.get(holder_name)
if holder is None or not hasattr(holder, attr):
continue
setattr(holder, attr, v)
applied[key] = v
return applied

View file

@ -1,48 +0,0 @@
"""systemd watchdog ping (sd_notify), stdlib-only.
Restart=always only catches a process that DIES. It cannot catch a process that is alive but
stuck — a v4l2 read blocking on a wedged USB controller, a HailoRT call that never returns, a
sqlite lock. In that state the daemon keeps its PID, stops detecting, and the only signal is the
dashboard turning the device offline 120s later: an unattended site then waits for a human to
notice. WatchdogSec + this ping turns that into an automatic restart.
No pysystemd dependency (the air-gapped bundle carries only wheels we vendor): the protocol is
one datagram to $NOTIFY_SOCKET.
"""
import os
import socket
def sd_notify(state: str) -> bool:
"""Send a status datagram to systemd. Returns False when not running under systemd
(NOTIFY_SOCKET unset) or on any socket error — never raises, because a telemetry failure
must not take down detection."""
addr = os.environ.get("NOTIFY_SOCKET")
if not addr:
return False
# AF_UNIX does not exist on Windows, where developers run the test suite. Guard rather than
# let an AttributeError escape a function whose whole contract is "never raises".
if not hasattr(socket, "AF_UNIX"):
return False
# Abstract namespace sockets start with '@' on Linux; the kernel expects a NUL byte.
if addr.startswith("@"):
addr = "\0" + addr[1:]
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
sock.connect(addr)
sock.sendall(state.encode())
return True
except OSError:
return False
def ping() -> bool:
"""One 'I am still looping' keepalive."""
return sd_notify("WATCHDOG=1")
def ready() -> bool:
"""Announce startup completion. Harmless under Type=exec; required if the unit ever
switches to Type=notify."""
return sd_notify("READY=1")

View file

@ -17,30 +17,6 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon) return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon)
_RULE_KEYS = ("min_workers", "max_workers", "max_dwell_s")
def _rules_of(row: dict) -> dict | None:
"""Optional occupancy rules from the zone row's rules_json. A malformed rules blob must
not kill the zone's INTRUSION detection (the polygon is still valid) — raise here and the
caller keeps the zone but logs the rules loudly as inactive."""
raw = row.get("rules_json")
if raw in (None, "", "null"):
return None
data = json.loads(raw) if isinstance(raw, str) else raw
if not isinstance(data, dict):
raise ValueError(f"rules_json is not an object: {data!r}")
rules = {}
for key in _RULE_KEYS:
if data.get(key) is None:
continue
v = int(data[key])
if v <= 0:
raise ValueError(f"{key} must be positive, got {v}")
rules[key] = v
return rules or None
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
"""Parse server config zone rows into Zone objects. When camera_id is given, only """Parse server config zone rows into Zone objects. When camera_id is given, only
rows for that camera are kept (multi-camera routing); None keeps all rows.""" rows for that camera are kept (multi-camera routing); None keeps all rows."""
@ -64,16 +40,7 @@ def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
"the dashboard so it is stored normalized (0..1).", "the dashboard so it is stored normalized (0..1).",
row.get("zone_id"), row.get("camera_id"), polygon[:2]) row.get("zone_id"), row.get("camera_id"), polygon[:2])
continue continue
try: out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
rules = _rules_of(row)
except (ValueError, TypeError, json.JSONDecodeError) as e:
# Same "never fail dark" principle: the polygon is fine, so intrusion detection
# stays live — but the occupancy rules the operator THINKS are active are not.
log.warning("zone %s on camera %s has malformed rules_json (%s: %s) — its "
"occupancy rules are INACTIVE until fixed in the dashboard.",
row.get("zone_id"), row.get("camera_id"), type(e).__name__, e)
rules = None
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon, rules))
except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError) as e: except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError) as e:
# Same principle as the pixel-space branch above: never fail dark. A row missing a key # Same principle as the pixel-space branch above: never fail dark. A row missing a key
# (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be # (level/zone_id), with unparseable polygon_json, or degenerate points would otherwise be

View file

@ -13,18 +13,4 @@ return [
'ingest_ip_rate_limit_per_min' => 300, 'ingest_ip_rate_limit_per_min' => 300,
// PIPA retention: worker snapshots are purged after this many days (0 disables). // PIPA retention: worker snapshots are purged after this many days (0 disables).
'snapshot_retention_days' => 7, 'snapshot_retention_days' => 7,
'event_retention_days' => 0, // 0 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료)
// --- 통보 (tools/dispatch_alerts.php) --------------------------------------------
// 대시보드의 외부 접속 주소. 알림 메시지의 사건 링크에 쓰인다. 비어 있으면 디스패처가
// 실행을 거부한다 — 열리지 않는 링크가 담긴 알림을 보내는 것보다 낫다.
'public_base_url' => '', // 예: 'https://example.com/move_detect'
// 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다.
'escalate_after_seconds' => 300,
// --- 보존 / 용량 ------------------------------------------------------------------
// 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리).
'metric_retention_days' => 30,
// 스냅샷 볼륨 여유 공간 하한(MB). 밑돌면 device_fault 로 알린다. 0 = 검사 안 함.
'storage_min_free_mb' => 200,
]; ];

View file

@ -70,40 +70,19 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
1. 카페24 관리자 → **phpMyAdmin** 열기 1. 카페24 관리자 → **phpMyAdmin** 열기
2. 이 앱용 DB 선택 2. 이 앱용 DB 선택
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행 3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
4. 표 11개가 생겼는지 확인 4. 표 10개가 생겼는지 확인
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다 5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
`schema.sql` 은 항상 최신 상태입니다. 처음 설치라면 아래 마이그레이션은 **실행하지 마세요**
(이미 반영되어 있어 충돌합니다).
### 이미 쓰던 DB 업그레이드(기존 데이터 있음) ### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다. > ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
> 대신 **마이그레이션**을 실행하세요. > 대신 **마이그레이션**을 실행하세요.
**반드시 아래 순서대로, 아직 적용하지 않은 것부터 전부 실행합니다.** 하나라도 건너뛰면
그 컬럼·표를 쓰는 요청이 전부 500 이 됩니다. 순서가 중요한 이유는 뒤 파일이 앞 파일이 만든
표 이름(`move_` 접두사)을 전제하기 때문입니다.
| 순서 | 파일 | 무엇이 바뀌나 |
|---|---|---|
| 1 | `2026-07-17-p1-9-p3-2b.sql` | site / event_uuid 컬럼 + 인덱스, 기존 이벤트 site 백필 |
| 2 | `2026-07-20-move-table-prefix.sql` | 모든 표 이름에 `move_` 접두사 부여 (**이 단계를 건너뛰면 이후 전부 실패**) |
| 3 | `2026-08-06-zone-rules.sql` | 존 점유 규칙(`rules_json`) |
| 4 | `2026-08-06-heatmap-spatial.sql` | 히트맵 좌표(`spatial_x`, `spatial_y`) |
| 5 | `2026-08-06-false-positive-tag.sql` | 오탐 태그 컬럼 |
| 6 | `2026-08-07-improvement-actions.sql` | 개선 조치 표(`move_actions`) |
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
1. phpMyAdmin → 해당 DB → **SQL** 탭 1. phpMyAdmin → 해당 DB → **SQL** 탭
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다 2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행
(각 파일 첫머리에 적용 조건이 주석으로 적혀 있습니다) (site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함)
3. 각 파일 맨 아래 **검증 쿼리**로 컬럼·표가 생겼는지 확인 3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인
4. 전부 끝난 뒤에 새 코드를 배포합니다 4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다
> 이미 적용한 마이그레이션을 다시 실행하면 "Duplicate column" 같은 오류가 납니다 —
> 재실행은 안전하지 않으니, 어디까지 적용했는지 기록해 두세요. 어디까지 했는지 모르겠으면
> phpMyAdmin 의 **Structure(구조)** 탭에서 위 표의 컬럼·표가 있는지 하나씩 확인하면 됩니다.
--- ---

View file

@ -144,28 +144,9 @@ echo ">> [4/6] staging app source"
cp -r "$SERVER/src/." "$PRIV/src/" cp -r "$SERVER/src/." "$PRIV/src/"
cp -r "$SERVER/public/." "$WEB/" cp -r "$SERVER/public/." "$WEB/"
cp "$SERVER/schema.sql" "$PRIV/schema.sql" cp "$SERVER/schema.sql" "$PRIV/schema.sql"
# migrations/ ships too: a host that already imported an older schema.sql upgrades by
# 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. # The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess" cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php" cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
# Provenance stamp. A package's own contents are always self-consistent, so nothing inside it
# reveals that it was built before the last four feature commits — which is exactly how a stale
# package once shipped. Keep it OUT of the web root: it names the build, not a public asset.
{
echo "package: move-detect-cafe24"
echo "version: $(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
echo "git_commit: $(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$PRIV/BUILD-INFO.txt"
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php" cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On). # .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini" cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"

View file

@ -28,23 +28,4 @@ return [
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한 'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어) 'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성) 'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성)
// 이벤트 '기록'(이미지 아님) 보존일. 0 = 영구 보존이 기본값입니다 — 산업안전 조사나
// 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다.
// DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다.
'event_retention_days' => 0,
// ---- 4. 통보 (알림을 쓰려면 채우세요) -------------------------------
// 대시보드 주소입니다. 알림 메일/웹훅에 들어가는 사건 링크가 이 주소로 만들어집니다.
// 비워두면 통보 디스패처(tools/dispatch_alerts.php)가 실행을 거부합니다 —
// 열리지 않는 링크가 담긴 알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다.
'public_base_url' => '', // 예: 'https://example.com/move_detect'
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
// 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
'escalate_after_seconds' => 300,
// ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
// 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.
'metric_retention_days' => 30,
// 스냅샷 저장 공간 여유 하한(MB). 밑돌면 대시보드에 경고가 뜹니다. 0 = 검사 안 함.
'storage_min_free_mb' => 200,
]; ];

View file

@ -139,14 +139,13 @@ or version control.
## 4. Import the schema ## 4. Import the schema
`server/schema.sql` contains the full MySQL 8.0+ DDL. All tables carry the `move_` prefix `server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
(`move_users`, `move_devices`, `move_cameras`, `move_actions`, `move_zones`, `move_events`, `events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
`move_event_snapshots`, `move_thresholds`, `move_nonces`, `move_rate_limits`, `move_audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this 1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
app. app.
2. **Import** tab → choose file → upload `schema.sql` → Go. 2. **Import** tab → choose file → upload `schema.sql` → Go.
3. Confirm all 11 tables were created (phpMyAdmin's table list on the left, or 3. Confirm all 10 tables were created (phpMyAdmin's table list on the left, or
`SHOW TABLES;` in the SQL tab). `SHOW TABLES;` in the SQL tab).
4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live 4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
@ -280,45 +279,3 @@ client as a bare 500 with no body/trace, which is the intended behavior — turn
- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per - [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per
§4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php` §4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php`
and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing. and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing.
## 11. Scheduled jobs (cron)
Two jobs turn the installation from "records incidents" into "responds to incidents" and keep
the database inside a shared-hosting quota. Both live in `move_detect_app/tools/` — **inside the
private folder, never under `www/`** — and refuse to run under any SAPI but CLI.
```cron
# 통보 디스패처 — 통보 발송 + 미확인 에스컬레이션 + 실패 재시도.
# 이것이 돌지 않으면 감지는 되지만 아무에게도 전달되지 않는다 (콘솔을 보고 있는 사람 제외).
*/1 * * * * /usr/bin/php /home/<account>/move_detect_app/tools/dispatch_alerts.php >> /home/<account>/logs/alerts.log 2>&1
# 보존기간 정리 — 스냅샷(PIPA) · 성능 시계열 · nonce.
# 이벤트 기록은 event_retention_days 를 명시적으로 설정한 현장에서만 지워진다.
17 3 * * * /usr/bin/php /home/<account>/move_detect_app/tools/maintain.php >> /home/<account>/logs/maintain.log 2>&1
```
**처음 켤 때는 반드시 dry-run 부터.** 잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는
두 번 다시 이 기능을 켜지 않습니다.
```bash
php move_detect_app/tools/dispatch_alerts.php --dry-run
php move_detect_app/tools/maintain.php --dry-run
```
`public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지 않는 링크가 담긴
알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다 (`config.php` §4).
### 그 외 도구 (수동 실행)
| 도구 | 용도 |
|------|------|
| `tools/backup.php --out=<디렉터리>` | DB 덤프 + 스냅샷 아카이브 + 복구 안내. 출력 경로가 `www/` 안이면 거부합니다. |
| `tools/verify_audit_chain.php` | 감사로그 해시체인 검증. 손상 시 종료 코드 1 과 함께 끊긴 지점을 출력합니다. |
| `tools/seed_demo.php --days=45` | 시연·QA용 데모 데이터. **운영 DB 에서는 절대 실행하지 마십시오** (`--purge` 로 데모 행만 제거). |
## 12. Post-deploy: 설치 점검 화면
로그인 후 `/setup` 을 엽니다. 각 단계는 실제 상태를 조회해 판정하므로, 체크가 켜졌다면 그
단계는 정말로 동작하는 것입니다. 특히 **⑤ 통보 경로** 가 비어 있으면 이 시스템은 감지 기록
장치일 뿐입니다 — 현장 경광등(엣지 GPIO)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
경로입니다.

View file

@ -1,6 +0,0 @@
-- Operator false-positive tag (오탐). Feeds per-camera/type FP-rate statistics only —
-- by product decision the tag never auto-tunes edge thresholds; a human reads the rate
-- and adjusts deliberately. Tagging also acknowledges the event (an FP is handled).
ALTER TABLE move_events
ADD COLUMN false_positive_by VARCHAR(190) NULL AFTER acknowledged_at,
ADD COLUMN false_positive_at BIGINT UNSIGNED NULL AFTER false_positive_by;

View file

@ -1,14 +0,0 @@
-- Heatmap raw material: normalized (0..1) spatial point per event, and the five new
-- event types (occupancy rules + near-miss + ergo report). The chk_events_type constraint
-- would REJECT the new types' inserts on MySQL 8.0.16+ without this. (Never applied to any
-- deployment before this revision, so the type list is edited in place.)
ALTER TABLE move_events
ADD COLUMN spatial_x DOUBLE NULL AFTER metrics_json,
ADD COLUMN spatial_y DOUBLE NULL AFTER spatial_x;
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'
));

View file

@ -1,5 +0,0 @@
-- Zone occupancy rules (2인 1조 / 정원 / 체류 상한).
-- Nullable JSON blob validated app-side (ZoneConfigController::rulesJson); NULL = no rules.
-- Delivered to the edge unchanged via GET /api/config -> zones rows.
ALTER TABLE move_zones
ADD COLUMN rules_json JSON NULL AFTER polygon_json;

View file

@ -1,13 +0,0 @@
-- Improvement action log (개선 조치 기록): "미끄럼 방지 매트 설치" 같은 현장 조치를
-- 카메라에 기록해 두면, 리포트가 조치 전후의 검출 밀도를 자동 비교한다 —
-- 안전 투자의 효과가 데이터로 증명되는 경로.
CREATE TABLE move_actions (
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
camera_id VARCHAR(64) NOT NULL,
note VARCHAR(500) NOT NULL,
taken_at BIGINT UNSIGNED NOT NULL, -- 조치 시행 시각(epoch s)
created_by VARCHAR(190) NOT NULL,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (action_id),
KEY ix_actions_taken (taken_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -1,22 +0,0 @@
-- 개선 조치(move_actions)에 site 스코프 추가.
-- 이 표만 site 컬럼 없이 추가되어, 스코프 운영자가 타 현장의 조치 기록(카메라 ID·메모·
-- 작성자)을 목록에서 그대로 읽을 수 있었다. 다른 표(events/devices/zones)와 동일하게
-- site 를 비정규화해 SQL 단계에서 걸러낸다.
--
-- 적용 조건: 2026-08-07-improvement-actions.sql 이후.
ALTER TABLE move_actions
ADD COLUMN site VARCHAR(64) NULL AFTER camera_id,
ADD KEY ix_actions_site_taken (site, taken_at);
-- 기존 행 백필: 조치가 달린 카메라가 이벤트를 남긴 현장을 그 조치의 현장으로 본다.
-- (카메라는 별도 등록 표가 없고 events.site 가 유일한 소유 근거다.)
UPDATE move_actions a
SET a.site = (SELECT e.site FROM move_events e
WHERE e.camera_id = a.camera_id AND e.site IS NOT NULL
ORDER BY e.event_id DESC LIMIT 1)
WHERE a.site IS NULL;
-- 검증: site 가 채워졌는지 확인한다. NULL 로 남은 행은 해당 카메라의 이벤트가 없거나
-- 현장 미지정(devices.site IS NULL)인 경우로, 전역 관리자에게만 보인다.
-- SELECT site, COUNT(*) FROM move_actions GROUP BY site;

View file

@ -1,191 +0,0 @@
-- 안전 플랫폼 확장 — 통보·억제·증거무결성·운영지표.
--
-- 이 마이그레이션 하나로 다음 기능의 저장소를 연다. 기능별로 파일을 쪼개지 않은 이유는
-- 서로를 참조하기 때문이다(억제 규칙은 통보를 막고, 통보 실패는 감사로그에 남고, 감사로그는
-- 해시체인으로 봉인된다). 부분 적용된 중간 상태가 더 위험하다.
--
-- 1. move_audit_log 해시체인 — 증거 무결성 (schema.sql 이 "P3 로 연기"라 적어둔 항목)
-- 2. move_suppressions — 정비/작업허가 중 알람 억제 (사유·기간·감사 필수)
-- 3. move_alert_channels/_log — 통보 경로와 전달 이력 (지금까지 통보 수단이 전무했다)
-- 4. move_shift_handovers — 교대 인수인계 기록
-- 5. move_risk_controls — 감지 유형 ↔ 위험요인·대책 (위험성평가 연계)
-- 6. move_device_metrics — 엣지 성능 시계열 (지금은 순간값만 있어 열화를 못 본다)
-- 7. move_thresholds 감사 컬럼 — 편집 UI 가 생기므로 누가 언제 바꿨는지 남긴다
-- 8. move_actions.event_id — 어떤 사건이 그 조치의 계기였는지
-- 9. move_events CHECK 확장 — 신규 감지 유형
--
-- 적용 조건: 2026-08-08-actions-site.sql 이후.
-- ---------------------------------------------------------------------------
-- 1. 감사로그 해시체인
-- 각 행은 직전 행의 entry_hash 를 prev_hash 로 물고, 자신의 내용+prev_hash 를 sha256 한
-- 값을 entry_hash 로 갖는다. 중간 행을 지우거나 고치면 그 이후 전 구간의 사슬이 끊긴다.
-- (삭제·수정을 "막는" 것이 아니라 "숨길 수 없게" 만드는 것이 목적이다. append-only 권한을
-- DB 레벨에서 강제할 수 없는 공유호스팅에서는 이것이 실질적인 방어선이다.)
-- 기존 행은 NULL 로 남는다 — 체인 이전 구간임을 검증 도구가 그대로 보고한다.
ALTER TABLE move_audit_log
ADD COLUMN prev_hash CHAR(64) NULL AFTER ts,
ADD COLUMN entry_hash CHAR(64) NULL AFTER prev_hash,
ADD KEY ix_audit_log_entry_hash (entry_hash);
-- ---------------------------------------------------------------------------
-- 2. 알람 억제 (정비 모드 / 작업허가)
-- 오탐의 최대 원인은 "계획된 작업"이다. 정비 중 제한구역 진입이 전부 알람이면 현장은
-- 시스템을 끄고, 그 다음 진짜 사고를 놓친다. 억제는 감지를 멈추지 않는다 — 이벤트는 그대로
-- 적재되고 통보만 막힌다. 사후 조사에서 "그때 무슨 일이 있었나"가 사라지면 안 되기 때문이다.
CREATE TABLE move_suppressions (
suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
scope_id VARCHAR(64) NOT NULL, -- 해당 스코프의 식별자
site VARCHAR(64) NULL, -- 접근제어 스코프
reason VARCHAR(500) NOT NULL, -- 필수: 사유 없는 억제는 허용하지 않는다
starts_at BIGINT UNSIGNED NOT NULL,
ends_at BIGINT UNSIGNED NOT NULL, -- 무기한 억제 없음. 반드시 만료된다.
created_by VARCHAR(190) NOT NULL,
created_at BIGINT UNSIGNED NOT NULL,
revoked_by VARCHAR(190) NULL, -- 조기 해제
revoked_at BIGINT UNSIGNED NULL,
PRIMARY KEY (suppression_id),
KEY ix_suppressions_window (ends_at, starts_at),
KEY ix_suppressions_scope (scope_type, scope_id),
KEY ix_suppressions_site (site),
CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 3. 통보 경로와 전달 이력
-- 폐쇄망이라 FCM/Slack 을 쓸 수 없다. 사내에서 실제로 닿는 경로만 둔다:
-- local — 엣지 노드의 경광등/부저 (GPIO). 인터넷이 필요 없는 유일한 경로이자 1순위.
-- email — 사내 SMTP 릴레이
-- webhook— 사내 시스템(그룹웨어·문자게이트웨이) HTTP 엔드포인트
-- config_json 은 경로별 설정(수신자, URL, GPIO 핀 등). 비밀번호류는 넣지 않는다 —
-- SMTP 인증정보는 config.php 에 두고 여기서는 참조만 한다.
CREATE TABLE move_alert_channels (
channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
kind VARCHAR(16) NOT NULL, -- local | email | webhook
name VARCHAR(190) NOT NULL,
config_json JSON NOT NULL,
min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
enabled TINYINT(1) NOT NULL DEFAULT 1,
-- 에스컬레이션 단계. 0 = 즉시. 1 이상이면 escalate_after_s 만큼 미확인일 때만 발송한다.
step INT UNSIGNED NOT NULL DEFAULT 0,
escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (channel_id),
KEY ix_alert_channels_site (site, enabled),
CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 전달 이력. (event_id, channel_id) UNIQUE 가 중복 발송을 막는 유일한 방어선이다 —
-- 디스패처는 크론으로 돌고 겹쳐 실행될 수 있다.
CREATE TABLE move_alert_log (
alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL, -- pending | sent | failed | suppressed
attempts INT UNSIGNED NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
queued_at BIGINT UNSIGNED NOT NULL,
sent_at BIGINT UNSIGNED NULL,
PRIMARY KEY (alert_id),
UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
KEY ix_alert_log_status (status, queued_at),
CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 4. 교대 인수인계
-- 24시간 브리프는 있으나 교대 경계와 책임 이관이 없었다. 인수자가 "무엇을 넘겨받았는지"를
-- 명시적으로 확인한 기록이 남아야 미처리 이벤트가 교대 사이에서 증발하지 않는다.
CREATE TABLE move_shift_handovers (
handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
from_user VARCHAR(190) NULL, -- 최초 교대는 인계자가 없다
to_user VARCHAR(190) NOT NULL,
period_start BIGINT UNSIGNED NOT NULL, -- 인계 대상 구간
period_end BIGINT UNSIGNED NOT NULL,
unacked_count INT UNSIGNED NOT NULL, -- 인계 시점 스냅샷 (사후 재계산과 대조 가능)
severe_count INT UNSIGNED NOT NULL,
note VARCHAR(1000) NULL,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (handover_id),
KEY ix_handovers_site_created (site, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 5. 위험성평가 연계
-- 감지 유형 하나하나가 위험요인 하나에 대응한다. 이 표가 있어야 "이 시스템이 무엇을
-- 통제하고 있는가"를 서식으로 뽑을 수 있고, 개선조치가 어느 위험요인을 낮췄는지 이어진다.
CREATE TABLE move_risk_controls (
control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
event_type VARCHAR(32) NOT NULL, -- move_events.event_type 와 같은 코드
hazard VARCHAR(300) NOT NULL, -- 위험요인
control VARCHAR(500) NOT NULL, -- 현재 통제 대책
severity_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (강도)
likelihood_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (빈도)
reviewed_by VARCHAR(190) NULL,
reviewed_at BIGINT UNSIGNED NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (control_id),
UNIQUE KEY uq_risk_controls_site_type (site, event_type),
CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 6. 엣지 성능 시계열
-- move_devices 는 마지막 값만 덮어쓴다. "어제부터 추론 지연이 두 배가 됐다"를 볼 수 없어
-- 서서히 죽는 노드를 놓친다. 하트비트마다 한 행씩 append 하고 보존기간으로 잘라낸다.
CREATE TABLE move_device_metrics (
metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
ts BIGINT UNSIGNED NOT NULL,
fps DOUBLE NULL,
temp DOUBLE NULL,
throttle TINYINT(1) NULL,
queue_pending INT UNSIGNED NULL,
cameras_total INT UNSIGNED NULL,
cameras_erroring INT UNSIGNED NULL,
errors INT UNSIGNED NULL,
PRIMARY KEY (metric_id),
KEY ix_device_metrics_device_ts (device_id, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 7. 임계값 감사 컬럼 (편집 UI 도입)
-- 지금까지 이 표는 phpMyAdmin 으로 직접 고치는 것이 유일한 방법이었다(스키마 주석 참조).
-- 화면이 생기면 누가 언제 바꿨는지가 사고 조사에서 반드시 필요해진다.
ALTER TABLE move_thresholds
ADD COLUMN updated_by VARCHAR(190) NULL,
ADD COLUMN updated_at BIGINT UNSIGNED NULL;
-- ---------------------------------------------------------------------------
-- 8. 조치 ↔ 계기 사건
ALTER TABLE move_actions
ADD COLUMN event_id INT UNSIGNED NULL AFTER site,
ADD KEY ix_actions_event (event_id);
-- ---------------------------------------------------------------------------
-- 9. 신규 감지 유형
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
ALTER TABLE move_events
DROP CHECK chk_events_type;
ALTER TABLE move_events
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
'camera_moved','camera_obstructed','low_light','unacked_escalation',
'ppe_missing','proximity','fire_smoke'
));
-- 검증:
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
-- php server/tools/verify_audit_chain.php -- 사슬 무결성
-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';

View file

@ -1,322 +0,0 @@
/**
* 시스템 설정: 통보 채널 + 감지 임계값.
*
* 이 두 화면이 없던 동안 납품된 현장은 (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();
});
})();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,420 +0,0 @@
/**
* 안전 관리: 교대 인수인계 · 정비 억제 · 위험성평가 · 설비 성능 추이.
*
* XSS-safe: 모든 동적 값은 textContent. 그래프는 인라인 SVG 를 DOM API 로 조립한다
* (문자열 innerHTML 금지 — 프로젝트 규약이자, 카메라 이름 같은 값이 마크업에 섞이는 것을 막는다).
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var root = document.querySelector('.compliance-page');
if (!root) return;
var csrf = null;
var TYPE_LABELS = {
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
};
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = text;
return n;
}
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function fmtDate(ts) {
var d = new Date(Number(ts) * 1000);
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function fmtStamp(ts) {
var d = new Date(Number(ts) * 1000);
return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
function fmtLeft(seconds) {
if (seconds <= 0) return '만료됨';
if (seconds < 3600) return Math.ceil(seconds / 60) + '분 남음';
return Math.floor(seconds / 3600) + '시간 ' + Math.round((seconds % 3600) / 60) + '분 남음';
}
function loadCsrf() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.token) csrf = d.token; })
.catch(function () {});
}
function post(path, body) {
return fetch(B + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' },
body: JSON.stringify(body || {})
}).then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
return r.json().then(function (d) { return { ok: r.ok, data: d }; });
});
}
function getJson(path) {
return fetch(B + path, { headers: { Accept: 'application/json' } })
.then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
return r.ok ? r.json() : null;
});
}
var ERRORS = {
reason_required: '사유를 입력하세요 — 사유 없는 억제는 나중에 아무도 해석할 수 없습니다.',
invalid_window: '기간은 1분 이상 8시간(480분) 이하여야 합니다.',
invalid_scope_id: '대상을 선택하세요.',
unknown_scope_id: '등록되지 않은 대상입니다.',
invalid_rating: '강도·빈도는 1~5 사이여야 합니다.',
invalid_hazard: '위험요인을 입력하세요.',
invalid_control: '현재 대책을 입력하세요.',
forbidden: '권한이 없습니다.',
csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.'
};
function errText(c) { return ERRORS[c] || ('처리에 실패했습니다 (' + String(c) + ')'); }
// ============================================================ 교대 인수인계
var pendingBox = document.getElementById('handover-pending');
var handoverList = document.getElementById('handover-list');
function renderHandovers(d) {
var p = d.pending || {};
pendingBox.textContent = '';
var head = el('div', 'handover-head');
head.appendChild(el('strong', null, '인수 대상 구간'));
head.appendChild(el('span', 'handover-range',
fmtStamp(p.period_start) + ' ~ ' + fmtStamp(p.period_end)));
pendingBox.appendChild(head);
var stats = el('div', 'handover-stats');
var a = el('div', 'handover-stat' + (p.unacked_count > 0 ? ' is-open' : ''));
a.appendChild(el('strong', null, String(p.unacked_count || 0)));
a.appendChild(el('span', null, '미확인'));
stats.appendChild(a);
var b = el('div', 'handover-stat' + (p.severe_count > 0 ? ' is-severe' : ''));
b.appendChild(el('strong', null, String(p.severe_count || 0)));
b.appendChild(el('span', null, '이 중 중대'));
stats.appendChild(b);
pendingBox.appendChild(stats);
if (p.from_user) {
pendingBox.appendChild(el('p', 'handover-from', '인계자: ' + p.from_user));
}
handoverList.textContent = '';
var list = d.handovers || [];
if (!list.length) {
handoverList.appendChild(el('p', 'admin-empty', '기록된 인수인계가 없습니다.'));
return;
}
list.forEach(function (h) {
var row = el('div', 'handover-row');
row.appendChild(el('span', 'handover-when', fmtStamp(h.created_at)));
row.appendChild(el('span', 'handover-who',
(h.from_user ? h.from_user + ' → ' : '') + h.to_user));
row.appendChild(el('span', 'handover-count',
'미확인 ' + h.unacked_count + ' (중대 ' + h.severe_count + ')'));
if (h.note) row.appendChild(el('span', 'handover-note', h.note));
handoverList.appendChild(row);
});
}
function loadHandovers() {
getJson('/api/handovers').then(function (d) { if (d) renderHandovers(d); })
.catch(function () { pendingBox.textContent = '인수인계 정보를 불러오지 못했습니다.'; });
}
var handoverForm = document.getElementById('handover-form');
if (handoverForm) {
handoverForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var note = document.getElementById('handover-note');
post('/api/handovers', { note: note.value.trim() }).then(function (r) {
if (!r) return;
if (r.ok) { note.value = ''; loadHandovers(); }
});
});
}
// ============================================================ 억제
var supList = document.getElementById('suppress-list');
var supStatus = document.getElementById('sup-status');
var supType = document.getElementById('sup-scope-type');
var supId = document.getElementById('sup-scope-id');
var deviceRows = [];
var cameraRows = [];
function fillScopeOptions() {
var rows = supType.value === 'device' ? deviceRows : cameraRows;
supId.textContent = '';
if (!rows.length) {
supId.appendChild(new Option(supType.value === 'device' ? '등록된 장치가 없습니다' : '관측된 카메라가 없습니다', ''));
return;
}
supId.appendChild(new Option('선택하세요', ''));
rows.forEach(function (r) { supId.appendChild(new Option(r.label, r.id)); });
}
function renderSuppressions(d) {
var now = d.now || Math.floor(Date.now() / 1000);
supList.textContent = '';
var list = d.suppressions || [];
if (!list.length) {
supList.appendChild(el('p', 'admin-empty', '현재 억제 중인 대상이 없습니다.'));
return;
}
list.forEach(function (s) {
var row = el('div', 'suppress-row');
var main = el('div', 'suppress-main');
main.appendChild(el('span', 'suppress-scope',
(s.scope_type === 'device' ? '장치' : s.scope_type === 'camera' ? '카메라' : '구역') + ' ' + s.scope_id));
main.appendChild(el('span', 'suppress-reason', s.reason));
main.appendChild(el('span', 'suppress-meta',
s.created_by + ' · ' + fmtStamp(s.starts_at) + ' 시작 · ' + fmtLeft(s.ends_at - now)));
row.appendChild(main);
var stop = el('button', null, '해제');
stop.type = 'button';
stop.addEventListener('click', function () {
post('/api/suppressions/' + s.suppression_id + '/revoke').then(function (r) {
if (r && r.ok) loadSuppressions();
});
});
row.appendChild(stop);
supList.appendChild(row);
});
}
function loadSuppressions() {
getJson('/api/suppressions').then(function (d) { if (d) renderSuppressions(d); })
.catch(function () { supList.textContent = '억제 목록을 불러오지 못했습니다.'; });
}
var supForm = document.getElementById('suppress-form');
if (supForm) {
supType.addEventListener('change', fillScopeOptions);
supForm.addEventListener('submit', function (ev) {
ev.preventDefault();
supStatus.textContent = '';
post('/api/suppressions', {
scope_type: supType.value,
scope_id: supId.value,
minutes: Number(document.getElementById('sup-minutes').value) || 0,
reason: document.getElementById('sup-reason').value.trim()
}).then(function (r) {
if (!r) return;
if (!r.ok) { supStatus.textContent = errText(r.data && r.data.error); return; }
supStatus.textContent = '억제를 시작했습니다. ' + fmtStamp(r.data.ends_at) + ' 에 자동 해제됩니다.';
document.getElementById('sup-reason').value = '';
loadSuppressions();
});
});
}
// ============================================================ 위험성평가
var riskList = document.getElementById('risk-list');
var riskForm = document.getElementById('risk-form');
var riskStatus = document.getElementById('risk-status');
function riskBand(score) {
if (score >= 15) return { cls: 'is-high', text: '높음' };
if (score >= 8) return { cls: 'is-mid', text: '보통' };
return { cls: 'is-low', text: '낮음' };
}
function renderRisks(list) {
riskList.textContent = '';
if (!list.length) {
riskList.appendChild(el('p', 'admin-empty',
'등록된 위험요인이 없습니다. 감지 유형별로 무엇을 통제하고 있는지 기록해 두면 사고조사 패키지에 함께 실립니다.'));
return;
}
list.forEach(function (r) {
var band = riskBand(r.risk_score);
var row = el('div', 'risk-row');
var score = el('div', 'risk-score ' + band.cls);
score.appendChild(el('strong', null, String(r.risk_score)));
score.appendChild(el('span', null, band.text));
row.appendChild(score);
var body = el('div', 'risk-body');
body.appendChild(el('span', 'risk-type', TYPE_LABELS[r.event_type] || r.event_type));
body.appendChild(el('span', 'risk-hazard', r.hazard));
body.appendChild(el('span', 'risk-control', '대책: ' + r.control));
var meta = '강도 ' + r.severity_rating + ' × 빈도 ' + r.likelihood_rating;
if (r.reviewed_by) meta += ' · ' + r.reviewed_by + ' ' + fmtDate(r.reviewed_at);
body.appendChild(el('span', 'risk-meta', meta));
row.appendChild(body);
var edit = el('button', null, '수정');
edit.type = 'button';
edit.addEventListener('click', function () { openRisk(r); });
row.appendChild(edit);
riskList.appendChild(row);
});
}
function openRisk(r) {
riskForm.hidden = false;
riskStatus.textContent = '';
var sel = document.getElementById('risk-type');
if (!sel.options.length) {
Object.keys(TYPE_LABELS).forEach(function (k) { sel.appendChild(new Option(TYPE_LABELS[k], k)); });
}
r = r || {};
sel.value = r.event_type || 'fall';
document.getElementById('risk-hazard').value = r.hazard || '';
document.getElementById('risk-control').value = r.control || '';
document.getElementById('risk-severity').value = r.severity_rating || 3;
document.getElementById('risk-likelihood').value = r.likelihood_rating || 3;
document.getElementById('risk-hazard').focus();
}
if (riskForm) {
document.getElementById('risk-new').addEventListener('click', function () { openRisk(null); });
document.getElementById('risk-cancel').addEventListener('click', function () { riskForm.hidden = true; });
riskForm.addEventListener('submit', function (ev) {
ev.preventDefault();
post('/api/risks', {
event_type: document.getElementById('risk-type').value,
hazard: document.getElementById('risk-hazard').value.trim(),
control: document.getElementById('risk-control').value.trim(),
severity_rating: Number(document.getElementById('risk-severity').value),
likelihood_rating: Number(document.getElementById('risk-likelihood').value)
}).then(function (r) {
if (!r) return;
if (!r.ok) { riskStatus.textContent = errText(r.data && r.data.error); return; }
riskStatus.textContent = '저장되었습니다.';
riskForm.hidden = true;
loadRisks();
});
});
}
function loadRisks() {
getJson('/api/risks').then(function (d) { if (d) renderRisks(d.controls || []); })
.catch(function () { riskList.textContent = '위험성평가를 불러오지 못했습니다.'; });
}
// ============================================================ 성능 추이
var metricDevice = document.getElementById('metric-device');
var metricChart = document.getElementById('metric-chart');
var SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs) {
var n = document.createElementNS(SVG_NS, tag);
Object.keys(attrs || {}).forEach(function (k) { n.setAttribute(k, String(attrs[k])); });
return n;
}
/** 값이 없는 구간은 선을 잇지 않는다 — 없는 데이터를 직선으로 메우면 그래프가 거짓말을 한다. */
function polylines(points, key, x, y) {
var segs = [], cur = [];
points.forEach(function (p, i) {
var v = p[key];
if (v === null || v === undefined) { if (cur.length > 1) segs.push(cur); cur = []; return; }
cur.push(x(i) + ',' + y(v));
});
if (cur.length > 1) segs.push(cur);
return segs;
}
function renderMetrics(d) {
metricChart.textContent = '';
var pts = d.points || [];
if (pts.length < 2) {
metricChart.appendChild(el('p', 'admin-empty',
'이 장치의 성능 기록이 아직 충분하지 않습니다. 하트비트가 쌓이면 추이가 그려집니다.'));
return;
}
var W = 720, H = 160, PAD = 28;
var fpsVals = pts.map(function (p) { return p.fps; }).filter(function (v) { return v !== null; });
var maxFps = Math.max(30, Math.ceil(Math.max.apply(null, fpsVals.length ? fpsVals : [30])));
var x = function (i) { return PAD + (i / (pts.length - 1)) * (W - PAD * 2); };
var yFps = function (v) { return H - PAD - (v / maxFps) * (H - PAD * 2); };
var chart = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, class: 'metric-svg',
role: 'img', 'aria-label': '프레임률 추이' });
chart.appendChild(svg('line', { x1: PAD, y1: H - PAD, x2: W - PAD, y2: H - PAD, class: 'axis' }));
chart.appendChild(svg('line', { x1: PAD, y1: PAD, x2: PAD, y2: H - PAD, class: 'axis' }));
polylines(pts, 'fps', x, yFps).forEach(function (seg) {
chart.appendChild(svg('polyline', { points: seg.join(' '), class: 'series-fps' }));
});
// 스로틀 구간은 배경 띠로 — 프레임률 하락의 원인을 같은 그림에서 읽을 수 있어야 한다.
pts.forEach(function (p, i) {
if (!p.throttle) return;
chart.appendChild(svg('rect', { x: x(i) - 1, y: PAD, width: 3, height: H - PAD * 2,
class: 'series-throttle' }));
});
metricChart.appendChild(chart);
var legend = el('div', 'metric-legend');
legend.appendChild(el('span', 'legend-fps', '프레임률 (최대 ' + maxFps + ')'));
legend.appendChild(el('span', 'legend-throttle', '과열 스로틀 구간'));
legend.appendChild(el('span', 'legend-range',
fmtStamp(pts[0].ts) + ' ~ ' + fmtStamp(pts[pts.length - 1].ts) + ' · ' + pts.length + '점'));
metricChart.appendChild(legend);
var last = pts[pts.length - 1];
var summary = el('p', 'metric-summary');
var bits = [];
if (last.fps !== null) bits.push('현재 ' + last.fps.toFixed(1) + ' fps');
if (last.temp !== null) bits.push(last.temp.toFixed(1) + '°C');
if (last.queue_pending !== null) bits.push('전송 대기 ' + last.queue_pending + '건');
if (last.cameras_erroring) bits.push('오류 카메라 ' + last.cameras_erroring + '대');
summary.textContent = bits.join(' · ');
metricChart.appendChild(summary);
}
function loadMetrics() {
var dev = metricDevice.value;
if (!dev) { metricChart.textContent = '장치를 선택하세요.'; return; }
metricChart.textContent = '불러오는 중…';
getJson('/api/devices/' + encodeURIComponent(dev) + '/metrics?hours=24')
.then(function (d) { if (d) renderMetrics(d); })
.catch(function () { metricChart.textContent = '성능 기록을 불러오지 못했습니다.'; });
}
if (metricDevice) metricDevice.addEventListener('change', loadMetrics);
// ---------------------------------------------------------------- 공통 목록
function loadTargets() {
return Promise.all([getJson('/api/devices'), getJson('/api/events?limit=200')])
.then(function (res) {
var devs = (res[0] && res[0].devices) || [];
deviceRows = devs.map(function (d) {
return { id: d.device_id, label: (d.name || d.device_id) + ' · ' + d.device_id };
});
var seen = {};
cameraRows = [];
((res[1] && res[1].events) || []).forEach(function (e) {
if (!e.camera_id || seen[e.camera_id]) return;
seen[e.camera_id] = true;
cameraRows.push({ id: e.camera_id, label: e.camera_id });
});
cameraRows.sort(function (a, b) { return a.id < b.id ? -1 : 1; });
fillScopeOptions();
metricDevice.textContent = '';
metricDevice.appendChild(new Option(devs.length ? '장치를 선택하세요' : '등록된 장치가 없습니다', ''));
deviceRows.forEach(function (d) { metricDevice.appendChild(new Option(d.label, d.id)); });
})
.catch(function () {});
}
loadCsrf().then(function () {
loadTargets();
loadHandovers();
loadSuppressions();
loadRisks();
});
})();

View file

@ -3,11 +3,6 @@
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata * GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
* plus each snapshot as an <img src="/snap/{file_uuid}">. * plus each snapshot as an <img src="/snap/{file_uuid}">.
* *
* This is where an incident is JUDGED, so it is also where it is RESOLVED: 확인/오탐 post to
* the same routes the stream uses. Previously the operator had to read the evidence here,
* navigate back, and find the row again — the deepest screen had no way to act on what it
* showed, which is exactly backwards.
*
* XSS-safe: every dynamic value is written via textContent or a validated URL * XSS-safe: every dynamic value is written via textContent or a validated URL
* attribute, never innerHTML. Severity is shown as icon + text (never color * attribute, never innerHTML. Severity is shown as icon + text (never color
* alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js. * alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
@ -26,51 +21,8 @@
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } info: { icon: 'ℹ', text: '정보', cls: 'sev-info' }
}; };
// Same wire-code -> Korean map as app.js (the two pages share no module system);
// an unknown code falls through as the raw code — never blank.
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: '작업자세 부담 리포트'
};
// 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 — 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: '부동 지속',
waited_s: '재출현 대기', dwell_s: '체류 시간', limit_s: '체류 상한',
alone_s: '단독 작업 지속', workers: '감지 인원', required: '최소 인원', limit: '정원',
torso_angle: '몸통 각도(°)', aspect_ratio: '박스 종횡비', height_remaining: '잔여 높이 비율',
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 meta = document.getElementById('event-detail-meta');
var snapBox = document.getElementById('event-detail-snapshots'); var snapBox = document.getElementById('event-detail-snapshots');
var actionsBox = document.getElementById('detail-actions');
var titleEl = document.getElementById('detail-title');
var whenEl = document.getElementById('detail-when');
if (!meta) return; if (!meta) return;
var m = location.pathname.match(/\/events\/(\d+)\/view/); var m = location.pathname.match(/\/events\/(\d+)\/view/);
@ -80,34 +32,16 @@
return; return;
} }
var id = m[1]; var id = m[1];
var currentEvent = null;
var csrfToken = null;
var deviceNames = {};
function severityInfo(sev) { function severityInfo(sev) {
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' }; return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
} }
function pad2(n) { return n < 10 ? '0' + n : String(n); }
// One stamp format across the product. This page used to print toLocaleString('ko-KR')
// ("2026. 8. 8. 오후 4:01:50") while the stream printed "08-08 16:01:50" for the same
// instant — the same event looked like two different records depending on the screen.
var DOW = ['일', '월', '화', '수', '목', '금', '토'];
function formatTime(occurredAt) { function formatTime(occurredAt) {
if (!occurredAt && occurredAt !== 0) return '-'; if (!occurredAt && occurredAt !== 0) return '-';
var d = new Date(Number(occurredAt) * 1000); var d = new Date(Number(occurredAt) * 1000);
if (isNaN(d.getTime())) return '-'; if (isNaN(d.getTime())) return '-';
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) return d.toLocaleString('ko-KR');
+ ' (' + DOW[d.getDay()] + ') '
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function fmtDuration(s) {
var v = Number(s);
if (!isFinite(v)) return '-';
if (v < 60) return (Math.round(v * 10) / 10) + '초';
return Math.floor(v / 60) + '분 ' + Math.round(v % 60) + '초';
} }
function buildBadge(sev) { function buildBadge(sev) {
@ -126,9 +60,8 @@
return badge; return badge;
} }
function textNode(value, cls) { function textNode(value) {
var span = document.createElement('span'); var span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value); span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
return span; return span;
} }
@ -136,257 +69,55 @@
function addRow(label, valueNode) { function addRow(label, valueNode) {
var row = document.createElement('div'); var row = document.createElement('div');
row.className = 'detail-row'; row.className = 'detail-row';
var k = document.createElement('dt'); var k = document.createElement('span');
k.textContent = label; k.className = 'detail-label';
var v = document.createElement('dd'); k.textContent = label + ': ';
v.appendChild(valueNode);
row.appendChild(k); row.appendChild(k);
row.appendChild(v); row.appendChild(valueNode);
meta.appendChild(row); meta.appendChild(row);
} }
// --- resolution actions -------------------------------------------------------------- function render(data) {
function loadCsrf() { var ev = data.event || {};
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) { if (d && d.token) csrfToken = d.token; })
.catch(function () { /* the POST surfaces it as a 403 */ });
}
function post(path) {
return fetch(B + '/events/' + encodeURIComponent(id) + path, {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
}).then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('action_failed:' + res.status);
return res;
});
}
function setNote(text, isError) {
var note = document.createElement('p');
note.className = 'detail-action-note' + (isError ? ' is-error' : '');
note.textContent = text;
actionsBox.appendChild(note);
}
// 오탐 is two-step here for the same reason as in the stream: it asserts the detector was
// wrong, it moves a per-camera statistic, and there is no undo.
var fpArmed = false;
function renderActions() {
if (!actionsBox || !currentEvent) return;
actionsBox.hidden = false;
actionsBox.textContent = '';
fpArmed = false;
if (currentEvent.false_positive_by) {
setNote('오탐으로 판정됨 · ' + currentEvent.false_positive_by);
return;
}
if (currentEvent.acknowledged_by) {
setNote('확인 완료 · ' + currentEvent.acknowledged_by);
return;
}
var ack = document.createElement('button');
ack.type = 'button';
ack.className = 'btn-primary';
ack.textContent = '확인 처리';
ack.addEventListener('click', function () {
ack.disabled = true;
post('/ack')
.then(function (res) {
if (!res) return;
currentEvent.acknowledged_by = window.APP_USER || '확인';
renderActions();
renderMeta();
})
.catch(function () { ack.disabled = false; setNote('확인 처리에 실패했습니다.', true); });
});
actionsBox.appendChild(ack);
var fp = document.createElement('button');
fp.type = 'button';
fp.className = 'fp-btn';
fp.textContent = '오탐';
fp.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
fp.addEventListener('click', function () {
if (!fpArmed) {
fpArmed = true;
fp.classList.add('is-armed');
fp.textContent = '오탐 확정?';
setTimeout(function () {
if (!fpArmed) return;
fpArmed = false; fp.classList.remove('is-armed'); fp.textContent = '오탐';
}, 4000);
return;
}
fp.disabled = true; ack.disabled = true;
post('/false-positive')
.then(function (res) {
if (!res) return;
currentEvent.acknowledged_by = window.APP_USER || '확인';
currentEvent.false_positive_by = window.APP_USER || '오탐';
renderActions();
renderMeta();
})
.catch(function () { fp.disabled = false; ack.disabled = false; setNote('오탐 처리에 실패했습니다.', true); });
});
actionsBox.appendChild(fp);
}
function renderMeta() {
var ev = currentEvent || {};
meta.textContent = ''; meta.textContent = '';
meta.removeAttribute('aria-busy'); meta.removeAttribute('aria-busy');
addRow('심각도', buildBadge(ev.severity)); addRow('심각도', buildBadge(ev.severity));
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type)); addRow('유형', textNode(ev.event_type));
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id)); addRow('카메라', textNode(ev.camera_id));
addRow('카메라', textNode(ev.camera_id, 'mono')); addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono')); addRow('지속(초)', textNode(ev.duration_s));
addRow('지속', textNode(fmtDuration(ev.duration_s))); addRow('확인', textNode(ev.acknowledged_by || '미확인'));
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
if (ev.zone_id) addRow('구역', textNode(ev.zone_id, 'mono'));
var status = ev.false_positive_by
? '오탐 (' + ev.false_positive_by + ')'
: (ev.acknowledged_by ? '확인됨 (' + ev.acknowledged_by + ')' : '미확인');
addRow('처리 상태', textNode(status));
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
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; if (!snapBox) return;
snapBox.textContent = ''; snapBox.textContent = '';
var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; }); var snaps = data.snapshots || [];
if (kept.length === 0) { if (snaps.length === 0) {
var none = document.createElement('p'); var none = document.createElement('p');
none.className = 'detail-empty'; none.textContent = '스냅샷이 없습니다.';
none.textContent = '이 이벤트에는 저장된 스냅샷이 없습니다 (미촬영이거나 PIPA 보존기간이 지나 삭제됨).';
snapBox.appendChild(none); snapBox.appendChild(none);
return; return;
} }
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by snaps.forEach(function (s) {
// position from the end — exact offsets are edge config the server doesn't know, and if (!s || !s.file_uuid) return;
// inventing seconds here would be a fabricated measurement on an accident record.
var CAPTIONS = ['이전', '직전', '발생 시점'];
kept.forEach(function (s, i) {
var fig = document.createElement('figure');
fig.className = 'detail-snap';
var img = document.createElement('img'); var img = document.createElement('img');
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid); img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
var cap = kept.length > 1 img.alt = '사고 스냅샷';
? CAPTIONS[Math.max(0, CAPTIONS.length - kept.length + i)] : '발생 시점';
img.alt = '사고 스냅샷 — ' + cap;
img.className = 'detail-snapshot'; img.className = 'detail-snapshot';
img.loading = 'lazy'; img.loading = 'lazy';
fig.appendChild(img); snapBox.appendChild(img);
var fc = document.createElement('figcaption');
fc.textContent = cap;
fig.appendChild(fc);
snapBox.appendChild(fig);
}); });
} }
// --- outbound links + improvement action --------------------------------------------- fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
function renderLinks() { .then(function (res) {
var ev = currentEvent || {}; if (res.status === 401) { window.location.href = B + '/login'; return null; }
var pkg = document.getElementById('detail-package'); if (!res.ok) throw new Error('detail_failed:' + res.status);
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view'; return res.json();
var zones = document.getElementById('detail-zones'); })
if (zones) { .then(function (data) { if (data) render(data); })
// Deep-link the zone editor straight at this camera. Reading an incident and then having .catch(function () {
// to re-select the camera by hand on the next screen is the same "the system knows this meta.textContent = '불러오기 실패';
// and asks you anyway" problem the editor's own picker exists to remove. meta.removeAttribute('aria-busy');
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');
});
});
})(); })();

View file

@ -1,248 +0,0 @@
/**
* Rail: the device → camera tree of the events console.
*
* A device row carries the heartbeat verdict (online/offline, fps, temp, throttle, config
* drift); the cameras that have reported events nest under it on hairline connectors, and a
* camera node doubles as the stream's camera filter. app.js owns the polling, the filter state
* and the per-camera aggregation — this file owns only the rail's DOM.
*
* XSS-safe: every dynamic value goes through textContent / setAttribute, never innerHTML.
*/
(function () {
'use strict';
var LEVELS = { critical: 'lv-critical', high: 'lv-high', warning: 'lv-warning', info: 'lv-info' };
var LEVEL_TEXT = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
var collapsed = {}; // device_id -> true; survives the 5s re-render
var lastSig = null; // rendered signature; an unchanged poll must not rebuild the DOM
var last = null; // {root, summaryEl, model} for the collapse toggle's own re-render
function ago(seconds) {
if (seconds === null || seconds === undefined) return '응답 없음';
if (seconds < 60) return Math.max(0, Math.round(seconds)) + '초 전';
if (seconds < 3600) return Math.floor(seconds / 60) + '분 전';
if (seconds < 86400) return Math.floor(seconds / 3600) + '시간 전';
return Math.floor(seconds / 86400) + '일 전';
}
function deviceMeta(d) {
// Liveness first, spelled out in words — the status dot is colour, and colour is never
// allowed to carry state on its own (NF9). The verdict itself is the server's (computed
// against the request clock), never re-derived here from a local clock.
if (d.seconds_since === null || d.seconds_since === undefined) return '응답 없음';
// fps/temp are the LAST values a dead device reported, so on an offline row they would read
// as current telemetry ("0 fps" for a device that simply stopped talking). Liveness only.
if (!d.online) return '오프라인 · ' + ago(d.seconds_since);
var parts = ['온라인 · ' + ago(d.seconds_since)];
if (d.fps !== null && d.fps !== undefined) parts.push((Math.round(d.fps * 10) / 10) + ' fps');
if (d.temp !== null && d.temp !== undefined && d.temp > 0) parts.push(Math.round(d.temp) + '°');
return parts.join(' · ');
}
function deviceWarn(d) {
var w = [];
if (d.throttle) w.push('과열 스로틀');
if (d.config_drift) w.push('설정 미적용');
return w.join(' · ');
}
/** Devices from the heartbeat API, each with its observed cameras nested underneath. */
function groupsOf(model) {
var groups = [], byId = {};
(model.devices || []).forEach(function (d) {
var id = String(d.device_id);
var g = { id: id, label: d.name || id, state: d.online ? 'online' : 'offline',
meta: deviceMeta(d), warn: deviceWarn(d), cams: [] };
byId[id] = g;
groups.push(g);
});
(model.cameras || []).forEach(function (c) {
var g = byId[c.device_id];
if (!g) {
// A camera whose device is missing from /api/devices — it was deleted, or it is sending
// events under an id nobody provisioned. Show it as its own unknown-device group: an
// unattributed camera is exactly the thing an operator must be told about, never dropped.
g = byId[c.device_id] = { id: c.device_id, label: c.device_id || '(장치 ID 없음)',
state: 'unknown', meta: '', warn: '장치 목록에 없음 — 등록 확인', cams: [] };
groups.push(g);
}
g.cams.push({
key: c.key,
id: c.camera_id,
when: c.last_at ? ago(model.now - c.last_at) : '감지 없음',
count: c.unacked,
level: c.worst
});
});
groups.forEach(function (g) {
// Stable order: the rail is a target the operator clicks, so a camera must not jump
// position because its event count changed.
g.cams.sort(function (a, b) { return a.id < b.id ? -1 : (a.id > b.id ? 1 : 0); });
});
return groups;
}
/** Collapsed, unless this branch holds the camera the stream is currently filtered by —
* hiding that node would leave the operator filtered with no visible sign of by what. */
function isCollapsed(g, selectedKey) {
if (!collapsed[g.id]) return false;
for (var i = 0; i < g.cams.length; i++) if (g.cams[i].key === selectedKey) return false;
return true;
}
function signature(groups, model) {
var parts = [model.stale ? 'S' : '-', model.loaded ? 'L' : '-', model.selectedKey || ''];
groups.forEach(function (g) {
parts.push(g.id + '|' + g.label + '|' + g.state + '|' + g.meta + '|' + g.warn
+ '|' + (isCollapsed(g, model.selectedKey) ? 'c' : 'o'));
g.cams.forEach(function (c) { parts.push(' ' + c.id + '|' + c.when + '|' + c.count + '|' + (c.level || '')); });
});
return parts.join('\n');
}
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 buildDeviceHead(g, shut) {
var head = el('button', 'device-head');
head.type = 'button';
head.setAttribute('data-node', 'd' + g.id);
head.setAttribute('aria-expanded', shut ? 'false' : 'true');
var line = el('span', 'device-line');
line.appendChild(el('span', 'twisty')).setAttribute('aria-hidden', 'true');
line.appendChild(el('span', 'dot')).setAttribute('aria-hidden', 'true');
line.appendChild(el('span', 'device-name', g.label));
line.appendChild(el('span', 'device-tally', g.cams.length + '대'));
head.appendChild(line);
if (g.meta) head.appendChild(el('span', 'device-meta', g.meta));
if (g.warn) head.appendChild(el('span', 'device-warn', g.warn));
// Spoken name: everything the row shows, in reading order and without the visual
// separators. It must stay a superset of the visible text — a shorter label would hide
// exactly the fields (오프라인, 과열 스로틀) that this rail exists to surface.
var spoken = [g.label];
if (g.meta) spoken.push(g.meta.split(' · ').join(', '));
spoken.push('카메라 ' + g.cams.length + '대');
if (g.warn) spoken.push(g.warn);
head.setAttribute('aria-label', spoken.join(', '));
return head;
}
function buildCamNode(c, onSelect, selectedKey) {
var node = el('button', 'cam-node');
node.type = 'button';
node.setAttribute('data-node', 'c' + c.key);
node.setAttribute('aria-pressed', c.key === selectedKey ? 'true' : 'false');
node.appendChild(el('span', 'cam-id', c.id));
node.appendChild(el('span', 'cam-when', c.when));
if (c.count > 0) {
node.appendChild(el('span', 'cam-count ' + (LEVELS[c.level] || 'lv-info'), String(c.count)));
}
node.setAttribute('aria-label',
c.id + ' 카메라, 마지막 감지 ' + c.when
+ (c.count > 0 ? ', 미확인 ' + (LEVEL_TEXT[c.level] || '') + ' 포함 ' + c.count + '건' : ', 미확인 없음')
+ ' — 이 카메라만 보기');
node.addEventListener('click', function () { onSelect(c.key); });
return node;
}
function renderSummary(summaryEl, groups, model) {
if (!summaryEl) return;
summaryEl.textContent = '';
if (!model.loaded) { summaryEl.textContent = '장치 상태 확인 중…'; return; }
// "장치" counts REGISTERED devices only — an unknown-device group is not a device the
// operator has, it is a discrepancy, and it gets its own count rather than padding this one.
var cams = 0, known = 0, down = 0, unknown = 0;
groups.forEach(function (g) {
cams += g.cams.length;
if (g.state === 'unknown') { unknown += 1; return; }
known += 1;
if (g.state === 'offline') down += 1;
});
summaryEl.appendChild(document.createTextNode('장치 ' + known + ' · 카메라 ' + cams));
if (down > 0) summaryEl.appendChild(el('span', 'is-down', ' · 오프라인 ' + down));
if (unknown > 0) summaryEl.appendChild(el('span', 'is-down', ' · 미등록 ' + unknown));
}
function render(root, summaryEl, model) {
if (!root) return;
last = { root: root, summaryEl: summaryEl, model: model };
var groups = groupsOf(model);
var sig = signature(groups, model);
if (sig === lastSig) return; // nothing visible changed: keep the DOM, and the focus in it
lastSig = sig;
// The tree is rebuilt, not patched, so a node the operator is tabbing through would lose
// focus on any change. Remember which node held it and hand focus back to its replacement.
var active = document.activeElement;
var refocus = (active && root.contains(active)) ? active.getAttribute('data-node') : null;
renderSummary(summaryEl, groups, model);
root.textContent = '';
root.className = 'device-tree' + (model.stale ? ' is-stale' : '') + (groups.length === 0 ? ' is-empty' : '');
if (model.stale) {
// A frozen panel is worse than an empty one: a camera that dies while the poll is down
// would keep showing "온라인". Say the state is old instead of quietly implying it is live.
root.appendChild(el('div', 'device-stale-note', '상태 갱신 지연 — 마지막으로 확인된 상태입니다'));
}
if (groups.length === 0) {
root.appendChild(document.createTextNode(
model.loaded ? '등록된 장치가 없습니다. 관리자가 장치를 등록하면 여기에 표시됩니다.' : '불러오는 중…'));
return;
}
var frag = document.createDocumentFragment();
groups.forEach(function (g) {
var shut = isCollapsed(g, model.selectedKey);
var node = el('div', 'device-node'
+ (g.state === 'offline' ? ' is-offline' : '')
+ (g.state === 'unknown' ? ' is-unknown' : '')
+ (shut ? ' is-collapsed' : ''));
var head = buildDeviceHead(g, shut);
head.addEventListener('click', function () { toggle(g.id); });
node.appendChild(head);
if (!shut) {
if (g.cams.length === 0) {
node.appendChild(el('div', 'cam-empty', '수신된 감지 이벤트 없음'));
} else {
var list = el('ul', 'cam-list');
g.cams.forEach(function (c) {
var item = el('li', 'cam-item');
item.appendChild(buildCamNode(c, model.onSelect, model.selectedKey));
list.appendChild(item);
});
node.appendChild(list);
}
}
frag.appendChild(node);
});
root.appendChild(frag);
if (refocus) {
var nodes = root.querySelectorAll('[data-node]');
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].getAttribute('data-node') === refocus) { nodes[i].focus(); break; }
}
}
}
function toggle(deviceId) {
if (collapsed[deviceId]) delete collapsed[deviceId];
else collapsed[deviceId] = true;
if (!last) return;
lastSig = null; // collapse state is part of the signature; force the rebuild
render(last.root, last.summaryEl, last.model);
}
window.DeviceTree = { render: render };
})();

View file

@ -1,240 +0,0 @@
/**
* 사고조사 패키지.
*
* 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다:
* 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지,
* 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면
* 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다.
*
* 인쇄 CSS 가 앱 크롬을 걷어내므로 이 화면 그대로 PDF 로 저장해 제출할 수 있다.
*
* XSS-safe: 모든 동적 값은 textContent.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var root = document.getElementById('package-root');
if (!root) return;
var m = location.pathname.match(/\/events\/(\d+)\/package/);
if (!m) { root.textContent = '잘못된 주소입니다.'; return; }
var id = m[1];
var TYPE_LABELS = {
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션',
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
};
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
var ALERT_STATUS = {
sent: '발송됨', pending: '발송 대기', failed: '발송 실패', suppressed: '억제됨 (정비 중)'
};
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = text;
return n;
}
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function stamp(ts) {
var d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '-';
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function clockOnly(ts) {
var d = new Date(Number(ts) * 1000);
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function section(title) {
var s = el('section', 'pkg-section');
s.appendChild(el('h2', 'pkg-h2', title));
return s;
}
function kv(parent, label, value) {
var row = el('div', 'pkg-kv');
row.appendChild(el('dt', null, label));
row.appendChild(el('dd', null, value === null || value === undefined || value === '' ? '-' : String(value)));
parent.appendChild(row);
}
function render(d) {
var ev = d.event || {};
root.textContent = '';
// ---- 표지 -----------------------------------------------------------------
var head = el('header', 'pkg-head');
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
var sub = el('p', 'pkg-sub');
sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, ev.camera_id, stamp(ev.occurred_at)]
.filter(Boolean).join(' · ');
head.appendChild(sub);
root.appendChild(head);
// ---- 사건 개요 -------------------------------------------------------------
var facts = section('사건 개요');
var dl = el('dl', 'pkg-facts');
kv(dl, '사건 번호', ev.event_id);
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
kv(dl, '장치', ev.device_id);
kv(dl, '카메라', ev.camera_id);
kv(dl, '현장', ev.site);
kv(dl, '발생 시각', stamp(ev.occurred_at));
kv(dl, '지속', (ev.duration_s || 0) + '초');
kv(dl, '처리 상태', ev.false_positive_by ? ('오탐 판정 · ' + ev.false_positive_by)
: (ev.acknowledged_by ? ('확인 · ' + ev.acknowledged_by + ' · ' + stamp(ev.acknowledged_at))
: '미확인'));
// 대응 시간은 조사에서 가장 먼저 묻는 숫자다.
if (ev.acknowledged_at) {
var tta = Math.max(0, Math.round(ev.acknowledged_at - ev.occurred_at));
kv(dl, '대응 소요', tta < 60 ? tta + '초' : Math.floor(tta / 60) + '분 ' + (tta % 60) + '초');
}
facts.appendChild(dl);
root.appendChild(facts);
// ---- 증거 프레임 -----------------------------------------------------------
var evidence = section('증거 프레임');
var snaps = (d.snapshots || []).filter(function (s) { return s && s.file_uuid; });
if (!snaps.length) {
evidence.appendChild(el('p', 'pkg-empty',
'저장된 스냅샷이 없습니다 (미촬영이거나 개인정보 보존기간이 지나 삭제됨).'));
} else {
var strip = el('div', 'pkg-frames');
var CAPTIONS = ['이전', '직전', '발생 시점'];
snaps.forEach(function (s, i) {
var fig = el('figure', 'pkg-frame');
var img = document.createElement('img');
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
img.alt = '사고 스냅샷';
img.loading = 'lazy';
fig.appendChild(img);
fig.appendChild(el('figcaption', null,
snaps.length > 1 ? CAPTIONS[Math.max(0, CAPTIONS.length - snaps.length + i)] : '발생 시점'));
strip.appendChild(fig);
});
evidence.appendChild(strip);
evidence.appendChild(el('p', 'pkg-note',
'얼굴은 개인정보 보호를 위해 촬영 시점에 블러 처리되어 저장됩니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.'));
}
root.appendChild(evidence);
// ---- 전후 타임라인 ---------------------------------------------------------
var win = Math.round((d.context_window_s || 1800) / 60);
var tl = section('같은 카메라의 전후 ' + win + '분');
var rows = d.timeline || [];
if (!rows.length) {
tl.appendChild(el('p', 'pkg-empty', '해당 구간에 다른 감지가 없습니다.'));
} else {
var list = el('ol', 'pkg-timeline');
rows.forEach(function (r) {
var li = el('li', 'pkg-tl-row' + (r.event_id === ev.event_id ? ' is-subject' : ''));
li.appendChild(el('span', 'pkg-tl-time', clockOnly(r.occurred_at)));
li.appendChild(el('span', 'pkg-tl-sev sev-' + r.severity, SEV_LABELS[r.severity] || r.severity));
li.appendChild(el('span', 'pkg-tl-type', TYPE_LABELS[r.event_type] || r.event_type));
li.appendChild(el('span', 'pkg-tl-state',
r.false_positive_by ? '오탐' : (r.acknowledged_by ? '확인됨' : '미확인')));
list.appendChild(li);
});
tl.appendChild(list);
}
root.appendChild(tl);
// ---- 통보 이력 -------------------------------------------------------------
// 시스템이 "봤다"는 것만 증명하고 사람에게 "닿았는가"를 비워두면 조사의 절반이 빈다.
var notif = section('통보 이력');
var alerts = d.notifications || [];
if (!alerts.length) {
notif.appendChild(el('p', 'pkg-empty',
'이 사건에 대한 통보 기록이 없습니다. 통보 경로가 설정되지 않았거나, 심각도가 발송 기준에 못 미쳤습니다.'));
} else {
var nt = el('ul', 'pkg-alerts');
alerts.forEach(function (a) {
var li = el('li', 'pkg-alert is-' + a.status);
li.appendChild(el('span', 'pkg-alert-ch', (a.channel_name || '삭제된 채널')));
li.appendChild(el('span', 'pkg-alert-status', ALERT_STATUS[a.status] || a.status));
li.appendChild(el('span', 'pkg-alert-when',
a.sent_at ? stamp(a.sent_at) : ('접수 ' + stamp(a.queued_at))));
if (a.last_error) li.appendChild(el('span', 'pkg-alert-err', a.last_error));
nt.appendChild(li);
});
notif.appendChild(nt);
}
root.appendChild(notif);
// ---- 위험성평가 -------------------------------------------------------------
var risk = section('해당 위험요인 · 통제 대책');
if (!d.risk_control) {
risk.appendChild(el('p', 'pkg-empty',
'이 감지 유형에 등록된 위험요인이 없습니다. [안전 관리] 화면에서 등록하면 다음 조사부터 함께 실립니다.'));
} else {
var rc = d.risk_control;
var rdl = el('dl', 'pkg-facts');
kv(rdl, '위험요인', rc.hazard);
kv(rdl, '현재 대책', rc.control);
kv(rdl, '위험도', rc.risk_score + ' (강도 ' + rc.severity_rating + ' × 빈도 ' + rc.likelihood_rating + ')');
kv(rdl, '최종 검토', rc.reviewed_by ? (rc.reviewed_by + ' · ' + stamp(rc.reviewed_at)) : '-');
risk.appendChild(rdl);
}
root.appendChild(risk);
// ---- 조치 이력 -------------------------------------------------------------
var acts = section('이 카메라의 개선 조치 이력');
var actions = d.actions || [];
if (!actions.length) {
acts.appendChild(el('p', 'pkg-empty', '기록된 조치가 없습니다.'));
} else {
var al = el('ul', 'pkg-actions');
actions.forEach(function (a) {
var li = el('li', null);
li.appendChild(el('span', 'pkg-act-date', stamp(a.taken_at).slice(0, 10)));
li.appendChild(el('span', 'pkg-act-note', a.note));
li.appendChild(el('span', 'pkg-act-by', a.created_by));
al.appendChild(li);
});
acts.appendChild(al);
}
root.appendChild(acts);
// ---- 서명 -----------------------------------------------------------------
var sign = section('확인');
var grid = el('div', 'pkg-sign');
['조사자', '안전관리자', '사업주 · 대리인'].forEach(function (role) {
var box = el('div', 'pkg-sign-box');
box.appendChild(el('span', 'pkg-sign-role', role));
box.appendChild(el('span', 'pkg-sign-line', ''));
grid.appendChild(box);
});
sign.appendChild(grid);
root.appendChild(sign);
var foot = el('footer', 'pkg-foot');
foot.textContent = '생성 ' + stamp(d.generated_at) + ' · 작성 ' + (d.generated_by || '-')
+ ' · 본 문서는 AI 감지 기록에 근거한 보조 자료이며 공식 재해조사 결과를 대체하지 않습니다.';
root.appendChild(foot);
document.title = '사고조사 #' + ev.event_id + ' · AI SAFETY CCTV';
}
var printBtn = document.getElementById('package-print');
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
fetch(B + '/api/events/' + encodeURIComponent(id) + '/package', { headers: { Accept: 'application/json' } })
.then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
if (r.status === 404) { root.textContent = '해당 사건을 찾을 수 없습니다.'; return null; }
if (!r.ok) throw new Error('package_failed');
return r.json();
})
.then(function (d) { if (d) render(d); })
.catch(function () { root.textContent = '패키지를 불러오지 못했습니다.'; });
})();

View file

@ -1,405 +0,0 @@
/**
* Safety operations report: fetches GET /api/report?days=N and renders the management
* indicators a 안전관리자 takes into the weekly safety meeting. Read-only; the 인쇄 button
* prints the page (print CSS strips the app chrome).
*
* XSS-safe: every dynamic value goes through textContent; the only inline style is a
* numeric bar width.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
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: '작업자세 부담 리포트'
};
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
var PATTERN_MIN_PEAK = 4; // 요일×시간 격자에서 최다 칸이 이보다 적으면 무늬는 우연
var ACTION_MIN_BEFORE = 5; // 개선조치 전후 비교의 최소 '전' 표본
var root = document.getElementById('report-kpis');
if (!root) return;
var daysSel = document.getElementById('report-days');
var printBtn = document.getElementById('report-print');
var rangeEl = document.getElementById('report-range');
var typesEl = document.getElementById('report-types');
var camerasEl = document.getElementById('report-cameras');
var ergoEl = document.getElementById('report-ergo');
var rulesEl = document.getElementById('report-rules');
var footEl = document.getElementById('report-foot');
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 fmtDuration(s) {
if (s === null || s === undefined) return '—';
if (s < 60) return Math.round(s) + '초';
if (s < 3600) return Math.floor(s / 60) + '분 ' + Math.round(s % 60) + '초';
return Math.floor(s / 3600) + '시간 ' + Math.floor((s % 3600) / 60) + '분';
}
function fmtHours(s) {
if (!s) return '0분';
if (s < 3600) return Math.round(s / 60) + '분';
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 : ''));
t.appendChild(el('span', 'kpi-label', label));
t.appendChild(el('strong', 'kpi-value', value));
if (note) t.appendChild(el('span', 'kpi-note', note));
return t;
}
// 전기간 대비 추이 한 줄. neutral: 니어미스처럼 증감의 좋고 나쁨을 단정할 수 없는 지표
// (감지가 늘어난 것일 수도, 위험이 늘어난 것일 수도) — 방향만 보여주고 색은 칠하지 않는다.
function trendLine(tile, prevVal, curVal, fmt, neutral) {
if (prevVal === null || prevVal === undefined) return;
var line = el('span', 'kpi-trend');
if (prevVal === curVal) {
line.textContent = '이전 기간과 동일';
} else {
var up = curVal > prevVal;
line.textContent = (up ? '▲ ' : '▼ ') + '이전 기간 ' + fmt(prevVal) + ' → ' + fmt(curVal);
if (!neutral) line.className = 'kpi-trend ' + (up ? 'is-worse' : 'is-better');
}
tile.appendChild(line);
}
function barRow(label, value, max, text, hot) {
var row = el('div', 'bar-row');
var head = el('div', 'bar-head');
head.appendChild(el('span', 'bar-label', label));
head.appendChild(el('span', 'bar-value' + (hot ? ' is-hot' : ''), text));
row.appendChild(head);
var track = el('div', 'bar-track');
var fill = el('div', 'bar-fill' + (hot ? ' is-hot' : ''));
fill.style.width = (max > 0 ? Math.max(2, Math.round(value / max * 100)) : 0) + '%';
track.appendChild(fill);
row.appendChild(track);
return row;
}
function render(r) {
var t = r.totals;
var severe = t.critical + t.high;
rangeEl.textContent = '최근 ' + r.days + '일 · '
+ fmtDate(r.generated_at - r.days * 86400) + ' ~ ' + fmtDate(r.generated_at);
// 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다.
var alertEl = document.getElementById('report-alert');
if (alertEl) {
var sp = r.precursor_alert || {};
alertEl.hidden = !sp.alert;
alertEl.textContent = '';
if (sp.alert) {
var lead = el('strong', null, '⚠ 전조 급증');
alertEl.appendChild(lead);
alertEl.appendChild(document.createTextNode(
' — 최근 7일 니어미스·구역 신호 ' + sp.this_week + '건, 이전 4주 주평균 '
+ sp.baseline_weekly_avg + '건' + (sp.ratio ? ' (' + sp.ratio + '배)' : '')
+ '. 배치·동선·휴식 등 사전 개입을 검토하세요.'));
}
}
root.textContent = '';
var prev = r.previous || {};
var kSevere = kpi('중대 이벤트', String(severe),
'위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : '');
trendLine(kSevere, (prev.critical || 0) + (prev.high || 0), severe, String);
root.appendChild(kSevere);
var kTta = kpi('평균 대응 시간', fmtDuration(r.tta.avg_s),
r.tta.acked > 0 ? '중대 ' + r.tta.acked + '건 확인 · 최장 ' + fmtDuration(r.tta.max_s) : '확인된 중대 이벤트 없음');
if (r.tta.avg_s !== null) trendLine(kTta, prev.tta_avg_s, r.tta.avg_s, fmtDuration);
root.appendChild(kTta);
// 하인리히 지표: 니어미스가 사고보다 얼마나 앞서 잡히고 있나 — 예방이 작동한다는 증거.
var nm = r.near_miss;
var kNm = kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents,
nm.near_fall > 0 ? '사고 전 신호가 먼저 잡히고 있습니다' : '기록된 니어미스 없음');
trendLine(kNm, prev.near_fall, nm.near_fall, String, true);
root.appendChild(kNm);
var fpPct = r.fp.total > 0 ? Math.round(r.fp.tagged / r.fp.total * 100) : 0;
var prevFpPct = prev.fp && prev.fp.total > 0 ? Math.round(prev.fp.tagged / prev.fp.total * 100) : null;
var kFp = kpi('오탐율', fpPct + '%',
'전체 ' + r.fp.total + '건 중 오탐 태그 ' + r.fp.tagged + '건', fpPct >= 30 ? 'is-warn' : '');
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', '기간 내 감지가 없습니다.'));
var maxType = typeKeys.reduce(function (m, k) { return Math.max(m, r.by_type[k]); }, 0);
typeKeys.forEach(function (k) {
typesEl.appendChild(barRow(TYPE_LABELS[k] || k, r.by_type[k], maxType, r.by_type[k] + '건'));
});
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, maxSevere > 0 && c.critical_high >= hotFloor));
});
if (maxSevere > 0) {
camerasEl.appendChild(el('p', 'report-empty',
'중대(위급+높음)가 ' + hotFloor + '건 이상인 카메라를 강조했습니다.'));
}
// 인체공학: 관측 시간 대비 각 밴드 노출 — 절대시간과 비율을 함께.
ergoEl.textContent = '';
var eg = r.ergo;
if (!eg.observed_s) {
ergoEl.appendChild(el('p', 'report-empty', '기간 내 자세 관측 데이터가 없습니다.'));
} else {
[['trunk_high_s', '허리 고위험 (60°+)', true],
['trunk_mod_s', '허리 중위험 (20~60°)', false],
['arm_high_s', '팔 거상 (90°+)', false],
['squat_s', '깊은 웅크림', false]].forEach(function (row) {
var v = eg[row[0]] || 0;
var pct = Math.round(v / eg.observed_s * 100);
ergoEl.appendChild(barRow(row[1], v, eg.observed_s,
fmtHours(v) + ' (' + pct + '%)', row[2] && pct >= 20));
});
ergoEl.appendChild(el('p', 'report-empty', '총 관측 ' + fmtHours(eg.observed_s)
+ ' — 비율은 관측 시간 대비입니다.'));
}
rulesEl.textContent = '';
var oc = r.occupancy;
var rules = el('div', 'rule-stats');
// '미확인 잔여'는 존 규칙 위반이 아니다. 같은 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');
if (patternEl) {
patternEl.textContent = '';
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');
var head = el('div', 'pattern-row pattern-head');
head.appendChild(el('span', 'pattern-day', ''));
for (var h = 0; h < 24; h++) head.appendChild(el('span', 'pattern-hour', h % 6 === 0 ? String(h) : ''));
table.appendChild(head);
pt.grid.forEach(function (rowArr, d) {
var row = el('div', 'pattern-row');
row.appendChild(el('span', 'pattern-day', DAYS[d]));
rowArr.forEach(function (n, h) {
var cell = el('span', 'pattern-cell');
if (n > 0) {
cell.style.opacity = String(0.25 + 0.75 * (n / pt.max));
cell.classList.add('is-filled');
}
cell.title = DAYS[d] + ' ' + h + '시 · ' + n + '건';
cell.setAttribute('aria-label', cell.title);
row.appendChild(cell);
});
table.appendChild(row);
});
patternEl.appendChild(table);
patternEl.appendChild(el('p', 'report-empty', '진할수록 감지가 몰린 시간대입니다 (최대 ' + pt.max + '건).'));
}
}
footEl.textContent = '생성 ' + fmtStamp(r.generated_at)
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
}
// --- 개선 조치 효과 --------------------------------------------------------------
var actionForm = document.getElementById('action-form');
var actionCamera = document.getElementById('action-camera');
var actionNote = document.getElementById('action-note');
var actionsRoot = document.getElementById('report-actions');
var csrfToken = null;
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 () { /* 기록 시 403으로 드러난다 */ });
}
// 이 줄은 안전회의에 그대로 올라간다. 표본 6건에서 나온 "▲33%"를 확정 색으로 칠하면
// 회의에서 바로 반박당하고, 그 다음부터는 이 카드 전체가 신뢰를 잃는다. 표본이 임계 미만이면
// 원시 수치만 중립 톤으로 적고 판정을 미룬다. 창 길이도 함께 적어 서로 다른 창의 두 행이
// 같은 잣대로 읽히지 않게 한다.
function actionEffect(a) {
if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)');
var e = a.effect;
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;
}
// 카운트 데이터의 자연 변동폭(√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) {
if (!actionsRoot) return;
actionsRoot.textContent = '';
if (!list || list.length === 0) {
actionsRoot.appendChild(el('p', 'report-empty',
'기록된 조치가 없습니다. 조치를 기록하면 전후 감지 밀도를 자동 비교합니다.'));
return;
}
list.forEach(function (a) {
var row = el('div', 'action-row');
var what = el('div', 'action-what');
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);
row.appendChild(actionEffect(a));
actionsRoot.appendChild(row);
});
}
function loadActions() {
if (!actionsRoot) return;
fetch(B + '/api/actions', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) { if (d) renderActions(d.actions); })
.catch(function () { /* 카드 유지 */ });
}
if (actionForm) {
actionForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var camera = (actionCamera.value || '').trim();
var note = (actionNote.value || '').trim();
if (!camera || !note) return;
fetch(B + '/api/actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
body: JSON.stringify({ camera_id: camera, note: note })
})
.then(function (res) {
if (!res.ok) throw new Error('action_failed:' + res.status);
actionNote.value = '';
loadActions();
})
.catch(function () { /* 권한/CSRF 실패: 입력 유지 */ });
});
}
var exportLink = document.getElementById('export-labels');
if (exportLink) exportLink.href = B + '/api/export/labels?days=90';
function load() {
var days = daysSel ? daysSel.value : '7';
fetch(B + '/api/report?days=' + encodeURIComponent(days), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('report_failed:' + res.status);
return res.json();
})
.then(function (data) { if (data) render(data); })
.catch(function () {
rangeEl.textContent = '리포트를 불러오지 못했습니다. 새로고침해 주세요.';
});
}
if (daysSel) daysSel.addEventListener('change', load);
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
load();
loadActions();
loadCsrf();
})();

View file

@ -1,168 +0,0 @@
/**
* 설치 점검 (온보딩).
*
* 납품 직후가 이 제품에서 가장 위험한 구간이다. 대시보드는 멀쩡히 뜨고, 로그인도 되고,
* 화면 어디에도 오류가 없는데 — 장치가 등록되지 않았거나, 존을 안 그렸거나, 통보 경로가
* 비어 있으면 시스템은 "감시 중"으로 보이면서 실제로는 아무것도 하지 않는다. 그 상태가
* 몇 주씩 유지되다가 사고가 나서야 발견되는 것이 최악의 시나리오다.
*
* 그래서 각 단계는 "설정했다고 표시"가 아니라 **실제 데이터를 조회해** 판정한다. 체크가
* 켜졌다면 그 단계는 정말로 동작하고 있다.
*
* XSS-safe: 모든 동적 값은 textContent.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var listEl = document.getElementById('setup-steps');
if (!listEl) return;
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = text;
return n;
}
function getJson(path) {
return fetch(B + path, { headers: { Accept: 'application/json' } })
.then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
// 403 은 "권한 없음"이지 "미설정"이 아니다. 단계는 판정 보류로 남긴다.
if (r.status === 403) return { _forbidden: true };
return r.ok ? r.json() : null;
})
.catch(function () { return null; });
}
function render(steps) {
listEl.textContent = '';
steps.forEach(function (s, i) {
var li = el('li', 'setup-step is-' + s.state);
var mark = el('span', 'setup-mark');
mark.textContent = s.state === 'ok' ? '✓' : (s.state === 'unknown' ? '?' : String(i + 1));
mark.setAttribute('aria-hidden', 'true');
li.appendChild(mark);
var body = el('div', 'setup-body');
body.appendChild(el('h2', 'setup-title', s.title));
body.appendChild(el('p', 'setup-status', s.status));
// 이 단계가 안 되어 있으면 무엇이 벌어지는가 — 체크리스트를 지시가 아니라 이유로 만든다.
if (s.state !== 'ok' && s.why) body.appendChild(el('p', 'setup-why', s.why));
if (s.href) {
var a = el('a', 'setup-link', s.linkText || '설정하러 가기 →');
a.href = B + s.href;
body.appendChild(a);
}
li.appendChild(body);
listEl.appendChild(li);
});
}
Promise.all([
getJson('/api/devices'),
getJson('/api/events?limit=200'),
getJson('/api/admin/channels')
]).then(function (res) {
var devicesRes = res[0] || {};
var eventsRes = res[1] || {};
var channelsRes = res[2] || {};
var devices = devicesRes.devices || [];
var online = devices.filter(function (d) { return d.online; });
var events = eventsRes.events || [];
var cameras = {};
events.forEach(function (e) { if (e.camera_id) cameras[e.camera_id] = true; });
var cameraCount = Object.keys(cameras).length;
var steps = [];
// 1. 장치 등록
steps.push(devices.length === 0 ? {
state: 'todo', title: '① 장치 등록',
status: '등록된 엣지 장치가 없습니다.',
why: '장치 행이 없으면 현장 노드가 보내는 이벤트는 인증에서 거부됩니다. 장치별 비밀키는 관리자가 DB에 직접 발급합니다 (dist/PROVISION_SECRETS.md).',
} : {
state: 'ok', title: '① 장치 등록',
status: devices.length + '대 등록됨 · 현재 온라인 ' + online.length + '대'
});
// 2. 장치 통신 (하트비트)
steps.push(online.length === 0 ? {
state: devices.length ? 'todo' : 'blocked', title: '② 장치 통신',
status: devices.length ? '하트비트를 보내는 장치가 없습니다.' : '장치 등록 후 확인할 수 있습니다.',
why: '장치가 서버에 닿지 못하면 감지는 노드 안에서만 일어나고 대시보드는 영원히 비어 있습니다. 노드의 CAFE24_URL·DEVICE_SECRET 과 방화벽을 확인하세요.',
} : {
state: 'ok', title: '② 장치 통신', status: online.length + '대가 하트비트를 보내고 있습니다.'
});
// 3. 카메라 감지
steps.push(cameraCount === 0 ? {
state: online.length ? 'todo' : 'blocked', title: '③ 카메라 감지',
status: online.length ? '아직 어떤 카메라도 이벤트를 보낸 적이 없습니다.' : '장치 통신 후 확인할 수 있습니다.',
why: '카메라가 붙어 있어도 이벤트가 한 번도 없으면 실제로 사람을 검출하고 있는지 알 수 없습니다. 감시 구역에서 한 번 앉았다 일어나 보고, 그 사건이 스트림에 뜨는지 확인하세요.',
href: '/', linkText: '감지 스트림 보기 →'
} : {
state: 'ok', title: '③ 카메라 감지', status: cameraCount + '대의 카메라가 이벤트를 보냈습니다.'
});
// 4. 위험구역
getJson('/api/zones/' + encodeURIComponent(Object.keys(cameras)[0] || '_none_'))
.then(function (zoneRes) {
var zones = (zoneRes && zoneRes.zones) || [];
steps.push(zones.length === 0 ? {
state: cameraCount ? 'todo' : 'blocked', title: '④ 위험구역 설정',
status: cameraCount ? '첫 카메라에 저장된 구역이 없습니다.' : '카메라 감지 후 설정할 수 있습니다.',
why: '구역이 없으면 넘어짐·부동 감지는 동작하지만 제한구역 침입·협착·2인1조 같은 규칙은 전부 비활성입니다.',
href: '/zones', linkText: '구역 설정으로 →'
} : {
state: 'ok', title: '④ 위험구역 설정',
status: '첫 카메라에 구역 ' + zones.length + '개가 설정되어 있습니다.'
});
// 5. 통보 경로 — 이 제품에서 가장 자주 비어 있는 칸이다.
if (channelsRes._forbidden) {
steps.push({
state: 'unknown', title: '⑤ 통보 경로',
status: '이 계정에는 통보 설정 권한이 없습니다. 관리자 계정으로 확인하세요.'
});
} else {
var chans = (channelsRes.channels || []).filter(function (c) { return c.enabled; });
var local = chans.filter(function (c) { return c.kind === 'local'; });
steps.push(chans.length === 0 ? {
state: 'todo', title: '⑤ 통보 경로',
status: '사용 중인 통보 경로가 없습니다.',
why: '지금 상태에서는 감지가 되어도 브라우저를 보고 있는 사람에게만 전달됩니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다. 현장 경광등(엣지 GPIO)부터 설정하세요 — 인터넷이 끊겨도 동작합니다.',
href: '/admin', linkText: '통보 경로 설정 →'
} : {
state: local.length ? 'ok' : 'warn', title: '⑤ 통보 경로',
status: chans.length + '개 사용 중' + (local.length ? ' (현장 경광등 포함)' : ' — 현장 경광등 없음'),
why: local.length ? null : '메일·웹훅은 서버와 네트워크가 살아 있을 때만 닿습니다. 정전이나 회선 장애 때도 울릴 경로가 하나는 있어야 합니다.',
href: '/admin', linkText: '통보 경로 보기 →'
});
}
// 6. 통보 디스패처 — 서버 상태로는 알 수 없다. 정직하게 '확인 필요'로 남긴다.
steps.push({
state: 'unknown', title: '⑥ 통보 디스패처 (크론)',
status: '이 화면에서는 확인할 수 없습니다.',
why: '메일·웹훅 통보와 미확인 에스컬레이션은 tools/dispatch_alerts.php 가 크론으로 돌아야 동작합니다. 호스팅 관리자 화면의 크론 설정에서 1분 주기로 등록되어 있는지 직접 확인하세요. (--dry-run 으로 먼저 시험할 수 있습니다.)'
});
// 7. 위험성평가
getJson('/api/risks').then(function (riskRes) {
var controls = (riskRes && riskRes.controls) || [];
steps.push(controls.length === 0 ? {
state: 'todo', title: '⑦ 위험성평가 연계',
status: '등록된 위험요인이 없습니다.',
why: '없어도 감지는 동작합니다. 다만 사고조사 패키지와 관리체계 증빙에서 "무엇을 통제하고 있었나" 칸이 비게 됩니다.',
href: '/compliance', linkText: '안전 관리로 →'
} : {
state: 'ok', title: '⑦ 위험성평가 연계', status: controls.length + '개 항목이 등록되어 있습니다.'
});
render(steps);
});
});
});
})();

View file

@ -33,31 +33,17 @@
var saveBtn = document.getElementById('zone-save-btn'); var saveBtn = document.getElementById('zone-save-btn');
var listEl = document.getElementById('zone-list'); var listEl = document.getElementById('zone-list');
var statusEl = document.getElementById('zone-status'); var statusEl = document.getElementById('zone-status');
var ruleMinInput = document.getElementById('rule-min-workers');
var ruleMaxInput = document.getElementById('rule-max-workers');
var ruleDwellInput = document.getElementById('rule-max-dwell');
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 = { var LEVEL_STYLE = {
warning: { stroke: '#b7820a', fill: 'rgba(183,130,10,.16)', label: '경고', dash: null }, warning: { stroke: '#eab308', label: '경고' },
restricted: { stroke: '#dc2626', fill: 'rgba(220,38,38,.16)', label: '제한', dash: null }, restricted: { stroke: '#dc2626', label: '제한' },
pinch: { stroke: '#e2560c', fill: 'rgba(226,86,12,.18)', label: '협착', dash: [9, 5] } pinch: { stroke: '#a855f7', label: '협착' }
}; };
var DRAFT_STYLE = { stroke: '#0b6b7a', fill: 'rgba(11,107,122,.14)', label: '편집중', dash: [5, 4] };
var backgroundImage = null; var backgroundImage = null;
var existingZones = []; // [{zone_id, level, points: [[x,y],...]}] var existingZones = []; // [{zone_id, level, points: [[x,y],...]}]
var currentPoints = []; // in-progress polygon being drawn var currentPoints = []; // in-progress polygon being drawn
var csrfToken = null; var csrfToken = null;
var heatmapOn = false;
var heatmapPoints = []; // [{x, y, severity}] normalized 0..1 (same space as zones)
var heatmapLayer = null; // offscreen canvas, rebuilt when points change
function setStatus(msg) { function setStatus(msg) {
statusEl.textContent = msg; statusEl.textContent = msg;
@ -86,120 +72,25 @@
} }
} }
function drawPolygon(points, style, label, closed) { function drawPolygon(points, strokeStyle, label, closed) {
if (points.length === 0) return; if (points.length === 0) return;
ctx.save(); ctx.strokeStyle = strokeStyle;
ctx.fillStyle = strokeStyle;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]); ctx.moveTo(points[0][0], points[0][1]);
for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]); for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
if (closed && points.length > 2) ctx.closePath(); if (closed && points.length > 2) ctx.closePath();
// Fill first so the stroke sits on top of its own tint.
if (closed && points.length > 2 && style.fill) {
ctx.fillStyle = style.fill;
ctx.fill();
}
ctx.strokeStyle = style.stroke;
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
if (style.dash) ctx.setLineDash(style.dash);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]);
// Vertex handles: white-cored so they stay visible on both a bright floor and a dark shadow.
points.forEach(function (p) { points.forEach(function (p) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(p[0], p[1], 4, 0, Math.PI * 2); ctx.arc(p[0], p[1], 3, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill(); ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = style.stroke;
ctx.stroke();
}); });
if (label) drawLabelChip(label, points[0][0], points[0][1], style.stroke); if (label) {
ctx.restore(); ctx.font = '14px sans-serif';
} ctx.fillText(label, points[0][0] + 6, points[0][1] - 6);
// A plain fillText label vanished into whatever the camera happened to be looking at.
// Draw it on its own solid chip so the level is legible over any frame.
function drawLabelChip(text, x, y, bg) {
ctx.save();
ctx.font = '600 13px system-ui, sans-serif';
ctx.textBaseline = 'middle';
var padX = 7, h = 20;
var w = ctx.measureText(text).width + padX * 2;
var cx = Math.min(Math.max(x + 8, 2), canvas.width - w - 2);
var cy = Math.min(Math.max(y - 14, h / 2 + 2), canvas.height - h / 2 - 2);
ctx.fillStyle = bg;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(cx, cy - h / 2, w, h, 5);
ctx.fill();
} else {
ctx.fillRect(cx, cy - h / 2, w, h);
} }
ctx.fillStyle = '#ffffff';
ctx.fillText(text, cx + padX, cy);
ctx.restore();
}
// ---- 위험 히트맵 오버레이 -------------------------------------------------------
// Density of everything this camera detected (incidents AND near-misses) over the window,
// drawn as additive radial blots UNDER the zone polygons. Severity weighs the alpha:
// a cluster of near-misses reads lighter than one confirmed fall at the same spot.
var HEAT_STYLE = {
critical: { color: '220,38,38', alpha: 0.34 },
high: { color: '220,38,38', alpha: 0.26 },
warning: { color: '217,119,6', alpha: 0.18 },
info: { color: '37,99,235', alpha: 0.11 }
};
function buildHeatmapLayer() {
var layer = document.createElement('canvas');
layer.width = canvas.width;
layer.height = canvas.height;
var lctx = layer.getContext('2d');
var radius = Math.round(canvas.width * 0.03); // ~29px at 960: a footprint, not a pin
heatmapPoints.forEach(function (p) {
var s = HEAT_STYLE[p.severity] || HEAT_STYLE.info;
var x = Math.min(1, Math.max(0, p.x)) * layer.width;
var y = Math.min(1, Math.max(0, p.y)) * layer.height;
var g = lctx.createRadialGradient(x, y, 0, x, y, radius);
g.addColorStop(0, 'rgba(' + s.color + ',' + s.alpha + ')');
g.addColorStop(1, 'rgba(' + s.color + ',0)');
lctx.fillStyle = g;
lctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
});
return layer;
}
function loadHeatmap() {
var camera = cameraInput.value.trim();
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,
{ headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('heatmap_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (!data) return;
heatmapPoints = data.points || [];
heatmapLayer = buildHeatmapLayer();
redraw();
setStatus(heatmapPoints.length
? '최근 ' + data.days + '일 감지 ' + heatmapPoints.length + '건을 표시합니다.'
: '최근 ' + data.days + '일 동안 좌표가 기록된 감지가 없습니다.');
})
.catch(function () { setStatus('히트맵을 불러오지 못했습니다.'); });
}
function toggleHeatmap() {
heatmapOn = !heatmapOn;
if (heatmapBtn) heatmapBtn.setAttribute('aria-pressed', heatmapOn ? 'true' : 'false');
if (heatmapOn) loadHeatmap();
else { heatmapLayer = null; heatmapPoints = []; redraw(); setStatus('히트맵을 껐습니다.'); }
} }
function redraw() { function redraw() {
@ -207,179 +98,34 @@
if (backgroundImage) { if (backgroundImage) {
ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height); ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
} }
if (heatmapOn && heatmapLayer) {
ctx.drawImage(heatmapLayer, 0, 0);
}
existingZones.forEach(function (z) { existingZones.forEach(function (z) {
var style = LEVEL_STYLE[z.level] || { stroke: '#626a79', fill: 'rgba(98,106,121,.14)', label: z.level, dash: null }; var style = LEVEL_STYLE[z.level] || { stroke: '#9ca3af', label: z.level };
drawPolygon(z.points, style, style.label, true); drawPolygon(z.points, style.stroke, style.label, true);
}); });
drawPolygon(currentPoints, DRAFT_STYLE, currentPoints.length ? DRAFT_STYLE.label : '', false); var current = LEVEL_STYLE[levelSelect.value] || { stroke: '#38bdf8', label: '' };
if (!backgroundImage && !existingZones.length && !currentPoints.length) drawCanvasHint(); drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', false);
}
// 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).
// Only filled fields are sent; an empty form means "no rules" and stores NULL.
function rulesFromInputs() {
var rules = {};
var minW = parseInt(ruleMinInput && ruleMinInput.value, 10);
var maxW = parseInt(ruleMaxInput && ruleMaxInput.value, 10);
var dwellMin = parseInt(ruleDwellInput && ruleDwellInput.value, 10);
if (minW > 0) rules.min_workers = minW;
if (maxW > 0) rules.max_workers = maxW;
if (dwellMin > 0) rules.max_dwell_s = dwellMin * 60;
return rules;
}
function rulesLabel(z) {
var r;
try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; }
var parts = [];
if (r.min_workers) parts.push('최소 ' + r.min_workers + '인');
if (r.max_workers) parts.push('최대 ' + r.max_workers + '인');
if (r.max_dwell_s) parts.push('체류 ' + Math.round(r.max_dwell_s / 60) + '분');
return parts.length ? ' · ' + parts.join(' · ') : '';
} }
function renderZoneList() { function renderZoneList() {
listEl.textContent = ''; listEl.textContent = '';
if (existingZones.length === 0) {
var none = document.createElement('li');
none.className = 'zone-empty';
none.textContent = '이 카메라에 저장된 구역이 없습니다.';
listEl.appendChild(none);
return;
}
existingZones.forEach(function (z) { existingZones.forEach(function (z) {
var li = document.createElement('li'); var li = document.createElement('li');
li.className = 'zone-item lv-' + z.level; var style = LEVEL_STYLE[z.level] || { label: z.level };
var label = document.createElement('span'); var label = document.createElement('span');
label.className = 'zone-item-label'; label.textContent = style.label + ' (' + z.points.length + '점)';
var swatch = document.createElement('span');
swatch.className = 'zone-swatch';
swatch.setAttribute('aria-hidden', 'true');
// Inline colour is the ONE place a raw hex belongs: it must match what the canvas drew.
swatch.style.background = (LEVEL_STYLE[z.level] || {}).fill || 'transparent';
swatch.style.borderColor = (LEVEL_STYLE[z.level] || {}).stroke || '#626a79';
label.appendChild(swatch);
label.appendChild(document.createTextNode(
(LEVEL_STYLE[z.level] || { label: z.level }).label + ' (' + z.points.length + '점)' + rulesLabel(z)));
li.appendChild(label); li.appendChild(label);
// Two-step delete. A zone is what makes 침입 alarms fire; it takes minutes to draw and
// the server has no restore. A single unconfirmed click was the least reversible action
// in the product and also the cheapest one to trigger by accident.
var del = document.createElement('button'); var del = document.createElement('button');
del.type = 'button'; del.type = 'button';
del.className = 'zone-del';
del.textContent = '삭제'; del.textContent = '삭제';
var armed = false, armTimer = null; del.addEventListener('click', function () { deleteZone(z.zone_id); });
del.addEventListener('click', function () {
if (!armed) {
armed = true;
del.classList.add('is-armed');
del.textContent = '정말 삭제?';
setStatus('한 번 더 누르면 이 구역이 삭제됩니다. 되돌릴 수 없습니다.');
armTimer = setTimeout(function () {
armed = false; del.classList.remove('is-armed'); del.textContent = '삭제';
}, 4000);
return;
}
if (armTimer) clearTimeout(armTimer);
deleteZone(z.zone_id);
});
li.appendChild(del); li.appendChild(del);
listEl.appendChild(li); listEl.appendChild(li);
}); });
} }
// --- 장치 · 카메라 선택기 ---------------------------------------------------------
// The editor used to demand two free-text ids the operator had to remember, while the
// events console already knew both. Devices come from /api/devices; the cameras under each
// device are derived from the events they have reported (there is no camera registry).
// A camera that has never sent an event cannot appear here, which is what the manual
// fallback in the view is for.
var deviceSelect = document.getElementById('device-select');
var cameraSelect = document.getElementById('camera-select');
var manualBox = document.getElementById('zone-manual');
var camerasByDevice = {};
function option(value, text) {
var o = document.createElement('option');
o.value = value;
o.textContent = text;
return o;
}
function fillCameraSelect(deviceId) {
if (!cameraSelect) return;
cameraSelect.textContent = '';
var cams = camerasByDevice[deviceId] || [];
if (!deviceId) { cameraSelect.appendChild(option('', '장치를 먼저 선택하세요')); return; }
if (cams.length === 0) {
cameraSelect.appendChild(option('', '이 장치가 보고한 카메라 없음'));
return;
}
cameraSelect.appendChild(option('', '카메라를 선택하세요'));
cams.forEach(function (c) { cameraSelect.appendChild(option(c, c)); });
}
function applySelection() {
if (deviceSelect) deviceInput.value = deviceSelect.value;
if (cameraSelect) cameraInput.value = cameraSelect.value;
}
function loadPickers() {
var devices = fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; });
var events = fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; });
return Promise.all([devices, events]).then(function (res) {
var devs = (res[0] && res[0].devices) || [];
var evs = (res[1] && res[1].events) || [];
evs.forEach(function (ev) {
if (!ev.camera_id) return;
var d = String(ev.device_id || '');
if (!camerasByDevice[d]) camerasByDevice[d] = [];
if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id);
});
Object.keys(camerasByDevice).forEach(function (d) { camerasByDevice[d].sort(); });
if (!deviceSelect) return;
deviceSelect.textContent = '';
if (devs.length === 0) {
deviceSelect.appendChild(option('', '등록된 장치가 없습니다'));
if (manualBox) manualBox.open = true;
return;
}
deviceSelect.appendChild(option('', '장치를 선택하세요'));
devs.forEach(function (d) {
deviceSelect.appendChild(option(d.device_id, (d.name || d.device_id) + ' · ' + d.device_id));
});
// Honour a deep link (?device=&camera=) or a value already typed into the manual fields.
if (deviceInput.value) {
deviceSelect.value = deviceInput.value;
fillCameraSelect(deviceInput.value);
if (cameraInput.value) cameraSelect.value = cameraInput.value;
}
});
}
function loadBackground(uuid) { function loadBackground(uuid) {
if (!uuid) { backgroundImage = null; redraw(); return; } if (!uuid) { backgroundImage = null; redraw(); return; }
var img = new Image(); var img = new Image();
@ -392,7 +138,7 @@
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope. // API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
function loadLatestFrame() { function loadLatestFrame() {
var camera = cameraInput.value.trim(); var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
setStatus('현재 화면을 불러오는 중...'); setStatus('현재 화면을 불러오는 중...');
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' }) fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
.then(function (r) { .then(function (r) {
@ -404,9 +150,9 @@
.catch(function (e) { setStatus(e.message); }); .catch(function (e) { setStatus(e.message); });
} }
function loadZones(doneMsg) { function loadZones() {
var camera = cameraInput.value.trim(); var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
setStatus('불러오는 중...'); setStatus('불러오는 중...');
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } }) fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
.then(function (res) { .then(function (res) {
@ -417,14 +163,12 @@
.then(function (data) { .then(function (data) {
if (!data) return; if (!data) return;
existingZones = (data.zones || []).map(function (z) { existingZones = (data.zones || []).map(function (z) {
return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z), rules_json: z.rules_json }; return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z) };
}); });
currentPoints = []; currentPoints = [];
renderZoneList(); renderZoneList();
redraw(); redraw();
setStatus(doneMsg || (existingZones.length setStatus('구역 ' + existingZones.length + '개를 불러왔습니다.');
? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.'
: '이 카메라에는 저장된 구역이 없습니다. 캔버스를 클릭해 새로 그리세요.'));
}) })
.catch(function () { setStatus('불러오기에 실패했습니다.'); }); .catch(function () { setStatus('불러오기에 실패했습니다.'); });
} }
@ -432,7 +176,7 @@
function saveZone() { function saveZone() {
var camera = cameraInput.value.trim(); var camera = cameraInput.value.trim();
var device = deviceInput.value.trim(); var device = deviceInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; } if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
setStatus('저장 중...'); setStatus('저장 중...');
fetch(B + '/api/zones', { fetch(B + '/api/zones', {
@ -446,8 +190,7 @@
// camera's own resolution). See the parsePolygon contract note above. // camera's own resolution). See the parsePolygon contract note above.
polygon: currentPoints.map(function (p) { polygon: currentPoints.map(function (p) {
return [p[0] / canvas.width, p[1] / canvas.height]; return [p[0] / canvas.width, p[1] / canvas.height];
}), })
rules: rulesFromInputs()
}) })
}) })
.then(function (res) { .then(function (res) {
@ -458,7 +201,8 @@
.then(function (data) { .then(function (data) {
if (!data) return; if (!data) return;
currentPoints = []; currentPoints = [];
loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.'); setStatus('저장되었습니다.');
loadZones();
}) })
.catch(function () { setStatus('저장에 실패했습니다.'); }); .catch(function () { setStatus('저장에 실패했습니다.'); });
} }
@ -471,7 +215,7 @@
.then(function (res) { .then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return; } if (res.status === 401) { window.location.href = B + '/login'; return; }
if (!res.ok) throw new Error('delete_failed:' + res.status); if (!res.ok) throw new Error('delete_failed:' + res.status);
loadZones('구역을 삭제했습니다.'); loadZones();
}) })
.catch(function () { setStatus('삭제에 실패했습니다.'); }); .catch(function () { setStatus('삭제에 실패했습니다.'); });
} }
@ -487,49 +231,15 @@
}); });
levelSelect.addEventListener('change', redraw); levelSelect.addEventListener('change', redraw);
loadBtn.addEventListener('click', function () { applySelection(); loadZones(); }); loadBtn.addEventListener('click', loadZones);
if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); }); if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); }); clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); }); 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); var params = new URLSearchParams(window.location.search);
if (params.get('camera')) cameraInput.value = params.get('camera'); if (params.get('camera')) cameraInput.value = params.get('camera');
if (params.get('device')) deviceInput.value = params.get('device'); if (params.get('device')) deviceInput.value = params.get('device');
if (params.get('camera') || params.get('device')) { if (manualBox) manualBox.open = false; }
loadBackground(params.get('snapshot')); loadBackground(params.get('snapshot'));
loadCsrfToken(); loadCsrfToken();
loadPickers(); if (cameraInput.value) loadZones();
renderZoneList(); // paint the "저장된 구역 없음" placeholder before any camera is chosen
redraw();
if (cameraInput.value) { loadZones(); loadLatestFrame(); }
})(); })();

View file

@ -41,15 +41,12 @@ use App\Db\Database;
use App\Auth\PhpSessionStore; use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository, use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository, PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository, PdoThresholdRepository, PdoAuditRepository};
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
use App\Ingest\IngestController; use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController}; use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController}; use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController, use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
DeviceStatusController, HeatmapController, FpStatsController, ReportController, DeviceStatusController};
ExportController, ActionsController, AdminConfigController, SuppressionController,
ComplianceController};
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so // Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off // an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
@ -87,37 +84,21 @@ $users = new PdoUserRepository($pdo);
$zones = new PdoZoneRepository($pdo); $zones = new PdoZoneRepository($pdo);
$thr = new PdoThresholdRepository($pdo); $thr = new PdoThresholdRepository($pdo);
$audit = new PdoAuditRepository($pdo); $audit = new PdoAuditRepository($pdo);
$suppressions = new PdoSuppressionRepository($pdo);
$alerts = new PdoAlertRepository($pdo);
$handovers = new PdoHandoverRepository($pdo);
$risks = new PdoRiskControlRepository($pdo);
$metrics = new PdoDeviceMetricRepository($pdo);
$actions = new PdoActionRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts); $config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate);
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate, $metrics); $heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
$auth = new AuthController($users, $audit, $rate); $auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController(); $csrfCtl = new CsrfController();
$list = new EventListController($events, $snaps, $sess); $list = new EventListController($events, $snaps, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg); $deviceStatus = new DeviceStatusController($devices, $sess);
$heatmap = new HeatmapController($events, $sess);
$fpStats = new FpStatsController($events, $sess);
$report = new ReportController($events, $sess);
$export = new ExportController($events, $snaps, $sess);
$actionsCtl = new ActionsController($actions, $events, $sess);
$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess);
$suppressCtl = new SuppressionController($suppressions, $events, $devices, $audit, $sess);
$complianceCtl = new ComplianceController($events, $snaps, $actions, $alerts, $risks, $handovers, $audit, $sess);
$render = function (string $view): Response { $render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
// Security headers travel with the HTML response (not only the packaged .htaccess) so the return Response::text((string)ob_get_clean());
// CSP nonce the views just emitted is the one the browser is told to trust.
return Response::text((string)ob_get_clean(), 200, \App\Security\Csp::headers());
}; };
$r = new Router(); $r = new Router();
@ -126,49 +107,20 @@ $r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now, (
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess)); $r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess));
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($q, $sess)); $r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); $r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now)); $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
$r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q));
$r->add('GET', '/api/report', fn(Request $q) => $report->json($q));
$r->add('GET', '/api/export/labels', fn(Request $q) => $export->labels($q));
$r->add('GET', '/api/actions', fn(Request $q) => $actionsCtl->json($q));
$r->add('POST', '/api/actions', fn(Request $q) => $actionsCtl->save($q));
// Background frame for the zone editor: the newest snapshot this camera produced. // Background frame for the zone editor: the newest snapshot this camera produced.
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p)); $r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p)); $r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p)); $r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
// --- 성능 추이 · 통보 · 억제 · 규정 --------------------------------------------------
$r->add('GET', '/api/devices/{id}/metrics', fn(Request $q, array $p) => $deviceStatus->metrics($q, $p, $now));
$r->add('GET', '/api/admin/thresholds', fn(Request $q) => $adminCtl->thresholds($q));
$r->add('POST', '/api/admin/thresholds', fn(Request $q) => $adminCtl->saveThreshold($q));
$r->add('POST', '/api/admin/thresholds/{id}/delete', fn(Request $q, array $p) => $adminCtl->deleteThreshold($q, $p));
$r->add('GET', '/api/admin/channels', fn(Request $q) => $adminCtl->channels($q));
$r->add('POST', '/api/admin/channels', fn(Request $q) => $adminCtl->saveChannel($q));
$r->add('POST', '/api/admin/channels/{id}/delete', fn(Request $q, array $p) => $adminCtl->deleteChannel($q, $p));
$r->add('GET', '/api/suppressions', fn(Request $q) => $suppressCtl->json($q));
$r->add('POST', '/api/suppressions', fn(Request $q) => $suppressCtl->create($q));
$r->add('POST', '/api/suppressions/{id}/revoke', fn(Request $q, array $p) => $suppressCtl->revoke($q, $p));
$r->add('GET', '/api/risks', fn(Request $q) => $complianceCtl->risks($q));
$r->add('POST', '/api/risks', fn(Request $q) => $complianceCtl->saveRisk($q));
$r->add('POST', '/api/risks/{id}/delete', fn(Request $q, array $p) => $complianceCtl->deleteRisk($q, $p));
$r->add('GET', '/api/handovers', fn(Request $q) => $complianceCtl->handovers($q));
$r->add('POST', '/api/handovers', fn(Request $q) => $complianceCtl->acceptHandover($q));
$r->add('GET', '/api/events/{id}/package', fn(Request $q, array $p) => $complianceCtl->package($q, $p));
$r->add('GET', '/', fn() => $render('events')); $r->add('GET', '/', fn() => $render('events'));
$r->add('GET', '/login', fn() => $render('login')); $r->add('GET', '/login', fn() => $render('login'));
$r->add('GET', '/zones', fn() => $render('zones')); $r->add('GET', '/zones', fn() => $render('zones'));
$r->add('GET', '/report', fn() => $render('report'));
$r->add('GET', '/admin', fn() => $render('admin'));
$r->add('GET', '/compliance', fn() => $render('compliance'));
$r->add('GET', '/setup', fn() => $render('setup'));
$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail')); $r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail'));
$r->add('GET', '/events/{id}/package/view', fn(Request $q, array $p) => $render('incident_package'));
$r->dispatch(Request::fromGlobals())->send(); $r->dispatch(Request::fromGlobals())->send();

View file

@ -94,32 +94,12 @@ CREATE TABLE move_cameras (
-- `cameras` row exists for a given camera_id (no camera-registration flow in -- `cameras` row exists for a given camera_id (no camera-registration flow in
-- P1-B), so a FK here would spuriously reject valid app writes. -- P1-B), so a FK here would spuriously reject valid app writes.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- Improvement action log (개선 조치): a site fix ("미끄럼 매트 설치") recorded against a
-- camera; the report compares detection density before/after to prove the fix worked.
-- ---------------------------------------------------------------------------
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_event (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE move_zones ( CREATE TABLE move_zones (
zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids
device_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL,
camera_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS) level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS)
polygon_json JSON NOT NULL, polygon_json JSON NOT NULL,
rules_json JSON NULL, -- optional occupancy rules: {"min_workers","max_workers","max_dwell_s"} (ZoneConfigController validates)
updated_at BIGINT UNSIGNED NOT NULL, updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (zone_id), PRIMARY KEY (zone_id),
KEY ix_zones_camera (camera_id), KEY ix_zones_camera (camera_id),
@ -145,12 +125,8 @@ CREATE TABLE move_events (
occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float) occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float)
duration_s DOUBLE NOT NULL DEFAULT 0, duration_s DOUBLE NOT NULL DEFAULT 0,
metrics_json JSON NULL, metrics_json JSON NULL,
spatial_x DOUBLE NULL, -- normalized foot/reference point (0..1 of the frame),
spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events
acknowledged_by VARCHAR(190) NULL, acknowledged_by VARCHAR(190) NULL,
acknowledged_at BIGINT UNSIGNED NULL, acknowledged_at BIGINT UNSIGNED NULL,
false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning)
false_positive_at BIGINT UNSIGNED NULL,
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
PRIMARY KEY (event_id), PRIMARY KEY (event_id),
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side -- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
@ -164,14 +140,7 @@ CREATE TABLE move_events (
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
CONSTRAINT chk_events_type CHECK (event_type IN ( CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning', 'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', 'zone_restricted','pinch','monitoring_degraded','device_fault'
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
-- 감시 자체의 건전성: 카메라가 틀어지거나 가려지면 존 판정이 조용히 무의미해진다.
'camera_moved','camera_obstructed','low_light',
-- 아무도 확인하지 않았다는 사실 자체를 사건으로 만든다 (에스컬레이션 디스패처가 발행).
'unacked_escalation',
-- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다.
'ppe_missing','proximity','fire_smoke'
)), )),
CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info')) CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -212,8 +181,6 @@ CREATE TABLE move_thresholds (
zone_id CHAR(32) NULL, zone_id CHAR(32) NULL,
`key` VARCHAR(64) NOT NULL, `key` VARCHAR(64) NOT NULL,
`value` VARCHAR(255) NOT NULL, `value` VARCHAR(255) NOT NULL,
updated_by VARCHAR(190) NULL, -- 편집 UI(관리자 전용)가 남기는 감사 흔적
updated_at BIGINT UNSIGNED NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY ix_thresholds_device (device_id), KEY ix_thresholds_device (device_id),
UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`) UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`)
@ -252,15 +219,9 @@ CREATE TABLE move_rate_limits (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- audit_log: append-only action log (acknowledge / zone save+delete / login / -- audit_log: append-only action log (acknowledge / zone save+delete / login).
-- threshold edit / suppression / handover). -- 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.
-- TAMPER-EVIDENT: each row carries prev_hash (the previous row's entry_hash) and
-- entry_hash = sha256(id|actor|action|target|before|after|ts|prev_hash). Editing or
-- deleting a row breaks every hash after it, so a doctored log is DETECTABLE even
-- though shared hosting cannot grant append-only rights at the DB level. Verified by
-- `php tools/verify_audit_chain.php`. Rows written before the chain existed keep NULL
-- hashes and the verifier reports them as the pre-chain segment rather than as damage.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE move_audit_log ( CREATE TABLE move_audit_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
@ -270,148 +231,9 @@ CREATE TABLE move_audit_log (
before_json JSON NOT NULL, before_json JSON NOT NULL,
after_json JSON NOT NULL, after_json JSON NOT NULL,
ts BIGINT UNSIGNED NOT NULL, ts BIGINT UNSIGNED NOT NULL,
prev_hash CHAR(64) NULL,
entry_hash CHAR(64) NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY ix_audit_log_ts (ts), KEY ix_audit_log_ts (ts),
KEY ix_audit_log_actor (actor), KEY ix_audit_log_actor (actor)
KEY ix_audit_log_entry_hash (entry_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- suppressions: planned-work alarm suppression (정비 모드 / 작업허가).
-- Detection is NOT paused -- events are still ingested and still appear in the console
-- and in reports. Only NOTIFICATION is withheld, and every suppression carries a
-- mandatory reason, a hard expiry (no open-ended suppression) and an audit entry.
-- Rationale: the single largest source of false alarms is planned maintenance, and a
-- console that cries wolf during every shutdown is a console the site switches off.
-- ---------------------------------------------------------------------------
CREATE TABLE move_suppressions (
suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
scope_id VARCHAR(64) NOT NULL,
site VARCHAR(64) NULL,
reason VARCHAR(500) NOT NULL,
starts_at BIGINT UNSIGNED NOT NULL,
ends_at BIGINT UNSIGNED NOT NULL,
created_by VARCHAR(190) NOT NULL,
created_at BIGINT UNSIGNED NOT NULL,
revoked_by VARCHAR(190) NULL,
revoked_at BIGINT UNSIGNED NULL,
PRIMARY KEY (suppression_id),
KEY ix_suppressions_window (ends_at, starts_at),
KEY ix_suppressions_scope (scope_type, scope_id),
KEY ix_suppressions_site (site),
CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- alert_channels / alert_log: how a detection reaches a human.
-- Air-gapped sites cannot use FCM/Slack, so the channels are the ones a closed network
-- actually has: `local` (the edge node's own beacon/buzzer over GPIO -- the only path
-- that needs no network at all), `email` (in-house SMTP relay) and `webhook` (in-house
-- groupware / SMS gateway). step>0 channels fire only after escalate_after_s of the
-- event still being unacknowledged, which is what turns "we detected it" into "someone
-- was made to look at it".
-- ---------------------------------------------------------------------------
CREATE TABLE move_alert_channels (
channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
kind VARCHAR(16) NOT NULL,
name VARCHAR(190) NOT NULL,
config_json JSON NOT NULL, -- recipients / url / gpio pin. NEVER credentials.
min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
enabled TINYINT(1) NOT NULL DEFAULT 1,
step INT UNSIGNED NOT NULL DEFAULT 0,
escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (channel_id),
KEY ix_alert_channels_site (site, enabled),
CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE move_alert_log (
alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
attempts INT UNSIGNED NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
queued_at BIGINT UNSIGNED NOT NULL,
sent_at BIGINT UNSIGNED NULL,
PRIMARY KEY (alert_id),
-- The dispatcher runs from cron and overlapping runs are expected; this unique key is
-- the only thing standing between a site and the same fall being phoned in five times.
UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
KEY ix_alert_log_status (status, queued_at),
CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- shift_handovers: who took responsibility for what, and when.
-- The 24h brief tells an operator what happened; it does not record that anyone
-- accepted the backlog. Without that, unacknowledged events evaporate across a shift
-- change with nobody accountable for them.
-- ---------------------------------------------------------------------------
CREATE TABLE move_shift_handovers (
handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
from_user VARCHAR(190) NULL,
to_user VARCHAR(190) NOT NULL,
period_start BIGINT UNSIGNED NOT NULL,
period_end BIGINT UNSIGNED NOT NULL,
unacked_count INT UNSIGNED NOT NULL,
severe_count INT UNSIGNED NOT NULL,
note VARCHAR(1000) NULL,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (handover_id),
KEY ix_handovers_site_created (site, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- risk_controls: 위험성평가 연계. One row per (site, detection type): the hazard it
-- stands for, the control currently in place, and the 5x5 rating. This is what lets the
-- system print "what are we controlling and how well", which is the artefact a safety
-- management system is actually audited on.
-- ---------------------------------------------------------------------------
CREATE TABLE move_risk_controls (
control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
event_type VARCHAR(32) NOT NULL,
hazard VARCHAR(300) NOT NULL,
control VARCHAR(500) NOT NULL,
severity_rating TINYINT UNSIGNED NOT NULL,
likelihood_rating TINYINT UNSIGNED NOT NULL,
reviewed_by VARCHAR(190) NULL,
reviewed_at BIGINT UNSIGNED NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (control_id),
UNIQUE KEY uq_risk_controls_site_type (site, event_type),
CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- device_metrics: heartbeat telemetry as a TIME SERIES.
-- move_devices holds only the latest value, so a node whose inference latency doubled
-- overnight looks identical to a healthy one. Appending each heartbeat is what makes
-- slow degradation visible before it becomes a monitoring outage.
-- ---------------------------------------------------------------------------
CREATE TABLE move_device_metrics (
metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
ts BIGINT UNSIGNED NOT NULL,
fps DOUBLE NULL,
temp DOUBLE NULL,
throttle TINYINT(1) NULL,
queue_pending INT UNSIGNED NULL,
cameras_total INT UNSIGNED NULL,
cameras_erroring INT UNSIGNED NULL,
errors INT UNSIGNED NULL,
PRIMARY KEY (metric_id),
KEY ix_device_metrics_device_ts (device_id, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- =========================================================================== -- ===========================================================================

View file

@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Alert;
use App\Repository\{AlertRepository, EventRepository, SuppressionRepository};
/**
* Decides WHICH notifications an event earns, and when.
*
* This is the piece the product was missing entirely: until now a detection reached a human only
* if a browser tab happened to be open and someone happened to be looking at it. A fall at 03:00
* produced a row in a database and nothing else.
*
* Deliberately pure decision logic — no I/O, no clock, no sending. plan() takes the world as
* arguments and returns a list of deliveries to attempt, so every rule below (severity floors,
* escalation delays, suppression windows, already-acknowledged short-circuit) is unit testable
* without a database, an SMTP server or a wall clock. Sending lives in the channels; claiming
* and recording lives in the repository.
*
* THE RULES, and why each exists:
*
* - Severity floor per channel. A beacon that fires on 정보 events is a beacon nobody looks at.
* - step 0 fires immediately; step >0 fires only after escalate_after_s of the event still
* being unacknowledged. That is what converts "we detected it" into "somebody was made to
* look at it", and it is the only mechanism here that can wake a second person.
* - An acknowledged event escalates no further. Escalation exists to find a human, and one has
* already been found.
* - A false-positive tag stops everything, for the same reason.
* - Suppression (planned maintenance / work permit) blocks the SEND but is still recorded, as
* status='suppressed'. An investigator has to be able to see that an alarm was raised and
* deliberately withheld — a silently dropped notification is indistinguishable from one that
* was never generated, and that difference is exactly what an inquiry turns on.
*/
final class AlertDispatcher
{
/** Ranked so a channel's min_severity acts as a floor, not an equality test. */
private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3];
public function __construct(
private EventRepository $events,
private AlertRepository $alerts,
private SuppressionRepository $suppressions,
) {}
/**
* @param array $event a row from EventRepository (mapRow shape)
* @param array $channels channel rows for this event's site
* @return array<int,array{channel:array,suppressed:bool}> deliveries to attempt
*/
public function planFor(array $event, array $channels, int $now): array
{
// Resolved events stop the chain. Note this covers the escalation steps too: an event
// acknowledged 30 seconds in never reaches the 5-minute escalation channel.
if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) return [];
$rank = self::SEVERITY_RANK[$event['severity']] ?? 0;
$age = $now - (int)$event['occurred_at'];
// One suppression lookup per event covers all of its scopes. Zone is included because a
// permit is usually issued for an area, not for a camera.
$scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]];
if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']];
$suppressed = $this->suppressions->activeFor($scopes, $now) !== [];
$out = [];
foreach ($channels as $ch) {
if (empty($ch['enabled'])) continue;
// `local` beacons are fired by the edge node itself the moment its own detector
// produces the event — no server round trip, and no dependency on this job running
// at all. Their rows exist only to carry the GPIO configuration down through
// GET /api/config, so the server has nothing to deliver and must not claim a
// delivery row for them (which would show up as a permanent failure).
if ($ch['kind'] === 'local') continue;
if ($rank < (self::SEVERITY_RANK[$ch['min_severity']] ?? 0)) continue;
// A site-scoped channel only serves its own site; a NULL-site channel is global.
if ($ch['site'] !== null && $ch['site'] !== ($event['site'] ?? null)) continue;
// Not yet due: a later dispatcher run will pick it up once the event has aged.
if ((int)$ch['step'] > 0 && $age < (int)$ch['escalate_after_s']) continue;
$out[] = ['channel' => $ch, 'suppressed' => $suppressed];
}
return $out;
}
/**
* Run one dispatch pass over the recent window.
*
* @param callable(array $event, array $channel): void $send throws on failure
* @return array{considered:int,sent:int,suppressed:int,failed:int,skipped:int}
*/
public function run(int $now, int $windowSeconds, int $limit, callable $send): array
{
$stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0];
$events = $this->events->forNotification((float)($now - $windowSeconds), $limit);
// One channel read per site, not per event: a busy window is hundreds of events over a
// handful of sites, and this job runs on shared hosting where every query costs.
$channelsBySite = [];
foreach ($events as $event) {
$stats['considered']++;
$site = $event['site'] ?? null;
$key = $site ?? "\x00global";
if (!array_key_exists($key, $channelsBySite)) {
$channelsBySite[$key] = $this->alerts->channels($site);
}
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
$channelId = (int)$delivery['channel']['channel_id'];
$eventId = (int)$event['event_id'];
// claim() is the duplicate guard. Losing it means another (possibly overlapping)
// dispatcher run owns this delivery — skip, do not send.
if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; }
if ($delivery['suppressed']) {
$this->alerts->markSuppressed($eventId, $channelId, $now);
$stats['suppressed']++;
continue;
}
try {
$send($event, $delivery['channel']);
$this->alerts->markSent($eventId, $channelId, $now);
$stats['sent']++;
} catch (\Throwable $e) {
// Never fatal: one unreachable SMTP relay must not stop the webhook that
// would have reached somebody. The row keeps the error for the retry pass.
$this->alerts->markFailed($eventId, $channelId, $e->getMessage(), $now);
$stats['failed']++;
}
}
}
return $stats;
}
}

View file

@ -1,185 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Alert;
/**
* The senders. Two of the three channel kinds are delivered from here:
*
* email — an in-house SMTP relay via PHP mail(). Deliberately not a library: the delivered
* package must run on Cafe24 shared hosting with no composer step on the host, and
* adding an SMTP client to vendor/ for one function is weight the operator carries
* forever. Sites that need authenticated SMTP use the webhook channel instead.
* webhook — an HTTP POST to in-house groupware or an SMS gateway.
*
* The third kind, `local`, is NOT sent from here. A beacon or buzzer lives on the edge node's
* GPIO, and the edge fires it the instant its own detector produces the event — no server round
* trip, no network, no dependency on this cron job running. Its row here exists only to carry
* the configuration down to the device through GET /api/config. That is the one notification
* path that still works when the uplink is down, which is why it is the first one a site should
* configure.
*
* PRIVACY: notifications carry WHAT and WHERE, never the snapshot. Images are personal data
* under PIPA and are served only through the authenticated proxy; mailing one to a distribution
* list would move a worker's image outside every control the rest of the system maintains. The
* message therefore links to the incident instead.
*/
final class Channels
{
private const TYPE_LABELS = [
'fall' => '넘어짐', 'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동',
'ghost' => '위험 상태 소실', 'zone_warning' => '경고구역 진입', 'zone_restricted' => '제한구역 침입',
'pinch' => '협착구역 침입', 'monitoring_degraded' => '감시 성능 저하', 'device_fault' => '장치 이상',
'lone_worker' => '위험구역 단독 작업', 'overcapacity' => '정원 초과', 'overstay' => '체류 상한 초과',
'near_fall' => '넘어질 뻔함 (니어미스)', 'ergo_report' => '작업자세 부담 리포트',
'camera_moved' => '카메라 화각 변경', 'camera_obstructed' => '카메라 가림', 'low_light' => '조도 저하',
'unacked_escalation' => '미확인 경보 에스컬레이션',
'ppe_missing' => '보호구 미착용', 'proximity' => '중장비 근접', 'fire_smoke' => '화재·연기',
];
private const SEVERITY_LABELS = [
'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
];
/** @param string $baseUrl absolute dashboard URL, for the incident link. */
public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {}
public function send(array $event, array $channel): void
{
$config = json_decode($channel['config_json'] ?? '{}', true);
if (!is_array($config)) $config = [];
match ($channel['kind']) {
'email' => $this->sendEmail($event, $config),
'webhook' => $this->sendWebhook($event, $channel, $config),
// `local` is delivered by the edge itself (see the class comment). Recording it as
// sent here would be a lie; recording it as failed would bury the real failures.
'local' => throw new \LogicException('local channels are delivered by the edge node, not the server'),
default => throw new \RuntimeException('unknown channel kind: ' . (string)$channel['kind']),
};
}
public function subject(array $event): string
{
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
return "[{$sev}] {$type} — " . (string)($event['camera_id'] ?? '');
}
public function body(array $event): string
{
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
$when = date('Y-m-d H:i:s', (int)$event['occurred_at']);
$link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
return implode("\n", [
"작업장 안전 감지 알림",
"",
"유형 : {$type}",
"심각도 : {$sev}",
"장치 : " . (string)($event['device_id'] ?? '-'),
"카메라 : " . (string)($event['camera_id'] ?? '-'),
"발생 시각: {$when}",
"지속 : " . (string)($event['duration_s'] ?? 0) . "초",
"",
"확인 및 증거 프레임: {$link}",
"",
"이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.",
"이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.",
]);
}
private function sendEmail(array $event, array $config): void
{
$to = $config['to'] ?? null;
$recipients = is_array($to) ? $to : (is_string($to) ? array_map('trim', explode(',', $to)) : []);
$recipients = array_values(array_filter($recipients, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL)));
if ($recipients === []) throw new \RuntimeException('email channel has no valid recipient');
$from = $config['from'] ?? null;
$headers = "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n";
if (is_string($from) && filter_var($from, FILTER_VALIDATE_EMAIL)) {
$headers .= 'From: ' . $from . "\r\n";
}
// Encode the subject: a raw Korean Subject header is mangled by most relays.
$subject = '=?UTF-8?B?' . base64_encode($this->subject($event)) . '?=';
// BCC-style: one call per recipient so one bad address cannot drop the whole batch, and
// so recipients do not see each other's addresses.
$failed = [];
foreach ($recipients as $rcpt) {
if (!@mail($rcpt, $subject, $this->body($event), $headers)) $failed[] = $rcpt;
}
if (count($failed) === count($recipients)) {
throw new \RuntimeException('mail() rejected every recipient: ' . implode(', ', $failed));
}
}
private function sendWebhook(array $event, array $channel, array $config): void
{
$url = $config['url'] ?? '';
if (!is_string($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
throw new \RuntimeException('webhook channel has no valid url');
}
// http/https only. Without this a stored config could name file:// or gopher:// and turn
// the dispatcher into a local-file reader.
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
throw new \RuntimeException('webhook url must be http(s)');
}
$payload = json_encode([
'event_id' => (int)$event['event_id'],
'event_type' => $event['event_type'],
'severity' => $event['severity'],
'device_id' => $event['device_id'] ?? null,
'camera_id' => $event['camera_id'] ?? null,
'site' => $event['site'] ?? null,
'occurred_at' => (int)$event['occurred_at'],
'duration_s' => (float)($event['duration_s'] ?? 0),
'subject' => $this->subject($event),
'text' => $this->body($event),
'url' => rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view',
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$headers = ['Content-Type: application/json'];
// An optional shared token, so the receiving system can reject anything that is not us.
if (!empty($config['token']) && is_string($config['token'])) {
$headers[] = 'X-Alert-Token: ' . $config['token'];
}
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => min(5, $this->timeoutSeconds),
// No redirects: a 302 from a compromised endpoint must not be able to walk this
// POST — with its token header — to another host.
CURLOPT_FOLLOWLOCATION => false,
]);
$body = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false) throw new \RuntimeException('webhook transport error: ' . $err);
if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status);
return;
}
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $payload,
'timeout' => $this->timeoutSeconds,
'ignore_errors' => true,
'follow_location' => 0,
]]);
$res = @file_get_contents($url, false, $ctx);
if ($res === false) throw new \RuntimeException('webhook transport error (stream)');
$status = 0;
foreach ($http_response_header ?? [] as $h) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int)$m[1];
}
if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status);
}
}

View file

@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Alert;
use App\Repository\EventRepository;
/**
* Turns "nobody looked at it" into a first-class event.
*
* A critical detection that sits unacknowledged is a second, different failure from the
* detection itself, and until now it was invisible: the console showed a red row, and whether
* anyone ever came back to it left no trace anywhere. This watch emits an `unacked_escalation`
* event once an unresolved critical passes the threshold, which means it shows up in the stream,
* counts in the report, and — because it is an event like any other — is itself notifiable, so
* the escalation channels can carry it to a second person.
*
* IDEMPOTENCY comes from the event_uuid, not from a read: the uuid is derived from the origin
* event id, and move_events has a UNIQUE key on it. Two overlapping cron runs therefore produce
* one escalation, with the loser's INSERT colliding rather than duplicating — the same mechanism
* the ingest path already relies on for re-sent events.
*/
final class EscalationWatch
{
/** Only genuine emergencies escalate. A 경고 that nobody acknowledged is a backlog, not an alarm. */
private const ESCALATING_SEVERITIES = ['critical', 'high'];
public function __construct(private EventRepository $events) {}
/** Deterministic 32-hex uuid for the escalation of a given origin event. */
public static function uuidFor(int $originEventId): string
{
// Prefixed so it is recognisable in the database, and hashed so it cannot collide with a
// device-generated uuid (which is random hex of the same width).
return substr('e5ca1a7e' . substr(hash('sha256', 'unacked:' . $originEventId), 0, 24), 0, 32);
}
/**
* @param int $afterSeconds how long an unresolved event may sit before it escalates
* @return array<int,int> origin event ids that were escalated in this pass
*/
public function run(int $now, int $afterSeconds, int $windowSeconds, int $limit): array
{
$escalated = [];
foreach ($this->events->forNotification((float)($now - $windowSeconds), $limit) as $ev) {
if (!in_array($ev['severity'], self::ESCALATING_SEVERITIES, true)) continue;
if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue;
// An escalation must never escalate itself — that is an infinite ladder.
if ($ev['event_type'] === 'unacked_escalation') continue;
if (($now - (int)$ev['occurred_at']) < $afterSeconds) continue;
$originId = (int)$ev['event_id'];
$uuid = self::uuidFor($originId);
if ($this->events->idByUuid($uuid) !== null) continue; // already escalated
try {
$this->events->insert([
'event_uuid' => $uuid,
'device_id' => $ev['device_id'],
'site' => $ev['site'] ?? null,
'camera_id' => $ev['camera_id'],
'event_type' => 'unacked_escalation',
// The escalation inherits the severity it is escalating: a critical left
// unattended is still a critical, and downgrading it here would let it fall
// below the very channel floors that exist to wake someone.
'severity' => $ev['severity'],
'track_id' => $ev['track_id'] ?? null,
'zone_id' => $ev['zone_id'] ?? null,
'occurred_at' => (float)$now,
'duration_s' => (float)($now - (int)$ev['occurred_at']),
'metrics' => [
'origin_event_id' => $originId,
'origin_event_type' => $ev['event_type'],
'unacked_s' => $now - (int)$ev['occurred_at'],
],
'spatial_x' => $ev['spatial_x'] ?? null,
'spatial_y' => $ev['spatial_y'] ?? null,
]);
$escalated[] = $originId;
} catch (\Throwable $e) {
// A unique-key collision means a concurrent run won the race; anything else is
// worth surfacing but must not stop the remaining events from escalating.
if (!str_contains($e->getMessage(), 'uq_events_uuid')
&& !str_contains($e->getMessage(), 'UNIQUE')
&& !str_contains($e->getMessage(), 'Duplicate')) {
throw $e;
}
}
}
return $escalated;
}
}

View file

@ -5,8 +5,7 @@ namespace App\Api;
use App\Config; use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Hmac; use App\Security\Hmac;
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
RateLimiterRepository, AlertRepository};
final class ConfigController final class ConfigController
{ {
@ -14,7 +13,6 @@ final class ConfigController
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds, private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
private NonceRepository $nonces, private Config $cfg, private NonceRepository $nonces, private Config $cfg,
private ?RateLimiterRepository $rateLimiter = null, private ?RateLimiterRepository $rateLimiter = null,
private ?AlertRepository $alerts = null,
) {} ) {}
public function get(Request $req, int $now, string $clientIp = ''): Response public function get(Request $req, int $now, string $clientIp = ''): Response
@ -42,33 +40,6 @@ final class ConfigController
'version' => $this->devices->configVersion($deviceId), 'version' => $this->devices->configVersion($deviceId),
'zones' => $this->zones->forDevice($deviceId), 'zones' => $this->zones->forDevice($deviceId),
'thresholds' => $this->thresholds->forDevice($deviceId), 'thresholds' => $this->thresholds->forDevice($deviceId),
// Local beacon wiring (GPIO pin, latch/pulse, hold time). The node fires its own
// beacon the instant its detector produces a qualifying event — no server round trip,
// so it still works with the uplink down, which is the whole point of having it.
// Carried here because /api/config is the only authenticated channel the device has.
'beacons' => $this->beaconsFor($deviceId),
], 200); ], 200);
} }
/** @return array<int,array> local-kind channels applicable to this device's site. */
private function beaconsFor(string $deviceId): array
{
if ($this->alerts === null) return [];
$site = $this->devices->siteFor($deviceId);
$out = [];
foreach ($this->alerts->channels($site) as $ch) {
if ($ch['kind'] !== 'local') continue;
$config = json_decode($ch['config_json'], true);
if (!is_array($config)) continue;
$out[] = [
'name' => $ch['name'],
'min_severity' => $ch['min_severity'],
'gpio_pin' => (int)($config['gpio_pin'] ?? 0),
'mode' => (string)($config['mode'] ?? 'latch'),
'hold_s' => (float)($config['hold_s'] ?? 30.0),
'active_high' => (bool)($config['active_high'] ?? false),
];
}
return $out;
}
} }

View file

@ -5,13 +5,12 @@ namespace App\Api;
use App\Config; use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Hmac; use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, DeviceMetricRepository}; use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository};
final class HeartbeatController final class HeartbeatController
{ {
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces, public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces,
private ?RateLimiterRepository $rateLimiter = null, private ?RateLimiterRepository $rateLimiter = null) {}
private ?DeviceMetricRepository $metrics = null) {}
public function post(Request $req, int $now, string $clientIp = ''): Response public function post(Request $req, int $now, string $clientIp = ''): Response
{ {
@ -47,25 +46,6 @@ final class HeartbeatController
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null, 'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
'at' => $now, 'at' => $now,
]); ]);
// Append the same beat to the time series. move_devices only ever holds the LATEST
// reading, which cannot answer "is this node getting worse?" — a device whose fps has
// been sliding for a week looks identical to a healthy one right up until it stops.
// Optional dependency so the many tests that construct this controller with four
// arguments keep working, and so a site that has not migrated yet still ingests.
//
// Absent readings stay absent: only keys the device actually sent are forwarded, because
// the repository stores a missing metric as NULL and coercing one to 0 here would make a
// device that cannot read its temperature look like a device sitting at 0°C.
if ($this->metrics !== null) {
$series = [];
foreach (['fps', 'temp', 'throttle', 'queue_pending', 'cameras_total',
'cameras_erroring', 'errors'] as $k) {
if (array_key_exists($k, $b)) $series[$k] = $b[$k];
}
if ($series !== []) $this->metrics->append($deviceId, $series, $now);
}
return Response::json(['ok' => true], 200); return Response::json(['ok' => true], 200);
} }
} }

View file

@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Api;
/**
* The tunables an operator may change, and the bounds they may change them within.
*
* This catalogue is the whole reason the threshold editor is safe to expose. The values it
* describes decide what the system will and will not detect: `fall_prone_s` too high and a fall
* is never confirmed; `cooldown_s` at 0 and one event re-fires every tick until the queue fills.
* The edge already rejects non-finite and non-positive values at startup (edge/config.py
* `_positive`), but by then the device is in a restart loop in a factory — the bounds below stop
* the same mistake at the keyboard, where somebody can still read the error.
*
* Keys mirror the edge's env names in lower case, which is the contract GET /api/config carries
* (see edge/zones.py thresholds_from_config and edge/runtime.py apply_thresholds).
*
* A key NOT listed here cannot be written through the API. That is deliberate: an unknown key
* would be delivered to the device, silently ignored, and look configured.
*/
final class ThresholdCatalog
{
/**
* @return array<string,array{label:string,unit:string,min:float,max:float,default:float,
* step:float,integer:bool,help:string}>
*/
public static function all(): array
{
return [
'fall_prone_s' => [
'label' => '넘어짐 확정 대기', 'unit' => '초',
'min' => 1.0, 'max' => 30.0, 'default' => 3.0, 'step' => 0.5, 'integer' => false,
'help' => '쓰러진 자세가 이 시간 이상 지속되어야 넘어짐으로 확정합니다. 낮추면 반응이 빨라지지만 앉거나 웅크린 자세를 오탐할 수 있습니다.',
],
'fall_no_motion_s' => [
'label' => '넘어짐 후 미동 판정', 'unit' => '초',
'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false,
'help' => '넘어진 뒤 이 시간 동안 움직임이 없으면 응급 상황(미동 없음)으로 올립니다.',
],
'immobile_s' => [
'label' => '장시간 부동 판정', 'unit' => '초',
'min' => 30.0, 'max' => 1800.0, 'default' => 120.0, 'step' => 10.0, 'integer' => false,
'help' => '서 있든 앉아 있든 이 시간 동안 움직임이 없으면 이상으로 봅니다. 정적인 작업이 많은 공정에서는 올리세요.',
],
'ghost_reappear_s' => [
'label' => '소실 후 재출현 대기', 'unit' => '초',
'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false,
'help' => '위험 상태였던 사람이 화면에서 사라진 뒤 이 시간 안에 다시 나타나지 않으면 경보합니다.',
],
'zone_debounce_s' => [
'label' => '구역 진입 디바운스', 'unit' => '초',
'min' => 0.2, 'max' => 10.0, 'default' => 0.75, 'step' => 0.05, 'integer' => false,
'help' => '경계선을 스치듯 지나가는 것을 침입으로 세지 않기 위한 최소 체류 시간입니다.',
],
'cooldown_s' => [
'label' => '동일 이벤트 재발행 억제', 'unit' => '초',
'min' => 5.0, 'max' => 600.0, 'default' => 30.0, 'step' => 5.0, 'integer' => false,
'help' => '같은 사람·같은 유형의 이벤트를 이 시간 안에는 다시 보내지 않습니다. 너무 낮추면 한 사건이 수십 건으로 쪼개집니다.',
],
'motion_thr' => [
'label' => '움직임 임계', 'unit' => '',
'min' => 0.05, 'max' => 2.0, 'default' => 0.25, 'step' => 0.05, 'integer' => false,
'help' => '이 값보다 작은 키포인트 변화는 "정지"로 봅니다. 카메라가 흔들리는 현장은 올리고, 미세한 움직임까지 살리려면 내리세요.',
],
'infer_every' => [
'label' => '추론 주기', 'unit' => '틱마다 1회',
'min' => 1.0, 'max' => 10.0, 'default' => 1.0, 'step' => 1.0, 'integer' => true,
'help' => '장비가 전 카메라를 full-rate 로 처리하지 못할 때만 올립니다. 반응 정밀도를 내주고 모든 카메라를 살리는 절충입니다.',
],
];
}
public static function has(string $key): bool
{
return array_key_exists($key, self::all());
}
/**
* Validate a submitted value against the catalogue.
*
* @return array{0:bool,1:string} [ok, normalized-or-error-code]
*/
public static function normalize(string $key, mixed $raw): array
{
$spec = self::all()[$key] ?? null;
if ($spec === null) return [false, 'unknown_key'];
if (!is_numeric($raw)) return [false, 'not_numeric'];
$v = (float)$raw;
// NaN/INF slip past is_numeric for the strings "nan"/"inf" on some platforms, and both
// are exactly the values edge/config.py exists to reject.
if (!is_finite($v)) return [false, 'not_finite'];
if ($v < $spec['min'] || $v > $spec['max']) return [false, 'out_of_range'];
if ($spec['integer']) {
if (floor($v) !== $v) return [false, 'not_integer'];
return [true, (string)(int)$v];
}
// Normalize the textual form so the same value always stores identically (the column is
// a VARCHAR and "3" vs "3.0" would otherwise read as two different settings).
return [true, rtrim(rtrim(number_format($v, 3, '.', ''), '0'), '.') ?: '0'];
}
}

View file

@ -45,12 +45,8 @@ final class AuthController
} }
if ((int)($u['locked_until'] ?? 0) > $now) { if ((int)($u['locked_until'] ?? 0) > $now) {
// Same 401/invalid_credentials as an unknown user. A distinct 423 was a username
// oracle: anyone could lock a guessed name with 5 bad tries, then read "locked" as
// confirmation that the account is real. The lockout is still recorded in the audit
// log, which is where an admin — not an anonymous caller — learns about it.
$this->audit->append($username, 'login_locked', 'auth', [], []); $this->audit->append($username, 'login_locked', 'auth', [], []);
return Response::json(['error' => 'invalid_credentials'], 401); return Response::json(['error' => 'locked'], 423);
} }
// lock window expired -> reset stale failure counter so the user gets a fresh set of attempts // lock window expired -> reset stale failure counter so the user gets a fresh set of attempts
@ -77,17 +73,8 @@ final class AuthController
return Response::json(['ok' => true], 200); return Response::json(['ok' => true], 200);
} }
public function logout(Request $req, SessionStore $sess): Response public function logout(SessionStore $sess): Response
{ {
// CSRF-checked like every other state-changing route. SameSite=Lax already blocks the
// cross-site POST, but a forced logout is a real availability action on THIS product —
// knocking an operator out of the console mid-incident — so it does not get to be the
// one exception to the rule. Only enforced for an actual session: an already-logged-out
// caller (no csrf seeded) must still get a clean 200 rather than a confusing 403.
$expected = (string)$sess->get('csrf');
if ($expected !== '' && !Csrf::check($expected, (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
// Rotate the id FIRST (invalidates the old cookie) while the session is still active, // Rotate the id FIRST (invalidates the old cookie) while the session is still active,
// then wipe. The old order called session_regenerate_id() AFTER session_destroy(), // then wipe. The old order called session_regenerate_id() AFTER session_destroy(),
// which warns on an inactive session and leaves the old cookie unreplaced. // which warns on an inactive session and leaves the old cookie unreplaced.

View file

@ -14,13 +14,7 @@ final class ArraySessionStore implements SessionStore {
public function get(string $k): mixed { return $this->d[$k] ?? null; } public function get(string $k): mixed { return $this->d[$k] ?? null; }
public function set(string $k, mixed $v): void { $this->d[$k] = $v; } public function set(string $k, mixed $v): void { $this->d[$k] = $v; }
public function clear(): void { $this->d = []; } public function clear(): void { $this->d = []; }
/** public function regenerate(): void { $this->d = []; }
* Rotates the id only — the DATA survives, matching PhpSessionStore's
* session_regenerate_id(true). This double used to wipe the array, which diverged from the
* real store and hid a live question: whether the csrf token seeded before login is still
* there after it (it is — that is what /logout's CSRF check relies on).
*/
public function regenerate(): void { /* id is not modeled; data is preserved */ }
} }
final class PhpSessionStore implements SessionStore { final class PhpSessionStore implements SessionStore {

View file

@ -17,43 +17,4 @@ final class Config
public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); } public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); }
public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); } public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); }
public function snapshotRetentionDays(): int { return (int)($this->a['snapshot_retention_days'] ?? 7); } public function snapshotRetentionDays(): int { return (int)($this->a['snapshot_retention_days'] ?? 7); }
/**
* Retention for the event ROWS (not the images). Default 0 = keep forever, deliberately:
* these are workplace safety incident records, and an occupational-safety investigation or
* a labour dispute may need them years later — silently deleting them by default would be
* destroying evidence on the operator's behalf. The images are a different matter and DO
* expire (snapshotRetentionDays), because those are the personal data PIPA covers.
* A site with a DB quota can opt in by setting this.
*/
public function eventRetentionDays(): int { return (int)($this->a['event_retention_days'] ?? 0); }
/**
* Absolute, externally reachable base URL of the dashboard ("https://example.com/move_detect").
* Notifications need it because they are read outside the browser session that produced them,
* so SCRIPT_NAME — which is how every in-app link is built — is not available. Empty by
* default and the dispatcher refuses to run without it, rather than mailing a broken link.
*/
public function publicBaseUrl(): string { return rtrim((string)($this->a['public_base_url'] ?? ''), '/'); }
/**
* How long an unresolved critical/high event may sit before it becomes an
* `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by
* default: long enough that a present operator handles it first, short enough that a fall
* at 03:00 does not wait for the morning shift.
*/
public function escalateAfterSeconds(): int { return (int)($this->a['escalate_after_seconds'] ?? 300); }
/**
* Retention for the heartbeat time series (move_device_metrics). At a 30s heartbeat each
* device writes ~2,880 rows/day, so on a shared-hosting quota this table is the one most
* likely to grow until it breaks ingest. 30 days keeps a month of degradation history.
*/
public function metricRetentionDays(): int { return (int)($this->a['metric_retention_days'] ?? 30); }
/**
* Free-space floor for the snapshot volume, in megabytes. Below it the system raises a
* `device_fault` notice instead of discovering the problem as silently-failing image writes.
* 0 disables the check (a host where disk_free_space() is not meaningful).
*/
public function storageMinFreeMb(): int { return (int)($this->a['storage_min_free_mb'] ?? 200); }
} }

View file

@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\{Csrf, SiteScope};
use App\Repository\{ActionRepository, EventRepository};
/**
* Improvement actions (개선 조치): record a site fix against a camera, and let the data
* answer "did it work?" — the report shows detection density before vs after the fix
* over equal windows. This is the only path where a safety investment's ROI comes out
* of the system as a number.
*/
final class ActionsController
{
private const LIST_LIMIT = 20;
private const EFFECT_WINDOW_S = 30 * 86400; // 비교 창 상한: 조치 전후 각 30일
private const EFFECT_MIN_S = 3 * 86400; // 시행 3일 미만이면 "측정 중" — 성급한 결론 방지
private const MAX_NOTE = 500;
public function __construct(
private ActionRepository $actions, private EventRepository $events,
private SessionStore $sess
) {}
public function save(Request $req): 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);
}
$b = $req->json();
$camera = trim((string)($b['camera_id'] ?? ''));
$note = trim((string)($b['note'] ?? ''));
if ($camera === '' || mb_strlen($camera) > 64 || $note === '' || mb_strlen($note) > self::MAX_NOTE) {
return Response::json(['error' => 'invalid_action'], 400);
}
$now = time();
$takenAt = isset($b['taken_at']) && is_numeric($b['taken_at']) ? (int)$b['taken_at'] : $now;
// 미래 시각·1년 초과 과거는 입력 실수: 전후 비교가 성립하지 않는다.
if ($takenAt > $now || $takenAt < $now - 365 * 86400) {
return Response::json(['error' => 'invalid_taken_at'], 400);
}
// The camera MUST be one that has actually reported: a mistyped id would record an action
// whose before/after effect is 0/0 forever — a safety investment that silently never gets
// measured. Cameras have no registry table, so their events are the ownership record.
// 400 (client input error), matching ZoneConfigController's unknown_device.
if (!$this->events->cameraExists($camera)) {
return Response::json(['error' => 'unknown_camera'], 400);
}
// Scope BEFORE the write: a scoped operator must not record an action against another
// site's camera (it would pollute that site's report). 404 hides existence, as everywhere.
$site = $this->events->siteForCamera($camera);
$scope = $this->sess->get('site_scope');
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, 'event_id' => $eventId,
'note' => $note, 'taken_at' => $takenAt,
'created_by' => (string)$this->sess->get('username'),
]);
return Response::json(['action_id' => $id], 201);
}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$scope = $this->sess->get('site_scope');
$site = is_string($scope) ? $scope : null;
$now = time();
$out = [];
foreach ($this->actions->recent(self::LIST_LIMIT, $site) as $a) {
// 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면
// 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중".
$elapsed = $now - $a['taken_at'];
$w = min(self::EFFECT_WINDOW_S, $elapsed);
$effect = null;
if ($w >= self::EFFECT_MIN_S) {
$before = $this->events->countForCamera($a['camera_id'],
(float)($a['taken_at'] - $w), (float)$a['taken_at'], $site);
$after = $this->events->countForCamera($a['camera_id'],
(float)$a['taken_at'], (float)($a['taken_at'] + $w), $site);
$effect = ['before' => $before, 'after' => $after, 'window_days' => (int)round($w / 86400)];
}
$out[] = $a + ['effect' => $effect];
}
return Response::json(['actions' => $out], 200);
}
}

View file

@ -1,247 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Api\ThresholdCatalog;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\Csrf;
use App\Repository\{ThresholdRepository, AlertRepository, DeviceRepository, AuditRepository};
/**
* System configuration an admin can change from the browser: detection tunables and the
* notification channels.
*
* Both used to be unreachable. Thresholds existed as a table, were delivered to devices by
* GET /api/config, and the schema comment told the operator to edit them in phpMyAdmin — which
* in a delivered, air-gapped install means the customer could not retune a camera at all, even
* though the console's own false-positive panel exists to tell them which one needs it.
* Channels did not exist in any form: a detection reached a human only if somebody happened to
* be looking at the screen.
*
* Every write here is audited. A threshold change alters what the system is capable of
* detecting, and an investigation has to be able to ask when that changed and who changed it.
*/
final class AdminConfigController
{
private const MAX_NAME = 190;
public function __construct(
private ThresholdRepository $thresholds,
private AlertRepository $alerts,
private DeviceRepository $devices,
private AuditRepository $audit,
private SessionStore $sess,
) {}
private function guard(Request $req, bool $write): ?Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), 'manage_thresholds')) {
return Response::json(['error' => 'forbidden'], 403);
}
if ($write && !Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
return null;
}
private function site(): ?string
{
$scope = $this->sess->get('site_scope');
return is_string($scope) ? $scope : null;
}
// ---------------------------------------------------------------- thresholds
public function thresholds(Request $req): Response
{
if ($r = $this->guard($req, false)) return $r;
return Response::json([
// The catalogue travels with the values so the editor can render a bounded control
// (a slider with the real min/max and the real explanation) instead of a free-text
// key/value box that invites a typo the device will silently ignore.
'catalog' => ThresholdCatalog::all(),
'thresholds' => $this->thresholds->listAll($this->site()),
'devices' => $this->devices->listStatus($this->site()),
], 200);
}
public function saveThreshold(Request $req): Response
{
if ($r = $this->guard($req, true)) return $r;
$b = $req->json();
$deviceId = trim((string)($b['device_id'] ?? ''));
$key = trim((string)($b['key'] ?? ''));
$cameraId = trim((string)($b['camera_id'] ?? ''));
$cameraId = $cameraId === '' ? null : $cameraId;
if ($deviceId === '' || mb_strlen($deviceId) > 64) return Response::json(['error' => 'invalid_device'], 400);
if ($cameraId !== null && mb_strlen($cameraId) > 64) return Response::json(['error' => 'invalid_camera'], 400);
[$ok, $value] = ThresholdCatalog::normalize($key, $b['value'] ?? null);
if (!$ok) return Response::json(['error' => $value], 400);
// Scope BEFORE the write: a scoped admin must not retune another site's device.
if (!$this->devices->exists($deviceId)) return Response::json(['error' => 'unknown_device'], 400);
$site = $this->site();
if ($site !== null && $this->devices->siteFor($deviceId) !== $site) {
return Response::json(['error' => 'not_found'], 404);
}
$now = time();
$actor = (string)$this->sess->get('username');
// Capture the old value first — the audit entry is worthless without it.
$before = $this->thresholds->forDevice($deviceId)[$key] ?? null;
$id = $this->thresholds->set($deviceId, $cameraId, $key, $value, $actor, $now);
$this->audit->append($actor, 'threshold_set', "threshold:$deviceId:$key",
['value' => $before], ['value' => $value, 'camera_id' => $cameraId]);
// Bumping the device's config version is what makes the change actually reach the node:
// without it the daemon's next sync sees the same version and the new value sits in the
// database looking applied. This is the failure the config-drift indicator exists for.
$this->devices->bumpConfigVersion($deviceId);
return Response::json(['id' => $id, 'value' => $value], 200);
}
public function deleteThreshold(Request $req, array $params): Response
{
if ($r = $this->guard($req, true)) return $r;
$id = (int)($params['id'] ?? 0);
$row = $this->thresholds->find($id);
if ($row === null) return Response::json(['error' => 'not_found'], 404);
$site = $this->site();
if ($site !== null && ($row['site'] ?? null) !== $site) return Response::json(['error' => 'not_found'], 404);
if (!$this->thresholds->remove($id, $site)) return Response::json(['error' => 'not_found'], 404);
$actor = (string)$this->sess->get('username');
$this->audit->append($actor, 'threshold_delete', 'threshold:' . $row['device_id'] . ':' . $row['key'],
['value' => $row['value']], []);
$this->devices->bumpConfigVersion($row['device_id']);
return Response::json(['ok' => true], 200);
}
// ---------------------------------------------------------------- alert channels
public function channels(Request $req): Response
{
if ($r = $this->guard($req, false)) return $r;
$out = [];
foreach ($this->alerts->allChannels($this->site()) as $c) {
$config = json_decode($c['config_json'], true);
if (!is_array($config)) $config = [];
// Never echo a shared secret back to the browser. It is write-only from the UI's
// point of view: the form can set a new token, but the page can never display the
// current one (a screen-shared config page must not leak the integration key).
if (isset($config['token'])) $config['token'] = '••••••••';
$c['config'] = $config;
unset($c['config_json']);
$out[] = $c;
}
return Response::json(['channels' => $out], 200);
}
public function saveChannel(Request $req): Response
{
if ($r = $this->guard($req, true)) return $r;
$b = $req->json();
$kind = (string)($b['kind'] ?? '');
$name = trim((string)($b['name'] ?? ''));
$minSeverity = (string)($b['min_severity'] ?? 'high');
$step = (int)($b['step'] ?? 0);
$after = (int)($b['escalate_after_s'] ?? 0);
if (!in_array($kind, ['local', 'email', 'webhook'], true)) return Response::json(['error' => 'invalid_kind'], 400);
if ($name === '' || mb_strlen($name) > self::MAX_NAME) return Response::json(['error' => 'invalid_name'], 400);
if (!in_array($minSeverity, ['critical', 'high', 'warning', 'info'], true)) {
return Response::json(['error' => 'invalid_severity'], 400);
}
if ($step < 0 || $step > 5) return Response::json(['error' => 'invalid_step'], 400);
// An escalation step with no delay would fire together with the immediate channels and
// wake the second person for every event — the opposite of an escalation.
if ($step > 0 && ($after < 30 || $after > 86400)) return Response::json(['error' => 'invalid_escalate_after'], 400);
$config = $this->normalizeChannelConfig($kind, is_array($b['config'] ?? null) ? $b['config'] : []);
if ($config === null) return Response::json(['error' => 'invalid_config'], 400);
$id = (int)($b['channel_id'] ?? 0);
if ($id > 0) {
$existing = null;
foreach ($this->alerts->allChannels($this->site()) as $c) {
if ((int)$c['channel_id'] === $id) { $existing = $c; break; }
}
if ($existing === null) return Response::json(['error' => 'not_found'], 404);
// A masked token means "leave it alone" — the GET never revealed the real one.
if (($config['token'] ?? null) === '••••••••') {
$old = json_decode($existing['config_json'], true);
if (is_array($old) && isset($old['token'])) $config['token'] = $old['token'];
else unset($config['token']);
}
}
$newId = $this->alerts->saveChannel([
'channel_id' => $id > 0 ? $id : null,
'site' => $this->site(),
'kind' => $kind,
'name' => $name,
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'min_severity' => $minSeverity,
'enabled' => !empty($b['enabled']) ? 1 : 0,
'step' => $step,
'escalate_after_s' => $step > 0 ? $after : 0,
'created_at' => time(),
]);
$actor = (string)$this->sess->get('username');
// The audit entry records the shape, never the token.
$this->audit->append($actor, $id > 0 ? 'alert_channel_update' : 'alert_channel_create',
"alert_channel:$newId", [], ['kind' => $kind, 'name' => $name, 'step' => $step,
'min_severity' => $minSeverity]);
return Response::json(['channel_id' => $newId], $id > 0 ? 200 : 201);
}
public function deleteChannel(Request $req, array $params): Response
{
if ($r = $this->guard($req, true)) return $r;
$id = (int)($params['id'] ?? 0);
if (!$this->alerts->deleteChannel($id, $this->site())) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'alert_channel_delete', "alert_channel:$id", [], []);
return Response::json(['ok' => true], 200);
}
/** @return array|null null = invalid for this kind. */
private function normalizeChannelConfig(string $kind, array $in): ?array
{
if ($kind === 'email') {
$raw = $in['to'] ?? '';
$list = is_array($raw) ? $raw : array_map('trim', explode(',', (string)$raw));
$list = array_values(array_filter($list, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL)));
if ($list === []) return null;
$out = ['to' => $list];
if (!empty($in['from']) && filter_var((string)$in['from'], FILTER_VALIDATE_EMAIL)) {
$out['from'] = (string)$in['from'];
}
return $out;
}
if ($kind === 'webhook') {
$url = (string)($in['url'] ?? '');
if (!filter_var($url, FILTER_VALIDATE_URL)) return null;
// http(s) only — a stored file:// or gopher:// would turn the dispatcher into a
// local file reader running as the web user.
if (!in_array(strtolower((string)parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) return null;
$out = ['url' => $url];
if (!empty($in['token']) && is_string($in['token'])) $out['token'] = $in['token'];
return $out;
}
// local: the edge fires this itself; the config is the GPIO wiring it should use.
$pin = isset($in['gpio_pin']) ? (int)$in['gpio_pin'] : 0;
if ($pin < 0 || $pin > 40) return null; // Pi header is 40 pins; 0 = unset/disabled
$mode = (string)($in['mode'] ?? 'latch');
if (!in_array($mode, ['latch', 'pulse'], true)) return null;
$out = ['gpio_pin' => $pin, 'mode' => $mode];
// How long a pulse lasts / how long a latch holds before auto-release. A latch that
// never releases needs somebody to walk to the node, which is not always possible.
$hold = isset($in['hold_s']) ? (float)$in['hold_s'] : 30.0;
if ($hold <= 0 || $hold > 3600) return null;
$out['hold_s'] = $hold;
$out['active_high'] = !empty($in['active_high']);
return $out;
}
}

View file

@ -1,225 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\{Csrf, SiteScope};
use App\Repository\{EventRepository, SnapshotRepository, ActionRepository, AlertRepository,
RiskControlRepository, HandoverRepository, AuditRepository};
/**
* The evidence side of the product: what an inquiry, an insurer or a regulator asks for.
*
* - 사고조사 패키지 (package): one incident, reconstructed. The event, its evidence frames, the
* same camera's timeline either side of it, who was notified and whether the notification
* actually went out, what the operator did, and which risk control was supposed to cover it.
* Assembling that by hand after an accident is exactly when nobody has time to.
* - 위험성평가 (risk controls): the mapping from each detection type to the hazard it stands for
* and the control currently in place. This is the artefact a safety management system is
* audited on; without it the console is a gadget rather than part of a management system.
* - 교대 인수인계 (handover): a record that a named person accepted a named backlog. The 24h
* brief says what happened; this says who took responsibility for it.
*/
final class ComplianceController
{
/** ±30 minutes of the same camera's activity around the incident. */
private const CONTEXT_WINDOW_S = 1800;
private const CONTEXT_LIMIT = 200;
private const MAX_NOTE = 1000;
private const HANDOVER_LIST = 20;
public function __construct(
private EventRepository $events,
private SnapshotRepository $snaps,
private ActionRepository $actions,
private AlertRepository $alerts,
private RiskControlRepository $risks,
private HandoverRepository $handovers,
private AuditRepository $audit,
private SessionStore $sess,
) {}
private function site(): ?string
{
$scope = $this->sess->get('site_scope');
return is_string($scope) ? $scope : null;
}
// ---------------------------------------------------------------- incident package
public function package(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$id = (int)($params['id'] ?? 0);
$event = $this->events->find($id);
// 404 (not 403) for an out-of-scope id, so a cross-site id is indistinguishable from a
// non-existent one — the same rule the rest of the dashboard follows.
if ($event === null || !SiteScope::allows($this->site(), $event['site'] ?? null)) {
return Response::json(['error' => 'not_found'], 404);
}
$at = (float)$event['occurred_at'];
$site = $this->site();
$timeline = $this->events->windowForCamera(
(string)$event['camera_id'], $at - self::CONTEXT_WINDOW_S, $at + self::CONTEXT_WINDOW_S,
$site, self::CONTEXT_LIMIT
);
// Improvement actions recorded against this camera, so the package shows whether the
// hazard had already been raised before the incident — the first question asked.
$cameraActions = [];
foreach ($this->actions->recent(50, $site) as $a) {
if (($a['camera_id'] ?? '') === $event['camera_id']) $cameraActions[] = $a;
}
$risk = null;
foreach ($this->risks->listAll($site) as $r) {
if ($r['event_type'] === $event['event_type']) { $risk = $r; break; }
}
return Response::json([
'event' => $event,
'snapshots' => $this->snaps->forEvent($id),
'timeline' => $timeline,
// Whether anyone was actually told. A package that only proves the system SAW the
// incident, without showing whether it reached a human, answers half the question.
'notifications' => $this->alerts->forEvent($id),
'actions' => $cameraActions,
'risk_control' => $risk,
'context_window_s' => self::CONTEXT_WINDOW_S,
'generated_at' => time(),
'generated_by' => (string)$this->sess->get('username'),
], 200);
}
// ---------------------------------------------------------------- risk controls
public function risks(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
return Response::json(['controls' => $this->risks->listAll($this->site())], 200);
}
public function saveRisk(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
return Response::json(['error' => 'forbidden'], 403);
}
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$b = $req->json();
$type = trim((string)($b['event_type'] ?? ''));
$hazard = trim((string)($b['hazard'] ?? ''));
$control = trim((string)($b['control'] ?? ''));
$sev = (int)($b['severity_rating'] ?? 0);
$like = (int)($b['likelihood_rating'] ?? 0);
if ($type === '' || mb_strlen($type) > 32) return Response::json(['error' => 'invalid_type'], 400);
if ($hazard === '' || mb_strlen($hazard) > 300) return Response::json(['error' => 'invalid_hazard'], 400);
if ($control === '' || mb_strlen($control) > 500) return Response::json(['error' => 'invalid_control'], 400);
if ($sev < 1 || $sev > 5 || $like < 1 || $like > 5) return Response::json(['error' => 'invalid_rating'], 400);
$now = time();
$actor = (string)$this->sess->get('username');
$this->risks->upsert([
'site' => $this->site(), 'event_type' => $type, 'hazard' => $hazard, 'control' => $control,
'severity_rating' => $sev, 'likelihood_rating' => $like,
'reviewed_by' => $actor, 'reviewed_at' => $now, 'updated_at' => $now,
]);
$this->audit->append($actor, 'risk_control_save', "risk:$type", [],
['severity' => $sev, 'likelihood' => $like]);
return Response::json(['ok' => true], 200);
}
public function deleteRisk(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
return Response::json(['error' => 'forbidden'], 403);
}
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$id = (int)($params['id'] ?? 0);
if (!$this->risks->delete($id, $this->site())) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'risk_control_delete', "risk:$id", [], []);
return Response::json(['ok' => true], 200);
}
// ---------------------------------------------------------------- shift handover
public function handovers(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
return Response::json([
'handovers' => $this->handovers->recent($this->site(), self::HANDOVER_LIST),
'pending' => $this->pendingHandover(),
], 200);
}
/**
* What the next handover would certify. Computed here and used by BOTH the read endpoint and
* the accept endpoint, so the number that is displayed is by construction the number that
* gets recorded — a browser-supplied count could understate the very backlog the record
* exists to pin down.
*/
private function pendingHandover(): array
{
$site = $this->site();
$last = $this->handovers->latest($site);
$now = time();
// The period runs from the last handover to now; on the very first one fall back to 12
// hours (one shift) rather than to the beginning of time.
$periodStart = $last !== null ? (int)$last['created_at'] : $now - 12 * 3600;
$unacked = 0;
$severe = 0;
foreach ($this->events->page(200, null, null, null, $site) as $ev) {
if ((float)$ev['occurred_at'] < $periodStart) continue;
if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue;
$unacked++;
if (in_array($ev['severity'], ['critical', 'high'], true)) $severe++;
}
return [
'period_start' => $periodStart,
'period_end' => $now,
'unacked_count' => $unacked,
'severe_count' => $severe,
'from_user' => $last['to_user'] ?? null,
];
}
public function acceptHandover(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) {
return Response::json(['error' => 'forbidden'], 403);
}
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$b = $req->json();
$note = trim((string)($b['note'] ?? ''));
if (mb_strlen($note) > self::MAX_NOTE) return Response::json(['error' => 'invalid_note'], 400);
// Recomputed server-side, never taken from the request body (see pendingHandover).
$p = $this->pendingHandover();
$actor = (string)$this->sess->get('username');
$now = time();
$id = $this->handovers->create([
'site' => $this->site(),
'from_user' => $p['from_user'] ?? null,
'to_user' => $actor,
'period_start' => (int)$p['period_start'],
'period_end' => (int)$p['period_end'],
'unacked_count' => (int)$p['unacked_count'],
'severe_count' => (int)$p['severe_count'],
'note' => $note !== '' ? $note : null,
'created_at' => $now,
]);
$this->audit->append($actor, 'shift_handover', "handover:$id", [],
['unacked' => (int)$p['unacked_count'], 'severe' => (int)$p['severe_count']]);
return Response::json(['handover_id' => $id], 201);
}
}

View file

@ -2,11 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Dashboard; namespace App\Dashboard;
use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Auth\SessionStore; use App\Auth\SessionStore;
use App\Security\SiteScope; use App\Repository\DeviceRepository;
use App\Repository\{DeviceRepository, DeviceMetricRepository};
/** /**
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already * GET /api/devices — device/camera health for the dashboard's status panel. The edge already
@ -27,14 +25,7 @@ final class DeviceStatusController
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls). // yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
private const OFFLINE_AFTER_S = 120; private const OFFLINE_AFTER_S = 120;
/** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */ public function __construct(private DeviceRepository $devices, private SessionStore $sess) {}
private const SERIES_WINDOW_S = 86400;
private const SERIES_LIMIT = 3000;
public function __construct(
private DeviceRepository $devices, private SessionStore $sess,
private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null
) {}
public function json(Request $req, int $now): Response public function json(Request $req, int $now): Response
{ {
@ -63,63 +54,6 @@ final class DeviceStatusController
]; ];
}, $this->devices->listStatus($site)); }, $this->devices->listStatus($site));
return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200); return Response::json(['devices' => $devices], 200);
}
/**
* GET /api/devices/{id}/metrics — the heartbeat time series for one device.
*
* The status panel answers "is it alive right now"; this answers "has it been getting
* worse", which is the question that catches a node before it dies. A thermally throttling
* Pi loses fps for days before it stops.
*/
public function metrics(Request $req, array $params, int $now): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if ($this->metrics === null) return Response::json(['error' => 'not_available'], 404);
$deviceId = (string)($params['id'] ?? '');
$scope = $this->sess->get('site_scope');
$site = is_string($scope) ? $scope : null;
// Scope before reading: telemetry reveals that a device exists and how it is doing.
if (!$this->devices->exists($deviceId)
|| !SiteScope::allows($site, $this->devices->siteFor($deviceId))) {
return Response::json(['error' => 'not_found'], 404);
}
$hours = (int)($req->query('hours') ?? 24);
$window = max(3600, min(self::SERIES_WINDOW_S * 7, $hours * 3600));
return Response::json([
'device_id' => $deviceId,
'from' => $now - $window,
'points' => $this->metrics->series($deviceId, $now - $window, self::SERIES_LIMIT),
], 200);
}
/**
* Free space on the snapshot volume.
*
* On shared hosting a full disk does not announce itself: image writes start failing, the
* ingest path logs and moves on (by design — an event without its picture still beats no
* event), and the board keeps looking healthy while the evidence quietly stops being kept.
* Surfacing the number is what turns that into something somebody notices.
*/
private function storage(): ?array
{
if ($this->cfg === null) return null;
$minFreeMb = $this->cfg->storageMinFreeMb();
if ($minFreeMb <= 0) return null; // explicitly disabled for this host
$dir = $this->cfg->snapshotDir();
if (!is_dir($dir)) return null;
$free = @disk_free_space($dir);
$total = @disk_total_space($dir);
if ($free === false || $free === null) return null; // not meaningful on this host
$freeMb = (int)floor($free / 1048576);
return [
'free_mb' => $freeMb,
'total_mb' => ($total !== false && $total !== null) ? (int)floor($total / 1048576) : null,
'min_free_mb' => $minFreeMb,
'low' => $freeMb < $minFreeMb,
];
} }
} }

View file

@ -48,24 +48,4 @@ final class EventDetailController
$this->audit->append($user, 'acknowledge', "event:$id", [], []); $this->audit->append($user, 'acknowledge', "event:$id", [], []);
return Response::json(['ok' => true], 200); return Response::json(['ok' => true], 200);
} }
/** POST /events/{id}/false-positive — operator's 오탐 tag. Also acknowledges (an FP is a
* handled event; leaving it unacknowledged would keep it alarming). Feeds FP-rate stats
* only — by product decision it never auto-tunes edge thresholds. */
public function falsePositive(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
// Same permission as acknowledge: tagging FP is the same "I handled this" judgement call.
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);
}
$id = (int)($params['id'] ?? 0);
$event = $this->events->find($id);
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
$user = (string)$this->sess->get('username');
if (!$this->events->markFalsePositive($id, $user)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append($user, 'false_positive', "event:$id", [], []);
return Response::json(['ok' => true], 200);
}
} }

View file

@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Repository\{EventRepository, SnapshotRepository};
/**
* GET /api/export/labels?days=N -> JSONL download.
*
* The operator's 확인/오탐 buttons put a human verdict on every event, and each verdict
* carries a 3-frame (blurred) evidence timeline — a labeled dataset accumulating for free.
* This export turns it into an asset: evaluation sets, threshold-tuning ground truth,
* eventually fine-tuning data. One JSON object per line:
* {event_uuid, event_id, event_type, severity, camera_id, zone_id, occurred_at,
* duration_s, metrics, spatial, label, labeled_by, snapshots:[file_uuid...]}
* label: confirmed (acknowledged real) | false_positive | unreviewed.
* Images are NOT inlined — fetch each uuid via /snap/{uuid} (session + site re-checked).
*
* Admin-only ('manage_compliance'): a bulk export of detection history is a compliance
* decision, not an operator convenience.
*/
final class ExportController
{
private const DEFAULT_DAYS = 90;
private const MAX_DAYS = 365;
private const MAX_ROWS = 20000;
public function __construct(
private EventRepository $events, private SnapshotRepository $snaps,
private SessionStore $sess
) {}
public function labels(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);
}
$days = self::clampDays($req->query('days'));
$scope = $this->sess->get('site_scope');
$site = is_string($scope) ? $scope : null;
$rows = $this->events->exportRows((float)(time() - $days * 86400), $site, self::MAX_ROWS);
$uuids = $this->snaps->uuidsFor(array_map(static fn ($r) => (int)$r['event_id'], $rows));
$lines = [];
foreach ($rows as $r) {
if (($r['event_type'] ?? '') === 'ergo_report') continue; // 통계 행: 라벨 대상 아님
$isFp = ($r['false_positive_by'] ?? null) !== null;
$isAcked = ($r['acknowledged_by'] ?? null) !== null;
$lines[] = json_encode([
'event_uuid' => $r['event_uuid'] ?? null,
'event_id' => (int)$r['event_id'],
'event_type' => $r['event_type'],
'severity' => $r['severity'],
'camera_id' => $r['camera_id'],
'zone_id' => $r['zone_id'] ?? null,
'occurred_at' => $r['occurred_at'],
'duration_s' => $r['duration_s'] ?? 0,
'metrics' => $r['metrics'] ?? [],
'spatial' => (($r['spatial_x'] ?? null) !== null && ($r['spatial_y'] ?? null) !== null)
? [$r['spatial_x'], $r['spatial_y']] : null,
'label' => $isFp ? 'false_positive' : ($isAcked ? 'confirmed' : 'unreviewed'),
'labeled_by' => $isFp ? $r['false_positive_by'] : ($r['acknowledged_by'] ?? null),
'snapshots' => $uuids[(int)$r['event_id']] ?? [],
], JSON_UNESCAPED_UNICODE);
}
$body = implode("\n", $lines) . ($lines ? "\n" : '');
return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [
'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"',
]);
}
private static function clampDays(mixed $raw): int
{
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS;
return max(1, min(self::MAX_DAYS, (int)$raw));
}
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\EventRepository;
/**
* GET /api/fp-stats?days=N -> {stats: [{camera_id, event_type, total, fp}], days}
*
* Per-(camera, event type) false-positive rates for the console's FP panel. This is the
* whole feedback loop by design: a human reads the rate and re-tunes thresholds
* deliberately — the tag never adjusts edge sensitivity on its own.
*/
final class FpStatsController
{
private const DEFAULT_DAYS = 30;
private const MAX_DAYS = 90;
public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$days = self::clampDays($req->query('days'));
// Fail-closed site scoping, mirroring the event list.
$scope = $this->sess->get('site_scope');
$site = is_string($scope) ? $scope : null;
$stats = $this->events->fpStats((float)(time() - $days * 86400), $site);
return Response::json(['stats' => $stats, 'days' => $days], 200);
}
private static function clampDays(mixed $raw): int
{
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS;
return max(1, min(self::MAX_DAYS, (int)$raw));
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\EventRepository;
/**
* GET /api/heatmap/{camera}?days=N -> {points: [{x,y,severity,event_type,occurred_at}], days}
*
* Spatial density of everything the camera detected (incidents AND near-misses) in the
* window — the raw material the zone editor overlays on the camera frame. Points are
* normalized 0..1 of the image, the same space the zone polygons live in.
*/
final class HeatmapController
{
private const DEFAULT_DAYS = 30;
private const MAX_DAYS = 90;
private const MAX_POINTS = 5000;
public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function json(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$camera = (string)($params['camera'] ?? '');
if ($camera === '') return Response::json(['error' => 'missing_camera'], 400);
$days = self::clampDays($req->query('days'));
// Same fail-closed site scoping as the event list: a scoped operator sees only their
// site's points; only a genuinely null scope is global.
$scope = $this->sess->get('site_scope');
$site = is_string($scope) ? $scope : null;
$points = $this->events->heatmapPoints($camera, time() - $days * 86400, $site, self::MAX_POINTS);
return Response::json(['points' => $points, 'days' => $days], 200);
}
private static function clampDays(mixed $raw): int
{
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS;
return max(1, min(self::MAX_DAYS, (int)$raw));
}
}

View file

@ -1,165 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\EventRepository;
/**
* GET /api/report?days=N — the safety operations report: the numbers a 안전관리자 takes
* into the weekly safety meeting. Everything the system collects, folded into management
* indicators over one window: incident counts, response time, the near-miss pyramid,
* false-positive rate, posture-load exposure, occupancy-rule violations.
*/
final class ReportController
{
private const DEFAULT_DAYS = 7;
private const MAX_DAYS = 90;
private const TOP_CAMERAS = 8;
private const OCCUPANCY_TYPES = ['lone_worker', 'overcapacity', 'overstay'];
private const ERGO_KEYS = ['observed_s', 'trunk_high_s', 'trunk_mod_s', 'arm_high_s', 'squat_s'];
// 전조(precursor) 그룹: 부상 없이 끝났지만 사고로 이어질 수 있던 신호 — 급증 경보와
// 시간 패턴의 대상. 사고 자체(fall/no_motion/ghost/immobility/pinch)는 제외.
private const PRECURSOR_TYPES = ['near_fall', 'zone_warning', 'zone_restricted',
'lone_worker', 'overcapacity', 'overstay'];
private const SPIKE_BASELINE_WEEKS = 4;
private const SPIKE_MIN_COUNT = 5; // 이보다 적으면 통계가 아니라 소음
private const SPIKE_RATIO = 1.5;
private const PATTERN_LIMIT = 20000;
public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$days = self::clampDays($req->query('days'));
$scope = $this->sess->get('site_scope');
$site = is_string($scope) ? $scope : null;
$now = time();
$since = (float)($now - $days * 86400);
$s = $this->events->reportSummary($since, $site, self::TOP_CAMERAS);
// 직전 동일 길이 기간 — KPI의 "지난 기간 대비" 추이. 같은 집계를 상한만 걸어 재사용.
$prev = $this->events->reportSummary((float)($now - 2 * $days * 86400), $site,
self::TOP_CAMERAS, $since);
$severityTotals = ['critical' => 0, 'high' => 0, 'warning' => 0, 'info' => 0];
$typeCounts = [];
$unacked = 0; $fpTagged = 0; $total = 0;
$nearFall = 0; $falls = 0;
$occupancy = array_fill_keys(self::OCCUPANCY_TYPES, 0);
foreach ($s['by_type'] as $row) {
$total += $row['count'];
$unacked += $row['unacked'];
$fpTagged += $row['fp'];
if (isset($severityTotals[$row['severity']])) $severityTotals[$row['severity']] += $row['count'];
$typeCounts[$row['event_type']] = ($typeCounts[$row['event_type']] ?? 0) + $row['count'];
if ($row['event_type'] === 'near_fall') $nearFall += $row['count'];
if (in_array($row['event_type'], ['fall', 'no_motion'], true)) $falls += $row['count'];
if (in_array($row['event_type'], self::OCCUPANCY_TYPES, true)) {
$occupancy[$row['event_type']] += $row['count'];
}
}
// ergo_report is bookkeeping, not a detection — it must not inflate "감지 N건".
$ergoReports = $typeCounts['ergo_report'] ?? 0;
$total -= $ergoReports;
if (isset($severityTotals['info'])) $severityTotals['info'] = max(0, $severityTotals['info'] - $ergoReports);
unset($typeCounts['ergo_report']);
arsort($typeCounts);
$ergo = array_fill_keys(self::ERGO_KEYS, 0.0);
foreach ($s['ergo_metrics'] as $m) {
foreach (self::ERGO_KEYS as $k) $ergo[$k] += (float)($m[$k] ?? 0);
}
$ergo = array_map(static fn ($v) => round($v, 1), $ergo);
// 이전 기간 요약 — KPI 추이에 필요한 축만 (전체 by_type 재전송은 낭비).
$prevSeverity = ['critical' => 0, 'high' => 0];
$prevTotal = 0; $prevFp = 0; $prevNearFall = 0;
foreach ($prev['by_type'] as $row) {
if ($row['event_type'] === 'ergo_report') continue;
$prevTotal += $row['count'];
$prevFp += $row['fp'];
if (isset($prevSeverity[$row['severity']])) $prevSeverity[$row['severity']] += $row['count'];
if ($row['event_type'] === 'near_fall') $prevNearFall += $row['count'];
}
// 시간 패턴 + 전조 급증. 버킷팅은 PHP에서(서버 로컬 시각 = 현장 시간대) — SQL은
// 이식성 유지. 급증 기준선은 리포트 기간과 무관하게 최근 5주로 고정한다.
$pattern = self::buildPattern($this->events->occurrenceTimes($since, $site, self::PATTERN_LIMIT));
$spike = self::precursorSpike(
$this->events->occurrenceTimes((float)($now - (self::SPIKE_BASELINE_WEEKS + 1) * 7 * 86400),
$site, self::PATTERN_LIMIT), $now);
return Response::json([
'days' => $days,
'generated_at' => time(),
'totals' => ['events' => $total, 'unacked' => $unacked] + $severityTotals,
'by_type' => $typeCounts,
'by_camera' => $s['by_camera'],
'tta' => [
'acked' => $s['tta']['acked'],
'avg_s' => $s['tta']['avg_s'] !== null ? round($s['tta']['avg_s'], 1) : null,
'max_s' => $s['tta']['max_s'] !== null ? round($s['tta']['max_s'], 1) : null,
],
'near_miss' => ['near_fall' => $nearFall, 'incidents' => $falls],
'fp' => ['tagged' => $fpTagged, 'total' => $total],
'ergo' => $ergo,
'occupancy' => $occupancy,
'previous' => [
'events' => $prevTotal,
'critical' => $prevSeverity['critical'], 'high' => $prevSeverity['high'],
'near_fall' => $prevNearFall,
'fp' => ['tagged' => $prevFp, 'total' => $prevTotal],
'tta_avg_s' => $prev['tta']['avg_s'] !== null ? round($prev['tta']['avg_s'], 1) : null,
],
'pattern' => $pattern,
'precursor_alert' => $spike,
// 현장별 롤업은 전역 운영자(본사)에게만 의미가 있다. 스코프 운영자에게는 정의상
// 자기 현장 한 줄뿐이라 표를 만들 이유가 없고, 다른 현장 수치가 새면 안 된다.
'by_site' => $site === null ? $this->events->siteSummary($since) : null,
], 200);
}
/** 요일(0=월)×시간(0..23) 발생 밀도 그리드 — 서버 로컬 시각 기준. */
private static function buildPattern(array $times): array
{
$grid = array_fill(0, 7, array_fill(0, 24, 0));
$max = 0;
foreach ($times as $t) {
$ts = (int)$t['occurred_at'];
$d = (int)date('N', $ts) - 1;
$h = (int)date('G', $ts);
$grid[$d][$h]++;
if ($grid[$d][$h] > $max) $max = $grid[$d][$h];
}
return ['grid' => $grid, 'max' => $max];
}
/**
* 전조 급증: 최근 7일 전조 건수를 그 이전 4주의 주평균과 비교. 사고가 나기 전에 올리는
* 경보이므로 보수적으로 — 표본이 SPIKE_MIN_COUNT 미만이면 절대 울리지 않는다.
*/
private static function precursorSpike(array $times, int $now): array
{
$weekAgo = $now - 7 * 86400;
$thisWeek = 0; $baselineTotal = 0;
foreach ($times as $t) {
if (!in_array($t['event_type'], self::PRECURSOR_TYPES, true)) continue;
if ($t['occurred_at'] >= $weekAgo) $thisWeek++;
else $baselineTotal++;
}
$baselineAvg = $baselineTotal / self::SPIKE_BASELINE_WEEKS;
$ratio = $baselineAvg > 0 ? $thisWeek / $baselineAvg : null;
$alert = $thisWeek >= self::SPIKE_MIN_COUNT
&& ($baselineAvg == 0.0 || $ratio >= self::SPIKE_RATIO);
return ['this_week' => $thisWeek, 'baseline_weekly_avg' => round($baselineAvg, 1),
'ratio' => $ratio !== null ? round($ratio, 2) : null, 'alert' => $alert];
}
private static function clampDays(mixed $raw): int
{
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS;
return max(1, min(self::MAX_DAYS, (int)$raw));
}
}

View file

@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Security\{Csrf, SiteScope};
use App\Repository\{SuppressionRepository, EventRepository, DeviceRepository, AuditRepository};
/**
* 정비 모드 / 작업허가 알람 억제.
*
* The single largest source of false alarms on a real site is planned work: a maintenance crew
* inside a restricted zone trips every intrusion rule they walk past. A console that cries wolf
* through every shutdown is a console the site turns off, and then the next real fall goes
* unseen. So the system needs a way to say "we know, it's planned".
*
* Three constraints make that safe rather than dangerous:
* - Detection is never paused. Events are ingested, listed and reported exactly as always;
* only NOTIFICATION is withheld, and the withholding is itself recorded (status
* 'suppressed' in the alert ledger). An investigator can always reconstruct what the
* cameras saw during the window.
* - A reason is mandatory. "Somebody silenced it" is not an answer anyone can act on later.
* - There is a hard expiry, capped at MAX_WINDOW_S. The failure mode of this feature is a
* zone muted "just for now" that nobody ever unmutes.
*/
final class SuppressionController
{
private const MAX_REASON = 500;
/** 8 hours: one shift. Longer work needs a deliberate re-issue, which is the point. */
private const MAX_WINDOW_S = 8 * 3600;
private const MAX_LEAD_S = 7 * 86400; // may be scheduled up to a week ahead
public function __construct(
private SuppressionRepository $suppressions,
private EventRepository $events,
private DeviceRepository $devices,
private AuditRepository $audit,
private SessionStore $sess,
) {}
private function site(): ?string
{
$scope = $this->sess->get('site_scope');
return is_string($scope) ? $scope : null;
}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$now = time();
$includeExpired = $req->query('all') === '1';
return Response::json([
'now' => $now,
'suppressions' => $this->suppressions->listAll($this->site(), $now, $includeExpired),
], 200);
}
public function create(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
// Same permission as zone editing: issuing a maintenance window is an operational act by
// somebody who is on site, not an administrative one.
if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403);
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$b = $req->json();
$scopeType = (string)($b['scope_type'] ?? '');
$scopeId = trim((string)($b['scope_id'] ?? ''));
$reason = trim((string)($b['reason'] ?? ''));
$minutes = (int)($b['minutes'] ?? 0);
$startsAt = isset($b['starts_at']) && is_numeric($b['starts_at']) ? (int)$b['starts_at'] : time();
if (!in_array($scopeType, ['device', 'camera', 'zone'], true)) return Response::json(['error' => 'invalid_scope'], 400);
if ($scopeId === '' || mb_strlen($scopeId) > 64) return Response::json(['error' => 'invalid_scope_id'], 400);
if ($reason === '' || mb_strlen($reason) > self::MAX_REASON) return Response::json(['error' => 'reason_required'], 400);
if ($minutes < 1 || $minutes * 60 > self::MAX_WINDOW_S) return Response::json(['error' => 'invalid_window'], 400);
$now = time();
if ($startsAt < $now - 3600 || $startsAt > $now + self::MAX_LEAD_S) {
return Response::json(['error' => 'invalid_start'], 400);
}
// Resolve the site from the scoped object, and refuse anything outside the caller's own
// scope BEFORE writing — a suppression is a licence to stop alarming, and it must not be
// possible to issue one for another site by guessing a camera name.
$site = $this->resolveSite($scopeType, $scopeId);
if ($site === false) return Response::json(['error' => 'unknown_scope_id'], 400);
if (!SiteScope::allows($this->site(), $site)) return Response::json(['error' => 'not_found'], 404);
$actor = (string)$this->sess->get('username');
$id = $this->suppressions->create([
'scope_type' => $scopeType, 'scope_id' => $scopeId, 'site' => $site,
'reason' => $reason, 'starts_at' => $startsAt, 'ends_at' => $startsAt + $minutes * 60,
'created_by' => $actor, 'created_at' => $now,
]);
$this->audit->append($actor, 'suppression_create', "suppression:$id", [],
['scope' => "$scopeType:$scopeId", 'minutes' => $minutes, 'reason' => $reason]);
return Response::json(['suppression_id' => $id, 'ends_at' => $startsAt + $minutes * 60], 201);
}
public function revoke(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403);
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
return Response::json(['error' => 'csrf'], 403);
}
$id = (int)($params['id'] ?? 0);
$now = time();
if (!$this->suppressions->revoke($id, (string)$this->sess->get('username'), $now, $this->site())) {
return Response::json(['error' => 'not_found'], 404);
}
$this->audit->append((string)$this->sess->get('username'), 'suppression_revoke', "suppression:$id", [], []);
return Response::json(['ok' => true], 200);
}
/** @return string|null|false site, or false when the scope target does not exist. */
private function resolveSite(string $scopeType, string $scopeId): string|null|false
{
if ($scopeType === 'device') {
if (!$this->devices->exists($scopeId)) return false;
return $this->devices->siteFor($scopeId);
}
if ($scopeType === 'camera') {
// Cameras have no registry (see schema.sql); their events are the ownership record.
if (!$this->events->cameraExists($scopeId)) return false;
return $this->events->siteForCamera($scopeId);
}
// A zone id is opaque here; scope is inherited from the caller so a scoped operator can
// only ever create one inside their own site, and a global operator may suppress any.
return $this->site();
}
}

View file

@ -11,13 +11,6 @@ use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotR
final class ZoneConfigController final class ZoneConfigController
{ {
private const LEVELS = ['warning','restricted','pinch']; private const LEVELS = ['warning','restricted','pinch'];
// Occupancy-rule bounds. min_workers starts at 2 (a 1-person minimum is always satisfied
// by an occupied zone — meaningless); dwell floor 10s rejects a seconds/minutes mixup.
private const RULE_BOUNDS = [
'min_workers' => [2, 50],
'max_workers' => [1, 50],
'max_dwell_s' => [10, 86400],
];
public function __construct( public function __construct(
private ZoneRepository $zones, private DeviceRepository $devices, private ZoneRepository $zones, private DeviceRepository $devices,
@ -69,33 +62,6 @@ final class ZoneConfigController
return Response::json(['uuid' => $shot['file_uuid']], 200); return Response::json(['uuid' => $shot['file_uuid']], 200);
} }
/**
* Validate the optional occupancy rules and return their canonical JSON (or null for
* "no rules"). Strict on purpose: an unknown key, a non-integer, an out-of-bounds value,
* or min > max is a CLIENT error — a rule silently dropped here would be one the operator
* believes is protecting a zone while the edge enforces nothing.
* @return array{0:?string,1:bool} [rules_json, ok]
*/
private static function rulesJson(mixed $rules): array
{
if ($rules === null || $rules === []) return [null, true];
if (!is_array($rules)) return [null, false];
$out = [];
foreach ($rules as $key => $value) {
if (!isset(self::RULE_BOUNDS[$key])) return [null, false];
if ($value === null || $value === '') continue;
if (!is_int($value) && !(is_string($value) && ctype_digit($value))) return [null, false];
$n = (int)$value;
[$lo, $hi] = self::RULE_BOUNDS[$key];
if ($n < $lo || $n > $hi) return [null, false];
$out[$key] = $n;
}
if (isset($out['min_workers'], $out['max_workers']) && $out['min_workers'] > $out['max_workers']) {
return [null, false];
}
return [$out === [] ? null : json_encode($out), true];
}
public function save(Request $req): Response public function save(Request $req): Response
{ {
if ($g = $this->guard('edit_zones')) return $g; if ($g = $this->guard('edit_zones')) return $g;
@ -108,8 +74,6 @@ final class ZoneConfigController
if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) { if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) {
return Response::json(['error' => 'invalid_zone'], 400); return Response::json(['error' => 'invalid_zone'], 400);
} }
[$rulesJson, $rulesOk] = self::rulesJson($b['rules'] ?? null);
if (!$rulesOk) return Response::json(['error' => 'invalid_rules'], 400);
$deviceId = (string)($b['device_id'] ?? ''); $deviceId = (string)($b['device_id'] ?? '');
// The zone MUST name a real device: it is delivered to the edge only via // The zone MUST name a real device: it is delivered to the edge only via
// GET /api/config -> zones->forDevice($deviceId), so a zone saved with a blank or mistyped // GET /api/config -> zones->forDevice($deviceId), so a zone saved with a blank or mistyped
@ -138,7 +102,6 @@ final class ZoneConfigController
'camera_id' => (string)($b['camera_id'] ?? ''), 'camera_id' => (string)($b['camera_id'] ?? ''),
'level' => $level, 'level' => $level,
'polygon_json' => json_encode($polygon), 'polygon_json' => json_encode($polygon),
'rules_json' => $rulesJson,
]); ]);
} catch (ZoneOwnershipException $e) { } catch (ZoneOwnershipException $e) {
// Editing a zone with a device_id/camera_id that does not own it (or a zone deleted // Editing a zone with a device_id/camera_id that does not own it (or a zone deleted
@ -147,11 +110,6 @@ final class ZoneConfigController
return Response::json(['error' => 'invalid_zone'], 400); return Response::json(['error' => 'invalid_zone'], 400);
} }
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]); $this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
// Bump the device's config version so the drift indicator becomes meaningful: it now
// reads "edited, not yet picked up" until the node's next sync echoes the new version
// back. Nothing ever incremented it before, so the indicator could never light up and an
// edit that failed to reach a device was indistinguishable from one that arrived.
$this->devices->bumpConfigVersion($deviceId);
return Response::json(['zone_id' => $zoneId], 201); return Response::json(['zone_id' => $zoneId], 201);
} }
@ -170,7 +128,6 @@ final class ZoneConfigController
} }
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404); if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []); $this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
$this->devices->bumpConfigVersion((string)($existing['device_id'] ?? ''));
return Response::json(['ok' => true], 200); return Response::json(['ok' => true], 200);
} }
} }

View file

@ -9,9 +9,8 @@ final class Response
public static function json(array $data, int $status = 200): self { public static function json(array $data, int $status = 200): self {
return new self($status, ['Content-Type' => 'application/json'], json_encode($data, JSON_UNESCAPED_UNICODE)); return new self($status, ['Content-Type' => 'application/json'], json_encode($data, JSON_UNESCAPED_UNICODE));
} }
public static function text(string $s, int $status = 200, array $headers = []): self { public static function text(string $s, int $status = 200): self {
// Content-Type stays authoritative; extra headers (e.g. Content-Security-Policy) are added. return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'] + $headers, $s);
} }
public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self { public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self {
// Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden. // Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden.

View file

@ -5,8 +5,7 @@ namespace App\Ingest;
final class EventValidator final class EventValidator
{ {
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning', private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', 'zone_restricted','pinch','monitoring_degraded','device_fault'];
'lone_worker','overcapacity','overstay','near_fall','ergo_report'];
private const SEVERITIES = ['critical','high','warning','info']; private const SEVERITIES = ['critical','high','warning','info'];
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at']; private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];
@ -32,16 +31,6 @@ final class EventValidator
if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id'; if (isset($p['track_id']) && !is_numeric($p['track_id'])) $errors[] = 'bad:track_id';
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id'; if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s'; if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
// Optional normalized point (heatmap raw material): [x, y], both finite numbers. Range is
// NOT enforced — a foot a hair outside 0..1 must not reject a FALL alert; renderers clamp.
if (isset($p['spatial'])) {
$sp = $p['spatial'];
if (!is_array($sp) || count($sp) !== 2
|| !is_numeric($sp[0] ?? null) || !is_numeric($sp[1] ?? null)
|| !is_finite((float)$sp[0]) || !is_finite((float)$sp[1])) {
$errors[] = 'bad:spatial';
}
}
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends // Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
// it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the // it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the
// snapshot-uuid convention); a malformed key is a bug, not a silent pass. // snapshot-uuid convention); a malformed key is a bug, not a silent pass.
@ -69,8 +58,6 @@ final class EventValidator
'occurred_at' => (float)$p['occurred_at'], 'occurred_at' => (float)$p['occurred_at'],
'duration_s' => (float)($p['duration_s'] ?? 0.0), 'duration_s' => (float)($p['duration_s'] ?? 0.0),
'metrics' => is_array($p['metrics'] ?? null) ? $p['metrics'] : [], 'metrics' => is_array($p['metrics'] ?? null) ? $p['metrics'] : [],
'spatial_x' => isset($p['spatial']) ? (float)$p['spatial'][0] : null,
'spatial_y' => isset($p['spatial']) ? (float)$p['spatial'][1] : null,
]; ];
return ['ok' => true, 'errors' => [], 'clean' => $clean]; return ['ok' => true, 'errors' => [], 'clean' => $clean];
} }

View file

@ -95,16 +95,8 @@ final class IngestController
// PIPA retention: purge worker snapshots older than the configured window. Runs rarely // PIPA retention: purge worker snapshots older than the configured window. Runs rarely
// (the gate) and off the critical path — the event is already stored. // (the gate) and off the critical path — the event is already stored.
$days = $this->cfg->snapshotRetentionDays(); $days = $this->cfg->snapshotRetentionDays();
$eventDays = $this->cfg->eventRetentionDays(); if ($days > 0 && ($this->purgeGate)()) {
if (($days > 0 || $eventDays > 0) && ($this->purgeGate)()) { $this->snapshots->purgeOlderThan($now - $days * 86400);
if ($days > 0) {
$this->snapshots->purgeOlderThan($now - $days * 86400);
}
// Event rows: opt-in only (default 0 = keep forever). Runs AFTER the snapshot purge
// and on a much longer window, so images are always gone before their event row is.
if ($eventDays > 0) {
$this->events->purgeOlderThan((float)($now - $eventDays * 86400));
}
} }
return Response::json(['event_id' => $eventId], 201); return Response::json(['event_id' => $eventId], 201);
} }

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
interface ActionRepository
{
/** {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.
* $site (non-null) restricts to that site — the SQL half of the site-scope access
* control, same contract as EventRepository::page() (see App\Security\SiteScope).
*/
public function recent(int $limit, ?string $site): array;
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
/** Notification channels and the delivery ledger. */
interface AlertRepository
{
/** @return array<int,array> enabled channels, ordered by step then id. */
public function channels(?string $site): array;
public function allChannels(?string $site): array;
public function saveChannel(array $row): int;
public function deleteChannel(int $id, ?string $site): bool;
/**
* Claim (event, channel) for delivery. Returns false when it was already claimed —
* that is the duplicate-send guard, enforced by a UNIQUE key rather than by a read.
*/
public function claim(int $eventId, int $channelId, int $now): bool;
public function markSent(int $eventId, int $channelId, int $now): void;
public function markFailed(int $eventId, int $channelId, string $error, int $now): void;
public function markSuppressed(int $eventId, int $channelId, int $now): void;
/** Delivery rows for one event, for the incident record. */
public function forEvent(int $eventId): array;
/** Rows still worth retrying (status=failed, attempts under the cap). */
public function retryable(int $maxAttempts, int $limit): array;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
/** Heartbeat telemetry as a time series, so slow degradation is visible. */
interface DeviceMetricRepository
{
public function append(string $deviceId, array $metrics, int $now): void;
/** @return array<int,array> ascending by ts. */
public function series(string $deviceId, int $sinceTs, int $limit): array;
/** Retention: drop points older than $before. Returns rows removed. */
public function prune(int $before): int;
}

View file

@ -13,14 +13,6 @@ interface DeviceRepository
public function siteFor(string $deviceId): ?string; public function siteFor(string $deviceId): ?string;
public function touchHeartbeat(string $deviceId, array $meta): void; public function touchHeartbeat(string $deviceId, array $meta): void;
public function configVersion(string $deviceId): int; public function configVersion(string $deviceId): int;
/**
* Increment config_version so the device's next sync sees a NEW version and applies what
* changed. Without this a zone or threshold edit sits in the database looking applied while
* the node keeps running the old value — the exact drift the dashboard's config-drift
* indicator exists to expose, except nothing would ever have caused it to light up.
* Returns the new version (or 0 when the device does not exist).
*/
public function bumpConfigVersion(string $deviceId): int;
/** All devices with their health telemetry, for the dashboard's device-status panel. /** All devices with their health telemetry, for the dashboard's device-status panel.
* Site-scoped: a non-null $site returns only that site's devices (a scoped operator); * Site-scoped: a non-null $site returns only that site's devices (a scoped operator);

View file

@ -20,83 +20,4 @@ interface EventRepository
public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array; public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array;
public function find(int $id): ?array; public function find(int $id): ?array;
public function acknowledge(int $id, string $user): bool; public function acknowledge(int $id, string $user): bool;
/**
* Spatial points for the heatmap: events of this camera since $sinceOccurredAt that carry
* a spatial point, newest first, capped at $limit. $site (non-null) applies the same
* site-scope filter as page(). Rows: {x, y, severity, event_type, occurred_at}.
*/
public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array;
/**
* Tag an event as a false positive AND acknowledge it (an FP is a handled event —
* leaving it unacknowledged would keep it alarming). Idempotent on the FP flag:
* the FIRST tagger is preserved. Returns false only when the event does not exist.
*/
public function markFalsePositive(int $id, string $user): bool;
/**
* FP-rate aggregation for the stats panel: per (camera_id, event_type) totals and
* false-positive counts since $sinceOccurredAt, only groups with at least one FP.
* $site applies the same fail-closed scope filter as page().
* Rows: {camera_id, event_type, total, fp}.
*/
public function fpStats(float $sinceOccurredAt, ?string $site): array;
/**
* Aggregations for the safety operations report, all over the same window + site scope:
* - by_type: [{event_type, severity, count, unacked, fp}]
* - by_camera: [{camera_id, count, critical_high}] worst-first, top $topCameras
* - tta: {acked, avg_s, max_s} for acknowledged critical+high (FP-tagged rows excluded —
* a dismissed false alarm is not an emergency response)
* - ergo_metrics: raw metrics arrays of ergo_report rows (caller sums)
*/
public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras,
?float $untilOccurredAt = null): array;
/**
* Bare (occurred_at, event_type) pairs of non-ergo events in the window, newest first,
* capped at $limit — bucketing (weekday×hour patterns, spike baselines) happens in PHP
* so the SQL stays portable (the sqlite integration harness has no FROM_UNIXTIME).
*/
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.
* NOT move_cameras: that table has no registration flow and no repository (see schema.sql),
* so it is empty in every deployment — checking it would reject every real camera.
*/
public function cameraExists(string $cameraId): bool;
/**
* Delete event rows older than $cutoff, at most $limit per call. Opt-in only
* (Config::eventRetentionDays defaults to 0 = never) — see that method for why safety
* incident rows are not expired by default. Returns the number deleted.
*/
public function purgeOlderThan(float $cutoff, int $limit = 500): int;
/**
* The site this camera's events are recorded under (from its most recent event), or null
* when the camera is unknown OR its device has no site assigned. Pair with cameraExists()
* to tell those two apart, exactly as DeviceRepository::exists()/siteFor() are paired.
*/
public function siteForCamera(string $cameraId): ?string;
} }

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
/** Shift handover records: who accepted responsibility for which backlog. */
interface HandoverRepository
{
public function recent(?string $site, int $limit): array;
public function latest(?string $site): ?array;
public function create(array $row): int;
}

View file

@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\ActionRepository;
use PDO;
final class PdoActionRepository implements ActionRepository
{
public function __construct(private PDO $pdo) {}
public function insert(array $action): int
{
$st = $this->pdo->prepare(
'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'],
time(),
]);
return (int)$this->pdo->lastInsertId();
}
public function recent(int $limit, ?string $site): array
{
$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,
// and a row with no site is never visible to a scoped caller.
$sql .= ' WHERE site = ?';
$binds[] = $site;
}
$sql .= ' ORDER BY taken_at DESC, action_id DESC LIMIT ?';
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as $b) $st->bindValue($pos++, $b, PDO::PARAM_STR);
$st->bindValue($pos, $limit, PDO::PARAM_INT);
$st->execute();
return array_map(static fn ($r) => [
'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'],
], $st->fetchAll());
}
}

View file

@ -1,184 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\AlertRepository;
use PDO;
use PDOException;
/**
* Notification channels and the delivery ledger.
*
* The ledger is not bookkeeping — it is the duplicate-send guard. The dispatcher runs from cron
* on shared hosting, where an overrunning job and the next scheduled one routinely overlap, so
* "read whether we sent it, then send it" is a race that phones the same fall in five times.
* claim() instead INSERTs a (event_id, channel_id) row and lets the UNIQUE key decide: exactly
* one caller wins, and it is the one that then does the sending.
*/
final class PdoAlertRepository implements AlertRepository
{
public function __construct(private PDO $pdo) {}
public function channels(?string $site): array
{
// A NULL-site channel is a global one (the single-site default install); it applies
// everywhere. A scoped channel applies only to its own site.
$sql = 'SELECT * FROM move_alert_channels WHERE enabled = 1';
$args = [];
if ($site !== null) { $sql .= ' AND (site IS NULL OR site = ?)'; $args[] = $site; }
$sql .= ' ORDER BY step ASC, channel_id ASC';
$st = $this->pdo->prepare($sql);
$st->execute($args);
return array_map([self::class, 'mapChannel'], $st->fetchAll());
}
public function allChannels(?string $site): array
{
$sql = 'SELECT * FROM move_alert_channels';
$args = [];
if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; }
$sql .= ' ORDER BY step ASC, channel_id ASC';
$st = $this->pdo->prepare($sql);
$st->execute($args);
return array_map([self::class, 'mapChannel'], $st->fetchAll());
}
public function saveChannel(array $row): int
{
if (!empty($row['channel_id'])) {
$st = $this->pdo->prepare(
'UPDATE move_alert_channels
SET site = ?, kind = ?, name = ?, config_json = ?, min_severity = ?,
enabled = ?, step = ?, escalate_after_s = ?
WHERE channel_id = ?'
);
$st->execute([
$row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'],
$row['min_severity'], (int)$row['enabled'], (int)$row['step'],
(int)$row['escalate_after_s'], (int)$row['channel_id'],
]);
return (int)$row['channel_id'];
}
$st = $this->pdo->prepare(
'INSERT INTO move_alert_channels (site, kind, name, config_json, min_severity, enabled, step, escalate_after_s, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$st->execute([
$row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'],
$row['min_severity'], (int)$row['enabled'], (int)$row['step'],
(int)$row['escalate_after_s'], $row['created_at'],
]);
return (int)$this->pdo->lastInsertId();
}
public function deleteChannel(int $id, ?string $site): bool
{
$sql = 'DELETE FROM move_alert_channels WHERE channel_id = ?';
$args = [$id];
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
$st = $this->pdo->prepare($sql);
$st->execute($args);
return $st->rowCount() > 0;
}
public function claim(int $eventId, int $channelId, int $now): bool
{
try {
$st = $this->pdo->prepare(
'INSERT INTO move_alert_log (event_id, channel_id, status, attempts, queued_at)
VALUES (?, ?, ?, 0, ?)'
);
$st->execute([$eventId, $channelId, 'pending', $now]);
return true;
} catch (PDOException $e) {
// Duplicate key = another dispatcher run already owns this delivery. That is the
// designed outcome, not an error: losing the race means someone else is sending.
if (self::isDuplicate($e)) return false;
throw $e;
}
}
public function markSent(int $eventId, int $channelId, int $now): void
{
$st = $this->pdo->prepare(
'UPDATE move_alert_log SET status = ?, sent_at = ?, attempts = attempts + 1, last_error = NULL
WHERE event_id = ? AND channel_id = ?'
);
$st->execute(['sent', $now, $eventId, $channelId]);
}
public function markFailed(int $eventId, int $channelId, string $error, int $now): void
{
$st = $this->pdo->prepare(
'UPDATE move_alert_log SET status = ?, attempts = attempts + 1, last_error = ?
WHERE event_id = ? AND channel_id = ?'
);
// Truncate: a curl error body can be long and the column is not the place to store it.
$st->execute(['failed', mb_substr($error, 0, 500), $eventId, $channelId]);
}
public function markSuppressed(int $eventId, int $channelId, int $now): void
{
$st = $this->pdo->prepare(
'UPDATE move_alert_log SET status = ?, sent_at = ? WHERE event_id = ? AND channel_id = ?'
);
$st->execute(['suppressed', $now, $eventId, $channelId]);
}
public function forEvent(int $eventId): array
{
$st = $this->pdo->prepare(
'SELECT l.*, c.name AS channel_name, c.kind
FROM move_alert_log l
LEFT JOIN move_alert_channels c ON c.channel_id = l.channel_id
WHERE l.event_id = ? ORDER BY l.alert_id ASC'
);
$st->execute([$eventId]);
return array_map(static fn (array $r): array => [
'channel_id' => (int)$r['channel_id'],
'channel_name' => $r['channel_name'] !== null ? (string)$r['channel_name'] : null,
'kind' => $r['kind'] !== null ? (string)$r['kind'] : null,
'status' => (string)$r['status'],
'attempts' => (int)$r['attempts'],
'last_error' => $r['last_error'] !== null ? (string)$r['last_error'] : null,
'queued_at' => (int)$r['queued_at'],
'sent_at' => $r['sent_at'] !== null ? (int)$r['sent_at'] : null,
], $st->fetchAll());
}
public function retryable(int $maxAttempts, int $limit): array
{
$st = $this->pdo->prepare(
'SELECT event_id, channel_id, attempts FROM move_alert_log
WHERE status = ? AND attempts < ? ORDER BY queued_at ASC LIMIT ' . max(1, min(500, $limit))
);
$st->execute(['failed', $maxAttempts]);
return array_map(static fn (array $r): array => [
'event_id' => (int)$r['event_id'],
'channel_id' => (int)$r['channel_id'],
'attempts' => (int)$r['attempts'],
], $st->fetchAll());
}
private static function isDuplicate(PDOException $e): bool
{
// MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class
// so this holds for both drivers the project runs (MySQL in production, sqlite in tests).
return ($e->getCode() === '23000') || str_contains($e->getMessage(), 'UNIQUE constraint failed');
}
private static function mapChannel(array $r): array
{
return [
'channel_id' => (int)$r['channel_id'],
'site' => $r['site'] !== null ? (string)$r['site'] : null,
'kind' => (string)$r['kind'],
'name' => (string)$r['name'],
'config_json' => (string)$r['config_json'],
'min_severity' => (string)$r['min_severity'],
'enabled' => (int)$r['enabled'],
'step' => (int)$r['step'],
'escalate_after_s' => (int)$r['escalate_after_s'],
];
}
}

View file

@ -5,79 +5,23 @@ namespace App\Repository\Pdo;
use App\Repository\AuditRepository; use App\Repository\AuditRepository;
use PDO; use PDO;
/**
* Tamper-EVIDENT audit log.
*
* Shared hosting cannot give the app an append-only table: the same DB user that inserts rows
* can also UPDATE and DELETE them, and there is no way to drop that privilege from inside the
* application. So the goal is not to make tampering impossible — it is to make it impossible to
* do QUIETLY. Each row carries the previous row's hash, and its own hash covers its contents
* plus that link. Change one row, or remove one, and every hash after it stops matching; the
* verifier (tools/verify_audit_chain.php) prints exactly where the chain forks.
*
* `id` is deliberately NOT part of the hash. Excluding it lets the whole row be written in a
* single INSERT (the hash is computed before the id exists), which keeps the append atomic;
* deletion is still detected, because the row after the hole carries a prev_hash that matches
* nothing that remains.
*/
final class PdoAuditRepository implements AuditRepository final class PdoAuditRepository implements AuditRepository
{ {
public function __construct(private PDO $pdo) {} public function __construct(private PDO $pdo) {}
public function append(string $actor, string $action, string $target, array $before, array $after): void public function append(string $actor, string $action, string $target, array $before, array $after): void
{ {
$beforeJson = json_encode($before, JSON_THROW_ON_ERROR); $st = $this->pdo->prepare(
$afterJson = json_encode($after, JSON_THROW_ON_ERROR); 'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts)
$ts = time(); VALUES (?, ?, ?, ?, ?, ?)'
);
// The read of the tail and the write of the new row must not interleave with another $st->execute([
// append, or two rows would claim the same predecessor and the chain would fork. When $actor,
// this repository owns the transaction it also takes a row lock (MySQL); sqlite's $action,
// single-writer model makes the lock unnecessary and it has no FOR UPDATE. $target,
$ownTransaction = !$this->pdo->inTransaction(); json_encode($before, JSON_THROW_ON_ERROR),
if ($ownTransaction) $this->pdo->beginTransaction(); json_encode($after, JSON_THROW_ON_ERROR),
try { time(),
$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 ?? '',
]));
} }
} }

View file

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\DeviceMetricRepository;
use PDO;
/**
* Heartbeat telemetry as a time series.
*
* move_devices keeps only the newest value, which cannot answer "is this node getting worse?".
* At a 30s heartbeat one device writes ~2,880 rows/day, so prune() is not optional housekeeping
* — on a shared-hosting DB quota it is what keeps this table from becoming the reason ingest
* itself starts failing.
*/
final class PdoDeviceMetricRepository implements DeviceMetricRepository
{
public function __construct(private PDO $pdo) {}
public function append(string $deviceId, array $metrics, int $now): void
{
$st = $this->pdo->prepare(
'INSERT INTO move_device_metrics (device_id, ts, fps, temp, throttle, queue_pending,
cameras_total, cameras_erroring, errors)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
// A missing metric stays NULL. Never coerce an absent reading to 0: a device that cannot
// read its temperature would then look like a device sitting at 0°C.
$num = static fn (string $k) => isset($metrics[$k]) && is_numeric($metrics[$k]) ? $metrics[$k] + 0 : null;
$st->execute([
$deviceId, $now,
$num('fps'), $num('temp'),
isset($metrics['throttle']) ? (int)(bool)$metrics['throttle'] : null,
$num('queue_pending'), $num('cameras_total'), $num('cameras_erroring'), $num('errors'),
]);
}
public function series(string $deviceId, int $sinceTs, int $limit): array
{
$st = $this->pdo->prepare(
'SELECT ts, fps, temp, throttle, queue_pending, cameras_total, cameras_erroring, errors
FROM move_device_metrics
WHERE device_id = ? AND ts >= ?
ORDER BY ts ASC LIMIT ' . max(1, min(5000, $limit))
);
$st->execute([$deviceId, $sinceTs]);
return array_map(static fn (array $r): array => [
'ts' => (int)$r['ts'],
'fps' => $r['fps'] !== null ? (float)$r['fps'] : null,
'temp' => $r['temp'] !== null ? (float)$r['temp'] : null,
'throttle' => $r['throttle'] !== null ? (bool)$r['throttle'] : null,
'queue_pending' => $r['queue_pending'] !== null ? (int)$r['queue_pending'] : null,
'cameras_total' => $r['cameras_total'] !== null ? (int)$r['cameras_total'] : null,
'cameras_erroring' => $r['cameras_erroring'] !== null ? (int)$r['cameras_erroring'] : null,
'errors' => $r['errors'] !== null ? (int)$r['errors'] : null,
], $st->fetchAll());
}
public function prune(int $before): int
{
$st = $this->pdo->prepare('DELETE FROM move_device_metrics WHERE ts < ?');
$st->execute([$before]);
return $st->rowCount();
}
}

View file

@ -67,18 +67,6 @@ final class PdoDeviceRepository implements DeviceRepository
return $version === false ? 1 : (int)$version; return $version === false ? 1 : (int)$version;
} }
public function bumpConfigVersion(string $deviceId): int
{
// Increment in SQL, not read-modify-write: two admins saving different zones at the same
// moment would otherwise both write version+1 and one edit would never reach the device.
$st = $this->pdo->prepare(
'UPDATE move_devices SET config_version = config_version + 1 WHERE device_id = ?'
);
$st->execute([$deviceId]);
if ($st->rowCount() === 0) return 0;
return $this->configVersion($deviceId);
}
public function listStatus(?string $site): array public function listStatus(?string $site): array
{ {
// Site-scope access control, same rule as the event list: a non-null scope filters to // Site-scope access control, same rule as the event list: a non-null scope filters to

View file

@ -9,8 +9,7 @@ final class PdoEventRepository implements EventRepository
{ {
private const SELECT_COLUMNS = private const SELECT_COLUMNS =
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, 'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at, occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at';
false_positive_by, false_positive_at';
public function __construct(private PDO $pdo) {} public function __construct(private PDO $pdo) {}
@ -19,8 +18,8 @@ final class PdoEventRepository implements EventRepository
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_events 'INSERT INTO move_events
(event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id, (event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, received_at) occurred_at, duration_s, metrics_json, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
); );
$st->execute([ $st->execute([
$event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy) $event['event_uuid'] ?? null, // NULL when the edge sent no idempotency key (legacy)
@ -34,8 +33,6 @@ final class PdoEventRepository implements EventRepository
(float)$event['occurred_at'], (float)$event['occurred_at'],
(float)($event['duration_s'] ?? 0.0), (float)($event['duration_s'] ?? 0.0),
json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR), json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR),
$event['spatial_x'] ?? null,
$event['spatial_y'] ?? null,
time(), time(),
]); ]);
return (int)$this->pdo->lastInsertId(); return (int)$this->pdo->lastInsertId();
@ -134,311 +131,6 @@ final class PdoEventRepository implements EventRepository
return true; return true;
} }
public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array
{
$sql = 'SELECT spatial_x, spatial_y, severity, event_type, occurred_at
FROM move_events
WHERE camera_id = ? AND occurred_at >= ?
AND spatial_x IS NOT NULL AND spatial_y IS NOT NULL';
$binds = [[$cameraId, PDO::PARAM_STR], [sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
// Same fail-closed site scoping as page(): an empty-string scope matches nothing.
$sql .= ' AND site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
$sql .= ' ORDER BY occurred_at DESC 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(static fn ($r) => [
'x' => (float)$r['spatial_x'],
'y' => (float)$r['spatial_y'],
'severity' => $r['severity'],
'event_type' => $r['event_type'],
'occurred_at' => (float)$r['occurred_at'],
], $st->fetchAll());
}
public function markFalsePositive(int $id, string $user): bool
{
// Same matched-vs-changed discipline as acknowledge(): existence checked separately,
// because a no-op UPDATE reports 0 affected rows on MySQL's default PDO settings.
$exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
$exists->execute([$id]);
if ($exists->fetchColumn() === false) return false;
$now = time();
// COALESCE keeps the FIRST tagger/acknowledger — a second tag must not rewrite who
// made the original call (it is an audit-relevant judgement).
$st = $this->pdo->prepare(
'UPDATE move_events
SET false_positive_by = COALESCE(false_positive_by, ?),
false_positive_at = COALESCE(false_positive_at, ?),
acknowledged_by = COALESCE(acknowledged_by, ?),
acknowledged_at = COALESCE(acknowledged_at, ?)
WHERE event_id = ?'
);
$st->execute([$user, $now, $user, $now, $id]);
return true;
}
public function fpStats(float $sinceOccurredAt, ?string $site): array
{
$sql = 'SELECT camera_id, event_type, COUNT(*) AS total,
SUM(false_positive_by IS NOT NULL) AS fp
FROM move_events
WHERE occurred_at >= ?';
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
$sql .= ' AND site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
$sql .= ' GROUP BY camera_id, event_type HAVING fp > 0 ORDER BY fp DESC';
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
$st->execute();
return array_map(static fn ($r) => [
'camera_id' => $r['camera_id'],
'event_type' => $r['event_type'],
'total' => (int)$r['total'],
'fp' => (int)$r['fp'],
], $st->fetchAll());
}
public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras,
?float $untilOccurredAt = null): array
{
$sinceStr = sprintf('%.6f', $sinceOccurredAt);
// Optional upper bound so the SAME aggregation can run over the preceding window
// (period-over-period trend) without duplicating any of these queries.
$until = $untilOccurredAt !== null ? ' AND occurred_at < ?' : '';
$untilBind = $untilOccurredAt !== null ? [sprintf('%.6f', $untilOccurredAt)] : [];
$scope = $site !== null ? ' AND site = ?' : '';
$scopeBind = $site !== null ? [$site] : [];
$byType = $this->pdo->prepare(
"SELECT event_type, severity, COUNT(*) AS count,
SUM(acknowledged_by IS NULL) AS unacked,
SUM(false_positive_by IS NOT NULL) AS fp
FROM move_events WHERE occurred_at >= ?$until$scope
GROUP BY event_type, severity"
);
$byType->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
$byCamera = $this->pdo->prepare(
"SELECT camera_id, COUNT(*) AS count,
SUM(severity IN ('critical','high')) AS critical_high
FROM move_events WHERE occurred_at >= ?$until$scope
GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?"
);
$pos = 1;
$byCamera->bindValue($pos++, $sinceStr, PDO::PARAM_STR);
foreach ($untilBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR);
foreach ($scopeBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR);
$byCamera->bindValue($pos, $topCameras, PDO::PARAM_INT);
$byCamera->execute();
// Response time counts only real emergencies someone actually confirmed: critical/high,
// acknowledged, not FP-tagged (dismissing a false alarm is not an emergency response).
// acknowledged_at >= occurred_at guards clock-skewed rows from producing negative TTA.
$tta = $this->pdo->prepare(
"SELECT COUNT(*) AS acked,
AVG(acknowledged_at - occurred_at) AS avg_s,
MAX(acknowledged_at - occurred_at) AS max_s
FROM move_events
WHERE occurred_at >= ?$until$scope AND severity IN ('critical','high')
AND acknowledged_at IS NOT NULL AND false_positive_by IS NULL
AND acknowledged_at >= occurred_at"
);
$tta->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
$ttaRow = $tta->fetch() ?: ['acked' => 0, 'avg_s' => null, 'max_s' => null];
$ergo = $this->pdo->prepare(
"SELECT metrics_json FROM move_events
WHERE occurred_at >= ?$until$scope AND event_type = 'ergo_report'
ORDER BY occurred_at DESC LIMIT 2000"
);
$ergo->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
return [
'by_type' => array_map(static fn ($r) => [
'event_type' => $r['event_type'], 'severity' => $r['severity'],
'count' => (int)$r['count'], 'unacked' => (int)$r['unacked'], 'fp' => (int)$r['fp'],
], $byType->fetchAll()),
'by_camera' => array_map(static fn ($r) => [
'camera_id' => $r['camera_id'], 'count' => (int)$r['count'],
'critical_high' => (int)$r['critical_high'],
], $byCamera->fetchAll()),
'tta' => [
'acked' => (int)$ttaRow['acked'],
'avg_s' => $ttaRow['avg_s'] !== null ? (float)$ttaRow['avg_s'] : null,
'max_s' => $ttaRow['max_s'] !== null ? (float)$ttaRow['max_s'] : null,
],
'ergo_metrics' => array_map(
static fn ($r) => json_decode((string)$r['metrics_json'], true) ?: [],
$ergo->fetchAll()
),
];
}
public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array
{
$sql = "SELECT occurred_at, event_type FROM move_events
WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
$sql .= ' AND site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
$sql .= ' ORDER BY occurred_at DESC 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(static fn ($r) => [
'occurred_at' => (float)$r['occurred_at'],
'event_type' => $r['event_type'],
], $st->fetchAll());
}
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array
{
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM move_events WHERE occurred_at >= ?';
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
$sql .= ' AND site = ?';
$binds[] = [$site, PDO::PARAM_STR];
}
$sql .= ' ORDER BY occurred_at DESC, event_id DESC 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 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
WHERE camera_id = ? AND occurred_at >= ? AND occurred_at < ?
AND event_type <> 'ergo_report'";
$binds = [$cameraId, sprintf('%.6f', $from), sprintf('%.6f', $to)];
if ($site !== null) {
$sql .= ' AND site = ?';
$binds[] = $site;
}
$st = $this->pdo->prepare($sql);
$st->execute($binds);
return (int)$st->fetchColumn();
}
public function purgeOlderThan(float $cutoff, int $limit = 500): int
{
// Bounded batch, same shape as the snapshot purge: a shared-hosting request must not
// stall deleting a year of rows. ON DELETE CASCADE on move_event_snapshots would drop
// snapshot ROWS with the event, so purge only what the snapshot purge has already
// emptied of files — cutoff is expected to be far older than the image retention window.
$sel = $this->pdo->prepare(
'SELECT event_id FROM move_events WHERE occurred_at < ? ORDER BY event_id LIMIT ?');
$sel->bindValue(1, sprintf('%.6f', $cutoff), PDO::PARAM_STR);
$sel->bindValue(2, $limit, PDO::PARAM_INT);
$sel->execute();
$ids = array_map('intval', $sel->fetchAll(PDO::FETCH_COLUMN));
if (!$ids) {
return 0;
}
$in = implode(',', array_fill(0, count($ids), '?'));
$this->pdo->prepare("DELETE FROM move_events WHERE event_id IN ($in)")->execute($ids);
return count($ids);
}
public function cameraExists(string $cameraId): bool
{
$st = $this->pdo->prepare('SELECT 1 FROM move_events WHERE camera_id = ? LIMIT 1');
$st->execute([$cameraId]);
return $st->fetchColumn() !== false;
}
public function siteForCamera(string $cameraId): ?string
{
// Newest event wins: if a camera is re-homed to another site, later actions belong to
// the site it reports to NOW. event_id DESC (not occurred_at) because ingest order is
// the authoritative "most recently seen" — occurred_at comes from the edge clock.
$st = $this->pdo->prepare(
'SELECT site FROM move_events WHERE camera_id = ? AND site IS NOT NULL
ORDER BY event_id DESC LIMIT 1'
);
$st->execute([$cameraId]);
$site = $st->fetchColumn();
return $site === false ? null : (string)$site;
}
private static function mapRow(array $row): array private static function mapRow(array $row): array
{ {
return [ return [
@ -454,10 +146,6 @@ final class PdoEventRepository implements EventRepository
'occurred_at' => (float)$row['occurred_at'], 'occurred_at' => (float)$row['occurred_at'],
'duration_s' => (float)$row['duration_s'], 'duration_s' => (float)$row['duration_s'],
'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [], 'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [],
'spatial_x' => $row['spatial_x'] !== null ? (float)$row['spatial_x'] : null,
'spatial_y' => $row['spatial_y'] !== null ? (float)$row['spatial_y'] : null,
'false_positive_by' => $row['false_positive_by'] ?? null,
'false_positive_at' => ($row['false_positive_at'] ?? null) !== null ? (int)$row['false_positive_at'] : null,
'acknowledged_by' => $row['acknowledged_by'], 'acknowledged_by' => $row['acknowledged_by'],
'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null, 'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null,
]; ];

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\HandoverRepository;
use PDO;
/**
* Shift handover records.
*
* unacked_count/severe_count are stored as a SNAPSHOT taken at handover time rather than
* recomputed on read. They are the number the incoming operator actually accepted, and if a
* later query disagrees with it that disagreement is itself the finding — recomputing would
* silently erase it.
*/
final class PdoHandoverRepository implements HandoverRepository
{
public function __construct(private PDO $pdo) {}
public function recent(?string $site, int $limit): array
{
$sql = 'SELECT * FROM move_shift_handovers';
$args = [];
if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; }
$sql .= ' ORDER BY created_at DESC, handover_id DESC LIMIT ' . max(1, min(100, $limit));
$st = $this->pdo->prepare($sql);
$st->execute($args);
return array_map([self::class, 'mapRow'], $st->fetchAll());
}
public function latest(?string $site): ?array
{
return $this->recent($site, 1)[0] ?? null;
}
public function create(array $row): int
{
$st = $this->pdo->prepare(
'INSERT INTO move_shift_handovers (site, from_user, to_user, period_start, period_end,
unacked_count, severe_count, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$st->execute([
$row['site'] ?? null, $row['from_user'] ?? null, $row['to_user'],
$row['period_start'], $row['period_end'], $row['unacked_count'], $row['severe_count'],
$row['note'] ?? null, $row['created_at'],
]);
return (int)$this->pdo->lastInsertId();
}
private static function mapRow(array $r): array
{
return [
'handover_id' => (int)$r['handover_id'],
'site' => $r['site'] !== null ? (string)$r['site'] : null,
'from_user' => $r['from_user'] !== null ? (string)$r['from_user'] : null,
'to_user' => (string)$r['to_user'],
'period_start' => (int)$r['period_start'],
'period_end' => (int)$r['period_end'],
'unacked_count' => (int)$r['unacked_count'],
'severe_count' => (int)$r['severe_count'],
'note' => $r['note'] !== null ? (string)$r['note'] : null,
'created_at' => (int)$r['created_at'],
];
}
}

View file

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\RiskControlRepository;
use PDO;
/**
* 위험성평가 매핑: one row per (site, detection type).
*
* The UNIQUE key is (site, event_type) and MySQL treats NULL sites as distinct, so a plain
* "INSERT ... ON DUPLICATE KEY UPDATE" would append a second global row instead of updating the
* first. upsert() therefore looks the row up explicitly, with an IS NULL predicate for the
* global case.
*/
final class PdoRiskControlRepository implements RiskControlRepository
{
public function __construct(private PDO $pdo) {}
public function listAll(?string $site): array
{
$sql = 'SELECT * FROM move_risk_controls';
$args = [];
if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; }
// Highest risk score first: the table is read to decide what to fix next.
$sql .= ' ORDER BY (severity_rating * likelihood_rating) DESC, event_type ASC';
$st = $this->pdo->prepare($sql);
$st->execute($args);
return array_map([self::class, 'mapRow'], $st->fetchAll());
}
public function upsert(array $row): void
{
$site = $row['site'] ?? null;
$sql = 'SELECT control_id FROM move_risk_controls WHERE event_type = ? AND site '
. ($site === null ? 'IS NULL' : '= ?');
$st = $this->pdo->prepare($sql);
$st->execute($site === null ? [$row['event_type']] : [$row['event_type'], $site]);
$id = $st->fetchColumn();
if ($id !== false) {
$up = $this->pdo->prepare(
'UPDATE move_risk_controls
SET hazard = ?, control = ?, severity_rating = ?, likelihood_rating = ?,
reviewed_by = ?, reviewed_at = ?, updated_at = ?
WHERE control_id = ?'
);
$up->execute([
$row['hazard'], $row['control'], (int)$row['severity_rating'], (int)$row['likelihood_rating'],
$row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'], (int)$id,
]);
return;
}
$ins = $this->pdo->prepare(
'INSERT INTO move_risk_controls (site, event_type, hazard, control, severity_rating,
likelihood_rating, reviewed_by, reviewed_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$ins->execute([
$site, $row['event_type'], $row['hazard'], $row['control'],
(int)$row['severity_rating'], (int)$row['likelihood_rating'],
$row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'],
]);
}
public function delete(int $id, ?string $site): bool
{
$sql = 'DELETE FROM move_risk_controls WHERE control_id = ?';
$args = [$id];
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
$st = $this->pdo->prepare($sql);
$st->execute($args);
return $st->rowCount() > 0;
}
private static function mapRow(array $r): array
{
return [
'control_id' => (int)$r['control_id'],
'site' => $r['site'] !== null ? (string)$r['site'] : null,
'event_type' => (string)$r['event_type'],
'hazard' => (string)$r['hazard'],
'control' => (string)$r['control'],
'severity_rating' => (int)$r['severity_rating'],
'likelihood_rating' => (int)$r['likelihood_rating'],
'risk_score' => (int)$r['severity_rating'] * (int)$r['likelihood_rating'],
'reviewed_by' => $r['reviewed_by'] !== null ? (string)$r['reviewed_by'] : null,
'reviewed_at' => $r['reviewed_at'] !== null ? (int)$r['reviewed_at'] : null,
'updated_at' => (int)$r['updated_at'],
];
}
}

View file

@ -28,22 +28,6 @@ final class PdoSnapshotRepository implements SnapshotRepository
return $st->fetchAll(); return $st->fetchAll();
} }
public function uuidsFor(array $eventIds): array
{
$ids = array_values(array_filter(array_map('intval', $eventIds), static fn (int $i): bool => $i > 0));
if ($ids === []) return [];
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $this->pdo->prepare(
"SELECT event_id, file_uuid FROM move_event_snapshots
WHERE event_id IN ($in) ORDER BY snapshot_id"
);
foreach ($ids as $i => $v) $st->bindValue($i + 1, $v, PDO::PARAM_INT);
$st->execute();
$out = [];
foreach ($st->fetchAll() as $r) $out[(int)$r['event_id']][] = (string)$r['file_uuid'];
return $out;
}
public function thumbUuidsFor(array $eventIds): array public function thumbUuidsFor(array $eventIds): array
{ {
// Sanitize to positive ints and build the IN list from their COUNT — never interpolate // Sanitize to positive ints and build the IN list from their COUNT — never interpolate

View file

@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\SuppressionRepository;
use PDO;
/**
* Planned-work alarm suppression.
*
* A suppression never stops detection or ingest — the events still land, still show in the
* console and still count in the report. It withholds NOTIFICATION only. That distinction is
* the whole design: an investigation after an accident must be able to see what the cameras saw
* during the maintenance window, and a "quiet mode" that dropped events would destroy exactly
* the evidence someone would later be prosecuted over.
*
* Every row has a hard expiry. There is no open-ended suppression, because the failure mode of
* this feature is somebody silencing a zone "for now" and nobody ever turning it back on.
*/
final class PdoSuppressionRepository implements SuppressionRepository
{
public function __construct(private PDO $pdo) {}
/** @param array<int,array{0:string,1:string}> $scopes [[type, id], ...] */
public function activeFor(array $scopes, int $now): array
{
if ($scopes === []) return [];
$where = [];
$args = [];
foreach ($scopes as [$type, $id]) {
$where[] = '(scope_type = ? AND scope_id = ?)';
$args[] = $type;
$args[] = (string)$id;
}
$sql = 'SELECT * FROM move_suppressions
WHERE revoked_at IS NULL AND starts_at <= ? AND ends_at > ?
AND (' . implode(' OR ', $where) . ')';
$st = $this->pdo->prepare($sql);
$st->execute(array_merge([$now, $now], $args));
return array_map([self::class, 'mapRow'], $st->fetchAll());
}
public function listAll(?string $site, int $now, bool $includeExpired = false): array
{
$sql = 'SELECT * FROM move_suppressions WHERE 1=1';
$args = [];
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
if (!$includeExpired) {
$sql .= ' AND revoked_at IS NULL AND ends_at > ?';
$args[] = $now;
}
$sql .= ' ORDER BY created_at DESC LIMIT 200';
$st = $this->pdo->prepare($sql);
$st->execute($args);
return array_map([self::class, 'mapRow'], $st->fetchAll());
}
public function create(array $row): int
{
$st = $this->pdo->prepare(
'INSERT INTO move_suppressions (scope_type, scope_id, site, reason, starts_at, ends_at, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$st->execute([
$row['scope_type'], $row['scope_id'], $row['site'] ?? null, $row['reason'],
$row['starts_at'], $row['ends_at'], $row['created_by'], $row['created_at'],
]);
return (int)$this->pdo->lastInsertId();
}
public function revoke(int $id, string $user, int $now, ?string $site): bool
{
// The site predicate is part of the UPDATE, not a prior read: a scoped operator must not
// be able to lift another site's suppression by guessing an id, and a check-then-write
// would leave a window between the two.
$sql = 'UPDATE move_suppressions SET revoked_by = ?, revoked_at = ?
WHERE suppression_id = ? AND revoked_at IS NULL';
$args = [$user, $now, $id];
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
$st = $this->pdo->prepare($sql);
$st->execute($args);
return $st->rowCount() > 0;
}
public function find(int $id): ?array
{
$st = $this->pdo->prepare('SELECT * FROM move_suppressions WHERE suppression_id = ?');
$st->execute([$id]);
$r = $st->fetch();
return $r === false ? null : self::mapRow($r);
}
private static function mapRow(array $r): array
{
return [
'suppression_id' => (int)$r['suppression_id'],
'scope_type' => (string)$r['scope_type'],
'scope_id' => (string)$r['scope_id'],
'site' => $r['site'] !== null ? (string)$r['site'] : null,
'reason' => (string)$r['reason'],
'starts_at' => (int)$r['starts_at'],
'ends_at' => (int)$r['ends_at'],
'created_by' => (string)$r['created_by'],
'created_at' => (int)$r['created_at'],
'revoked_by' => $r['revoked_by'] !== null ? (string)$r['revoked_by'] : null,
'revoked_at' => $r['revoked_at'] !== null ? (int)$r['revoked_at'] : null,
];
}
}

View file

@ -5,31 +5,13 @@ namespace App\Repository\Pdo;
use App\Repository\ThresholdRepository; use App\Repository\ThresholdRepository;
use PDO; use PDO;
/**
* Per-device (optionally per-camera) tunables, served to the edge by GET /api/config.
*
* These rows used to be editable only by hand in phpMyAdmin — which meant that after delivery
* the customer had no way to retune a camera at all, even though the console's false-positive
* panel exists precisely to tell them which camera needs it. The write methods below back the
* admin editor; every write records who made it, because a threshold change is a change to what
* the system will and will not detect, and an incident investigation has to be able to ask when
* it happened.
*
* Site scope is resolved through move_devices: a scoped operator must never see, let alone
* retune, another site's device.
*/
final class PdoThresholdRepository implements ThresholdRepository final class PdoThresholdRepository implements ThresholdRepository
{ {
public function __construct(private PDO $pdo) {} public function __construct(private PDO $pdo) {}
public function forDevice(string $deviceId): array public function forDevice(string $deviceId): array
{ {
// Device-wide rows only (camera_id IS NULL). Per-camera overrides are addressed by the $st = $this->pdo->prepare('SELECT `key`, `value` FROM move_thresholds WHERE device_id = ?');
// edge through its own camera id and are not flattened into this map, which the daemon
// applies globally — merging them here would silently apply one camera's tuning to all.
$st = $this->pdo->prepare(
'SELECT `key`, `value` FROM move_thresholds WHERE device_id = ? AND camera_id IS NULL'
);
$st->execute([$deviceId]); $st->execute([$deviceId]);
$out = []; $out = [];
@ -38,92 +20,4 @@ final class PdoThresholdRepository implements ThresholdRepository
} }
return $out; return $out;
} }
public function listAll(?string $site): array
{
$sql = 'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at,
d.name AS device_name, d.site
FROM move_thresholds t
LEFT JOIN move_devices d ON d.device_id = t.device_id';
$args = [];
if ($site !== null) { $sql .= ' WHERE d.site = ?'; $args[] = $site; }
$sql .= ' ORDER BY t.device_id, t.camera_id, t.`key`';
$st = $this->pdo->prepare($sql);
$st->execute($args);
return array_map(static fn (array $r): array => [
'id' => (int)$r['id'],
'device_id' => (string)$r['device_id'],
'device_name' => $r['device_name'] !== null ? (string)$r['device_name'] : null,
'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null,
'key' => (string)$r['key'],
'value' => (string)$r['value'],
'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null,
'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null,
], $st->fetchAll());
}
public function set(string $deviceId, ?string $cameraId, string $key, string $value, string $actor, int $now): int
{
// The unique key is (device_id, camera_id, zone_id, key), and in MySQL a UNIQUE index
// treats NULLs as distinct — so "ON DUPLICATE KEY" would NOT collide for the common
// camera_id IS NULL row and every save would append a duplicate. Look it up explicitly.
$existing = $this->findScoped($deviceId, $cameraId, $key);
if ($existing !== null) {
$st = $this->pdo->prepare(
'UPDATE move_thresholds SET `value` = ?, updated_by = ?, updated_at = ? WHERE id = ?'
);
$st->execute([$value, $actor, $now, $existing]);
return $existing;
}
$st = $this->pdo->prepare(
'INSERT INTO move_thresholds (device_id, camera_id, zone_id, `key`, `value`, updated_by, updated_at)
VALUES (?, ?, NULL, ?, ?, ?, ?)'
);
$st->execute([$deviceId, $cameraId, $key, $value, $actor, $now]);
return (int)$this->pdo->lastInsertId();
}
private function findScoped(string $deviceId, ?string $cameraId, string $key): ?int
{
$sql = 'SELECT id FROM move_thresholds WHERE device_id = ? AND `key` = ? AND camera_id '
. ($cameraId === null ? 'IS NULL' : '= ?');
$args = $cameraId === null ? [$deviceId, $key] : [$deviceId, $key, $cameraId];
$st = $this->pdo->prepare($sql);
$st->execute($args);
$id = $st->fetchColumn();
return $id === false ? null : (int)$id;
}
public function remove(int $id, ?string $site): bool
{
$row = $this->find($id);
if ($row === null) return false;
if ($site !== null && ($row['site'] ?? null) !== $site) return false;
$st = $this->pdo->prepare('DELETE FROM move_thresholds WHERE id = ?');
$st->execute([$id]);
return $st->rowCount() > 0;
}
public function find(int $id): ?array
{
$st = $this->pdo->prepare(
'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at, d.site
FROM move_thresholds t
LEFT JOIN move_devices d ON d.device_id = t.device_id
WHERE t.id = ?'
);
$st->execute([$id]);
$r = $st->fetch();
if ($r === false) return null;
return [
'id' => (int)$r['id'],
'device_id' => (string)$r['device_id'],
'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null,
'key' => (string)$r['key'],
'value' => (string)$r['value'],
'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null,
'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null,
'site' => $r['site'] !== null ? (string)$r['site'] : null,
];
}
} }

View file

@ -13,7 +13,7 @@ final class PdoZoneRepository implements ZoneRepository
public function find(string $zoneId): ?array public function find(string $zoneId): ?array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_json FROM move_zones WHERE zone_id = ? LIMIT 1' 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE zone_id = ? LIMIT 1'
); );
$st->execute([$zoneId]); $st->execute([$zoneId]);
$row = $st->fetch(); $row = $st->fetch();
@ -23,7 +23,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forDevice(string $deviceId): array public function forDevice(string $deviceId): array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_json FROM move_zones WHERE device_id = ?' 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE device_id = ?'
); );
$st->execute([$deviceId]); $st->execute([$deviceId]);
return $st->fetchAll(); return $st->fetchAll();
@ -32,7 +32,7 @@ final class PdoZoneRepository implements ZoneRepository
public function forCamera(string $cameraId): array public function forCamera(string $cameraId): array
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_json FROM move_zones WHERE camera_id = ?' 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE camera_id = ?'
); );
$st->execute([$cameraId]); $st->execute([$cameraId]);
return $st->fetchAll(); return $st->fetchAll();
@ -45,19 +45,17 @@ final class PdoZoneRepository implements ZoneRepository
$cameraId = (string)($zone['camera_id'] ?? ''); $cameraId = (string)($zone['camera_id'] ?? '');
$level = (string)($zone['level'] ?? ''); $level = (string)($zone['level'] ?? '');
$polygonJson = (string)($zone['polygon_json'] ?? '[]'); $polygonJson = (string)($zone['polygon_json'] ?? '[]');
$rulesJson = isset($zone['rules_json']) && $zone['rules_json'] !== null
? (string)$zone['rules_json'] : null;
if (is_string($zoneId) && $zoneId !== '') { if (is_string($zoneId) && $zoneId !== '') {
return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson, $rulesJson); return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson);
} }
$newId = bin2hex(random_bytes(16)); $newId = bin2hex(random_bytes(16));
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, rules_json, updated_at) 'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?)'
); );
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, $rulesJson, time()]); $st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]);
return $newId; return $newId;
} }
@ -89,7 +87,6 @@ final class PdoZoneRepository implements ZoneRepository
string $cameraId, string $cameraId,
string $level, string $level,
string $polygonJson, string $polygonJson,
?string $rulesJson,
): string { ): string {
$owns = $this->pdo->prepare( $owns = $this->pdo->prepare(
'SELECT 1 FROM move_zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1' 'SELECT 1 FROM move_zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1'
@ -100,10 +97,10 @@ final class PdoZoneRepository implements ZoneRepository
} }
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'UPDATE move_zones SET level = ?, polygon_json = ?, rules_json = ?, updated_at = ? 'UPDATE move_zones SET level = ?, polygon_json = ?, updated_at = ?
WHERE zone_id = ? AND device_id = ? AND camera_id = ?' WHERE zone_id = ? AND device_id = ? AND camera_id = ?'
); );
$st->execute([$level, $polygonJson, $rulesJson, time(), $zoneId, $deviceId, $cameraId]); $st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]);
if ($st->rowCount() === 0) { if ($st->rowCount() === 0) {
// Ambiguous on its own: MySQL's rowCount() reports "changed" rows, // Ambiguous on its own: MySQL's rowCount() reports "changed" rows,
// not "matched" rows, so 0 here could mean either (a) a genuine // not "matched" rows, so 0 here could mean either (a) a genuine

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
/** 위험성평가: one row per (site, detection type) — the hazard it stands for and its control. */
interface RiskControlRepository
{
public function listAll(?string $site): array;
public function upsert(array $row): void;
public function delete(int $id, ?string $site): bool;
}

View file

@ -25,8 +25,4 @@ interface SnapshotRepository
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention: /** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
* worker images must not be kept forever. Returns the number purged. */ * worker images must not be kept forever. Returns the number purged. */
public function purgeOlderThan(int $cutoff): int; public function purgeOlderThan(int $cutoff): int;
/** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order —
* batched for the label export (thumbUuidsFor only carries the newest). */
public function uuidsFor(array $eventIds): array;
} }

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
/**
* Planned-work alarm suppression. Detection and ingest are untouched — only notification is
* withheld — so an investigation after the fact still sees everything that happened.
*/
interface SuppressionRepository
{
/** Active (not expired, not revoked) suppressions covering any of the given scopes. */
public function activeFor(array $scopes, int $now): array;
/** @return array<int,array> newest first; $site null = all sites (global operator). */
public function listAll(?string $site, int $now, bool $includeExpired = false): array;
public function create(array $row): int;
/** @return bool false when the id does not exist or is already revoked. */
public function revoke(int $id, string $user, int $now, ?string $site): bool;
public function find(int $id): ?array;
}

Some files were not shown because too many files have changed in this diff Show more