Compare commits
16 commits
9d25e0a2d1
...
5c7c65351d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7c65351d | ||
|
|
e00ad69aa3 | ||
|
|
c3227c179c | ||
|
|
6b1f4c483f | ||
|
|
6b69f22275 | ||
|
|
d8b8e1f381 | ||
|
|
51eed139cf | ||
|
|
d8cef54fe4 | ||
|
|
0ffc41681a | ||
|
|
b358822247 | ||
|
|
47ab1143e1 | ||
|
|
9e4225a338 | ||
|
|
a203c42bf3 | ||
|
|
01d36d0a1c | ||
|
|
32f2237db7 | ||
|
|
eaff1765a4 |
153 changed files with 14725 additions and 548 deletions
44
.forgejo/workflows/verify.yml
Normal file
44
.forgejo/workflows/verify.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 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
|
||||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# 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 금지).
|
||||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.9.0
|
||||||
|
|
@ -6,11 +6,13 @@ 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: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
|
> Scope: the delivered bundle is **Hailo-8 + `yolov8s_pose.hef`**, which
|
||||||
> daemon wires today (`edge/daemon.py:main`) and the one verified end-to-end.
|
> `build_offline_bundle.sh` fetches (sha256-pinned) and stages into `models/`.
|
||||||
> Hailo-8 acceleration (`edge/adapters/hailo_pose.py`) and the CSI camera
|
> The CPU MoveNet fallback is **not** bundled: its upstream ONNX URL 404s, so
|
||||||
> (`edge/adapters/picamera_source.py`) are separate device-validated paths — see
|
> `install_offline.sh` configures the unit for whatever backend is actually
|
||||||
> `docs/hardware-bringup.md`.
|
> present and refuses to install if none is. Camera side: USB (UVC) and CSI are
|
||||||
|
> 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
|
||||||
|
|
||||||
|
|
@ -105,17 +107,18 @@ 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 `devices` row in the server (see
|
`DEVICE_ID`/`DEVICE_SECRET` must match a `move_devices` row in the server (see
|
||||||
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
|
`server/docs/server-deploy.md` §6). `HAILO_HEF` points at the bundled model
|
||||||
model (`/opt/move-detect/models/movenet_lightning.onnx`).
|
(`/opt/move-detect/models/yolov8s_pose.hef`); the installer sets it for you and
|
||||||
|
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` (onnxruntime + cv2 +
|
- [ ] Step [4/6] of the installer prints `edge import OK` (cv2 + numpy + requests
|
||||||
numpy + requests all import from the venv).
|
import from the venv; onnxruntime only if a MoveNet bundle was built).
|
||||||
- [ ] `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
|
||||||
|
|
@ -166,6 +169,10 @@ 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 + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
|
- **HailoRT + picamera2** — Hailo's runtime/driver and the CSI camera stack are Pi
|
||||||
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
|
system packages, installed out-of-band (above). Note the HEF itself **is** bundled
|
||||||
the default CPU MoveNet + USB path needs none of them.
|
(`models/yolov8s_pose.hef`); only the driver is not.
|
||||||
|
- **`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.
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,19 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,15 @@ CAFE24_URL=https://your-domain.example/move_detect
|
||||||
CAMERA_ID=cam-01
|
CAMERA_ID=cam-01
|
||||||
|
|
||||||
# --- pose backend / capture ---
|
# --- pose backend / capture ---
|
||||||
# POSE_BACKEND: "movenet" (CPU, needs MOVENET_ONNX) or "hailo" (needs HAILO_HEF).
|
# POSE_BACKEND: "hailo" (Pi 5 + AI HAT+, needs HAILO_HEF) or "movenet" (CPU, needs MOVENET_ONNX).
|
||||||
POSE_BACKEND=movenet
|
# The delivered bundle carries the HEF only — the MoveNet ONNX has no reachable upstream, so a
|
||||||
# MOVENET_ONNX: required when POSE_BACKEND=movenet.
|
# movenet unit would point at a file that is not there and fail to start. install_offline.sh
|
||||||
MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx
|
# rewrites these to match whatever the bundle actually contains; edit them only if you know the
|
||||||
# HAILO_HEF: required when POSE_BACKEND=hailo (uncomment and set).
|
# file exists on this device.
|
||||||
#HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef
|
POSE_BACKEND=hailo
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,21 @@ 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
|
||||||
|
|
|
||||||
115
docs/INDEX.md
Normal file
115
docs/INDEX.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# 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 실렌더(편집기 저장·목록·콘솔 라벨).
|
||||||
89
docs/PROJECT_MAP.md
Normal file
89
docs/PROJECT_MAP.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# 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`(작업 기록)
|
||||||
|
|
@ -95,13 +95,23 @@ 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.
|
||||||
- [ ] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
|
- [x] **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.
|
||||||
- [ ] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
|
DONE — global greedy (all pairs sorted by IoU desc) replaces track-order greedy; any track
|
||||||
|
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.
|
||||||
- [ ] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
|
DONE — STANDING→FALLING now requires sustained prone (`falling_confirm_s`, default 1.0s,
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,15 @@ 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. Configure edge detection core to support tier selection (via thresholds file or env var).
|
1. Tier selection is `INFER_EVERY` in `/etc/move-detect/edge.env` (per device):
|
||||||
2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2/T3 on perimeter).
|
`1` = T1 (every tick, the default), `2`–`3` = T2. On a skipped tick the frame is still read
|
||||||
|
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).
|
||||||
|
|
||||||
|
|
@ -97,14 +104,23 @@ Validate sustained operation and resource cleanup:
|
||||||
|
|
||||||
After benchmark completion, update configuration:
|
After benchmark completion, update configuration:
|
||||||
|
|
||||||
1. **`edge/config.py` thresholds:**
|
Every knob below lives in `/etc/move-detect/edge.env` (read by `edge/config.py::load_config`),
|
||||||
- Adjust `motion.cooldown_ms`, `motion.min_duration_ms` based on measured T2/T3 FPS.
|
**not** in a code file — no redeploy is needed to retune a site. All are validated at startup:
|
||||||
- Tune `fall.confidence_threshold` if fall recall vs. precision trade-off differs on real video.
|
a non-finite or non-positive value refuses to boot rather than silently disabling a detector.
|
||||||
|
The full list is in `docs/pilot-runbook.md` §3.
|
||||||
|
|
||||||
2. **`edge/config.py` camera roles:**
|
1. **Detector thresholds:**
|
||||||
- Assign `role="fall_primary"` to high-fps T1 cam (≥ 12 fps).
|
- `MOTION_THR` (default 0.25) — per-second stillness threshold shared by fall no-motion and
|
||||||
- Assign `role="context"` to T2 cameras (8–10 fps).
|
immobility. This is the primary re-tuning knob on real video: it is frame-rate normalised,
|
||||||
- Assign `role="perimeter"` to T3 motion-only cameras.
|
so a device running at a different measured fps needs it re-checked.
|
||||||
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -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` (10개 테이블).
|
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (11개 테이블, 전부 `move_` 접두사).
|
||||||
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 devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
|
- `INSERT INTO move_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 users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
|
- `INSERT INTO move_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) 참조.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,17 @@ import cv2
|
||||||
|
|
||||||
|
|
||||||
class UvcSource:
|
class UvcSource:
|
||||||
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None):
|
def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30,
|
||||||
|
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()
|
||||||
|
|
@ -22,6 +28,14 @@ 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):
|
||||||
|
|
|
||||||
55
edge/app.py
55
edge/app.py
|
|
@ -8,6 +8,11 @@ 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:
|
||||||
|
|
@ -29,7 +34,8 @@ def _head_boxes(persons: list) -> list:
|
||||||
return boxes
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
|
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None,
|
||||||
|
integrity=None):
|
||||||
ok, frame = source.read()
|
ok, frame = source.read()
|
||||||
if not ok:
|
if not ok:
|
||||||
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
|
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
|
||||||
|
|
@ -41,21 +47,58 @@ 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()
|
||||||
for tr in tracks:
|
# Head boxes are computed EVERY tick and stored with the frame (not only when an event
|
||||||
|
# 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)
|
ring.push(now, frame, head_boxes)
|
||||||
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(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
|
events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now)
|
||||||
|
# Camera-integrity findings are about the FRAME, not about any track, so they are produced
|
||||||
|
# here (where the frame is) rather than inside the engine (which only sees tracks). They
|
||||||
|
# matter because a camera that has been knocked askew keeps producing perfectly healthy
|
||||||
|
# frames while every zone drawn on it now points somewhere else — the failure that used to
|
||||||
|
# be invisible until after an incident.
|
||||||
|
if integrity is not None:
|
||||||
|
stamp = wall_now if wall_now is not None else now
|
||||||
|
for etype, metrics in integrity.observe(frame, now):
|
||||||
|
et = EventType(etype)
|
||||||
|
events.append(Event(et, severity_for(et), engine.cfg.camera_id, None, None,
|
||||||
|
stamp, 0.0, metrics, None))
|
||||||
for tr in lost:
|
for tr in lost:
|
||||||
pool.forget(tr.track_id)
|
pool.forget(tr.track_id)
|
||||||
if events:
|
if events:
|
||||||
snaps = snapshotter.capture([frame], [_head_boxes(persons)])
|
# ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame
|
||||||
|
# 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, snaps)
|
sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
176
edge/beacon.py
Normal file
176
edge/beacon.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""현장 경광등·부저 — 엣지 노드가 스스로 울리는 유일한 통보 경로.
|
||||||
|
|
||||||
|
WHY LOCAL
|
||||||
|
폐쇄망 현장에서는 FCM 도 Slack 도 쓸 수 없고, 사내 SMTP·웹훅조차 없는 곳이 있다. 게다가
|
||||||
|
서버 경유 통보는 업링크가 살아 있을 때만 동작한다 — 정전이나 스위치 장애로 회선이 끊긴
|
||||||
|
순간은 사고 위험이 오히려 높아지는 때다. GPIO 출력은 그 모든 것과 무관하게 동작한다:
|
||||||
|
감지기가 이벤트를 만든 그 자리에서, 네트워크 없이, 수십 밀리초 안에.
|
||||||
|
|
||||||
|
그래서 이것이 어떤 사이트든 **가장 먼저 설정해야 할 통보 경로**다. 서버측 채널
|
||||||
|
(App\\Alert\\Channels)은 이 위에 얹는 보조 수단이다.
|
||||||
|
|
||||||
|
설정 전달
|
||||||
|
서버 /api/config 의 `beacons` 배열로 내려온다 (핀 번호, latch/pulse, 유지 시간,
|
||||||
|
active_high). 관리 화면에서 켜고 끄므로 현장에서 edge.env 를 고칠 필요가 없다.
|
||||||
|
|
||||||
|
하드웨어 부재
|
||||||
|
gpiozero / RPi.GPIO 가 없거나(개발 PC, CI) 핀 열기에 실패하면 NullBeacon 으로 조용히
|
||||||
|
떨어진다. 경광등이 없다고 감지가 멈추면 안 된다 — 통보는 감지의 부가 기능이지 전제가
|
||||||
|
아니다. 다만 그 사실은 로그로 남긴다: 아무도 모르게 경광등이 죽어 있는 것이 최악이다.
|
||||||
|
|
||||||
|
실장비 검증
|
||||||
|
핀이 실제로 떨어지는지는 이 파일의 단위 테스트로 증명할 수 없다. tests/hardware/ 의
|
||||||
|
OFF-HW 항목으로 남긴다 (docs/hardware-bringup.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
_SEVERITY_RANK = {"info": 0, "warning": 1, "high": 2, "critical": 3}
|
||||||
|
|
||||||
|
|
||||||
|
class NullBeacon:
|
||||||
|
"""하드웨어가 없을 때의 무동작 구현. 인터페이스는 동일하다."""
|
||||||
|
|
||||||
|
available = False
|
||||||
|
|
||||||
|
def fire(self, severity: str, now: float) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def tick(self, now: float) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GpioBeacon:
|
||||||
|
"""핀 하나를 latch(고정) 또는 pulse(일정 시간 후 해제) 로 구동한다.
|
||||||
|
|
||||||
|
`latch` 는 사람이 확인할 때까지 계속 울리는 대신 hold_s 뒤 자동 해제한다. 무한 latch 는
|
||||||
|
현장에 사람이 갈 수 없는 시간대(야간·주말)에 아무도 끌 수 없는 소음이 되고, 그러면 다음
|
||||||
|
주에 배선이 뽑혀 있다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
available = True
|
||||||
|
|
||||||
|
def __init__(self, pin: int, *, mode: str = "latch", hold_s: float = 30.0,
|
||||||
|
active_high: bool = False, min_severity: str = "high",
|
||||||
|
backend=None, log=None):
|
||||||
|
self.pin = int(pin)
|
||||||
|
self.mode = mode if mode in ("latch", "pulse") else "latch"
|
||||||
|
self.hold_s = max(0.5, float(hold_s))
|
||||||
|
self.active_high = bool(active_high)
|
||||||
|
self.min_rank = _SEVERITY_RANK.get(min_severity, 2)
|
||||||
|
self._on_until: float | None = None
|
||||||
|
self._log = log or (lambda m: None)
|
||||||
|
self._backend = backend if backend is not None else _open_pin(self.pin, self.active_high)
|
||||||
|
|
||||||
|
def _write(self, on: bool) -> None:
|
||||||
|
# active_high=False 는 릴레이 모듈의 통상 배선(LOW 에서 도통)이다. 반대로 물린
|
||||||
|
# 현장에서 경광등이 상시 켜져 있는 것을 설정으로 뒤집을 수 있어야 한다.
|
||||||
|
self._backend.set(on if self.active_high else not on)
|
||||||
|
|
||||||
|
def fire(self, severity: str, now: float) -> bool:
|
||||||
|
if _SEVERITY_RANK.get(severity, 0) < self.min_rank:
|
||||||
|
return False
|
||||||
|
self._write(True)
|
||||||
|
# 이미 울리는 중에 새 이벤트가 오면 유지 시간을 연장한다 — 사고가 이어지는 동안
|
||||||
|
# 경광등이 중간에 꺼졌다 켜지면 사람이 "끝났다"로 읽는다.
|
||||||
|
self._on_until = now + self.hold_s
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tick(self, now: float) -> None:
|
||||||
|
"""루프가 매 틱 호출한다. 유지 시간이 지나면 해제."""
|
||||||
|
if self._on_until is not None and now >= self._on_until:
|
||||||
|
self._write(False)
|
||||||
|
self._on_until = None
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
try:
|
||||||
|
self._write(False)
|
||||||
|
finally:
|
||||||
|
close = getattr(self._backend, "close", None)
|
||||||
|
if close:
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
|
class _PinBackend:
|
||||||
|
"""gpiozero / RPi.GPIO 중 있는 쪽에 얇게 붙는다."""
|
||||||
|
|
||||||
|
def __init__(self, device=None, rpi=None, pin: int = 0):
|
||||||
|
self._device = device
|
||||||
|
self._rpi = rpi
|
||||||
|
self._pin = pin
|
||||||
|
|
||||||
|
def set(self, high: bool) -> None:
|
||||||
|
if self._device is not None:
|
||||||
|
if high:
|
||||||
|
self._device.on()
|
||||||
|
else:
|
||||||
|
self._device.off()
|
||||||
|
elif self._rpi is not None:
|
||||||
|
self._rpi.output(self._pin, self._rpi.HIGH if high else self._rpi.LOW)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._device is not None:
|
||||||
|
self._device.close()
|
||||||
|
elif self._rpi is not None:
|
||||||
|
self._rpi.cleanup(self._pin)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pin(pin: int, active_high: bool): # pragma: no cover (hardware)
|
||||||
|
try:
|
||||||
|
from gpiozero import DigitalOutputDevice
|
||||||
|
# initial_value=False 는 "비활성" 을 뜻한다. active_high 해석은 _write 가 담당하므로
|
||||||
|
# 여기서는 항상 raw 출력으로 열고, 부팅 순간 경광등이 번쩍이지 않도록 초기값을 준다.
|
||||||
|
dev = DigitalOutputDevice(pin, active_high=True, initial_value=not active_high)
|
||||||
|
return _PinBackend(device=dev, pin=pin)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import RPi.GPIO as GPIO
|
||||||
|
GPIO.setmode(GPIO.BCM)
|
||||||
|
GPIO.setup(pin, GPIO.OUT, initial=GPIO.LOW if active_high else GPIO.HIGH)
|
||||||
|
return _PinBackend(rpi=GPIO, pin=pin)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("no usable GPIO backend")
|
||||||
|
|
||||||
|
|
||||||
|
def build_beacons(config: dict, log=None) -> list:
|
||||||
|
"""/api/config 의 `beacons` 배열 -> 구동 가능한 비콘 목록.
|
||||||
|
|
||||||
|
핀 하나가 열리지 않아도 나머지는 살린다. 전부 실패하면 빈 목록이고, 그때도 감지·전송은
|
||||||
|
그대로 돈다.
|
||||||
|
"""
|
||||||
|
log = log or (lambda m: None)
|
||||||
|
out = []
|
||||||
|
for b in (config or {}).get("beacons") or []:
|
||||||
|
if not isinstance(b, dict):
|
||||||
|
continue
|
||||||
|
pin = int(b.get("gpio_pin") or 0)
|
||||||
|
if pin <= 0:
|
||||||
|
continue # 0 = 미배선 (관리 화면에서 핀을 아직 안 정한 상태)
|
||||||
|
try:
|
||||||
|
out.append(GpioBeacon(
|
||||||
|
pin,
|
||||||
|
mode=str(b.get("mode") or "latch"),
|
||||||
|
hold_s=float(b.get("hold_s") or 30.0),
|
||||||
|
active_high=bool(b.get("active_high")),
|
||||||
|
min_severity=str(b.get("min_severity") or "high"),
|
||||||
|
log=log,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
# 조용히 넘어가면 아무도 경광등이 죽은 줄 모른다.
|
||||||
|
log(f"beacon pin {pin} unavailable ({type(e).__name__}: {e}); alerts on this node "
|
||||||
|
f"will not sound locally")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fire_all(beacons, events, now: float | None = None) -> int:
|
||||||
|
"""이벤트 목록 중 가장 높은 심각도로 비콘을 울린다. @return 실제로 울린 비콘 수."""
|
||||||
|
if not beacons or not events:
|
||||||
|
return 0
|
||||||
|
now = time.monotonic() if now is None else now
|
||||||
|
worst = max((getattr(e.severity, "value", e.severity) for e in events),
|
||||||
|
key=lambda s: _SEVERITY_RANK.get(s, 0))
|
||||||
|
return sum(1 for b in beacons if b.fire(worst, now))
|
||||||
|
|
@ -19,12 +19,32 @@ 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
|
||||||
|
|
@ -98,20 +118,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 = _f(env, "MOTION_THR", 0.25)
|
motion_thr = _positive(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=_f(env, "FALL_PRONE_S", 3.0),
|
prone_confirm_s=_positive(env, "FALL_PRONE_S", 3.0),
|
||||||
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
|
no_motion_s=_positive(env, "FALL_NO_MOTION_S", 10.0),
|
||||||
motion_thr=motion_thr,
|
motion_thr=motion_thr,
|
||||||
)
|
)
|
||||||
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
|
immobility = ImmobilityConfig(immobile_s=_positive(env, "IMMOBILE_S", 120.0),
|
||||||
motion_thr=motion_thr)
|
motion_thr=motion_thr)
|
||||||
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
|
ghost = GhostConfig(reappear_s=_positive(env, "GHOST_REAPPEAR_S", 10.0))
|
||||||
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
|
zone = ZoneConfig(debounce_s=_positive(env, "ZONE_DEBOUNCE_S", 0.75))
|
||||||
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))
|
engine = EngineConfig(camera_id=camera_id, cooldown_s=_positive(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", ""),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ import os
|
||||||
_LOG_EVERY = 300
|
_LOG_EVERY = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _beacon_signature(beacons):
|
||||||
|
"""Identity of a beacon set, so a sync only re-opens GPIO when the wiring actually changed.
|
||||||
|
Rebuilding unconditionally would blink every strobe on every sync (every ~150 ticks)."""
|
||||||
|
return tuple(sorted((getattr(b, "pin", 0), getattr(b, "mode", ""), getattr(b, "hold_s", 0.0),
|
||||||
|
getattr(b, "active_high", False), getattr(b, "min_rank", 0))
|
||||||
|
for b in (beacons or [])))
|
||||||
|
|
||||||
|
|
||||||
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
|
@ -18,7 +26,8 @@ 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,
|
||||||
|
|
@ -33,7 +42,22 @@ 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
|
||||||
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
|
if notify is None:
|
||||||
|
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):
|
||||||
|
|
@ -52,6 +76,10 @@ 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)
|
||||||
|
|
@ -75,12 +103,48 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
else:
|
else:
|
||||||
counters["frames"] += 1
|
counters["frames"] += 1
|
||||||
streak.pop(id(rt), None) # recovered — reset the streak
|
streak.pop(id(rt), None) # recovered — reset the streak
|
||||||
|
# Sound the local beacon on this tick's events. Wrapped: a wiring fault must
|
||||||
|
# never take down the detection loop — a node that stopped watching because
|
||||||
|
# its strobe failed would be the worst possible trade.
|
||||||
|
if produced and beacons:
|
||||||
|
try:
|
||||||
|
counters["beacon_fires"] += _fire_beacons(beacons, produced, now)
|
||||||
|
except Exception as e:
|
||||||
|
note("beacon", "beacon fire failed", e)
|
||||||
|
# Release latched beacons whose hold time has elapsed.
|
||||||
|
for b in beacons:
|
||||||
|
try:
|
||||||
|
b.tick(now)
|
||||||
|
except Exception as e:
|
||||||
|
note("beacon", "beacon tick failed", e)
|
||||||
try:
|
try:
|
||||||
if sync_every and i % sync_every == 0:
|
if sync_every and i % sync_every == 0:
|
||||||
cfg = client.fetch_config()
|
cfg = client.fetch_config()
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
for rt in runtimes:
|
for rt in runtimes:
|
||||||
rt.apply_zones(zones_for(cfg, rt))
|
rt.apply_zones(zones_for(cfg, rt))
|
||||||
|
# Thresholds too. The server has always SENT these; nothing ever applied
|
||||||
|
# them, so an administrator's retuning never reached the device.
|
||||||
|
thresholds = cfg.get("thresholds") if isinstance(cfg, dict) else None
|
||||||
|
if isinstance(thresholds, dict) and thresholds:
|
||||||
|
for rt in runtimes:
|
||||||
|
applier = getattr(rt, "apply_thresholds", None)
|
||||||
|
if applier is None:
|
||||||
|
continue
|
||||||
|
applied = applier(thresholds, log=lambda m: log(m, None))
|
||||||
|
counters["tuned"] += len(applied)
|
||||||
|
# Rebuild the local beacons from the published wiring. Doing it on sync
|
||||||
|
# (not only at boot) is what lets an administrator turn a site's strobe on
|
||||||
|
# without anyone walking to the node.
|
||||||
|
try:
|
||||||
|
rebuilt = build_beacons_fn(cfg if isinstance(cfg, dict) else {},
|
||||||
|
log=lambda m: log(m, None))
|
||||||
|
if _beacon_signature(rebuilt) != _beacon_signature(beacons):
|
||||||
|
for b in beacons:
|
||||||
|
b.release()
|
||||||
|
beacons = rebuilt
|
||||||
|
except Exception as e:
|
||||||
|
note("beacon", "beacon rebuild failed", e)
|
||||||
# Remember the config version so heartbeats can echo it back — the server's
|
# Remember the config version so heartbeats can echo it back — the server's
|
||||||
# config-drift indicator is meaningless until the device reports what it runs.
|
# config-drift indicator is meaningless until the device reports what it runs.
|
||||||
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
||||||
|
|
|
||||||
70
edge/detectors/ergonomics.py
Normal file
70
edge/detectors/ergonomics.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
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()}
|
||||||
|
|
@ -14,6 +14,7 @@ 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
|
||||||
|
|
@ -94,7 +95,13 @@ 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
|
||||||
|
|
@ -104,6 +111,9 @@ 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"
|
||||||
|
|
|
||||||
151
edge/detectors/integrity.py
Normal file
151
edge/detectors/integrity.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
존(위험구역) 판정은 "카메라가 어제와 같은 곳을 보고 있다"는 전제 위에 통째로 서 있다.
|
||||||
|
청소하다 삼각대를 스치거나, 지게차가 브래킷을 치거나, 누가 각도를 조금 돌리면 제한구역
|
||||||
|
폴리곤은 엉뚱한 바닥을 가리키게 된다. 그런데 시스템은 아무 오류도 내지 않는다 — 프레임은
|
||||||
|
계속 들어오고, 사람은 계속 검출되고, 대시보드는 초록불이다. 침입 감지가 죽었다는 사실을
|
||||||
|
누군가 사고가 난 뒤에야 알게 되는 것이 이 제품의 가장 조용한 실패 모드였다.
|
||||||
|
|
||||||
|
접근
|
||||||
|
무거운 특징점 매칭 대신 저해상도 그레이스케일 요약본을 기준으로 삼는다. Pi 에서 매 프레임
|
||||||
|
돌아야 하고, 우리가 감지하려는 것은 미세한 흔들림이 아니라 "다른 곳을 보고 있다" 수준의
|
||||||
|
변화이기 때문에 이 해상도로 충분하다. 세 신호를 각각 다른 물리량으로 잡는다:
|
||||||
|
|
||||||
|
moved — 기준 대비 평균 절대 차이(MAD)가 임계 초과. 각도가 바뀌면 화면 전체가 바뀐다.
|
||||||
|
obstructed — 프레임 자체의 분산 급락. 렌즈를 천으로 덮으면 평탄한 회색이 된다.
|
||||||
|
low_light — 평균 밝기 하강. 야간·조명 고장 시 검출력이 떨어졌음을 스스로 신고한다.
|
||||||
|
|
||||||
|
사람이 화면을 지나가도 MAD 는 오른다. 그래서 **지속 시간**을 요구한다: 순간 변화가 아니라
|
||||||
|
sustain_s 이상 유지될 때만 확정한다. 사람은 지나가고 화각 변화는 남는다는 성질을 쓴다.
|
||||||
|
|
||||||
|
기준 프레임
|
||||||
|
부팅 후 warmup_s 동안 조용히 관찰하다가 그 시점의 요약본을 기준으로 잡는다. 부팅 직후
|
||||||
|
한 장을 그냥 쓰면 마침 그때 사람이 서 있었을 경우 그 사람이 영원히 배경이 된다.
|
||||||
|
확정 이벤트를 낸 뒤에는 기준을 현재 화면으로 재설정한다 — 재설정하지 않으면 옮겨진
|
||||||
|
카메라가 cooldown 마다 같은 이벤트를 영원히 재발행한다.
|
||||||
|
|
||||||
|
의존성 없음: numpy 만 쓴다 (OpenCV 도 불필요). 폐쇄망 번들에 새 휠을 추가하지 않는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntegrityConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
# 기준 프레임을 잡기까지의 관찰 시간. 이 사이 화면에 사람이 있어도 평균이 희석된다.
|
||||||
|
warmup_s: float = 20.0
|
||||||
|
# 요약본 해상도. 32x24 면 사람 하나가 화면의 몇 퍼센트인지 구분하기엔 충분하고,
|
||||||
|
# 조명 깜빡임 같은 화소 단위 잡음은 자연히 뭉개진다.
|
||||||
|
grid_w: int = 32
|
||||||
|
grid_h: int = 24
|
||||||
|
# 0..255 스케일의 평균 절대 차이. 30 은 "화면 절반 이상이 다른 밝기" 수준으로,
|
||||||
|
# 사람 한 명이 지나가는 것(보통 5~15)과 명확히 갈린다.
|
||||||
|
moved_mad: float = 30.0
|
||||||
|
# 화각 변화로 확정하기까지 필요한 지속 시간. 지나가는 사람은 이 시간을 못 채운다.
|
||||||
|
sustain_s: float = 8.0
|
||||||
|
# 렌즈 가림: 프레임 분산이 이 밑이면 사실상 단색 화면이다.
|
||||||
|
obstructed_var: float = 25.0
|
||||||
|
obstructed_sustain_s: float = 5.0
|
||||||
|
# 조도: 평균 밝기가 이 밑이면 포즈 추정 신뢰도가 급격히 떨어진다.
|
||||||
|
low_light_mean: float = 40.0
|
||||||
|
low_light_sustain_s: float = 60.0
|
||||||
|
# 확정 후 같은 유형을 다시 낼 때까지의 최소 간격 (엔진 cooldown 과 별개의 2차 방어).
|
||||||
|
reemit_s: float = 900.0
|
||||||
|
|
||||||
|
|
||||||
|
class CameraIntegrityWatch:
|
||||||
|
"""프레임을 받아 (event_type, metrics) 목록을 돌려준다. 이벤트 생성은 엔진이 한다."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: IntegrityConfig | None = None):
|
||||||
|
self.cfg = cfg or IntegrityConfig()
|
||||||
|
self._baseline: np.ndarray | None = None
|
||||||
|
self._first_seen: float | None = None
|
||||||
|
# 각 상태가 "연속으로 참이기 시작한 시각". None 이면 지금은 정상.
|
||||||
|
self._since: dict[str, float | None] = {"moved": None, "obstructed": None, "low_light": None}
|
||||||
|
self._last_emit: dict[str, float] = {}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
def _summarize(self, frame) -> np.ndarray | None:
|
||||||
|
"""프레임 -> (grid_h, grid_w) float32 그레이스케일 요약본."""
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
a = np.asarray(frame)
|
||||||
|
if a.ndim == 3:
|
||||||
|
# BGR/RGB 어느 쪽이든 평균 그레이면 충분하다 (우리가 재는 것은 구조 변화이지 색이 아니다).
|
||||||
|
a = a.mean(axis=2)
|
||||||
|
elif a.ndim != 2:
|
||||||
|
return None
|
||||||
|
if a.size == 0:
|
||||||
|
return None
|
||||||
|
h, w = a.shape[:2]
|
||||||
|
gh, gw = self.cfg.grid_h, self.cfg.grid_w
|
||||||
|
if h < gh or w < gw:
|
||||||
|
return None
|
||||||
|
# 정수 인덱싱으로 다운샘플: 보간 라이브러리 없이 균등 격자를 뽑는다.
|
||||||
|
ys = (np.linspace(0, h - 1, gh)).astype(np.int32)
|
||||||
|
xs = (np.linspace(0, w - 1, gw)).astype(np.int32)
|
||||||
|
return a[np.ix_(ys, xs)].astype(np.float32)
|
||||||
|
|
||||||
|
def _hold(self, key: str, active: bool, now: float, need_s: float) -> bool:
|
||||||
|
"""상태가 need_s 이상 연속 유지되면 True 를 한 번 돌려준다."""
|
||||||
|
if not active:
|
||||||
|
self._since[key] = None
|
||||||
|
return False
|
||||||
|
if self._since[key] is None:
|
||||||
|
self._since[key] = now
|
||||||
|
return False
|
||||||
|
if now - self._since[key] < need_s:
|
||||||
|
return False
|
||||||
|
last = self._last_emit.get(key)
|
||||||
|
if last is not None and now - last < self.cfg.reemit_s:
|
||||||
|
return False
|
||||||
|
self._last_emit[key] = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- main
|
||||||
|
def observe(self, frame, now: float) -> list[tuple[str, dict]]:
|
||||||
|
"""@return [(event_type_value, metrics), ...] — 보통 빈 리스트."""
|
||||||
|
if not self.cfg.enabled:
|
||||||
|
return []
|
||||||
|
summary = self._summarize(frame)
|
||||||
|
if summary is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if self._first_seen is None:
|
||||||
|
self._first_seen = now
|
||||||
|
mean = float(summary.mean())
|
||||||
|
var = float(summary.var())
|
||||||
|
|
||||||
|
out: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
# 조도와 가림은 기준 프레임이 없어도 판정할 수 있다 — 프레임 자체의 성질이다.
|
||||||
|
if self._hold("obstructed", var < self.cfg.obstructed_var, now, self.cfg.obstructed_sustain_s):
|
||||||
|
out.append(("camera_obstructed", {"variance": round(var, 2), "mean_luma": round(mean, 1)}))
|
||||||
|
if self._hold("low_light", mean < self.cfg.low_light_mean, now, self.cfg.low_light_sustain_s):
|
||||||
|
out.append(("low_light", {"mean_luma": round(mean, 1)}))
|
||||||
|
|
||||||
|
# 화각 이동은 기준이 있어야 한다.
|
||||||
|
if self._baseline is None:
|
||||||
|
if now - self._first_seen >= self.cfg.warmup_s:
|
||||||
|
self._baseline = summary
|
||||||
|
return out
|
||||||
|
|
||||||
|
mad = float(np.abs(summary - self._baseline).mean())
|
||||||
|
if self._hold("moved", mad > self.cfg.moved_mad, now, self.cfg.sustain_s):
|
||||||
|
out.append(("camera_moved", {"mad": round(mad, 1), "threshold": self.cfg.moved_mad}))
|
||||||
|
# 새 화각을 기준으로 승격한다. 하지 않으면 옮겨진 카메라가 영원히 같은 경보를 낸다.
|
||||||
|
# (존은 여전히 무효 상태다 — 그래서 이 이벤트가 HIGH 이고, 사람이 존을 다시 그려야 한다.)
|
||||||
|
self._baseline = summary
|
||||||
|
self._since["moved"] = None
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def reset_baseline(self) -> None:
|
||||||
|
"""존을 다시 그린 뒤 등, 현재 화각을 정상으로 재선언할 때."""
|
||||||
|
self._baseline = None
|
||||||
|
self._first_seen = None
|
||||||
|
self._since = {k: None for k in self._since}
|
||||||
95
edge/detectors/occupancy.py
Normal file
95
edge/detectors/occupancy.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
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
|
||||||
103
edge/detectors/pending.py
Normal file
103
edge/detectors/pending.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""모델 대기 감지기 — 보호구 미착용 · 중장비 근접 · 화재/연기.
|
||||||
|
|
||||||
|
STATUS: 배선 완료, 모델 없음. 기본 비활성이며 이 상태에서는 어떤 이벤트도 발화하지 않는다.
|
||||||
|
|
||||||
|
왜 모델 없이 이 파일이 있는가
|
||||||
|
이 세 가지는 현장에서 가장 자주 요구되는 항목이고(특히 보호구), 나중에 급하게 끼워
|
||||||
|
넣으면 이벤트 계약·심각도·쿨다운·스냅샷 정책을 그때그때 즉흥적으로 정하게 된다. 계약을
|
||||||
|
지금 고정해 두면 모델이 확보되는 날 바꿀 것은 `Model` 구현 하나뿐이다.
|
||||||
|
|
||||||
|
무엇이 막고 있는가 (정직하게)
|
||||||
|
ultralytics(YOLO) 계열은 AGPL 이라 이 프로젝트에서 사용 금지다(CLAUDE.md). 따라서
|
||||||
|
- 보호구/중장비: AGPL 이 아닌 라이선스의 가중치 + Hailo HEF 컴파일이 선행되어야 한다.
|
||||||
|
- 화재/연기: 위와 같거나, 열화상 센서 같은 다른 감지 방식을 택해야 한다.
|
||||||
|
확보 전까지 `enabled=False` 이며, 켜더라도 `model=None` 이면 아무 일도 하지 않는다.
|
||||||
|
"곧 됩니다" 상태로 켜 두는 것이 가장 위험하다 — 현장은 감지되고 있다고 믿는다.
|
||||||
|
|
||||||
|
모델 인터페이스 (구현체가 만족해야 하는 계약)
|
||||||
|
detect(frame, persons) -> list[dict]
|
||||||
|
각 dict: {"kind": "ppe_missing"|"proximity"|"fire_smoke",
|
||||||
|
"confidence": float 0..1,
|
||||||
|
"track_id": int | None,
|
||||||
|
"spatial": (x, y) | None, # 0..1 정규화
|
||||||
|
"metrics": dict} # 유형별 부가 수치
|
||||||
|
프레임당 한 번 호출되며, 예외를 던지면 그 틱만 건너뛴다(파이프라인은 계속 돈다).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
_KINDS = ("ppe_missing", "proximity", "fire_smoke")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingConfig:
|
||||||
|
# 기본 False. 모델이 없는 채로 켜면 "감지되고 있다"는 잘못된 믿음만 만든다.
|
||||||
|
enabled: bool = False
|
||||||
|
# 유형별 최소 신뢰도. 보호구는 오탐이 곧 현장 반발이라 높게 잡는다.
|
||||||
|
min_confidence: dict = field(default_factory=lambda: {
|
||||||
|
"ppe_missing": 0.80, "proximity": 0.70, "fire_smoke": 0.85,
|
||||||
|
})
|
||||||
|
# 유형별 재발행 억제(초). 엔진 쿨다운과 별개의 2차 방어 — 보호구 미착용은 한 사람이
|
||||||
|
# 한 시간을 그러고 있어도 사건 하나다.
|
||||||
|
reemit_s: dict = field(default_factory=lambda: {
|
||||||
|
"ppe_missing": 600.0, "proximity": 30.0, "fire_smoke": 60.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDetectors:
|
||||||
|
"""모델이 주어지면 그 출력을 이벤트 후보로 정규화한다. 없으면 아무것도 하지 않는다."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: PendingConfig | None = None, model=None, log=None):
|
||||||
|
self.cfg = cfg or PendingConfig()
|
||||||
|
self.model = model
|
||||||
|
self._log = log or (lambda m: None)
|
||||||
|
self._last: dict[tuple, float] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
"""켜져 있고 실제로 판정할 수단이 있는가. 대시보드/기동 로그가 이것을 보고한다."""
|
||||||
|
return bool(self.cfg.enabled and self.model is not None)
|
||||||
|
|
||||||
|
def _allow(self, kind: str, track_id, now: float) -> bool:
|
||||||
|
key = (kind, track_id)
|
||||||
|
last = self._last.get(key)
|
||||||
|
if last is not None and now - last < float(self.cfg.reemit_s.get(kind, 60.0)):
|
||||||
|
return False
|
||||||
|
self._last[key] = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
def forget(self, track_id: int) -> None:
|
||||||
|
self._last = {k: v for k, v in self._last.items() if k[1] != track_id}
|
||||||
|
|
||||||
|
def observe(self, frame, persons, now: float) -> list[tuple[str, dict, object, object]]:
|
||||||
|
"""@return [(event_type, metrics, track_id, spatial), ...] — 보통 빈 리스트.
|
||||||
|
|
||||||
|
모델이 없거나 비활성이면 항상 빈 리스트다. 그것이 정상 상태다.
|
||||||
|
"""
|
||||||
|
if not self.active:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = self.model.detect(frame, persons) or []
|
||||||
|
except Exception as e:
|
||||||
|
# 한 틱의 추론 실패로 넘어짐 감지까지 멈추면 안 된다.
|
||||||
|
self._log(f"pending detector model failed: {type(e).__name__}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for d in raw:
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
continue
|
||||||
|
kind = d.get("kind")
|
||||||
|
if kind not in _KINDS:
|
||||||
|
continue
|
||||||
|
conf = float(d.get("confidence") or 0.0)
|
||||||
|
if conf < float(self.cfg.min_confidence.get(kind, 1.0)):
|
||||||
|
continue
|
||||||
|
track_id = d.get("track_id")
|
||||||
|
if not self._allow(kind, track_id, now):
|
||||||
|
continue
|
||||||
|
metrics = dict(d.get("metrics") or {})
|
||||||
|
metrics["confidence"] = round(conf, 3)
|
||||||
|
out.append((kind, metrics, track_id, d.get("spatial")))
|
||||||
|
return out
|
||||||
|
|
@ -25,6 +25,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,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.occupancy import OccupancyWatchdog, OccupancyConfig
|
||||||
|
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -14,14 +16,23 @@ 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):
|
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None,
|
||||||
|
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] = {}
|
||||||
|
|
||||||
|
|
@ -37,13 +48,15 @@ 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 0.0
|
duration_s = m.get("immobility_s") or m.get("waited_s") or m.get("dwell_s") \
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -63,18 +76,32 @@ 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, metrics=meta))
|
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id,
|
||||||
|
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, metrics=meta))
|
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id,
|
||||||
|
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, metrics=imeta))
|
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id,
|
||||||
|
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
|
||||||
|
|
@ -84,7 +111,30 @@ 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, zone_id=hit["zone_id"]))
|
events.append(self._mk(etype, occurred_at, tr.track_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
|
||||||
|
|
@ -92,8 +142,16 @@ 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=g["spatial"],
|
spatial=gs,
|
||||||
metrics={"last_state": g["last_state"],
|
metrics={"last_state": g["last_state"],
|
||||||
"waited_s": g["waited_s"]}))
|
"waited_s": g["waited_s"]}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,24 @@ 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):
|
||||||
|
|
@ -32,6 +50,22 @@ _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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,7 +90,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:
|
||||||
return {
|
payload = {
|
||||||
"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,
|
||||||
|
|
@ -68,3 +102,8 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from edge.models import (
|
from edge.models import (
|
||||||
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS,
|
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_ELBOW, R_ELBOW,
|
||||||
|
L_HIP, R_HIP, L_KNEE, R_KNEE, L_ANKLE, R_ANKLE, CORE_JOINTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,6 +46,44 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,17 @@ 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_send_attempts: int = 20, max_flush_rows: 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 ("
|
||||||
|
|
@ -60,8 +67,11 @@ 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").fetchall()
|
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id LIMIT ?",
|
||||||
|
(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:
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ from edge.detectors.fall import FallDetector
|
||||||
from edge.detectors.immobility import ImmobilityWatchdog
|
from edge.detectors.immobility import ImmobilityWatchdog
|
||||||
from edge.detectors.ghost import GhostIncidentDetector
|
from edge.detectors.ghost import GhostIncidentDetector
|
||||||
from edge.detectors.zone import ZoneIntrusionDetector
|
from edge.detectors.zone import ZoneIntrusionDetector
|
||||||
|
from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig
|
||||||
from edge.engine import EventEngine
|
from edge.engine import EventEngine
|
||||||
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
||||||
|
from edge.tuning import apply_thresholds
|
||||||
|
|
||||||
|
|
||||||
class PipelineRuntime:
|
class PipelineRuntime:
|
||||||
|
|
@ -16,6 +18,12 @@ 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
|
||||||
|
|
@ -31,11 +39,35 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,34 @@ 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)
|
self.frames: deque = deque(maxlen=maxlen) # (t, frame, head_boxes)
|
||||||
|
|
||||||
def push(self, t: float, frame) -> None:
|
def push(self, t: float, frame, head_boxes: list | None = None) -> None:
|
||||||
self.frames.append((t, frame))
|
# head_boxes ride along per frame: a PAST frame must be blurred with the head
|
||||||
|
# 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) in list(self.frames)[lo:hi]]
|
return [f for (_t, f, _b) 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:
|
||||||
|
|
|
||||||
|
|
@ -40,24 +40,46 @@ 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 = []
|
||||||
unmatched = list(persons)
|
self._ambiguous = set()
|
||||||
# 기존 트랙과 그리디 IoU 매칭
|
# 전역 그리디: 모든 (트랙, 사람) 쌍을 IoU 내림차순으로 배정. 트랙 순회 순서 우선의
|
||||||
for tid, track in list(self._tracks.items()):
|
# 로컬 그리디는 교차 시 먼저 온 트랙이 남의 박스를 가로채(0.48이 0.74를 이김) FALLEN
|
||||||
best, best_iou = None, self.iou_thr
|
# 상태·frozen baseline이 다른 사람에게 넘어갔다.
|
||||||
for p in unmatched:
|
pairs = []
|
||||||
|
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 >= best_iou:
|
if iou >= self.iou_thr:
|
||||||
best, best_iou = p, iou
|
pairs.append((iou, tid, i))
|
||||||
if best is not None:
|
track_cands[tid] = track_cands.get(tid, 0) + 1
|
||||||
unmatched.remove(best)
|
person_cands[i] = person_cands.get(i, 0) + 1
|
||||||
track.frames.append(TrackFrame(t, best.bbox, best.keypoints))
|
pairs.sort(key=lambda pr: pr[0], reverse=True)
|
||||||
|
used_tracks: set[int] = set()
|
||||||
|
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
|
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 p in unmatched:
|
for i, p in enumerate(persons):
|
||||||
|
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)],
|
||||||
|
|
@ -72,3 +94,7 @@ 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)
|
||||||
|
|
|
||||||
101
edge/tuning.py
Normal file
101
edge/tuning.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""서버에서 내려온 임계값을 살아 있는 감지기에 적용한다.
|
||||||
|
|
||||||
|
WHAT WAS BROKEN
|
||||||
|
`edge/zones.py:thresholds_from_config()` 는 정의만 되어 있고 어디에서도 호출되지 않았다.
|
||||||
|
서버에는 `move_thresholds` 표가 있고, `GET /api/config` 가 그것을 성실히 실어 보내고,
|
||||||
|
엣지는 그 값을 파싱한 뒤 그대로 버렸다. 즉 배선의 양 끝이 다 있는데 가운데가 비어 있어서,
|
||||||
|
관리자가 무엇을 바꾸든 장비는 부팅 시 edge.env 값으로 영원히 돌았다. 이 파일이 그 가운데다.
|
||||||
|
|
||||||
|
WHY VALIDATE HERE TOO
|
||||||
|
서버의 ThresholdCatalog 가 이미 범위를 강제하지만, 그것은 *편집 화면* 의 방어선이다.
|
||||||
|
DB 를 직접 고친 값, 구버전 서버, 손상된 응답은 여전히 도달할 수 있고, `now - t >= limit`
|
||||||
|
비교에 NaN 이 들어가면 감지기가 조용히 꺼지고 0 이 들어가면 영구 알람이 된다
|
||||||
|
(edge/config.py `_positive` 의 주석과 같은 이유). 기동 시점의 검증은 여기서 반복된다.
|
||||||
|
|
||||||
|
WHY NOT CRASH
|
||||||
|
기동 검증은 잘못된 값에 예외를 던진다 — 그때는 운영자가 화면 앞에 있다. 여기는 24/7 로
|
||||||
|
도는 동기화 경로다. 서버가 보낸 한 값이 이상하다고 공장 안 장비를 재시작 루프에 빠뜨리면
|
||||||
|
그 카메라는 그 순간부터 아무것도 못 본다. 나쁜 값은 무시하고 로그로 남기고, 나머지는
|
||||||
|
적용한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
# 서버 키 -> (대상 객체 경로, 속성명). 하나의 키가 두 곳에 적용되는 경우도 있다
|
||||||
|
# (motion_thr 은 fall 과 immobility 가 공유한다 — edge/config.py 가 같은 값을 둘에 넣는다).
|
||||||
|
_TARGETS: dict[str, tuple[tuple[str, str], ...]] = {
|
||||||
|
"fall_prone_s": (("fall", "prone_confirm_s"),),
|
||||||
|
"fall_no_motion_s": (("fall", "no_motion_s"),),
|
||||||
|
"immobile_s": (("immobility", "immobile_s"),),
|
||||||
|
"ghost_reappear_s": (("ghost", "reappear_s"),),
|
||||||
|
"zone_debounce_s": (("zone", "debounce_s"),),
|
||||||
|
"cooldown_s": (("engine", "cooldown_s"),),
|
||||||
|
"motion_thr": (("fall", "motion_thr"), ("immobility", "motion_thr")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정수 노브는 별도 취급: 감지기 설정이 아니라 런타임 자체의 샘플링 주기다.
|
||||||
|
_INT_KNOBS = {"infer_every"}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(raw) -> float | None:
|
||||||
|
"""유한 양수만 통과. 그 외에는 None (호출부가 로그하고 무시)."""
|
||||||
|
try:
|
||||||
|
v = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not math.isfinite(v) or v <= 0:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def apply_thresholds(runtime, thresholds: dict, log=None) -> dict:
|
||||||
|
"""`runtime` 의 감지기 설정에 서버 임계값을 적용한다.
|
||||||
|
|
||||||
|
@return 실제로 적용된 {key: value} — 호출부가 로그/테스트에 쓴다.
|
||||||
|
"""
|
||||||
|
log = log or (lambda m: None)
|
||||||
|
if not isinstance(thresholds, dict) or not thresholds:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
engine = getattr(runtime, "engine", None)
|
||||||
|
if engine is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 감지기별 설정 객체. 이름이 다른 구현이 오더라도 없는 것은 조용히 건너뛴다.
|
||||||
|
holders = {
|
||||||
|
"fall": getattr(getattr(engine, "fall", None), "cfg", None),
|
||||||
|
"immobility": getattr(getattr(engine, "immobility", None), "cfg", None),
|
||||||
|
"ghost": getattr(getattr(engine, "ghost", None), "cfg", None),
|
||||||
|
"zone": getattr(getattr(engine, "zone", None), "cfg", None),
|
||||||
|
"engine": getattr(engine, "cfg", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
applied: dict[str, float] = {}
|
||||||
|
for key, raw in thresholds.items():
|
||||||
|
if key in _INT_KNOBS:
|
||||||
|
v = _clean(raw)
|
||||||
|
if v is None or v != int(v):
|
||||||
|
log(f"threshold {key}={raw!r} ignored (must be a positive integer)")
|
||||||
|
continue
|
||||||
|
# infer_every 는 런타임의 샘플링 주기. 상한을 두는 이유: 서버가 실수로 1000 을
|
||||||
|
# 보내면 그 카메라는 사실상 감시를 멈춘다.
|
||||||
|
runtime.infer_every = max(1, min(10, int(v)))
|
||||||
|
applied[key] = runtime.infer_every
|
||||||
|
continue
|
||||||
|
|
||||||
|
targets = _TARGETS.get(key)
|
||||||
|
if targets is None:
|
||||||
|
# 알 수 없는 키는 무시한다. 서버가 신버전이고 엣지가 구버전인 정상 상황이다.
|
||||||
|
continue
|
||||||
|
v = _clean(raw)
|
||||||
|
if v is None:
|
||||||
|
log(f"threshold {key}={raw!r} ignored (must be a finite positive number)")
|
||||||
|
continue
|
||||||
|
for holder_name, attr in targets:
|
||||||
|
holder = holders.get(holder_name)
|
||||||
|
if holder is None or not hasattr(holder, attr):
|
||||||
|
continue
|
||||||
|
setattr(holder, attr, v)
|
||||||
|
applied[key] = v
|
||||||
|
|
||||||
|
return applied
|
||||||
48
edge/watchdog.py
Normal file
48
edge/watchdog.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -17,6 +17,30 @@ 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."""
|
||||||
|
|
@ -40,7 +64,16 @@ 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
|
||||||
out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon))
|
try:
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,18 @@ 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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -70,19 +70,40 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
|
||||||
1. 카페24 관리자 → **phpMyAdmin** 열기
|
1. 카페24 관리자 → **phpMyAdmin** 열기
|
||||||
2. 이 앱용 DB 선택
|
2. 이 앱용 DB 선택
|
||||||
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
|
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
|
||||||
4. 표 10개가 생겼는지 확인
|
4. 표 11개가 생겼는지 확인
|
||||||
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. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행
|
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
|
||||||
(site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함)
|
(각 파일 첫머리에 적용 조건이 주석으로 적혀 있습니다)
|
||||||
3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인
|
3. 각 파일 맨 아래 **검증 쿼리**로 컬럼·표가 생겼는지 확인
|
||||||
4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다
|
4. 전부 끝난 뒤에 새 코드를 배포합니다
|
||||||
|
|
||||||
|
> 이미 적용한 마이그레이션을 다시 실행하면 "Duplicate column" 같은 오류가 납니다 —
|
||||||
|
> 재실행은 안전하지 않으니, 어디까지 적용했는지 기록해 두세요. 어디까지 했는지 모르겠으면
|
||||||
|
> phpMyAdmin 의 **Structure(구조)** 탭에서 위 표의 컬럼·표가 있는지 하나씩 확인하면 됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,28 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,23 @@ 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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -139,13 +139,14 @@ or version control.
|
||||||
|
|
||||||
## 4. Import the schema
|
## 4. Import the schema
|
||||||
|
|
||||||
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
|
`server/schema.sql` contains the full MySQL 8.0+ DDL. All tables carry the `move_` prefix
|
||||||
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
|
(`move_users`, `move_devices`, `move_cameras`, `move_actions`, `move_zones`, `move_events`,
|
||||||
|
`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 10 tables were created (phpMyAdmin's table list on the left, or
|
3. Confirm all 11 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
|
||||||
|
|
@ -279,3 +280,45 @@ client as a bare 500 with no body/trace, which is the intended behavior — turn
|
||||||
- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per
|
- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per
|
||||||
§4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php`
|
§4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php`
|
||||||
and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing.
|
and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing.
|
||||||
|
|
||||||
|
## 11. Scheduled jobs (cron)
|
||||||
|
|
||||||
|
Two jobs turn the installation from "records incidents" into "responds to incidents" and keep
|
||||||
|
the database inside a shared-hosting quota. Both live in `move_detect_app/tools/` — **inside the
|
||||||
|
private folder, never under `www/`** — and refuse to run under any SAPI but CLI.
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# 통보 디스패처 — 통보 발송 + 미확인 에스컬레이션 + 실패 재시도.
|
||||||
|
# 이것이 돌지 않으면 감지는 되지만 아무에게도 전달되지 않는다 (콘솔을 보고 있는 사람 제외).
|
||||||
|
*/1 * * * * /usr/bin/php /home/<account>/move_detect_app/tools/dispatch_alerts.php >> /home/<account>/logs/alerts.log 2>&1
|
||||||
|
|
||||||
|
# 보존기간 정리 — 스냅샷(PIPA) · 성능 시계열 · nonce.
|
||||||
|
# 이벤트 기록은 event_retention_days 를 명시적으로 설정한 현장에서만 지워진다.
|
||||||
|
17 3 * * * /usr/bin/php /home/<account>/move_detect_app/tools/maintain.php >> /home/<account>/logs/maintain.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**처음 켤 때는 반드시 dry-run 부터.** 잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는
|
||||||
|
두 번 다시 이 기능을 켜지 않습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php move_detect_app/tools/dispatch_alerts.php --dry-run
|
||||||
|
php move_detect_app/tools/maintain.php --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지 않는 링크가 담긴
|
||||||
|
알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다 (`config.php` §4).
|
||||||
|
|
||||||
|
### 그 외 도구 (수동 실행)
|
||||||
|
|
||||||
|
| 도구 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `tools/backup.php --out=<디렉터리>` | DB 덤프 + 스냅샷 아카이브 + 복구 안내. 출력 경로가 `www/` 안이면 거부합니다. |
|
||||||
|
| `tools/verify_audit_chain.php` | 감사로그 해시체인 검증. 손상 시 종료 코드 1 과 함께 끊긴 지점을 출력합니다. |
|
||||||
|
| `tools/seed_demo.php --days=45` | 시연·QA용 데모 데이터. **운영 DB 에서는 절대 실행하지 마십시오** (`--purge` 로 데모 행만 제거). |
|
||||||
|
|
||||||
|
## 12. Post-deploy: 설치 점검 화면
|
||||||
|
|
||||||
|
로그인 후 `/setup` 을 엽니다. 각 단계는 실제 상태를 조회해 판정하므로, 체크가 켜졌다면 그
|
||||||
|
단계는 정말로 동작하는 것입니다. 특히 **⑤ 통보 경로** 가 비어 있으면 이 시스템은 감지 기록
|
||||||
|
장치일 뿐입니다 — 현장 경광등(엣지 GPIO)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
|
||||||
|
경로입니다.
|
||||||
|
|
|
||||||
6
server/migrations/2026-08-06-false-positive-tag.sql
Normal file
6
server/migrations/2026-08-06-false-positive-tag.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- 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;
|
||||||
14
server/migrations/2026-08-06-heatmap-spatial.sql
Normal file
14
server/migrations/2026-08-06-heatmap-spatial.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- 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'
|
||||||
|
));
|
||||||
5
server/migrations/2026-08-06-zone-rules.sql
Normal file
5
server/migrations/2026-08-06-zone-rules.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- 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;
|
||||||
13
server/migrations/2026-08-07-improvement-actions.sql
Normal file
13
server/migrations/2026-08-07-improvement-actions.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- 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;
|
||||||
22
server/migrations/2026-08-08-actions-site.sql
Normal file
22
server/migrations/2026-08-08-actions-site.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- 개선 조치(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;
|
||||||
191
server/migrations/2026-08-09-safety-platform.sql
Normal file
191
server/migrations/2026-08-09-safety-platform.sql
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
-- 안전 플랫폼 확장 — 통보·억제·증거무결성·운영지표.
|
||||||
|
--
|
||||||
|
-- 이 마이그레이션 하나로 다음 기능의 저장소를 연다. 기능별로 파일을 쪼개지 않은 이유는
|
||||||
|
-- 서로를 참조하기 때문이다(억제 규칙은 통보를 막고, 통보 실패는 감사로그에 남고, 감사로그는
|
||||||
|
-- 해시체인으로 봉인된다). 부분 적용된 중간 상태가 더 위험하다.
|
||||||
|
--
|
||||||
|
-- 1. move_audit_log 해시체인 — 증거 무결성 (schema.sql 이 "P3 로 연기"라 적어둔 항목)
|
||||||
|
-- 2. move_suppressions — 정비/작업허가 중 알람 억제 (사유·기간·감사 필수)
|
||||||
|
-- 3. move_alert_channels/_log — 통보 경로와 전달 이력 (지금까지 통보 수단이 전무했다)
|
||||||
|
-- 4. move_shift_handovers — 교대 인수인계 기록
|
||||||
|
-- 5. move_risk_controls — 감지 유형 ↔ 위험요인·대책 (위험성평가 연계)
|
||||||
|
-- 6. move_device_metrics — 엣지 성능 시계열 (지금은 순간값만 있어 열화를 못 본다)
|
||||||
|
-- 7. move_thresholds 감사 컬럼 — 편집 UI 가 생기므로 누가 언제 바꿨는지 남긴다
|
||||||
|
-- 8. move_actions.event_id — 어떤 사건이 그 조치의 계기였는지
|
||||||
|
-- 9. move_events CHECK 확장 — 신규 감지 유형
|
||||||
|
--
|
||||||
|
-- 적용 조건: 2026-08-08-actions-site.sql 이후.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1. 감사로그 해시체인
|
||||||
|
-- 각 행은 직전 행의 entry_hash 를 prev_hash 로 물고, 자신의 내용+prev_hash 를 sha256 한
|
||||||
|
-- 값을 entry_hash 로 갖는다. 중간 행을 지우거나 고치면 그 이후 전 구간의 사슬이 끊긴다.
|
||||||
|
-- (삭제·수정을 "막는" 것이 아니라 "숨길 수 없게" 만드는 것이 목적이다. append-only 권한을
|
||||||
|
-- DB 레벨에서 강제할 수 없는 공유호스팅에서는 이것이 실질적인 방어선이다.)
|
||||||
|
-- 기존 행은 NULL 로 남는다 — 체인 이전 구간임을 검증 도구가 그대로 보고한다.
|
||||||
|
ALTER TABLE move_audit_log
|
||||||
|
ADD COLUMN prev_hash CHAR(64) NULL AFTER ts,
|
||||||
|
ADD COLUMN entry_hash CHAR(64) NULL AFTER prev_hash,
|
||||||
|
ADD KEY ix_audit_log_entry_hash (entry_hash);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 2. 알람 억제 (정비 모드 / 작업허가)
|
||||||
|
-- 오탐의 최대 원인은 "계획된 작업"이다. 정비 중 제한구역 진입이 전부 알람이면 현장은
|
||||||
|
-- 시스템을 끄고, 그 다음 진짜 사고를 놓친다. 억제는 감지를 멈추지 않는다 — 이벤트는 그대로
|
||||||
|
-- 적재되고 통보만 막힌다. 사후 조사에서 "그때 무슨 일이 있었나"가 사라지면 안 되기 때문이다.
|
||||||
|
CREATE TABLE move_suppressions (
|
||||||
|
suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
|
||||||
|
scope_id VARCHAR(64) NOT NULL, -- 해당 스코프의 식별자
|
||||||
|
site VARCHAR(64) NULL, -- 접근제어 스코프
|
||||||
|
reason VARCHAR(500) NOT NULL, -- 필수: 사유 없는 억제는 허용하지 않는다
|
||||||
|
starts_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
ends_at BIGINT UNSIGNED NOT NULL, -- 무기한 억제 없음. 반드시 만료된다.
|
||||||
|
created_by VARCHAR(190) NOT NULL,
|
||||||
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
revoked_by VARCHAR(190) NULL, -- 조기 해제
|
||||||
|
revoked_at BIGINT UNSIGNED NULL,
|
||||||
|
PRIMARY KEY (suppression_id),
|
||||||
|
KEY ix_suppressions_window (ends_at, starts_at),
|
||||||
|
KEY ix_suppressions_scope (scope_type, scope_id),
|
||||||
|
KEY ix_suppressions_site (site),
|
||||||
|
CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 3. 통보 경로와 전달 이력
|
||||||
|
-- 폐쇄망이라 FCM/Slack 을 쓸 수 없다. 사내에서 실제로 닿는 경로만 둔다:
|
||||||
|
-- local — 엣지 노드의 경광등/부저 (GPIO). 인터넷이 필요 없는 유일한 경로이자 1순위.
|
||||||
|
-- email — 사내 SMTP 릴레이
|
||||||
|
-- webhook— 사내 시스템(그룹웨어·문자게이트웨이) HTTP 엔드포인트
|
||||||
|
-- config_json 은 경로별 설정(수신자, URL, GPIO 핀 등). 비밀번호류는 넣지 않는다 —
|
||||||
|
-- SMTP 인증정보는 config.php 에 두고 여기서는 참조만 한다.
|
||||||
|
CREATE TABLE move_alert_channels (
|
||||||
|
channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
site VARCHAR(64) NULL,
|
||||||
|
kind VARCHAR(16) NOT NULL, -- local | email | webhook
|
||||||
|
name VARCHAR(190) NOT NULL,
|
||||||
|
config_json JSON NOT NULL,
|
||||||
|
min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
-- 에스컬레이션 단계. 0 = 즉시. 1 이상이면 escalate_after_s 만큼 미확인일 때만 발송한다.
|
||||||
|
step INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (channel_id),
|
||||||
|
KEY ix_alert_channels_site (site, enabled),
|
||||||
|
CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
|
||||||
|
CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 전달 이력. (event_id, channel_id) UNIQUE 가 중복 발송을 막는 유일한 방어선이다 —
|
||||||
|
-- 디스패처는 크론으로 돌고 겹쳐 실행될 수 있다.
|
||||||
|
CREATE TABLE move_alert_log (
|
||||||
|
alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
event_id INT UNSIGNED NOT NULL,
|
||||||
|
channel_id INT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL, -- pending | sent | failed | suppressed
|
||||||
|
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
last_error VARCHAR(500) NULL,
|
||||||
|
queued_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
sent_at BIGINT UNSIGNED NULL,
|
||||||
|
PRIMARY KEY (alert_id),
|
||||||
|
UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
|
||||||
|
KEY ix_alert_log_status (status, queued_at),
|
||||||
|
CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
|
||||||
|
CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 4. 교대 인수인계
|
||||||
|
-- 24시간 브리프는 있으나 교대 경계와 책임 이관이 없었다. 인수자가 "무엇을 넘겨받았는지"를
|
||||||
|
-- 명시적으로 확인한 기록이 남아야 미처리 이벤트가 교대 사이에서 증발하지 않는다.
|
||||||
|
CREATE TABLE move_shift_handovers (
|
||||||
|
handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
site VARCHAR(64) NULL,
|
||||||
|
from_user VARCHAR(190) NULL, -- 최초 교대는 인계자가 없다
|
||||||
|
to_user VARCHAR(190) NOT NULL,
|
||||||
|
period_start BIGINT UNSIGNED NOT NULL, -- 인계 대상 구간
|
||||||
|
period_end BIGINT UNSIGNED NOT NULL,
|
||||||
|
unacked_count INT UNSIGNED NOT NULL, -- 인계 시점 스냅샷 (사후 재계산과 대조 가능)
|
||||||
|
severe_count INT UNSIGNED NOT NULL,
|
||||||
|
note VARCHAR(1000) NULL,
|
||||||
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (handover_id),
|
||||||
|
KEY ix_handovers_site_created (site, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 5. 위험성평가 연계
|
||||||
|
-- 감지 유형 하나하나가 위험요인 하나에 대응한다. 이 표가 있어야 "이 시스템이 무엇을
|
||||||
|
-- 통제하고 있는가"를 서식으로 뽑을 수 있고, 개선조치가 어느 위험요인을 낮췄는지 이어진다.
|
||||||
|
CREATE TABLE move_risk_controls (
|
||||||
|
control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
site VARCHAR(64) NULL,
|
||||||
|
event_type VARCHAR(32) NOT NULL, -- move_events.event_type 와 같은 코드
|
||||||
|
hazard VARCHAR(300) NOT NULL, -- 위험요인
|
||||||
|
control VARCHAR(500) NOT NULL, -- 현재 통제 대책
|
||||||
|
severity_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (강도)
|
||||||
|
likelihood_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (빈도)
|
||||||
|
reviewed_by VARCHAR(190) NULL,
|
||||||
|
reviewed_at BIGINT UNSIGNED NULL,
|
||||||
|
updated_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (control_id),
|
||||||
|
UNIQUE KEY uq_risk_controls_site_type (site, event_type),
|
||||||
|
CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
|
||||||
|
CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 6. 엣지 성능 시계열
|
||||||
|
-- move_devices 는 마지막 값만 덮어쓴다. "어제부터 추론 지연이 두 배가 됐다"를 볼 수 없어
|
||||||
|
-- 서서히 죽는 노드를 놓친다. 하트비트마다 한 행씩 append 하고 보존기간으로 잘라낸다.
|
||||||
|
CREATE TABLE move_device_metrics (
|
||||||
|
metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
device_id VARCHAR(64) NOT NULL,
|
||||||
|
ts BIGINT UNSIGNED NOT NULL,
|
||||||
|
fps DOUBLE NULL,
|
||||||
|
temp DOUBLE NULL,
|
||||||
|
throttle TINYINT(1) NULL,
|
||||||
|
queue_pending INT UNSIGNED NULL,
|
||||||
|
cameras_total INT UNSIGNED NULL,
|
||||||
|
cameras_erroring INT UNSIGNED NULL,
|
||||||
|
errors INT UNSIGNED NULL,
|
||||||
|
PRIMARY KEY (metric_id),
|
||||||
|
KEY ix_device_metrics_device_ts (device_id, ts)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 7. 임계값 감사 컬럼 (편집 UI 도입)
|
||||||
|
-- 지금까지 이 표는 phpMyAdmin 으로 직접 고치는 것이 유일한 방법이었다(스키마 주석 참조).
|
||||||
|
-- 화면이 생기면 누가 언제 바꿨는지가 사고 조사에서 반드시 필요해진다.
|
||||||
|
ALTER TABLE move_thresholds
|
||||||
|
ADD COLUMN updated_by VARCHAR(190) NULL,
|
||||||
|
ADD COLUMN updated_at BIGINT UNSIGNED NULL;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 8. 조치 ↔ 계기 사건
|
||||||
|
ALTER TABLE move_actions
|
||||||
|
ADD COLUMN event_id INT UNSIGNED NULL AFTER site,
|
||||||
|
ADD KEY ix_actions_event (event_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 9. 신규 감지 유형
|
||||||
|
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
|
||||||
|
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
|
||||||
|
-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
|
||||||
|
ALTER TABLE move_events
|
||||||
|
DROP CHECK chk_events_type;
|
||||||
|
ALTER TABLE move_events
|
||||||
|
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||||
|
'fall','no_motion','immobility','ghost','zone_warning',
|
||||||
|
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
||||||
|
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
|
||||||
|
'camera_moved','camera_obstructed','low_light','unacked_escalation',
|
||||||
|
'ppe_missing','proximity','fire_smoke'
|
||||||
|
));
|
||||||
|
|
||||||
|
-- 검증:
|
||||||
|
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
|
||||||
|
-- php server/tools/verify_audit_chain.php -- 사슬 무결성
|
||||||
|
-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';
|
||||||
322
server/public/assets/admin.js
Normal file
322
server/public/assets/admin.js
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
/**
|
||||||
|
* 시스템 설정: 통보 채널 + 감지 임계값.
|
||||||
|
*
|
||||||
|
* 이 두 화면이 없던 동안 납품된 현장은 (1) 감지되어도 아무에게도 알릴 수 없었고
|
||||||
|
* (2) 오탐이 나도 임계값을 고칠 방법이 phpMyAdmin 뿐이었다. 콘솔의 오탐율 패널이
|
||||||
|
* "이 카메라를 재튜닝하라"고 알려주는데 재튜닝할 화면이 없는 상태였다.
|
||||||
|
*
|
||||||
|
* 임계값 입력은 자유 텍스트가 아니라 서버가 내려준 카탈로그(범위·단위·설명)로 렌더한다.
|
||||||
|
* 범위 밖 값은 엣지가 기동 시 거부하는데, 그때는 이미 공장 안 장비가 재시작 루프에 빠진
|
||||||
|
* 뒤다 — 키보드 앞에서 막는 편이 낫다.
|
||||||
|
*
|
||||||
|
* XSS-safe: 모든 동적 값은 textContent. innerHTML 금지 (프로젝트 규약).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var B = window.APP_BASE || '';
|
||||||
|
var root = document.querySelector('.admin-page');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var csrf = null;
|
||||||
|
var catalog = {};
|
||||||
|
var devices = [];
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (text !== undefined && text !== null) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCsrf() {
|
||||||
|
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) { if (d && d.token) csrf = d.token; })
|
||||||
|
.catch(function () { /* 저장 시 403 으로 드러난다 */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(path, body) {
|
||||||
|
return fetch(B + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' },
|
||||||
|
body: JSON.stringify(body || {})
|
||||||
|
}).then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
return r.json().then(function (d) { return { ok: r.ok, data: d }; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 오류 코드 -> 사람이 고칠 수 있는 문장. 코드를 그대로 보여주면 운영자는
|
||||||
|
// 무엇을 고쳐야 할지 알 수 없다.
|
||||||
|
var ERRORS = {
|
||||||
|
out_of_range: '허용 범위를 벗어났습니다.',
|
||||||
|
not_numeric: '숫자를 입력하세요.',
|
||||||
|
not_finite: '유효한 숫자가 아닙니다.',
|
||||||
|
not_integer: '정수만 입력할 수 있습니다.',
|
||||||
|
unknown_key: '알 수 없는 항목입니다.',
|
||||||
|
unknown_device: '등록되지 않은 장치입니다.',
|
||||||
|
invalid_kind: '채널 종류가 올바르지 않습니다.',
|
||||||
|
invalid_name: '이름을 입력하세요.',
|
||||||
|
invalid_config: '연결 설정을 확인하세요 (수신자 주소 / URL).',
|
||||||
|
invalid_escalate_after: '에스컬레이션 지연은 30초 이상이어야 합니다.',
|
||||||
|
invalid_step: '단계는 0~5 사이여야 합니다.',
|
||||||
|
csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.',
|
||||||
|
forbidden: '권한이 없습니다.',
|
||||||
|
not_found: '대상을 찾을 수 없습니다.'
|
||||||
|
};
|
||||||
|
function errText(code) { return ERRORS[code] || ('저장에 실패했습니다 (' + String(code) + ')'); }
|
||||||
|
|
||||||
|
// ============================================================ 통보 채널
|
||||||
|
var chList = document.getElementById('channel-list');
|
||||||
|
var chForm = document.getElementById('channel-form');
|
||||||
|
var chStatus = document.getElementById('ch-status');
|
||||||
|
var KIND_LABELS = { local: '현장 경광등', email: '메일', webhook: '웹훅' };
|
||||||
|
var SEV_LABELS = { critical: '위급만', high: '높음 이상', warning: '경고 이상', info: '전부' };
|
||||||
|
|
||||||
|
function field(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function showKind(kind) {
|
||||||
|
['local', 'email', 'webhook'].forEach(function (k) {
|
||||||
|
var box = root.querySelector('.kind-' + k);
|
||||||
|
if (box) box.hidden = (k !== kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChannels(list) {
|
||||||
|
chList.textContent = '';
|
||||||
|
if (!list.length) {
|
||||||
|
var empty = el('p', 'admin-empty',
|
||||||
|
'설정된 통보 경로가 없습니다. 지금은 감지가 되어도 화면을 보고 있는 사람에게만 전달됩니다.');
|
||||||
|
chList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach(function (c) {
|
||||||
|
var row = el('div', 'channel-row' + (c.enabled ? '' : ' is-off'));
|
||||||
|
var main = el('div', 'channel-main');
|
||||||
|
main.appendChild(el('span', 'channel-kind', KIND_LABELS[c.kind] || c.kind));
|
||||||
|
main.appendChild(el('span', 'channel-name', c.name));
|
||||||
|
var meta = el('span', 'channel-meta');
|
||||||
|
var bits = [SEV_LABELS[c.min_severity] || c.min_severity];
|
||||||
|
if (c.step > 0) bits.push('에스컬레이션 ' + Math.round(c.escalate_after_s / 60) + '분 후');
|
||||||
|
else bits.push('즉시');
|
||||||
|
if (c.kind === 'local' && c.config && c.config.gpio_pin) bits.push('GPIO ' + c.config.gpio_pin);
|
||||||
|
if (c.kind === 'email' && c.config && c.config.to) bits.push([].concat(c.config.to).join(', '));
|
||||||
|
if (c.kind === 'webhook' && c.config && c.config.url) bits.push(c.config.url);
|
||||||
|
if (!c.enabled) bits.push('사용 안 함');
|
||||||
|
meta.textContent = bits.join(' · ');
|
||||||
|
main.appendChild(meta);
|
||||||
|
row.appendChild(main);
|
||||||
|
|
||||||
|
var edit = el('button', null, '수정');
|
||||||
|
edit.type = 'button';
|
||||||
|
edit.addEventListener('click', function () { openForm(c); });
|
||||||
|
row.appendChild(edit);
|
||||||
|
|
||||||
|
var del = el('button', 'zone-del', '삭제');
|
||||||
|
del.type = 'button';
|
||||||
|
var armed = false, timer = null;
|
||||||
|
del.addEventListener('click', function () {
|
||||||
|
if (!armed) {
|
||||||
|
armed = true; del.classList.add('is-armed'); del.textContent = '정말 삭제?';
|
||||||
|
timer = setTimeout(function () { armed = false; del.classList.remove('is-armed'); del.textContent = '삭제'; }, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
post('/api/admin/channels/' + c.channel_id + '/delete').then(function (r) {
|
||||||
|
if (r && r.ok) loadChannels();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(del);
|
||||||
|
chList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(c) {
|
||||||
|
chForm.hidden = false;
|
||||||
|
chStatus.textContent = '';
|
||||||
|
c = c || {};
|
||||||
|
var cfg = c.config || {};
|
||||||
|
field('ch-id').value = c.channel_id || '';
|
||||||
|
field('ch-kind').value = c.kind || 'local';
|
||||||
|
field('ch-name').value = c.name || '';
|
||||||
|
field('ch-severity').value = c.min_severity || 'high';
|
||||||
|
field('ch-step').value = c.step || 0;
|
||||||
|
field('ch-after').value = c.escalate_after_s || 300;
|
||||||
|
field('ch-enabled').checked = c.channel_id ? !!c.enabled : true;
|
||||||
|
field('ch-pin').value = cfg.gpio_pin || 0;
|
||||||
|
field('ch-mode').value = cfg.mode || 'latch';
|
||||||
|
field('ch-hold').value = cfg.hold_s || 30;
|
||||||
|
field('ch-active-high').checked = !!cfg.active_high;
|
||||||
|
field('ch-to').value = cfg.to ? [].concat(cfg.to).join(', ') : '';
|
||||||
|
field('ch-from').value = cfg.from || '';
|
||||||
|
field('ch-url').value = cfg.url || '';
|
||||||
|
// 토큰은 서버가 마스킹해서 준다. 그대로 두면 "변경 안 함"이 된다.
|
||||||
|
field('ch-token').value = cfg.token || '';
|
||||||
|
showKind(field('ch-kind').value);
|
||||||
|
field('ch-after-wrap').hidden = Number(field('ch-step').value) === 0;
|
||||||
|
field('ch-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectConfig(kind) {
|
||||||
|
if (kind === 'email') {
|
||||||
|
return { to: field('ch-to').value, from: field('ch-from').value.trim() };
|
||||||
|
}
|
||||||
|
if (kind === 'webhook') {
|
||||||
|
var out = { url: field('ch-url').value.trim() };
|
||||||
|
var t = field('ch-token').value.trim();
|
||||||
|
if (t) out.token = t;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
gpio_pin: Number(field('ch-pin').value) || 0,
|
||||||
|
mode: field('ch-mode').value,
|
||||||
|
hold_s: Number(field('ch-hold').value) || 30,
|
||||||
|
active_high: field('ch-active-high').checked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chForm) {
|
||||||
|
field('ch-kind').addEventListener('change', function () { showKind(this.value); });
|
||||||
|
field('ch-step').addEventListener('input', function () {
|
||||||
|
field('ch-after-wrap').hidden = Number(this.value) === 0;
|
||||||
|
});
|
||||||
|
document.getElementById('ch-cancel').addEventListener('click', function () { chForm.hidden = true; });
|
||||||
|
document.getElementById('channel-new').addEventListener('click', function () { openForm(null); });
|
||||||
|
|
||||||
|
chForm.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
var kind = field('ch-kind').value;
|
||||||
|
var body = {
|
||||||
|
channel_id: Number(field('ch-id').value) || 0,
|
||||||
|
kind: kind,
|
||||||
|
name: field('ch-name').value.trim(),
|
||||||
|
min_severity: field('ch-severity').value,
|
||||||
|
step: Number(field('ch-step').value) || 0,
|
||||||
|
escalate_after_s: Number(field('ch-after').value) || 0,
|
||||||
|
enabled: field('ch-enabled').checked ? 1 : 0,
|
||||||
|
config: collectConfig(kind)
|
||||||
|
};
|
||||||
|
chStatus.textContent = '저장 중…';
|
||||||
|
post('/api/admin/channels', body).then(function (r) {
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.ok) { chStatus.textContent = errText(r.data && r.data.error); return; }
|
||||||
|
chStatus.textContent = '저장되었습니다.';
|
||||||
|
chForm.hidden = true;
|
||||||
|
loadChannels();
|
||||||
|
}).catch(function () { chStatus.textContent = '저장에 실패했습니다.'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChannels() {
|
||||||
|
fetch(B + '/api/admin/channels', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (d) { if (d) renderChannels(d.channels || []); })
|
||||||
|
.catch(function () { chList.textContent = '통보 경로를 불러오지 못했습니다.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ 임계값
|
||||||
|
var thrDevice = document.getElementById('thr-device');
|
||||||
|
var thrList = document.getElementById('thr-list');
|
||||||
|
var thrStatus = document.getElementById('thr-status');
|
||||||
|
var values = {}; // device_id -> {key: value}
|
||||||
|
|
||||||
|
function renderThresholds() {
|
||||||
|
var dev = thrDevice.value;
|
||||||
|
thrList.textContent = '';
|
||||||
|
if (!dev) { thrList.textContent = '장치를 선택하세요.'; return; }
|
||||||
|
var current = values[dev] || {};
|
||||||
|
|
||||||
|
Object.keys(catalog).forEach(function (key) {
|
||||||
|
var spec = catalog[key];
|
||||||
|
var row = el('div', 'thr-row');
|
||||||
|
|
||||||
|
var head = el('div', 'thr-head');
|
||||||
|
head.appendChild(el('span', 'thr-label', spec.label));
|
||||||
|
var val = el('span', 'thr-value');
|
||||||
|
row.appendChild(head);
|
||||||
|
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.min = spec.min;
|
||||||
|
input.max = spec.max;
|
||||||
|
input.step = spec.step;
|
||||||
|
// 값이 없으면 장비가 쓰고 있는 기본값을 보여준다 — 빈칸이면 "설정 안 됨"과
|
||||||
|
// "기본값과 같음"을 구분할 수 없다.
|
||||||
|
var isSet = Object.prototype.hasOwnProperty.call(current, key);
|
||||||
|
input.value = isSet ? current[key] : spec['default'];
|
||||||
|
head.appendChild(val);
|
||||||
|
val.textContent = (spec.unit ? spec.unit : '') + (isSet ? '' : ' · 기본값');
|
||||||
|
|
||||||
|
var controls = el('div', 'thr-controls');
|
||||||
|
controls.appendChild(input);
|
||||||
|
var save = el('button', 'btn-primary', '적용');
|
||||||
|
save.type = 'button';
|
||||||
|
save.addEventListener('click', function () {
|
||||||
|
thrStatus.textContent = '적용 중…';
|
||||||
|
post('/api/admin/thresholds', { device_id: dev, key: key, value: input.value })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.ok) {
|
||||||
|
thrStatus.textContent = spec.label + ': ' + errText(r.data && r.data.error)
|
||||||
|
+ ' (허용 ' + spec.min + '~' + spec.max + ')';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thrStatus.textContent = spec.label + ' 적용됨 — 다음 동기화 때 장치에 반영됩니다.';
|
||||||
|
loadThresholds();
|
||||||
|
})
|
||||||
|
.catch(function () { thrStatus.textContent = '적용에 실패했습니다.'; });
|
||||||
|
});
|
||||||
|
controls.appendChild(save);
|
||||||
|
row.appendChild(controls);
|
||||||
|
row.appendChild(el('p', 'thr-help', spec.help));
|
||||||
|
thrList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThresholds() {
|
||||||
|
fetch(B + '/api/admin/thresholds', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
if (r.status === 403) { thrList.textContent = '임계값 편집 권한이 없습니다.'; return null; }
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d) return;
|
||||||
|
catalog = d.catalog || {};
|
||||||
|
devices = d.devices || [];
|
||||||
|
values = {};
|
||||||
|
(d.thresholds || []).forEach(function (t) {
|
||||||
|
if (t.camera_id) return; // 카메라별 재정의는 이 화면의 범위 밖
|
||||||
|
if (!values[t.device_id]) values[t.device_id] = {};
|
||||||
|
values[t.device_id][t.key] = t.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
var keep = thrDevice.value;
|
||||||
|
thrDevice.textContent = '';
|
||||||
|
var first = document.createElement('option');
|
||||||
|
first.value = '';
|
||||||
|
first.textContent = devices.length ? '장치를 선택하세요' : '등록된 장치가 없습니다';
|
||||||
|
thrDevice.appendChild(first);
|
||||||
|
devices.forEach(function (dev) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = dev.device_id;
|
||||||
|
o.textContent = (dev.name || dev.device_id) + ' · ' + dev.device_id;
|
||||||
|
thrDevice.appendChild(o);
|
||||||
|
});
|
||||||
|
if (keep) thrDevice.value = keep;
|
||||||
|
renderThresholds();
|
||||||
|
})
|
||||||
|
.catch(function () { thrList.textContent = '임계값을 불러오지 못했습니다.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thrDevice) thrDevice.addEventListener('change', renderThresholds);
|
||||||
|
|
||||||
|
loadCsrf().then(function () {
|
||||||
|
loadChannels();
|
||||||
|
loadThresholds();
|
||||||
|
});
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
420
server/public/assets/compliance.js
Normal file
420
server/public/assets/compliance.js
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
/**
|
||||||
|
* 안전 관리: 교대 인수인계 · 정비 억제 · 위험성평가 · 설비 성능 추이.
|
||||||
|
*
|
||||||
|
* XSS-safe: 모든 동적 값은 textContent. 그래프는 인라인 SVG 를 DOM API 로 조립한다
|
||||||
|
* (문자열 innerHTML 금지 — 프로젝트 규약이자, 카메라 이름 같은 값이 마크업에 섞이는 것을 막는다).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var B = window.APP_BASE || '';
|
||||||
|
var root = document.querySelector('.compliance-page');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var csrf = null;
|
||||||
|
var TYPE_LABELS = {
|
||||||
|
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
||||||
|
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||||
|
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||||
|
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||||||
|
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
|
||||||
|
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
|
||||||
|
unacked_escalation: '미확인 경보 에스컬레이션',
|
||||||
|
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||||||
|
};
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (text !== undefined && text !== null) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
||||||
|
function fmtDate(ts) {
|
||||||
|
var d = new Date(Number(ts) * 1000);
|
||||||
|
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||||
|
}
|
||||||
|
function fmtStamp(ts) {
|
||||||
|
var d = new Date(Number(ts) * 1000);
|
||||||
|
return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||||
|
}
|
||||||
|
function fmtLeft(seconds) {
|
||||||
|
if (seconds <= 0) return '만료됨';
|
||||||
|
if (seconds < 3600) return Math.ceil(seconds / 60) + '분 남음';
|
||||||
|
return Math.floor(seconds / 3600) + '시간 ' + Math.round((seconds % 3600) / 60) + '분 남음';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCsrf() {
|
||||||
|
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) { if (d && d.token) csrf = d.token; })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
function post(path, body) {
|
||||||
|
return fetch(B + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf || '' },
|
||||||
|
body: JSON.stringify(body || {})
|
||||||
|
}).then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
return r.json().then(function (d) { return { ok: r.ok, data: d }; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getJson(path) {
|
||||||
|
return fetch(B + path, { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var ERRORS = {
|
||||||
|
reason_required: '사유를 입력하세요 — 사유 없는 억제는 나중에 아무도 해석할 수 없습니다.',
|
||||||
|
invalid_window: '기간은 1분 이상 8시간(480분) 이하여야 합니다.',
|
||||||
|
invalid_scope_id: '대상을 선택하세요.',
|
||||||
|
unknown_scope_id: '등록되지 않은 대상입니다.',
|
||||||
|
invalid_rating: '강도·빈도는 1~5 사이여야 합니다.',
|
||||||
|
invalid_hazard: '위험요인을 입력하세요.',
|
||||||
|
invalid_control: '현재 대책을 입력하세요.',
|
||||||
|
forbidden: '권한이 없습니다.',
|
||||||
|
csrf: '세션이 만료되었습니다. 새로고침 후 다시 시도하세요.'
|
||||||
|
};
|
||||||
|
function errText(c) { return ERRORS[c] || ('처리에 실패했습니다 (' + String(c) + ')'); }
|
||||||
|
|
||||||
|
// ============================================================ 교대 인수인계
|
||||||
|
var pendingBox = document.getElementById('handover-pending');
|
||||||
|
var handoverList = document.getElementById('handover-list');
|
||||||
|
|
||||||
|
function renderHandovers(d) {
|
||||||
|
var p = d.pending || {};
|
||||||
|
pendingBox.textContent = '';
|
||||||
|
var head = el('div', 'handover-head');
|
||||||
|
head.appendChild(el('strong', null, '인수 대상 구간'));
|
||||||
|
head.appendChild(el('span', 'handover-range',
|
||||||
|
fmtStamp(p.period_start) + ' ~ ' + fmtStamp(p.period_end)));
|
||||||
|
pendingBox.appendChild(head);
|
||||||
|
|
||||||
|
var stats = el('div', 'handover-stats');
|
||||||
|
var a = el('div', 'handover-stat' + (p.unacked_count > 0 ? ' is-open' : ''));
|
||||||
|
a.appendChild(el('strong', null, String(p.unacked_count || 0)));
|
||||||
|
a.appendChild(el('span', null, '미확인'));
|
||||||
|
stats.appendChild(a);
|
||||||
|
var b = el('div', 'handover-stat' + (p.severe_count > 0 ? ' is-severe' : ''));
|
||||||
|
b.appendChild(el('strong', null, String(p.severe_count || 0)));
|
||||||
|
b.appendChild(el('span', null, '이 중 중대'));
|
||||||
|
stats.appendChild(b);
|
||||||
|
pendingBox.appendChild(stats);
|
||||||
|
if (p.from_user) {
|
||||||
|
pendingBox.appendChild(el('p', 'handover-from', '인계자: ' + p.from_user));
|
||||||
|
}
|
||||||
|
|
||||||
|
handoverList.textContent = '';
|
||||||
|
var list = d.handovers || [];
|
||||||
|
if (!list.length) {
|
||||||
|
handoverList.appendChild(el('p', 'admin-empty', '기록된 인수인계가 없습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach(function (h) {
|
||||||
|
var row = el('div', 'handover-row');
|
||||||
|
row.appendChild(el('span', 'handover-when', fmtStamp(h.created_at)));
|
||||||
|
row.appendChild(el('span', 'handover-who',
|
||||||
|
(h.from_user ? h.from_user + ' → ' : '') + h.to_user));
|
||||||
|
row.appendChild(el('span', 'handover-count',
|
||||||
|
'미확인 ' + h.unacked_count + ' (중대 ' + h.severe_count + ')'));
|
||||||
|
if (h.note) row.appendChild(el('span', 'handover-note', h.note));
|
||||||
|
handoverList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHandovers() {
|
||||||
|
getJson('/api/handovers').then(function (d) { if (d) renderHandovers(d); })
|
||||||
|
.catch(function () { pendingBox.textContent = '인수인계 정보를 불러오지 못했습니다.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
var handoverForm = document.getElementById('handover-form');
|
||||||
|
if (handoverForm) {
|
||||||
|
handoverForm.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
var note = document.getElementById('handover-note');
|
||||||
|
post('/api/handovers', { note: note.value.trim() }).then(function (r) {
|
||||||
|
if (!r) return;
|
||||||
|
if (r.ok) { note.value = ''; loadHandovers(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ 억제
|
||||||
|
var supList = document.getElementById('suppress-list');
|
||||||
|
var supStatus = document.getElementById('sup-status');
|
||||||
|
var supType = document.getElementById('sup-scope-type');
|
||||||
|
var supId = document.getElementById('sup-scope-id');
|
||||||
|
var deviceRows = [];
|
||||||
|
var cameraRows = [];
|
||||||
|
|
||||||
|
function fillScopeOptions() {
|
||||||
|
var rows = supType.value === 'device' ? deviceRows : cameraRows;
|
||||||
|
supId.textContent = '';
|
||||||
|
if (!rows.length) {
|
||||||
|
supId.appendChild(new Option(supType.value === 'device' ? '등록된 장치가 없습니다' : '관측된 카메라가 없습니다', ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
supId.appendChild(new Option('선택하세요', ''));
|
||||||
|
rows.forEach(function (r) { supId.appendChild(new Option(r.label, r.id)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuppressions(d) {
|
||||||
|
var now = d.now || Math.floor(Date.now() / 1000);
|
||||||
|
supList.textContent = '';
|
||||||
|
var list = d.suppressions || [];
|
||||||
|
if (!list.length) {
|
||||||
|
supList.appendChild(el('p', 'admin-empty', '현재 억제 중인 대상이 없습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach(function (s) {
|
||||||
|
var row = el('div', 'suppress-row');
|
||||||
|
var main = el('div', 'suppress-main');
|
||||||
|
main.appendChild(el('span', 'suppress-scope',
|
||||||
|
(s.scope_type === 'device' ? '장치' : s.scope_type === 'camera' ? '카메라' : '구역') + ' ' + s.scope_id));
|
||||||
|
main.appendChild(el('span', 'suppress-reason', s.reason));
|
||||||
|
main.appendChild(el('span', 'suppress-meta',
|
||||||
|
s.created_by + ' · ' + fmtStamp(s.starts_at) + ' 시작 · ' + fmtLeft(s.ends_at - now)));
|
||||||
|
row.appendChild(main);
|
||||||
|
var stop = el('button', null, '해제');
|
||||||
|
stop.type = 'button';
|
||||||
|
stop.addEventListener('click', function () {
|
||||||
|
post('/api/suppressions/' + s.suppression_id + '/revoke').then(function (r) {
|
||||||
|
if (r && r.ok) loadSuppressions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(stop);
|
||||||
|
supList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSuppressions() {
|
||||||
|
getJson('/api/suppressions').then(function (d) { if (d) renderSuppressions(d); })
|
||||||
|
.catch(function () { supList.textContent = '억제 목록을 불러오지 못했습니다.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
var supForm = document.getElementById('suppress-form');
|
||||||
|
if (supForm) {
|
||||||
|
supType.addEventListener('change', fillScopeOptions);
|
||||||
|
supForm.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
supStatus.textContent = '';
|
||||||
|
post('/api/suppressions', {
|
||||||
|
scope_type: supType.value,
|
||||||
|
scope_id: supId.value,
|
||||||
|
minutes: Number(document.getElementById('sup-minutes').value) || 0,
|
||||||
|
reason: document.getElementById('sup-reason').value.trim()
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.ok) { supStatus.textContent = errText(r.data && r.data.error); return; }
|
||||||
|
supStatus.textContent = '억제를 시작했습니다. ' + fmtStamp(r.data.ends_at) + ' 에 자동 해제됩니다.';
|
||||||
|
document.getElementById('sup-reason').value = '';
|
||||||
|
loadSuppressions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ 위험성평가
|
||||||
|
var riskList = document.getElementById('risk-list');
|
||||||
|
var riskForm = document.getElementById('risk-form');
|
||||||
|
var riskStatus = document.getElementById('risk-status');
|
||||||
|
|
||||||
|
function riskBand(score) {
|
||||||
|
if (score >= 15) return { cls: 'is-high', text: '높음' };
|
||||||
|
if (score >= 8) return { cls: 'is-mid', text: '보통' };
|
||||||
|
return { cls: 'is-low', text: '낮음' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRisks(list) {
|
||||||
|
riskList.textContent = '';
|
||||||
|
if (!list.length) {
|
||||||
|
riskList.appendChild(el('p', 'admin-empty',
|
||||||
|
'등록된 위험요인이 없습니다. 감지 유형별로 무엇을 통제하고 있는지 기록해 두면 사고조사 패키지에 함께 실립니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach(function (r) {
|
||||||
|
var band = riskBand(r.risk_score);
|
||||||
|
var row = el('div', 'risk-row');
|
||||||
|
var score = el('div', 'risk-score ' + band.cls);
|
||||||
|
score.appendChild(el('strong', null, String(r.risk_score)));
|
||||||
|
score.appendChild(el('span', null, band.text));
|
||||||
|
row.appendChild(score);
|
||||||
|
|
||||||
|
var body = el('div', 'risk-body');
|
||||||
|
body.appendChild(el('span', 'risk-type', TYPE_LABELS[r.event_type] || r.event_type));
|
||||||
|
body.appendChild(el('span', 'risk-hazard', r.hazard));
|
||||||
|
body.appendChild(el('span', 'risk-control', '대책: ' + r.control));
|
||||||
|
var meta = '강도 ' + r.severity_rating + ' × 빈도 ' + r.likelihood_rating;
|
||||||
|
if (r.reviewed_by) meta += ' · ' + r.reviewed_by + ' ' + fmtDate(r.reviewed_at);
|
||||||
|
body.appendChild(el('span', 'risk-meta', meta));
|
||||||
|
row.appendChild(body);
|
||||||
|
|
||||||
|
var edit = el('button', null, '수정');
|
||||||
|
edit.type = 'button';
|
||||||
|
edit.addEventListener('click', function () { openRisk(r); });
|
||||||
|
row.appendChild(edit);
|
||||||
|
riskList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRisk(r) {
|
||||||
|
riskForm.hidden = false;
|
||||||
|
riskStatus.textContent = '';
|
||||||
|
var sel = document.getElementById('risk-type');
|
||||||
|
if (!sel.options.length) {
|
||||||
|
Object.keys(TYPE_LABELS).forEach(function (k) { sel.appendChild(new Option(TYPE_LABELS[k], k)); });
|
||||||
|
}
|
||||||
|
r = r || {};
|
||||||
|
sel.value = r.event_type || 'fall';
|
||||||
|
document.getElementById('risk-hazard').value = r.hazard || '';
|
||||||
|
document.getElementById('risk-control').value = r.control || '';
|
||||||
|
document.getElementById('risk-severity').value = r.severity_rating || 3;
|
||||||
|
document.getElementById('risk-likelihood').value = r.likelihood_rating || 3;
|
||||||
|
document.getElementById('risk-hazard').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskForm) {
|
||||||
|
document.getElementById('risk-new').addEventListener('click', function () { openRisk(null); });
|
||||||
|
document.getElementById('risk-cancel').addEventListener('click', function () { riskForm.hidden = true; });
|
||||||
|
riskForm.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
post('/api/risks', {
|
||||||
|
event_type: document.getElementById('risk-type').value,
|
||||||
|
hazard: document.getElementById('risk-hazard').value.trim(),
|
||||||
|
control: document.getElementById('risk-control').value.trim(),
|
||||||
|
severity_rating: Number(document.getElementById('risk-severity').value),
|
||||||
|
likelihood_rating: Number(document.getElementById('risk-likelihood').value)
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.ok) { riskStatus.textContent = errText(r.data && r.data.error); return; }
|
||||||
|
riskStatus.textContent = '저장되었습니다.';
|
||||||
|
riskForm.hidden = true;
|
||||||
|
loadRisks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRisks() {
|
||||||
|
getJson('/api/risks').then(function (d) { if (d) renderRisks(d.controls || []); })
|
||||||
|
.catch(function () { riskList.textContent = '위험성평가를 불러오지 못했습니다.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ 성능 추이
|
||||||
|
var metricDevice = document.getElementById('metric-device');
|
||||||
|
var metricChart = document.getElementById('metric-chart');
|
||||||
|
var SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
function svg(tag, attrs) {
|
||||||
|
var n = document.createElementNS(SVG_NS, tag);
|
||||||
|
Object.keys(attrs || {}).forEach(function (k) { n.setAttribute(k, String(attrs[k])); });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 값이 없는 구간은 선을 잇지 않는다 — 없는 데이터를 직선으로 메우면 그래프가 거짓말을 한다. */
|
||||||
|
function polylines(points, key, x, y) {
|
||||||
|
var segs = [], cur = [];
|
||||||
|
points.forEach(function (p, i) {
|
||||||
|
var v = p[key];
|
||||||
|
if (v === null || v === undefined) { if (cur.length > 1) segs.push(cur); cur = []; return; }
|
||||||
|
cur.push(x(i) + ',' + y(v));
|
||||||
|
});
|
||||||
|
if (cur.length > 1) segs.push(cur);
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics(d) {
|
||||||
|
metricChart.textContent = '';
|
||||||
|
var pts = d.points || [];
|
||||||
|
if (pts.length < 2) {
|
||||||
|
metricChart.appendChild(el('p', 'admin-empty',
|
||||||
|
'이 장치의 성능 기록이 아직 충분하지 않습니다. 하트비트가 쌓이면 추이가 그려집니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var W = 720, H = 160, PAD = 28;
|
||||||
|
var fpsVals = pts.map(function (p) { return p.fps; }).filter(function (v) { return v !== null; });
|
||||||
|
var maxFps = Math.max(30, Math.ceil(Math.max.apply(null, fpsVals.length ? fpsVals : [30])));
|
||||||
|
var x = function (i) { return PAD + (i / (pts.length - 1)) * (W - PAD * 2); };
|
||||||
|
var yFps = function (v) { return H - PAD - (v / maxFps) * (H - PAD * 2); };
|
||||||
|
|
||||||
|
var chart = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, class: 'metric-svg',
|
||||||
|
role: 'img', 'aria-label': '프레임률 추이' });
|
||||||
|
chart.appendChild(svg('line', { x1: PAD, y1: H - PAD, x2: W - PAD, y2: H - PAD, class: 'axis' }));
|
||||||
|
chart.appendChild(svg('line', { x1: PAD, y1: PAD, x2: PAD, y2: H - PAD, class: 'axis' }));
|
||||||
|
|
||||||
|
polylines(pts, 'fps', x, yFps).forEach(function (seg) {
|
||||||
|
chart.appendChild(svg('polyline', { points: seg.join(' '), class: 'series-fps' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 스로틀 구간은 배경 띠로 — 프레임률 하락의 원인을 같은 그림에서 읽을 수 있어야 한다.
|
||||||
|
pts.forEach(function (p, i) {
|
||||||
|
if (!p.throttle) return;
|
||||||
|
chart.appendChild(svg('rect', { x: x(i) - 1, y: PAD, width: 3, height: H - PAD * 2,
|
||||||
|
class: 'series-throttle' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
metricChart.appendChild(chart);
|
||||||
|
|
||||||
|
var legend = el('div', 'metric-legend');
|
||||||
|
legend.appendChild(el('span', 'legend-fps', '프레임률 (최대 ' + maxFps + ')'));
|
||||||
|
legend.appendChild(el('span', 'legend-throttle', '과열 스로틀 구간'));
|
||||||
|
legend.appendChild(el('span', 'legend-range',
|
||||||
|
fmtStamp(pts[0].ts) + ' ~ ' + fmtStamp(pts[pts.length - 1].ts) + ' · ' + pts.length + '점'));
|
||||||
|
metricChart.appendChild(legend);
|
||||||
|
|
||||||
|
var last = pts[pts.length - 1];
|
||||||
|
var summary = el('p', 'metric-summary');
|
||||||
|
var bits = [];
|
||||||
|
if (last.fps !== null) bits.push('현재 ' + last.fps.toFixed(1) + ' fps');
|
||||||
|
if (last.temp !== null) bits.push(last.temp.toFixed(1) + '°C');
|
||||||
|
if (last.queue_pending !== null) bits.push('전송 대기 ' + last.queue_pending + '건');
|
||||||
|
if (last.cameras_erroring) bits.push('오류 카메라 ' + last.cameras_erroring + '대');
|
||||||
|
summary.textContent = bits.join(' · ');
|
||||||
|
metricChart.appendChild(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMetrics() {
|
||||||
|
var dev = metricDevice.value;
|
||||||
|
if (!dev) { metricChart.textContent = '장치를 선택하세요.'; return; }
|
||||||
|
metricChart.textContent = '불러오는 중…';
|
||||||
|
getJson('/api/devices/' + encodeURIComponent(dev) + '/metrics?hours=24')
|
||||||
|
.then(function (d) { if (d) renderMetrics(d); })
|
||||||
|
.catch(function () { metricChart.textContent = '성능 기록을 불러오지 못했습니다.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricDevice) metricDevice.addEventListener('change', loadMetrics);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 공통 목록
|
||||||
|
function loadTargets() {
|
||||||
|
return Promise.all([getJson('/api/devices'), getJson('/api/events?limit=200')])
|
||||||
|
.then(function (res) {
|
||||||
|
var devs = (res[0] && res[0].devices) || [];
|
||||||
|
deviceRows = devs.map(function (d) {
|
||||||
|
return { id: d.device_id, label: (d.name || d.device_id) + ' · ' + d.device_id };
|
||||||
|
});
|
||||||
|
var seen = {};
|
||||||
|
cameraRows = [];
|
||||||
|
((res[1] && res[1].events) || []).forEach(function (e) {
|
||||||
|
if (!e.camera_id || seen[e.camera_id]) return;
|
||||||
|
seen[e.camera_id] = true;
|
||||||
|
cameraRows.push({ id: e.camera_id, label: e.camera_id });
|
||||||
|
});
|
||||||
|
cameraRows.sort(function (a, b) { return a.id < b.id ? -1 : 1; });
|
||||||
|
fillScopeOptions();
|
||||||
|
|
||||||
|
metricDevice.textContent = '';
|
||||||
|
metricDevice.appendChild(new Option(devs.length ? '장치를 선택하세요' : '등록된 장치가 없습니다', ''));
|
||||||
|
deviceRows.forEach(function (d) { metricDevice.appendChild(new Option(d.label, d.id)); });
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCsrf().then(function () {
|
||||||
|
loadTargets();
|
||||||
|
loadHandovers();
|
||||||
|
loadSuppressions();
|
||||||
|
loadRisks();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -3,6 +3,11 @@
|
||||||
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
|
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
|
||||||
* plus each snapshot as an <img src="/snap/{file_uuid}">.
|
* plus each snapshot as an <img src="/snap/{file_uuid}">.
|
||||||
*
|
*
|
||||||
|
* This is where an incident is JUDGED, so it is also where it is RESOLVED: 확인/오탐 post to
|
||||||
|
* the same routes the stream uses. Previously the operator had to read the evidence here,
|
||||||
|
* navigate back, and find the row again — the deepest screen had no way to act on what it
|
||||||
|
* showed, which is exactly backwards.
|
||||||
|
*
|
||||||
* XSS-safe: every dynamic value is written via textContent or a validated URL
|
* XSS-safe: every dynamic value is written via textContent or a validated URL
|
||||||
* attribute, never innerHTML. Severity is shown as icon + text (never color
|
* attribute, never innerHTML. Severity is shown as icon + text (never color
|
||||||
* alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
|
* alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
|
||||||
|
|
@ -21,8 +26,51 @@
|
||||||
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/);
|
||||||
|
|
@ -32,16 +80,34 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var id = m[1];
|
var id = m[1];
|
||||||
|
var currentEvent = null;
|
||||||
|
var csrfToken = null;
|
||||||
|
var deviceNames = {};
|
||||||
|
|
||||||
function severityInfo(sev) {
|
function severityInfo(sev) {
|
||||||
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
||||||
|
|
||||||
|
// One stamp format across the product. This page used to print toLocaleString('ko-KR')
|
||||||
|
// ("2026. 8. 8. 오후 4:01:50") while the stream printed "08-08 16:01:50" for the same
|
||||||
|
// instant — the same event looked like two different records depending on the screen.
|
||||||
|
var DOW = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
function formatTime(occurredAt) {
|
function formatTime(occurredAt) {
|
||||||
if (!occurredAt && occurredAt !== 0) return '-';
|
if (!occurredAt && occurredAt !== 0) return '-';
|
||||||
var d = new Date(Number(occurredAt) * 1000);
|
var d = new Date(Number(occurredAt) * 1000);
|
||||||
if (isNaN(d.getTime())) return '-';
|
if (isNaN(d.getTime())) return '-';
|
||||||
return d.toLocaleString('ko-KR');
|
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
|
||||||
|
+ ' (' + DOW[d.getDay()] + ') '
|
||||||
|
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(s) {
|
||||||
|
var v = Number(s);
|
||||||
|
if (!isFinite(v)) return '-';
|
||||||
|
if (v < 60) return (Math.round(v * 10) / 10) + '초';
|
||||||
|
return Math.floor(v / 60) + '분 ' + Math.round(v % 60) + '초';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBadge(sev) {
|
function buildBadge(sev) {
|
||||||
|
|
@ -60,8 +126,9 @@
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
function textNode(value) {
|
function textNode(value, cls) {
|
||||||
var span = document.createElement('span');
|
var span = document.createElement('span');
|
||||||
|
if (cls) span.className = cls;
|
||||||
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
|
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
@ -69,47 +136,248 @@
|
||||||
function addRow(label, valueNode) {
|
function addRow(label, valueNode) {
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'detail-row';
|
row.className = 'detail-row';
|
||||||
var k = document.createElement('span');
|
var k = document.createElement('dt');
|
||||||
k.className = 'detail-label';
|
k.textContent = label;
|
||||||
k.textContent = label + ': ';
|
var v = document.createElement('dd');
|
||||||
|
v.appendChild(valueNode);
|
||||||
row.appendChild(k);
|
row.appendChild(k);
|
||||||
row.appendChild(valueNode);
|
row.appendChild(v);
|
||||||
meta.appendChild(row);
|
meta.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(data) {
|
// --- resolution actions --------------------------------------------------------------
|
||||||
var ev = data.event || {};
|
function loadCsrf() {
|
||||||
|
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (d) { if (d && d.token) csrfToken = d.token; })
|
||||||
|
.catch(function () { /* the POST surfaces it as a 403 */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(path) {
|
||||||
|
return fetch(B + '/events/' + encodeURIComponent(id) + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
if (!res.ok) throw new Error('action_failed:' + res.status);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNote(text, isError) {
|
||||||
|
var note = document.createElement('p');
|
||||||
|
note.className = 'detail-action-note' + (isError ? ' is-error' : '');
|
||||||
|
note.textContent = text;
|
||||||
|
actionsBox.appendChild(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오탐 is two-step here for the same reason as in the stream: it asserts the detector was
|
||||||
|
// wrong, it moves a per-camera statistic, and there is no undo.
|
||||||
|
var fpArmed = false;
|
||||||
|
|
||||||
|
function renderActions() {
|
||||||
|
if (!actionsBox || !currentEvent) return;
|
||||||
|
actionsBox.hidden = false;
|
||||||
|
actionsBox.textContent = '';
|
||||||
|
fpArmed = false;
|
||||||
|
|
||||||
|
if (currentEvent.false_positive_by) {
|
||||||
|
setNote('오탐으로 판정됨 · ' + currentEvent.false_positive_by);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentEvent.acknowledged_by) {
|
||||||
|
setNote('확인 완료 · ' + currentEvent.acknowledged_by);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ack = document.createElement('button');
|
||||||
|
ack.type = 'button';
|
||||||
|
ack.className = 'btn-primary';
|
||||||
|
ack.textContent = '확인 처리';
|
||||||
|
ack.addEventListener('click', function () {
|
||||||
|
ack.disabled = true;
|
||||||
|
post('/ack')
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res) return;
|
||||||
|
currentEvent.acknowledged_by = window.APP_USER || '확인';
|
||||||
|
renderActions();
|
||||||
|
renderMeta();
|
||||||
|
})
|
||||||
|
.catch(function () { ack.disabled = false; setNote('확인 처리에 실패했습니다.', true); });
|
||||||
|
});
|
||||||
|
actionsBox.appendChild(ack);
|
||||||
|
|
||||||
|
var fp = document.createElement('button');
|
||||||
|
fp.type = 'button';
|
||||||
|
fp.className = 'fp-btn';
|
||||||
|
fp.textContent = '오탐';
|
||||||
|
fp.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
|
||||||
|
fp.addEventListener('click', function () {
|
||||||
|
if (!fpArmed) {
|
||||||
|
fpArmed = true;
|
||||||
|
fp.classList.add('is-armed');
|
||||||
|
fp.textContent = '오탐 확정?';
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!fpArmed) return;
|
||||||
|
fpArmed = false; fp.classList.remove('is-armed'); fp.textContent = '오탐';
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fp.disabled = true; ack.disabled = true;
|
||||||
|
post('/false-positive')
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res) return;
|
||||||
|
currentEvent.acknowledged_by = window.APP_USER || '확인';
|
||||||
|
currentEvent.false_positive_by = window.APP_USER || '오탐';
|
||||||
|
renderActions();
|
||||||
|
renderMeta();
|
||||||
|
})
|
||||||
|
.catch(function () { fp.disabled = false; ack.disabled = false; setNote('오탐 처리에 실패했습니다.', true); });
|
||||||
|
});
|
||||||
|
actionsBox.appendChild(fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMeta() {
|
||||||
|
var ev = currentEvent || {};
|
||||||
meta.textContent = '';
|
meta.textContent = '';
|
||||||
meta.removeAttribute('aria-busy');
|
meta.removeAttribute('aria-busy');
|
||||||
|
|
||||||
addRow('심각도', buildBadge(ev.severity));
|
addRow('심각도', buildBadge(ev.severity));
|
||||||
addRow('유형', textNode(ev.event_type));
|
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
|
||||||
addRow('카메라', textNode(ev.camera_id));
|
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
|
||||||
addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
|
addRow('카메라', textNode(ev.camera_id, 'mono'));
|
||||||
addRow('지속(초)', textNode(ev.duration_s));
|
addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono'));
|
||||||
addRow('확인', textNode(ev.acknowledged_by || '미확인'));
|
addRow('지속', textNode(fmtDuration(ev.duration_s)));
|
||||||
|
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
|
||||||
|
if (ev.zone_id) addRow('구역', textNode(ev.zone_id, 'mono'));
|
||||||
|
|
||||||
if (!snapBox) return;
|
var status = ev.false_positive_by
|
||||||
snapBox.textContent = '';
|
? '오탐 (' + ev.false_positive_by + ')'
|
||||||
var snaps = data.snapshots || [];
|
: (ev.acknowledged_by ? '확인됨 (' + ev.acknowledged_by + ')' : '미확인');
|
||||||
if (snaps.length === 0) {
|
addRow('처리 상태', textNode(status));
|
||||||
var none = document.createElement('p');
|
|
||||||
none.textContent = '스냅샷이 없습니다.';
|
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
|
||||||
snapBox.appendChild(none);
|
var metrics = ev.metrics || {};
|
||||||
return;
|
Object.keys(metrics).forEach(function (key) {
|
||||||
}
|
addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[key])));
|
||||||
snaps.forEach(function (s) {
|
|
||||||
if (!s || !s.file_uuid) return;
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
|
||||||
img.alt = '사고 스냅샷';
|
|
||||||
img.className = 'detail-snapshot';
|
|
||||||
img.loading = 'lazy';
|
|
||||||
snapBox.appendChild(img);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
function renderHead() {
|
||||||
|
var ev = currentEvent || {};
|
||||||
|
if (titleEl) titleEl.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '이벤트 상세');
|
||||||
|
if (whenEl) {
|
||||||
|
whenEl.textContent = '';
|
||||||
|
whenEl.appendChild(textNode(deviceNames[ev.device_id] || ev.device_id, 'ev-src'));
|
||||||
|
whenEl.appendChild(textNode(ev.camera_id, 'ev-cam'));
|
||||||
|
whenEl.appendChild(textNode(formatTime(ev.occurred_at), 'ev-at'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSnapshots(snaps) {
|
||||||
|
if (!snapBox) return;
|
||||||
|
snapBox.textContent = '';
|
||||||
|
var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; });
|
||||||
|
if (kept.length === 0) {
|
||||||
|
var none = document.createElement('p');
|
||||||
|
none.className = 'detail-empty';
|
||||||
|
none.textContent = '이 이벤트에는 저장된 스냅샷이 없습니다 (미촬영이거나 PIPA 보존기간이 지나 삭제됨).';
|
||||||
|
snapBox.appendChild(none);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by
|
||||||
|
// position from the end — exact offsets are edge config the server doesn't know, and
|
||||||
|
// 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');
|
||||||
|
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||||||
|
var cap = kept.length > 1
|
||||||
|
? CAPTIONS[Math.max(0, CAPTIONS.length - kept.length + i)] : '발생 시점';
|
||||||
|
img.alt = '사고 스냅샷 — ' + cap;
|
||||||
|
img.className = 'detail-snapshot';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
fig.appendChild(img);
|
||||||
|
var fc = document.createElement('figcaption');
|
||||||
|
fc.textContent = cap;
|
||||||
|
fig.appendChild(fc);
|
||||||
|
snapBox.appendChild(fig);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- outbound links + improvement action ---------------------------------------------
|
||||||
|
function renderLinks() {
|
||||||
|
var ev = currentEvent || {};
|
||||||
|
var pkg = document.getElementById('detail-package');
|
||||||
|
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
|
||||||
|
var zones = document.getElementById('detail-zones');
|
||||||
|
if (zones) {
|
||||||
|
// Deep-link the zone editor straight at this camera. Reading an incident and then having
|
||||||
|
// to re-select the camera by hand on the next screen is the same "the system knows this
|
||||||
|
// and asks you anyway" problem the editor's own picker exists to remove.
|
||||||
|
zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
|
||||||
|
+ '&camera=' + encodeURIComponent(ev.camera_id || '');
|
||||||
|
zones.hidden = !ev.camera_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireActionForm() {
|
||||||
|
var form = document.getElementById('detail-action-form');
|
||||||
|
var note = document.getElementById('detail-action-note');
|
||||||
|
var status = document.getElementById('detail-action-status');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var text = (note.value || '').trim();
|
||||||
|
if (!text || !currentEvent) return;
|
||||||
|
status.textContent = '기록 중…';
|
||||||
|
fetch(B + '/api/actions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
camera_id: currentEvent.camera_id,
|
||||||
|
note: text,
|
||||||
|
event_id: currentEvent.event_id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return; }
|
||||||
|
if (!r.ok) throw new Error('action_failed');
|
||||||
|
note.value = '';
|
||||||
|
status.textContent = '기록되었습니다. 안전 리포트의 [개선 조치 효과]에서 전후 비교를 볼 수 있습니다.';
|
||||||
|
})
|
||||||
|
.catch(function () { status.textContent = '기록에 실패했습니다. 권한을 확인하세요.'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
currentEvent = data.event || {};
|
||||||
|
renderHead();
|
||||||
|
renderMeta();
|
||||||
|
renderActions();
|
||||||
|
renderLinks();
|
||||||
|
renderSnapshots(data.snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device NAMES, so the incident record says "1공장 프레스라인" and not "pi-a1" — the same
|
||||||
|
// provenance the stream row shows. A failure here is non-fatal: the raw id stands in.
|
||||||
|
function loadDevices() {
|
||||||
|
return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d || !Array.isArray(d.devices)) return;
|
||||||
|
d.devices.forEach(function (dev) { if (dev.name) deviceNames[dev.device_id] = dev.name; });
|
||||||
|
})
|
||||||
|
.catch(function () { /* raw id stands in */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCsrf();
|
||||||
|
wireActionForm();
|
||||||
|
// Names first so the record never paints an id and then swap it for a name a beat later.
|
||||||
|
loadDevices().then(function () {
|
||||||
|
return fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
if (!res.ok) throw new Error('detail_failed:' + res.status);
|
if (!res.ok) throw new Error('detail_failed:' + res.status);
|
||||||
|
|
@ -120,4 +388,5 @@
|
||||||
meta.textContent = '불러오기 실패';
|
meta.textContent = '불러오기 실패';
|
||||||
meta.removeAttribute('aria-busy');
|
meta.removeAttribute('aria-busy');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
248
server/public/assets/device-tree.js
Normal file
248
server/public/assets/device-tree.js
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
})();
|
||||||
240
server/public/assets/package.js
Normal file
240
server/public/assets/package.js
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
/**
|
||||||
|
* 사고조사 패키지.
|
||||||
|
*
|
||||||
|
* 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다:
|
||||||
|
* 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지,
|
||||||
|
* 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면
|
||||||
|
* 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다.
|
||||||
|
*
|
||||||
|
* 인쇄 CSS 가 앱 크롬을 걷어내므로 이 화면 그대로 PDF 로 저장해 제출할 수 있다.
|
||||||
|
*
|
||||||
|
* XSS-safe: 모든 동적 값은 textContent.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var B = window.APP_BASE || '';
|
||||||
|
var root = document.getElementById('package-root');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var m = location.pathname.match(/\/events\/(\d+)\/package/);
|
||||||
|
if (!m) { root.textContent = '잘못된 주소입니다.'; return; }
|
||||||
|
var id = m[1];
|
||||||
|
|
||||||
|
var TYPE_LABELS = {
|
||||||
|
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
||||||
|
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||||
|
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||||
|
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||||||
|
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
|
||||||
|
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
|
||||||
|
unacked_escalation: '미확인 경보 에스컬레이션',
|
||||||
|
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
||||||
|
};
|
||||||
|
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
|
||||||
|
var ALERT_STATUS = {
|
||||||
|
sent: '발송됨', pending: '발송 대기', failed: '발송 실패', suppressed: '억제됨 (정비 중)'
|
||||||
|
};
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (text !== undefined && text !== null) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
||||||
|
function stamp(ts) {
|
||||||
|
var d = new Date(Number(ts) * 1000);
|
||||||
|
if (isNaN(d.getTime())) return '-';
|
||||||
|
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
|
||||||
|
+ ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
||||||
|
}
|
||||||
|
function clockOnly(ts) {
|
||||||
|
var d = new Date(Number(ts) * 1000);
|
||||||
|
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title) {
|
||||||
|
var s = el('section', 'pkg-section');
|
||||||
|
s.appendChild(el('h2', 'pkg-h2', title));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function kv(parent, label, value) {
|
||||||
|
var row = el('div', 'pkg-kv');
|
||||||
|
row.appendChild(el('dt', null, label));
|
||||||
|
row.appendChild(el('dd', null, value === null || value === undefined || value === '' ? '-' : String(value)));
|
||||||
|
parent.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(d) {
|
||||||
|
var ev = d.event || {};
|
||||||
|
root.textContent = '';
|
||||||
|
|
||||||
|
// ---- 표지 -----------------------------------------------------------------
|
||||||
|
var head = el('header', 'pkg-head');
|
||||||
|
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
|
||||||
|
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
|
||||||
|
var sub = el('p', 'pkg-sub');
|
||||||
|
sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, ev.camera_id, stamp(ev.occurred_at)]
|
||||||
|
.filter(Boolean).join(' · ');
|
||||||
|
head.appendChild(sub);
|
||||||
|
root.appendChild(head);
|
||||||
|
|
||||||
|
// ---- 사건 개요 -------------------------------------------------------------
|
||||||
|
var facts = section('사건 개요');
|
||||||
|
var dl = el('dl', 'pkg-facts');
|
||||||
|
kv(dl, '사건 번호', ev.event_id);
|
||||||
|
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
|
||||||
|
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
|
||||||
|
kv(dl, '장치', ev.device_id);
|
||||||
|
kv(dl, '카메라', ev.camera_id);
|
||||||
|
kv(dl, '현장', ev.site);
|
||||||
|
kv(dl, '발생 시각', stamp(ev.occurred_at));
|
||||||
|
kv(dl, '지속', (ev.duration_s || 0) + '초');
|
||||||
|
kv(dl, '처리 상태', ev.false_positive_by ? ('오탐 판정 · ' + ev.false_positive_by)
|
||||||
|
: (ev.acknowledged_by ? ('확인 · ' + ev.acknowledged_by + ' · ' + stamp(ev.acknowledged_at))
|
||||||
|
: '미확인'));
|
||||||
|
// 대응 시간은 조사에서 가장 먼저 묻는 숫자다.
|
||||||
|
if (ev.acknowledged_at) {
|
||||||
|
var tta = Math.max(0, Math.round(ev.acknowledged_at - ev.occurred_at));
|
||||||
|
kv(dl, '대응 소요', tta < 60 ? tta + '초' : Math.floor(tta / 60) + '분 ' + (tta % 60) + '초');
|
||||||
|
}
|
||||||
|
facts.appendChild(dl);
|
||||||
|
root.appendChild(facts);
|
||||||
|
|
||||||
|
// ---- 증거 프레임 -----------------------------------------------------------
|
||||||
|
var evidence = section('증거 프레임');
|
||||||
|
var snaps = (d.snapshots || []).filter(function (s) { return s && s.file_uuid; });
|
||||||
|
if (!snaps.length) {
|
||||||
|
evidence.appendChild(el('p', 'pkg-empty',
|
||||||
|
'저장된 스냅샷이 없습니다 (미촬영이거나 개인정보 보존기간이 지나 삭제됨).'));
|
||||||
|
} else {
|
||||||
|
var strip = el('div', 'pkg-frames');
|
||||||
|
var CAPTIONS = ['이전', '직전', '발생 시점'];
|
||||||
|
snaps.forEach(function (s, i) {
|
||||||
|
var fig = el('figure', 'pkg-frame');
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||||||
|
img.alt = '사고 스냅샷';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
fig.appendChild(img);
|
||||||
|
fig.appendChild(el('figcaption', null,
|
||||||
|
snaps.length > 1 ? CAPTIONS[Math.max(0, CAPTIONS.length - snaps.length + i)] : '발생 시점'));
|
||||||
|
strip.appendChild(fig);
|
||||||
|
});
|
||||||
|
evidence.appendChild(strip);
|
||||||
|
evidence.appendChild(el('p', 'pkg-note',
|
||||||
|
'얼굴은 개인정보 보호를 위해 촬영 시점에 블러 처리되어 저장됩니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.'));
|
||||||
|
}
|
||||||
|
root.appendChild(evidence);
|
||||||
|
|
||||||
|
// ---- 전후 타임라인 ---------------------------------------------------------
|
||||||
|
var win = Math.round((d.context_window_s || 1800) / 60);
|
||||||
|
var tl = section('같은 카메라의 전후 ' + win + '분');
|
||||||
|
var rows = d.timeline || [];
|
||||||
|
if (!rows.length) {
|
||||||
|
tl.appendChild(el('p', 'pkg-empty', '해당 구간에 다른 감지가 없습니다.'));
|
||||||
|
} else {
|
||||||
|
var list = el('ol', 'pkg-timeline');
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var li = el('li', 'pkg-tl-row' + (r.event_id === ev.event_id ? ' is-subject' : ''));
|
||||||
|
li.appendChild(el('span', 'pkg-tl-time', clockOnly(r.occurred_at)));
|
||||||
|
li.appendChild(el('span', 'pkg-tl-sev sev-' + r.severity, SEV_LABELS[r.severity] || r.severity));
|
||||||
|
li.appendChild(el('span', 'pkg-tl-type', TYPE_LABELS[r.event_type] || r.event_type));
|
||||||
|
li.appendChild(el('span', 'pkg-tl-state',
|
||||||
|
r.false_positive_by ? '오탐' : (r.acknowledged_by ? '확인됨' : '미확인')));
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
tl.appendChild(list);
|
||||||
|
}
|
||||||
|
root.appendChild(tl);
|
||||||
|
|
||||||
|
// ---- 통보 이력 -------------------------------------------------------------
|
||||||
|
// 시스템이 "봤다"는 것만 증명하고 사람에게 "닿았는가"를 비워두면 조사의 절반이 빈다.
|
||||||
|
var notif = section('통보 이력');
|
||||||
|
var alerts = d.notifications || [];
|
||||||
|
if (!alerts.length) {
|
||||||
|
notif.appendChild(el('p', 'pkg-empty',
|
||||||
|
'이 사건에 대한 통보 기록이 없습니다. 통보 경로가 설정되지 않았거나, 심각도가 발송 기준에 못 미쳤습니다.'));
|
||||||
|
} else {
|
||||||
|
var nt = el('ul', 'pkg-alerts');
|
||||||
|
alerts.forEach(function (a) {
|
||||||
|
var li = el('li', 'pkg-alert is-' + a.status);
|
||||||
|
li.appendChild(el('span', 'pkg-alert-ch', (a.channel_name || '삭제된 채널')));
|
||||||
|
li.appendChild(el('span', 'pkg-alert-status', ALERT_STATUS[a.status] || a.status));
|
||||||
|
li.appendChild(el('span', 'pkg-alert-when',
|
||||||
|
a.sent_at ? stamp(a.sent_at) : ('접수 ' + stamp(a.queued_at))));
|
||||||
|
if (a.last_error) li.appendChild(el('span', 'pkg-alert-err', a.last_error));
|
||||||
|
nt.appendChild(li);
|
||||||
|
});
|
||||||
|
notif.appendChild(nt);
|
||||||
|
}
|
||||||
|
root.appendChild(notif);
|
||||||
|
|
||||||
|
// ---- 위험성평가 -------------------------------------------------------------
|
||||||
|
var risk = section('해당 위험요인 · 통제 대책');
|
||||||
|
if (!d.risk_control) {
|
||||||
|
risk.appendChild(el('p', 'pkg-empty',
|
||||||
|
'이 감지 유형에 등록된 위험요인이 없습니다. [안전 관리] 화면에서 등록하면 다음 조사부터 함께 실립니다.'));
|
||||||
|
} else {
|
||||||
|
var rc = d.risk_control;
|
||||||
|
var rdl = el('dl', 'pkg-facts');
|
||||||
|
kv(rdl, '위험요인', rc.hazard);
|
||||||
|
kv(rdl, '현재 대책', rc.control);
|
||||||
|
kv(rdl, '위험도', rc.risk_score + ' (강도 ' + rc.severity_rating + ' × 빈도 ' + rc.likelihood_rating + ')');
|
||||||
|
kv(rdl, '최종 검토', rc.reviewed_by ? (rc.reviewed_by + ' · ' + stamp(rc.reviewed_at)) : '-');
|
||||||
|
risk.appendChild(rdl);
|
||||||
|
}
|
||||||
|
root.appendChild(risk);
|
||||||
|
|
||||||
|
// ---- 조치 이력 -------------------------------------------------------------
|
||||||
|
var acts = section('이 카메라의 개선 조치 이력');
|
||||||
|
var actions = d.actions || [];
|
||||||
|
if (!actions.length) {
|
||||||
|
acts.appendChild(el('p', 'pkg-empty', '기록된 조치가 없습니다.'));
|
||||||
|
} else {
|
||||||
|
var al = el('ul', 'pkg-actions');
|
||||||
|
actions.forEach(function (a) {
|
||||||
|
var li = el('li', null);
|
||||||
|
li.appendChild(el('span', 'pkg-act-date', stamp(a.taken_at).slice(0, 10)));
|
||||||
|
li.appendChild(el('span', 'pkg-act-note', a.note));
|
||||||
|
li.appendChild(el('span', 'pkg-act-by', a.created_by));
|
||||||
|
al.appendChild(li);
|
||||||
|
});
|
||||||
|
acts.appendChild(al);
|
||||||
|
}
|
||||||
|
root.appendChild(acts);
|
||||||
|
|
||||||
|
// ---- 서명 -----------------------------------------------------------------
|
||||||
|
var sign = section('확인');
|
||||||
|
var grid = el('div', 'pkg-sign');
|
||||||
|
['조사자', '안전관리자', '사업주 · 대리인'].forEach(function (role) {
|
||||||
|
var box = el('div', 'pkg-sign-box');
|
||||||
|
box.appendChild(el('span', 'pkg-sign-role', role));
|
||||||
|
box.appendChild(el('span', 'pkg-sign-line', ''));
|
||||||
|
grid.appendChild(box);
|
||||||
|
});
|
||||||
|
sign.appendChild(grid);
|
||||||
|
root.appendChild(sign);
|
||||||
|
|
||||||
|
var foot = el('footer', 'pkg-foot');
|
||||||
|
foot.textContent = '생성 ' + stamp(d.generated_at) + ' · 작성 ' + (d.generated_by || '-')
|
||||||
|
+ ' · 본 문서는 AI 감지 기록에 근거한 보조 자료이며 공식 재해조사 결과를 대체하지 않습니다.';
|
||||||
|
root.appendChild(foot);
|
||||||
|
|
||||||
|
document.title = '사고조사 #' + ev.event_id + ' · AI SAFETY CCTV';
|
||||||
|
}
|
||||||
|
|
||||||
|
var printBtn = document.getElementById('package-print');
|
||||||
|
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
|
||||||
|
|
||||||
|
fetch(B + '/api/events/' + encodeURIComponent(id) + '/package', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
if (r.status === 404) { root.textContent = '해당 사건을 찾을 수 없습니다.'; return null; }
|
||||||
|
if (!r.ok) throw new Error('package_failed');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (d) { if (d) render(d); })
|
||||||
|
.catch(function () { root.textContent = '패키지를 불러오지 못했습니다.'; });
|
||||||
|
})();
|
||||||
405
server/public/assets/report.js
Normal file
405
server/public/assets/report.js
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
})();
|
||||||
168
server/public/assets/setup.js
Normal file
168
server/public/assets/setup.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* 설치 점검 (온보딩).
|
||||||
|
*
|
||||||
|
* 납품 직후가 이 제품에서 가장 위험한 구간이다. 대시보드는 멀쩡히 뜨고, 로그인도 되고,
|
||||||
|
* 화면 어디에도 오류가 없는데 — 장치가 등록되지 않았거나, 존을 안 그렸거나, 통보 경로가
|
||||||
|
* 비어 있으면 시스템은 "감시 중"으로 보이면서 실제로는 아무것도 하지 않는다. 그 상태가
|
||||||
|
* 몇 주씩 유지되다가 사고가 나서야 발견되는 것이 최악의 시나리오다.
|
||||||
|
*
|
||||||
|
* 그래서 각 단계는 "설정했다고 표시"가 아니라 **실제 데이터를 조회해** 판정한다. 체크가
|
||||||
|
* 켜졌다면 그 단계는 정말로 동작하고 있다.
|
||||||
|
*
|
||||||
|
* XSS-safe: 모든 동적 값은 textContent.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var B = window.APP_BASE || '';
|
||||||
|
var listEl = document.getElementById('setup-steps');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (text !== undefined && text !== null) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJson(path) {
|
||||||
|
return fetch(B + path, { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
|
// 403 은 "권한 없음"이지 "미설정"이 아니다. 단계는 판정 보류로 남긴다.
|
||||||
|
if (r.status === 403) return { _forbidden: true };
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.catch(function () { return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(steps) {
|
||||||
|
listEl.textContent = '';
|
||||||
|
steps.forEach(function (s, i) {
|
||||||
|
var li = el('li', 'setup-step is-' + s.state);
|
||||||
|
var mark = el('span', 'setup-mark');
|
||||||
|
mark.textContent = s.state === 'ok' ? '✓' : (s.state === 'unknown' ? '?' : String(i + 1));
|
||||||
|
mark.setAttribute('aria-hidden', 'true');
|
||||||
|
li.appendChild(mark);
|
||||||
|
|
||||||
|
var body = el('div', 'setup-body');
|
||||||
|
body.appendChild(el('h2', 'setup-title', s.title));
|
||||||
|
body.appendChild(el('p', 'setup-status', s.status));
|
||||||
|
// 이 단계가 안 되어 있으면 무엇이 벌어지는가 — 체크리스트를 지시가 아니라 이유로 만든다.
|
||||||
|
if (s.state !== 'ok' && s.why) body.appendChild(el('p', 'setup-why', s.why));
|
||||||
|
if (s.href) {
|
||||||
|
var a = el('a', 'setup-link', s.linkText || '설정하러 가기 →');
|
||||||
|
a.href = B + s.href;
|
||||||
|
body.appendChild(a);
|
||||||
|
}
|
||||||
|
li.appendChild(body);
|
||||||
|
listEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
getJson('/api/devices'),
|
||||||
|
getJson('/api/events?limit=200'),
|
||||||
|
getJson('/api/admin/channels')
|
||||||
|
]).then(function (res) {
|
||||||
|
var devicesRes = res[0] || {};
|
||||||
|
var eventsRes = res[1] || {};
|
||||||
|
var channelsRes = res[2] || {};
|
||||||
|
|
||||||
|
var devices = devicesRes.devices || [];
|
||||||
|
var online = devices.filter(function (d) { return d.online; });
|
||||||
|
var events = eventsRes.events || [];
|
||||||
|
var cameras = {};
|
||||||
|
events.forEach(function (e) { if (e.camera_id) cameras[e.camera_id] = true; });
|
||||||
|
var cameraCount = Object.keys(cameras).length;
|
||||||
|
|
||||||
|
var steps = [];
|
||||||
|
|
||||||
|
// 1. 장치 등록
|
||||||
|
steps.push(devices.length === 0 ? {
|
||||||
|
state: 'todo', title: '① 장치 등록',
|
||||||
|
status: '등록된 엣지 장치가 없습니다.',
|
||||||
|
why: '장치 행이 없으면 현장 노드가 보내는 이벤트는 인증에서 거부됩니다. 장치별 비밀키는 관리자가 DB에 직접 발급합니다 (dist/PROVISION_SECRETS.md).',
|
||||||
|
} : {
|
||||||
|
state: 'ok', title: '① 장치 등록',
|
||||||
|
status: devices.length + '대 등록됨 · 현재 온라인 ' + online.length + '대'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 장치 통신 (하트비트)
|
||||||
|
steps.push(online.length === 0 ? {
|
||||||
|
state: devices.length ? 'todo' : 'blocked', title: '② 장치 통신',
|
||||||
|
status: devices.length ? '하트비트를 보내는 장치가 없습니다.' : '장치 등록 후 확인할 수 있습니다.',
|
||||||
|
why: '장치가 서버에 닿지 못하면 감지는 노드 안에서만 일어나고 대시보드는 영원히 비어 있습니다. 노드의 CAFE24_URL·DEVICE_SECRET 과 방화벽을 확인하세요.',
|
||||||
|
} : {
|
||||||
|
state: 'ok', title: '② 장치 통신', status: online.length + '대가 하트비트를 보내고 있습니다.'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 카메라 감지
|
||||||
|
steps.push(cameraCount === 0 ? {
|
||||||
|
state: online.length ? 'todo' : 'blocked', title: '③ 카메라 감지',
|
||||||
|
status: online.length ? '아직 어떤 카메라도 이벤트를 보낸 적이 없습니다.' : '장치 통신 후 확인할 수 있습니다.',
|
||||||
|
why: '카메라가 붙어 있어도 이벤트가 한 번도 없으면 실제로 사람을 검출하고 있는지 알 수 없습니다. 감시 구역에서 한 번 앉았다 일어나 보고, 그 사건이 스트림에 뜨는지 확인하세요.',
|
||||||
|
href: '/', linkText: '감지 스트림 보기 →'
|
||||||
|
} : {
|
||||||
|
state: 'ok', title: '③ 카메라 감지', status: cameraCount + '대의 카메라가 이벤트를 보냈습니다.'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 위험구역
|
||||||
|
getJson('/api/zones/' + encodeURIComponent(Object.keys(cameras)[0] || '_none_'))
|
||||||
|
.then(function (zoneRes) {
|
||||||
|
var zones = (zoneRes && zoneRes.zones) || [];
|
||||||
|
steps.push(zones.length === 0 ? {
|
||||||
|
state: cameraCount ? 'todo' : 'blocked', title: '④ 위험구역 설정',
|
||||||
|
status: cameraCount ? '첫 카메라에 저장된 구역이 없습니다.' : '카메라 감지 후 설정할 수 있습니다.',
|
||||||
|
why: '구역이 없으면 넘어짐·부동 감지는 동작하지만 제한구역 침입·협착·2인1조 같은 규칙은 전부 비활성입니다.',
|
||||||
|
href: '/zones', linkText: '구역 설정으로 →'
|
||||||
|
} : {
|
||||||
|
state: 'ok', title: '④ 위험구역 설정',
|
||||||
|
status: '첫 카메라에 구역 ' + zones.length + '개가 설정되어 있습니다.'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 통보 경로 — 이 제품에서 가장 자주 비어 있는 칸이다.
|
||||||
|
if (channelsRes._forbidden) {
|
||||||
|
steps.push({
|
||||||
|
state: 'unknown', title: '⑤ 통보 경로',
|
||||||
|
status: '이 계정에는 통보 설정 권한이 없습니다. 관리자 계정으로 확인하세요.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var chans = (channelsRes.channels || []).filter(function (c) { return c.enabled; });
|
||||||
|
var local = chans.filter(function (c) { return c.kind === 'local'; });
|
||||||
|
steps.push(chans.length === 0 ? {
|
||||||
|
state: 'todo', title: '⑤ 통보 경로',
|
||||||
|
status: '사용 중인 통보 경로가 없습니다.',
|
||||||
|
why: '지금 상태에서는 감지가 되어도 브라우저를 보고 있는 사람에게만 전달됩니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다. 현장 경광등(엣지 GPIO)부터 설정하세요 — 인터넷이 끊겨도 동작합니다.',
|
||||||
|
href: '/admin', linkText: '통보 경로 설정 →'
|
||||||
|
} : {
|
||||||
|
state: local.length ? 'ok' : 'warn', title: '⑤ 통보 경로',
|
||||||
|
status: chans.length + '개 사용 중' + (local.length ? ' (현장 경광등 포함)' : ' — 현장 경광등 없음'),
|
||||||
|
why: local.length ? null : '메일·웹훅은 서버와 네트워크가 살아 있을 때만 닿습니다. 정전이나 회선 장애 때도 울릴 경로가 하나는 있어야 합니다.',
|
||||||
|
href: '/admin', linkText: '통보 경로 보기 →'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 통보 디스패처 — 서버 상태로는 알 수 없다. 정직하게 '확인 필요'로 남긴다.
|
||||||
|
steps.push({
|
||||||
|
state: 'unknown', title: '⑥ 통보 디스패처 (크론)',
|
||||||
|
status: '이 화면에서는 확인할 수 없습니다.',
|
||||||
|
why: '메일·웹훅 통보와 미확인 에스컬레이션은 tools/dispatch_alerts.php 가 크론으로 돌아야 동작합니다. 호스팅 관리자 화면의 크론 설정에서 1분 주기로 등록되어 있는지 직접 확인하세요. (--dry-run 으로 먼저 시험할 수 있습니다.)'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 위험성평가
|
||||||
|
getJson('/api/risks').then(function (riskRes) {
|
||||||
|
var controls = (riskRes && riskRes.controls) || [];
|
||||||
|
steps.push(controls.length === 0 ? {
|
||||||
|
state: 'todo', title: '⑦ 위험성평가 연계',
|
||||||
|
status: '등록된 위험요인이 없습니다.',
|
||||||
|
why: '없어도 감지는 동작합니다. 다만 사고조사 패키지와 관리체계 증빙에서 "무엇을 통제하고 있었나" 칸이 비게 됩니다.',
|
||||||
|
href: '/compliance', linkText: '안전 관리로 →'
|
||||||
|
} : {
|
||||||
|
state: 'ok', title: '⑦ 위험성평가 연계', status: controls.length + '개 항목이 등록되어 있습니다.'
|
||||||
|
});
|
||||||
|
render(steps);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -33,17 +33,31 @@
|
||||||
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: '#eab308', label: '경고' },
|
warning: { stroke: '#b7820a', fill: 'rgba(183,130,10,.16)', label: '경고', dash: null },
|
||||||
restricted: { stroke: '#dc2626', label: '제한' },
|
restricted: { stroke: '#dc2626', fill: 'rgba(220,38,38,.16)', label: '제한', dash: null },
|
||||||
pinch: { stroke: '#a855f7', label: '협착' }
|
pinch: { stroke: '#e2560c', fill: 'rgba(226,86,12,.18)', label: '협착', dash: [9, 5] }
|
||||||
};
|
};
|
||||||
|
var DRAFT_STYLE = { stroke: '#0b6b7a', fill: 'rgba(11,107,122,.14)', label: '편집중', dash: [5, 4] };
|
||||||
|
|
||||||
var backgroundImage = null;
|
var backgroundImage = null;
|
||||||
var existingZones = []; // [{zone_id, level, points: [[x,y],...]}]
|
var existingZones = []; // [{zone_id, level, points: [[x,y],...]}]
|
||||||
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;
|
||||||
|
|
@ -72,25 +86,120 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawPolygon(points, strokeStyle, label, closed) {
|
function drawPolygon(points, style, label, closed) {
|
||||||
if (points.length === 0) return;
|
if (points.length === 0) return;
|
||||||
ctx.strokeStyle = strokeStyle;
|
ctx.save();
|
||||||
ctx.fillStyle = strokeStyle;
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(points[0][0], points[0][1]);
|
ctx.moveTo(points[0][0], points[0][1]);
|
||||||
for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||||
if (closed && points.length > 2) ctx.closePath();
|
if (closed && points.length > 2) ctx.closePath();
|
||||||
|
// Fill first so the stroke sits on top of its own tint.
|
||||||
|
if (closed && points.length > 2 && style.fill) {
|
||||||
|
ctx.fillStyle = style.fill;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = style.stroke;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
if (style.dash) ctx.setLineDash(style.dash);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
// Vertex handles: white-cored so they stay visible on both a bright floor and a dark shadow.
|
||||||
points.forEach(function (p) {
|
points.forEach(function (p) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p[0], p[1], 3, 0, Math.PI * 2);
|
ctx.arc(p[0], p[1], 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = style.stroke;
|
||||||
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
if (label) {
|
if (label) drawLabelChip(label, points[0][0], points[0][1], style.stroke);
|
||||||
ctx.font = '14px sans-serif';
|
ctx.restore();
|
||||||
ctx.fillText(label, points[0][0] + 6, points[0][1] - 6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A plain fillText label vanished into whatever the camera happened to be looking at.
|
||||||
|
// Draw it on its own solid chip so the level is legible over any frame.
|
||||||
|
function drawLabelChip(text, x, y, bg) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '600 13px system-ui, sans-serif';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
var padX = 7, h = 20;
|
||||||
|
var w = ctx.measureText(text).width + padX * 2;
|
||||||
|
var cx = Math.min(Math.max(x + 8, 2), canvas.width - w - 2);
|
||||||
|
var cy = Math.min(Math.max(y - 14, h / 2 + 2), canvas.height - h / 2 - 2);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
if (ctx.roundRect) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(cx, cy - h / 2, w, h, 5);
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(cx, cy - h / 2, w, h);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillText(text, cx + padX, cy);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 위험 히트맵 오버레이 -------------------------------------------------------
|
||||||
|
// 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() {
|
||||||
|
|
@ -98,34 +207,179 @@
|
||||||
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: '#9ca3af', label: z.level };
|
var style = LEVEL_STYLE[z.level] || { stroke: '#626a79', fill: 'rgba(98,106,121,.14)', label: z.level, dash: null };
|
||||||
drawPolygon(z.points, style.stroke, style.label, true);
|
drawPolygon(z.points, style, style.label, true);
|
||||||
});
|
});
|
||||||
var current = LEVEL_STYLE[levelSelect.value] || { stroke: '#38bdf8', label: '' };
|
drawPolygon(currentPoints, DRAFT_STYLE, currentPoints.length ? DRAFT_STYLE.label : '', false);
|
||||||
drawPolygon(currentPoints, current.stroke, currentPoints.length ? '편집중' : '', false);
|
if (!backgroundImage && !existingZones.length && !currentPoints.length) drawCanvasHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty black rectangle told the operator nothing about what to do with it.
|
||||||
|
function drawCanvasHint() {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,.5)';
|
||||||
|
ctx.font = '500 15px system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('① 왼쪽에서 카메라를 고르고 "카메라 화면을 배경으로"를 누르세요.',
|
||||||
|
canvas.width / 2, canvas.height / 2 - 10);
|
||||||
|
ctx.fillText('② 그다음 이 위를 클릭해 구역 폴리곤을 그립니다.',
|
||||||
|
canvas.width / 2, canvas.height / 2 + 16);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire).
|
||||||
|
// 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');
|
||||||
var style = LEVEL_STYLE[z.level] || { label: z.level };
|
li.className = 'zone-item lv-' + z.level;
|
||||||
|
|
||||||
var label = document.createElement('span');
|
var label = document.createElement('span');
|
||||||
label.textContent = style.label + ' (' + z.points.length + '점)';
|
label.className = 'zone-item-label';
|
||||||
|
var swatch = document.createElement('span');
|
||||||
|
swatch.className = 'zone-swatch';
|
||||||
|
swatch.setAttribute('aria-hidden', 'true');
|
||||||
|
// Inline colour is the ONE place a raw hex belongs: it must match what the canvas drew.
|
||||||
|
swatch.style.background = (LEVEL_STYLE[z.level] || {}).fill || 'transparent';
|
||||||
|
swatch.style.borderColor = (LEVEL_STYLE[z.level] || {}).stroke || '#626a79';
|
||||||
|
label.appendChild(swatch);
|
||||||
|
label.appendChild(document.createTextNode(
|
||||||
|
(LEVEL_STYLE[z.level] || { label: z.level }).label + ' (' + z.points.length + '점)' + rulesLabel(z)));
|
||||||
li.appendChild(label);
|
li.appendChild(label);
|
||||||
|
|
||||||
|
// Two-step delete. A zone is what makes 침입 alarms fire; it takes minutes to draw and
|
||||||
|
// the server has no restore. A single unconfirmed click was the least reversible action
|
||||||
|
// in the product and also the cheapest one to trigger by accident.
|
||||||
var del = document.createElement('button');
|
var del = document.createElement('button');
|
||||||
del.type = 'button';
|
del.type = 'button';
|
||||||
|
del.className = 'zone-del';
|
||||||
del.textContent = '삭제';
|
del.textContent = '삭제';
|
||||||
del.addEventListener('click', function () { deleteZone(z.zone_id); });
|
var armed = false, armTimer = null;
|
||||||
|
del.addEventListener('click', function () {
|
||||||
|
if (!armed) {
|
||||||
|
armed = true;
|
||||||
|
del.classList.add('is-armed');
|
||||||
|
del.textContent = '정말 삭제?';
|
||||||
|
setStatus('한 번 더 누르면 이 구역이 삭제됩니다. 되돌릴 수 없습니다.');
|
||||||
|
armTimer = setTimeout(function () {
|
||||||
|
armed = false; del.classList.remove('is-armed'); del.textContent = '삭제';
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (armTimer) clearTimeout(armTimer);
|
||||||
|
deleteZone(z.zone_id);
|
||||||
|
});
|
||||||
li.appendChild(del);
|
li.appendChild(del);
|
||||||
|
|
||||||
listEl.appendChild(li);
|
listEl.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 장치 · 카메라 선택기 ---------------------------------------------------------
|
||||||
|
// The editor used to demand two free-text ids the operator had to remember, while the
|
||||||
|
// events console already knew both. Devices come from /api/devices; the cameras under each
|
||||||
|
// device are derived from the events they have reported (there is no camera registry).
|
||||||
|
// A camera that has never sent an event cannot appear here, which is what the manual
|
||||||
|
// fallback in the view is for.
|
||||||
|
var deviceSelect = document.getElementById('device-select');
|
||||||
|
var cameraSelect = document.getElementById('camera-select');
|
||||||
|
var manualBox = document.getElementById('zone-manual');
|
||||||
|
var camerasByDevice = {};
|
||||||
|
|
||||||
|
function option(value, text) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = value;
|
||||||
|
o.textContent = text;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCameraSelect(deviceId) {
|
||||||
|
if (!cameraSelect) return;
|
||||||
|
cameraSelect.textContent = '';
|
||||||
|
var cams = camerasByDevice[deviceId] || [];
|
||||||
|
if (!deviceId) { cameraSelect.appendChild(option('', '장치를 먼저 선택하세요')); return; }
|
||||||
|
if (cams.length === 0) {
|
||||||
|
cameraSelect.appendChild(option('', '이 장치가 보고한 카메라 없음'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cameraSelect.appendChild(option('', '카메라를 선택하세요'));
|
||||||
|
cams.forEach(function (c) { cameraSelect.appendChild(option(c, c)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelection() {
|
||||||
|
if (deviceSelect) deviceInput.value = deviceSelect.value;
|
||||||
|
if (cameraSelect) cameraInput.value = cameraSelect.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPickers() {
|
||||||
|
var devices = fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; });
|
||||||
|
var events = fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; });
|
||||||
|
return Promise.all([devices, events]).then(function (res) {
|
||||||
|
var devs = (res[0] && res[0].devices) || [];
|
||||||
|
var evs = (res[1] && res[1].events) || [];
|
||||||
|
evs.forEach(function (ev) {
|
||||||
|
if (!ev.camera_id) return;
|
||||||
|
var d = String(ev.device_id || '');
|
||||||
|
if (!camerasByDevice[d]) camerasByDevice[d] = [];
|
||||||
|
if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id);
|
||||||
|
});
|
||||||
|
Object.keys(camerasByDevice).forEach(function (d) { camerasByDevice[d].sort(); });
|
||||||
|
|
||||||
|
if (!deviceSelect) return;
|
||||||
|
deviceSelect.textContent = '';
|
||||||
|
if (devs.length === 0) {
|
||||||
|
deviceSelect.appendChild(option('', '등록된 장치가 없습니다'));
|
||||||
|
if (manualBox) manualBox.open = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deviceSelect.appendChild(option('', '장치를 선택하세요'));
|
||||||
|
devs.forEach(function (d) {
|
||||||
|
deviceSelect.appendChild(option(d.device_id, (d.name || d.device_id) + ' · ' + d.device_id));
|
||||||
|
});
|
||||||
|
// Honour a deep link (?device=&camera=) or a value already typed into the manual fields.
|
||||||
|
if (deviceInput.value) {
|
||||||
|
deviceSelect.value = deviceInput.value;
|
||||||
|
fillCameraSelect(deviceInput.value);
|
||||||
|
if (cameraInput.value) cameraSelect.value = cameraInput.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadBackground(uuid) {
|
function loadBackground(uuid) {
|
||||||
if (!uuid) { backgroundImage = null; redraw(); return; }
|
if (!uuid) { backgroundImage = null; redraw(); return; }
|
||||||
var img = new Image();
|
var img = new Image();
|
||||||
|
|
@ -138,7 +392,7 @@
|
||||||
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
|
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
|
||||||
function loadLatestFrame() {
|
function loadLatestFrame() {
|
||||||
var camera = cameraInput.value.trim();
|
var camera = cameraInput.value.trim();
|
||||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
|
||||||
setStatus('현재 화면을 불러오는 중...');
|
setStatus('현재 화면을 불러오는 중...');
|
||||||
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
|
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
|
|
@ -150,9 +404,9 @@
|
||||||
.catch(function (e) { setStatus(e.message); });
|
.catch(function (e) { setStatus(e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadZones() {
|
function loadZones(doneMsg) {
|
||||||
var camera = cameraInput.value.trim();
|
var camera = cameraInput.value.trim();
|
||||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
|
||||||
setStatus('불러오는 중...');
|
setStatus('불러오는 중...');
|
||||||
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
|
|
@ -163,12 +417,14 @@
|
||||||
.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) };
|
return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z), rules_json: z.rules_json };
|
||||||
});
|
});
|
||||||
currentPoints = [];
|
currentPoints = [];
|
||||||
renderZoneList();
|
renderZoneList();
|
||||||
redraw();
|
redraw();
|
||||||
setStatus('구역 ' + existingZones.length + '개를 불러왔습니다.');
|
setStatus(doneMsg || (existingZones.length
|
||||||
|
? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.'
|
||||||
|
: '이 카메라에는 저장된 구역이 없습니다. 캔버스를 클릭해 새로 그리세요.'));
|
||||||
})
|
})
|
||||||
.catch(function () { setStatus('불러오기에 실패했습니다.'); });
|
.catch(function () { setStatus('불러오기에 실패했습니다.'); });
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +432,7 @@
|
||||||
function saveZone() {
|
function saveZone() {
|
||||||
var camera = cameraInput.value.trim();
|
var camera = cameraInput.value.trim();
|
||||||
var device = deviceInput.value.trim();
|
var device = deviceInput.value.trim();
|
||||||
if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; }
|
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
|
||||||
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
|
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
|
||||||
setStatus('저장 중...');
|
setStatus('저장 중...');
|
||||||
fetch(B + '/api/zones', {
|
fetch(B + '/api/zones', {
|
||||||
|
|
@ -190,7 +446,8 @@
|
||||||
// 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) {
|
||||||
|
|
@ -201,8 +458,7 @@
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
currentPoints = [];
|
currentPoints = [];
|
||||||
setStatus('저장되었습니다.');
|
loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.');
|
||||||
loadZones();
|
|
||||||
})
|
})
|
||||||
.catch(function () { setStatus('저장에 실패했습니다.'); });
|
.catch(function () { setStatus('저장에 실패했습니다.'); });
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +471,7 @@
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.status === 401) { window.location.href = B + '/login'; return; }
|
if (res.status === 401) { window.location.href = B + '/login'; return; }
|
||||||
if (!res.ok) throw new Error('delete_failed:' + res.status);
|
if (!res.ok) throw new Error('delete_failed:' + res.status);
|
||||||
loadZones();
|
loadZones('구역을 삭제했습니다.');
|
||||||
})
|
})
|
||||||
.catch(function () { setStatus('삭제에 실패했습니다.'); });
|
.catch(function () { setStatus('삭제에 실패했습니다.'); });
|
||||||
}
|
}
|
||||||
|
|
@ -231,15 +487,49 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
levelSelect.addEventListener('change', redraw);
|
levelSelect.addEventListener('change', redraw);
|
||||||
loadBtn.addEventListener('click', loadZones);
|
loadBtn.addEventListener('click', function () { applySelection(); loadZones(); });
|
||||||
if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame);
|
if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); });
|
||||||
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); });
|
if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); });
|
||||||
saveBtn.addEventListener('click', saveZone);
|
if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); });
|
||||||
|
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();
|
||||||
if (cameraInput.value) loadZones();
|
loadPickers();
|
||||||
|
renderZoneList(); // paint the "저장된 구역 없음" placeholder before any camera is chosen
|
||||||
|
redraw();
|
||||||
|
if (cameraInput.value) { loadZones(); loadLatestFrame(); }
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,15 @@ use App\Db\Database;
|
||||||
use App\Auth\PhpSessionStore;
|
use App\Auth\PhpSessionStore;
|
||||||
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
||||||
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
||||||
PdoThresholdRepository, PdoAuditRepository};
|
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
|
||||||
|
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
|
||||||
use App\Ingest\IngestController;
|
use App\Ingest\IngestController;
|
||||||
use App\Api\{ConfigController, HeartbeatController};
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
use App\Auth\{AuthController, CsrfController};
|
use App\Auth\{AuthController, CsrfController};
|
||||||
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
|
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
|
||||||
DeviceStatusController};
|
DeviceStatusController, HeatmapController, FpStatsController, ReportController,
|
||||||
|
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
|
||||||
|
|
@ -84,21 +87,37 @@ $users = new PdoUserRepository($pdo);
|
||||||
$zones = new PdoZoneRepository($pdo);
|
$zones = new PdoZoneRepository($pdo);
|
||||||
$thr = new PdoThresholdRepository($pdo);
|
$thr = new PdoThresholdRepository($pdo);
|
||||||
$audit = new PdoAuditRepository($pdo);
|
$audit = new PdoAuditRepository($pdo);
|
||||||
|
$suppressions = new PdoSuppressionRepository($pdo);
|
||||||
|
$alerts = new PdoAlertRepository($pdo);
|
||||||
|
$handovers = new PdoHandoverRepository($pdo);
|
||||||
|
$risks = new PdoRiskControlRepository($pdo);
|
||||||
|
$metrics = new PdoDeviceMetricRepository($pdo);
|
||||||
|
$actions = new PdoActionRepository($pdo);
|
||||||
|
|
||||||
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
|
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
|
||||||
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate);
|
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
|
||||||
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
|
$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate, $metrics);
|
||||||
$auth = new AuthController($users, $audit, $rate);
|
$auth = new AuthController($users, $audit, $rate);
|
||||||
$csrfCtl = new CsrfController();
|
$csrfCtl = new CsrfController();
|
||||||
$list = new EventListController($events, $snaps, $sess);
|
$list = new EventListController($events, $snaps, $sess);
|
||||||
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
||||||
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||||
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
||||||
$deviceStatus = new DeviceStatusController($devices, $sess);
|
$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg);
|
||||||
|
$heatmap = new HeatmapController($events, $sess);
|
||||||
|
$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';
|
||||||
return Response::text((string)ob_get_clean());
|
// Security headers travel with the HTML response (not only the packaged .htaccess) so the
|
||||||
|
// 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();
|
||||||
|
|
@ -107,20 +126,49 @@ $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($sess));
|
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($q, $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();
|
||||||
|
|
|
||||||
|
|
@ -94,12 +94,32 @@ 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),
|
||||||
|
|
@ -125,8 +145,12 @@ 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
|
||||||
|
|
@ -140,7 +164,14 @@ 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;
|
||||||
|
|
@ -181,6 +212,8 @@ CREATE TABLE move_thresholds (
|
||||||
zone_id CHAR(32) NULL,
|
zone_id CHAR(32) NULL,
|
||||||
`key` VARCHAR(64) NOT NULL,
|
`key` VARCHAR(64) NOT NULL,
|
||||||
`value` VARCHAR(255) NOT NULL,
|
`value` VARCHAR(255) NOT NULL,
|
||||||
|
updated_by VARCHAR(190) NULL, -- 편집 UI(관리자 전용)가 남기는 감사 흔적
|
||||||
|
updated_at BIGINT UNSIGNED NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY ix_thresholds_device (device_id),
|
KEY ix_thresholds_device (device_id),
|
||||||
UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`)
|
UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`)
|
||||||
|
|
@ -219,9 +252,15 @@ CREATE TABLE move_rate_limits (
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- audit_log: append-only action log (acknowledge / zone save+delete / login).
|
-- audit_log: append-only action log (acknowledge / zone save+delete / login /
|
||||||
-- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the
|
-- threshold edit / suppression / handover).
|
||||||
-- Task 16 brief self-review -- this table is the plain append-only record only.
|
--
|
||||||
|
-- TAMPER-EVIDENT: each row carries prev_hash (the previous row's entry_hash) and
|
||||||
|
-- entry_hash = sha256(id|actor|action|target|before|after|ts|prev_hash). Editing or
|
||||||
|
-- deleting a row breaks every hash after it, so a doctored log is DETECTABLE even
|
||||||
|
-- though shared hosting cannot grant append-only rights at the DB level. Verified by
|
||||||
|
-- `php tools/verify_audit_chain.php`. Rows written before the chain existed keep NULL
|
||||||
|
-- hashes and the verifier reports them as the pre-chain segment rather than as damage.
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
CREATE TABLE move_audit_log (
|
CREATE TABLE move_audit_log (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|
@ -231,9 +270,148 @@ CREATE TABLE move_audit_log (
|
||||||
before_json JSON NOT NULL,
|
before_json JSON NOT NULL,
|
||||||
after_json JSON NOT NULL,
|
after_json JSON NOT NULL,
|
||||||
ts BIGINT UNSIGNED NOT NULL,
|
ts BIGINT UNSIGNED NOT NULL,
|
||||||
|
prev_hash CHAR(64) NULL,
|
||||||
|
entry_hash CHAR(64) NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY ix_audit_log_ts (ts),
|
KEY ix_audit_log_ts (ts),
|
||||||
KEY ix_audit_log_actor (actor)
|
KEY ix_audit_log_actor (actor),
|
||||||
|
KEY ix_audit_log_entry_hash (entry_hash)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- suppressions: planned-work alarm suppression (정비 모드 / 작업허가).
|
||||||
|
-- Detection is NOT paused -- events are still ingested and still appear in the console
|
||||||
|
-- and in reports. Only NOTIFICATION is withheld, and every suppression carries a
|
||||||
|
-- mandatory reason, a hard expiry (no open-ended suppression) and an audit entry.
|
||||||
|
-- Rationale: the single largest source of false alarms is planned maintenance, and a
|
||||||
|
-- console that cries wolf during every shutdown is a console the site switches off.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE move_suppressions (
|
||||||
|
suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
|
||||||
|
scope_id VARCHAR(64) NOT NULL,
|
||||||
|
site VARCHAR(64) NULL,
|
||||||
|
reason VARCHAR(500) NOT NULL,
|
||||||
|
starts_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
ends_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_by VARCHAR(190) NOT NULL,
|
||||||
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
revoked_by VARCHAR(190) NULL,
|
||||||
|
revoked_at BIGINT UNSIGNED NULL,
|
||||||
|
PRIMARY KEY (suppression_id),
|
||||||
|
KEY ix_suppressions_window (ends_at, starts_at),
|
||||||
|
KEY ix_suppressions_scope (scope_type, scope_id),
|
||||||
|
KEY ix_suppressions_site (site),
|
||||||
|
CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- alert_channels / alert_log: how a detection reaches a human.
|
||||||
|
-- Air-gapped sites cannot use FCM/Slack, so the channels are the ones a closed network
|
||||||
|
-- actually has: `local` (the edge node's own beacon/buzzer over GPIO -- the only path
|
||||||
|
-- that needs no network at all), `email` (in-house SMTP relay) and `webhook` (in-house
|
||||||
|
-- groupware / SMS gateway). step>0 channels fire only after escalate_after_s of the
|
||||||
|
-- event still being unacknowledged, which is what turns "we detected it" into "someone
|
||||||
|
-- was made to look at it".
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE move_alert_channels (
|
||||||
|
channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
site VARCHAR(64) NULL,
|
||||||
|
kind VARCHAR(16) NOT NULL,
|
||||||
|
name VARCHAR(190) NOT NULL,
|
||||||
|
config_json JSON NOT NULL, -- recipients / url / gpio pin. NEVER credentials.
|
||||||
|
min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
step INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (channel_id),
|
||||||
|
KEY ix_alert_channels_site (site, enabled),
|
||||||
|
CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
|
||||||
|
CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE move_alert_log (
|
||||||
|
alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
event_id INT UNSIGNED NOT NULL,
|
||||||
|
channel_id INT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL,
|
||||||
|
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
last_error VARCHAR(500) NULL,
|
||||||
|
queued_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
sent_at BIGINT UNSIGNED NULL,
|
||||||
|
PRIMARY KEY (alert_id),
|
||||||
|
-- The dispatcher runs from cron and overlapping runs are expected; this unique key is
|
||||||
|
-- the only thing standing between a site and the same fall being phoned in five times.
|
||||||
|
UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
|
||||||
|
KEY ix_alert_log_status (status, queued_at),
|
||||||
|
CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
|
||||||
|
CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- shift_handovers: who took responsibility for what, and when.
|
||||||
|
-- The 24h brief tells an operator what happened; it does not record that anyone
|
||||||
|
-- accepted the backlog. Without that, unacknowledged events evaporate across a shift
|
||||||
|
-- change with nobody accountable for them.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE move_shift_handovers (
|
||||||
|
handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
site VARCHAR(64) NULL,
|
||||||
|
from_user VARCHAR(190) NULL,
|
||||||
|
to_user VARCHAR(190) NOT NULL,
|
||||||
|
period_start BIGINT UNSIGNED NOT NULL,
|
||||||
|
period_end BIGINT UNSIGNED NOT NULL,
|
||||||
|
unacked_count INT UNSIGNED NOT NULL,
|
||||||
|
severe_count INT UNSIGNED NOT NULL,
|
||||||
|
note VARCHAR(1000) NULL,
|
||||||
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (handover_id),
|
||||||
|
KEY ix_handovers_site_created (site, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- risk_controls: 위험성평가 연계. One row per (site, detection type): the hazard it
|
||||||
|
-- stands for, the control currently in place, and the 5x5 rating. This is what lets the
|
||||||
|
-- system print "what are we controlling and how well", which is the artefact a safety
|
||||||
|
-- management system is actually audited on.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE move_risk_controls (
|
||||||
|
control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
site VARCHAR(64) NULL,
|
||||||
|
event_type VARCHAR(32) NOT NULL,
|
||||||
|
hazard VARCHAR(300) NOT NULL,
|
||||||
|
control VARCHAR(500) NOT NULL,
|
||||||
|
severity_rating TINYINT UNSIGNED NOT NULL,
|
||||||
|
likelihood_rating TINYINT UNSIGNED NOT NULL,
|
||||||
|
reviewed_by VARCHAR(190) NULL,
|
||||||
|
reviewed_at BIGINT UNSIGNED NULL,
|
||||||
|
updated_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (control_id),
|
||||||
|
UNIQUE KEY uq_risk_controls_site_type (site, event_type),
|
||||||
|
CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
|
||||||
|
CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- device_metrics: heartbeat telemetry as a TIME SERIES.
|
||||||
|
-- move_devices holds only the latest value, so a node whose inference latency doubled
|
||||||
|
-- overnight looks identical to a healthy one. Appending each heartbeat is what makes
|
||||||
|
-- slow degradation visible before it becomes a monitoring outage.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE move_device_metrics (
|
||||||
|
metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
device_id VARCHAR(64) NOT NULL,
|
||||||
|
ts BIGINT UNSIGNED NOT NULL,
|
||||||
|
fps DOUBLE NULL,
|
||||||
|
temp DOUBLE NULL,
|
||||||
|
throttle TINYINT(1) NULL,
|
||||||
|
queue_pending INT UNSIGNED NULL,
|
||||||
|
cameras_total INT UNSIGNED NULL,
|
||||||
|
cameras_erroring INT UNSIGNED NULL,
|
||||||
|
errors INT UNSIGNED NULL,
|
||||||
|
PRIMARY KEY (metric_id),
|
||||||
|
KEY ix_device_metrics_device_ts (device_id, ts)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- ===========================================================================
|
-- ===========================================================================
|
||||||
|
|
|
||||||
130
server/src/Alert/AlertDispatcher.php
Normal file
130
server/src/Alert/AlertDispatcher.php
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Alert;
|
||||||
|
|
||||||
|
use App\Repository\{AlertRepository, EventRepository, SuppressionRepository};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides WHICH notifications an event earns, and when.
|
||||||
|
*
|
||||||
|
* This is the piece the product was missing entirely: until now a detection reached a human only
|
||||||
|
* if a browser tab happened to be open and someone happened to be looking at it. A fall at 03:00
|
||||||
|
* produced a row in a database and nothing else.
|
||||||
|
*
|
||||||
|
* Deliberately pure decision logic — no I/O, no clock, no sending. plan() takes the world as
|
||||||
|
* arguments and returns a list of deliveries to attempt, so every rule below (severity floors,
|
||||||
|
* escalation delays, suppression windows, already-acknowledged short-circuit) is unit testable
|
||||||
|
* without a database, an SMTP server or a wall clock. Sending lives in the channels; claiming
|
||||||
|
* and recording lives in the repository.
|
||||||
|
*
|
||||||
|
* THE RULES, and why each exists:
|
||||||
|
*
|
||||||
|
* - Severity floor per channel. A beacon that fires on 정보 events is a beacon nobody looks at.
|
||||||
|
* - step 0 fires immediately; step >0 fires only after escalate_after_s of the event still
|
||||||
|
* being unacknowledged. That is what converts "we detected it" into "somebody was made to
|
||||||
|
* look at it", and it is the only mechanism here that can wake a second person.
|
||||||
|
* - An acknowledged event escalates no further. Escalation exists to find a human, and one has
|
||||||
|
* already been found.
|
||||||
|
* - A false-positive tag stops everything, for the same reason.
|
||||||
|
* - Suppression (planned maintenance / work permit) blocks the SEND but is still recorded, as
|
||||||
|
* status='suppressed'. An investigator has to be able to see that an alarm was raised and
|
||||||
|
* deliberately withheld — a silently dropped notification is indistinguishable from one that
|
||||||
|
* was never generated, and that difference is exactly what an inquiry turns on.
|
||||||
|
*/
|
||||||
|
final class AlertDispatcher
|
||||||
|
{
|
||||||
|
/** Ranked so a channel's min_severity acts as a floor, not an equality test. */
|
||||||
|
private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EventRepository $events,
|
||||||
|
private AlertRepository $alerts,
|
||||||
|
private SuppressionRepository $suppressions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $event a row from EventRepository (mapRow shape)
|
||||||
|
* @param array $channels channel rows for this event's site
|
||||||
|
* @return array<int,array{channel:array,suppressed:bool}> deliveries to attempt
|
||||||
|
*/
|
||||||
|
public function planFor(array $event, array $channels, int $now): array
|
||||||
|
{
|
||||||
|
// Resolved events stop the chain. Note this covers the escalation steps too: an event
|
||||||
|
// acknowledged 30 seconds in never reaches the 5-minute escalation channel.
|
||||||
|
if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) return [];
|
||||||
|
|
||||||
|
$rank = self::SEVERITY_RANK[$event['severity']] ?? 0;
|
||||||
|
$age = $now - (int)$event['occurred_at'];
|
||||||
|
|
||||||
|
// One suppression lookup per event covers all of its scopes. Zone is included because a
|
||||||
|
// permit is usually issued for an area, not for a camera.
|
||||||
|
$scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]];
|
||||||
|
if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']];
|
||||||
|
$suppressed = $this->suppressions->activeFor($scopes, $now) !== [];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($channels as $ch) {
|
||||||
|
if (empty($ch['enabled'])) continue;
|
||||||
|
// `local` beacons are fired by the edge node itself the moment its own detector
|
||||||
|
// produces the event — no server round trip, and no dependency on this job running
|
||||||
|
// at all. Their rows exist only to carry the GPIO configuration down through
|
||||||
|
// GET /api/config, so the server has nothing to deliver and must not claim a
|
||||||
|
// delivery row for them (which would show up as a permanent failure).
|
||||||
|
if ($ch['kind'] === 'local') continue;
|
||||||
|
if ($rank < (self::SEVERITY_RANK[$ch['min_severity']] ?? 0)) continue;
|
||||||
|
// A site-scoped channel only serves its own site; a NULL-site channel is global.
|
||||||
|
if ($ch['site'] !== null && $ch['site'] !== ($event['site'] ?? null)) continue;
|
||||||
|
// Not yet due: a later dispatcher run will pick it up once the event has aged.
|
||||||
|
if ((int)$ch['step'] > 0 && $age < (int)$ch['escalate_after_s']) continue;
|
||||||
|
$out[] = ['channel' => $ch, 'suppressed' => $suppressed];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one dispatch pass over the recent window.
|
||||||
|
*
|
||||||
|
* @param callable(array $event, array $channel): void $send throws on failure
|
||||||
|
* @return array{considered:int,sent:int,suppressed:int,failed:int,skipped:int}
|
||||||
|
*/
|
||||||
|
public function run(int $now, int $windowSeconds, int $limit, callable $send): array
|
||||||
|
{
|
||||||
|
$stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0];
|
||||||
|
$events = $this->events->forNotification((float)($now - $windowSeconds), $limit);
|
||||||
|
// One channel read per site, not per event: a busy window is hundreds of events over a
|
||||||
|
// handful of sites, and this job runs on shared hosting where every query costs.
|
||||||
|
$channelsBySite = [];
|
||||||
|
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$stats['considered']++;
|
||||||
|
$site = $event['site'] ?? null;
|
||||||
|
$key = $site ?? "\x00global";
|
||||||
|
if (!array_key_exists($key, $channelsBySite)) {
|
||||||
|
$channelsBySite[$key] = $this->alerts->channels($site);
|
||||||
|
}
|
||||||
|
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
|
||||||
|
$channelId = (int)$delivery['channel']['channel_id'];
|
||||||
|
$eventId = (int)$event['event_id'];
|
||||||
|
// claim() is the duplicate guard. Losing it means another (possibly overlapping)
|
||||||
|
// dispatcher run owns this delivery — skip, do not send.
|
||||||
|
if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; }
|
||||||
|
if ($delivery['suppressed']) {
|
||||||
|
$this->alerts->markSuppressed($eventId, $channelId, $now);
|
||||||
|
$stats['suppressed']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$send($event, $delivery['channel']);
|
||||||
|
$this->alerts->markSent($eventId, $channelId, $now);
|
||||||
|
$stats['sent']++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never fatal: one unreachable SMTP relay must not stop the webhook that
|
||||||
|
// would have reached somebody. The row keeps the error for the retry pass.
|
||||||
|
$this->alerts->markFailed($eventId, $channelId, $e->getMessage(), $now);
|
||||||
|
$stats['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
server/src/Alert/Channels.php
Normal file
185
server/src/Alert/Channels.php
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Alert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The senders. Two of the three channel kinds are delivered from here:
|
||||||
|
*
|
||||||
|
* email — an in-house SMTP relay via PHP mail(). Deliberately not a library: the delivered
|
||||||
|
* package must run on Cafe24 shared hosting with no composer step on the host, and
|
||||||
|
* adding an SMTP client to vendor/ for one function is weight the operator carries
|
||||||
|
* forever. Sites that need authenticated SMTP use the webhook channel instead.
|
||||||
|
* webhook — an HTTP POST to in-house groupware or an SMS gateway.
|
||||||
|
*
|
||||||
|
* The third kind, `local`, is NOT sent from here. A beacon or buzzer lives on the edge node's
|
||||||
|
* GPIO, and the edge fires it the instant its own detector produces the event — no server round
|
||||||
|
* trip, no network, no dependency on this cron job running. Its row here exists only to carry
|
||||||
|
* the configuration down to the device through GET /api/config. That is the one notification
|
||||||
|
* path that still works when the uplink is down, which is why it is the first one a site should
|
||||||
|
* configure.
|
||||||
|
*
|
||||||
|
* PRIVACY: notifications carry WHAT and WHERE, never the snapshot. Images are personal data
|
||||||
|
* under PIPA and are served only through the authenticated proxy; mailing one to a distribution
|
||||||
|
* list would move a worker's image outside every control the rest of the system maintains. The
|
||||||
|
* message therefore links to the incident instead.
|
||||||
|
*/
|
||||||
|
final class Channels
|
||||||
|
{
|
||||||
|
private const TYPE_LABELS = [
|
||||||
|
'fall' => '넘어짐', 'no_motion' => '넘어짐 후 미동 없음', 'immobility' => '장시간 부동',
|
||||||
|
'ghost' => '위험 상태 소실', 'zone_warning' => '경고구역 진입', 'zone_restricted' => '제한구역 침입',
|
||||||
|
'pinch' => '협착구역 침입', 'monitoring_degraded' => '감시 성능 저하', 'device_fault' => '장치 이상',
|
||||||
|
'lone_worker' => '위험구역 단독 작업', 'overcapacity' => '정원 초과', 'overstay' => '체류 상한 초과',
|
||||||
|
'near_fall' => '넘어질 뻔함 (니어미스)', 'ergo_report' => '작업자세 부담 리포트',
|
||||||
|
'camera_moved' => '카메라 화각 변경', 'camera_obstructed' => '카메라 가림', 'low_light' => '조도 저하',
|
||||||
|
'unacked_escalation' => '미확인 경보 에스컬레이션',
|
||||||
|
'ppe_missing' => '보호구 미착용', 'proximity' => '중장비 근접', 'fire_smoke' => '화재·연기',
|
||||||
|
];
|
||||||
|
private const SEVERITY_LABELS = [
|
||||||
|
'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @param string $baseUrl absolute dashboard URL, for the incident link. */
|
||||||
|
public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {}
|
||||||
|
|
||||||
|
public function send(array $event, array $channel): void
|
||||||
|
{
|
||||||
|
$config = json_decode($channel['config_json'] ?? '{}', true);
|
||||||
|
if (!is_array($config)) $config = [];
|
||||||
|
match ($channel['kind']) {
|
||||||
|
'email' => $this->sendEmail($event, $config),
|
||||||
|
'webhook' => $this->sendWebhook($event, $channel, $config),
|
||||||
|
// `local` is delivered by the edge itself (see the class comment). Recording it as
|
||||||
|
// sent here would be a lie; recording it as failed would bury the real failures.
|
||||||
|
'local' => throw new \LogicException('local channels are delivered by the edge node, not the server'),
|
||||||
|
default => throw new \RuntimeException('unknown channel kind: ' . (string)$channel['kind']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subject(array $event): string
|
||||||
|
{
|
||||||
|
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
|
||||||
|
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
|
||||||
|
return "[{$sev}] {$type} — " . (string)($event['camera_id'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function body(array $event): string
|
||||||
|
{
|
||||||
|
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
|
||||||
|
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
|
||||||
|
$when = date('Y-m-d H:i:s', (int)$event['occurred_at']);
|
||||||
|
$link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
|
||||||
|
return implode("\n", [
|
||||||
|
"작업장 안전 감지 알림",
|
||||||
|
"",
|
||||||
|
"유형 : {$type}",
|
||||||
|
"심각도 : {$sev}",
|
||||||
|
"장치 : " . (string)($event['device_id'] ?? '-'),
|
||||||
|
"카메라 : " . (string)($event['camera_id'] ?? '-'),
|
||||||
|
"발생 시각: {$when}",
|
||||||
|
"지속 : " . (string)($event['duration_s'] ?? 0) . "초",
|
||||||
|
"",
|
||||||
|
"확인 및 증거 프레임: {$link}",
|
||||||
|
"",
|
||||||
|
"이 메일에는 개인정보 보호를 위해 현장 이미지가 포함되지 않습니다.",
|
||||||
|
"이미지는 위 링크에서 로그인 후에만 열람할 수 있습니다.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendEmail(array $event, array $config): void
|
||||||
|
{
|
||||||
|
$to = $config['to'] ?? null;
|
||||||
|
$recipients = is_array($to) ? $to : (is_string($to) ? array_map('trim', explode(',', $to)) : []);
|
||||||
|
$recipients = array_values(array_filter($recipients, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL)));
|
||||||
|
if ($recipients === []) throw new \RuntimeException('email channel has no valid recipient');
|
||||||
|
|
||||||
|
$from = $config['from'] ?? null;
|
||||||
|
$headers = "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n";
|
||||||
|
if (is_string($from) && filter_var($from, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$headers .= 'From: ' . $from . "\r\n";
|
||||||
|
}
|
||||||
|
// Encode the subject: a raw Korean Subject header is mangled by most relays.
|
||||||
|
$subject = '=?UTF-8?B?' . base64_encode($this->subject($event)) . '?=';
|
||||||
|
// BCC-style: one call per recipient so one bad address cannot drop the whole batch, and
|
||||||
|
// so recipients do not see each other's addresses.
|
||||||
|
$failed = [];
|
||||||
|
foreach ($recipients as $rcpt) {
|
||||||
|
if (!@mail($rcpt, $subject, $this->body($event), $headers)) $failed[] = $rcpt;
|
||||||
|
}
|
||||||
|
if (count($failed) === count($recipients)) {
|
||||||
|
throw new \RuntimeException('mail() rejected every recipient: ' . implode(', ', $failed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendWebhook(array $event, array $channel, array $config): void
|
||||||
|
{
|
||||||
|
$url = $config['url'] ?? '';
|
||||||
|
if (!is_string($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
throw new \RuntimeException('webhook channel has no valid url');
|
||||||
|
}
|
||||||
|
// http/https only. Without this a stored config could name file:// or gopher:// and turn
|
||||||
|
// the dispatcher into a local-file reader.
|
||||||
|
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
|
||||||
|
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||||
|
throw new \RuntimeException('webhook url must be http(s)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'event_id' => (int)$event['event_id'],
|
||||||
|
'event_type' => $event['event_type'],
|
||||||
|
'severity' => $event['severity'],
|
||||||
|
'device_id' => $event['device_id'] ?? null,
|
||||||
|
'camera_id' => $event['camera_id'] ?? null,
|
||||||
|
'site' => $event['site'] ?? null,
|
||||||
|
'occurred_at' => (int)$event['occurred_at'],
|
||||||
|
'duration_s' => (float)($event['duration_s'] ?? 0),
|
||||||
|
'subject' => $this->subject($event),
|
||||||
|
'text' => $this->body($event),
|
||||||
|
'url' => rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view',
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$headers = ['Content-Type: application/json'];
|
||||||
|
// An optional shared token, so the receiving system can reject anything that is not us.
|
||||||
|
if (!empty($config['token']) && is_string($config['token'])) {
|
||||||
|
$headers[] = 'X-Alert-Token: ' . $config['token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => min(5, $this->timeoutSeconds),
|
||||||
|
// No redirects: a 302 from a compromised endpoint must not be able to walk this
|
||||||
|
// POST — with its token header — to another host.
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($body === false) throw new \RuntimeException('webhook transport error: ' . $err);
|
||||||
|
if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctx = stream_context_create(['http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => implode("\r\n", $headers),
|
||||||
|
'content' => $payload,
|
||||||
|
'timeout' => $this->timeoutSeconds,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'follow_location' => 0,
|
||||||
|
]]);
|
||||||
|
$res = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($res === false) throw new \RuntimeException('webhook transport error (stream)');
|
||||||
|
$status = 0;
|
||||||
|
foreach ($http_response_header ?? [] as $h) {
|
||||||
|
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int)$m[1];
|
||||||
|
}
|
||||||
|
if ($status < 200 || $status >= 300) throw new \RuntimeException('webhook HTTP ' . $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
server/src/Alert/EscalationWatch.php
Normal file
91
server/src/Alert/EscalationWatch.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Alert;
|
||||||
|
|
||||||
|
use App\Repository\EventRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns "nobody looked at it" into a first-class event.
|
||||||
|
*
|
||||||
|
* A critical detection that sits unacknowledged is a second, different failure from the
|
||||||
|
* detection itself, and until now it was invisible: the console showed a red row, and whether
|
||||||
|
* anyone ever came back to it left no trace anywhere. This watch emits an `unacked_escalation`
|
||||||
|
* event once an unresolved critical passes the threshold, which means it shows up in the stream,
|
||||||
|
* counts in the report, and — because it is an event like any other — is itself notifiable, so
|
||||||
|
* the escalation channels can carry it to a second person.
|
||||||
|
*
|
||||||
|
* IDEMPOTENCY comes from the event_uuid, not from a read: the uuid is derived from the origin
|
||||||
|
* event id, and move_events has a UNIQUE key on it. Two overlapping cron runs therefore produce
|
||||||
|
* one escalation, with the loser's INSERT colliding rather than duplicating — the same mechanism
|
||||||
|
* the ingest path already relies on for re-sent events.
|
||||||
|
*/
|
||||||
|
final class EscalationWatch
|
||||||
|
{
|
||||||
|
/** Only genuine emergencies escalate. A 경고 that nobody acknowledged is a backlog, not an alarm. */
|
||||||
|
private const ESCALATING_SEVERITIES = ['critical', 'high'];
|
||||||
|
|
||||||
|
public function __construct(private EventRepository $events) {}
|
||||||
|
|
||||||
|
/** Deterministic 32-hex uuid for the escalation of a given origin event. */
|
||||||
|
public static function uuidFor(int $originEventId): string
|
||||||
|
{
|
||||||
|
// Prefixed so it is recognisable in the database, and hashed so it cannot collide with a
|
||||||
|
// device-generated uuid (which is random hex of the same width).
|
||||||
|
return substr('e5ca1a7e' . substr(hash('sha256', 'unacked:' . $originEventId), 0, 24), 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $afterSeconds how long an unresolved event may sit before it escalates
|
||||||
|
* @return array<int,int> origin event ids that were escalated in this pass
|
||||||
|
*/
|
||||||
|
public function run(int $now, int $afterSeconds, int $windowSeconds, int $limit): array
|
||||||
|
{
|
||||||
|
$escalated = [];
|
||||||
|
foreach ($this->events->forNotification((float)($now - $windowSeconds), $limit) as $ev) {
|
||||||
|
if (!in_array($ev['severity'], self::ESCALATING_SEVERITIES, true)) continue;
|
||||||
|
if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue;
|
||||||
|
// An escalation must never escalate itself — that is an infinite ladder.
|
||||||
|
if ($ev['event_type'] === 'unacked_escalation') continue;
|
||||||
|
if (($now - (int)$ev['occurred_at']) < $afterSeconds) continue;
|
||||||
|
|
||||||
|
$originId = (int)$ev['event_id'];
|
||||||
|
$uuid = self::uuidFor($originId);
|
||||||
|
if ($this->events->idByUuid($uuid) !== null) continue; // already escalated
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->events->insert([
|
||||||
|
'event_uuid' => $uuid,
|
||||||
|
'device_id' => $ev['device_id'],
|
||||||
|
'site' => $ev['site'] ?? null,
|
||||||
|
'camera_id' => $ev['camera_id'],
|
||||||
|
'event_type' => 'unacked_escalation',
|
||||||
|
// The escalation inherits the severity it is escalating: a critical left
|
||||||
|
// unattended is still a critical, and downgrading it here would let it fall
|
||||||
|
// below the very channel floors that exist to wake someone.
|
||||||
|
'severity' => $ev['severity'],
|
||||||
|
'track_id' => $ev['track_id'] ?? null,
|
||||||
|
'zone_id' => $ev['zone_id'] ?? null,
|
||||||
|
'occurred_at' => (float)$now,
|
||||||
|
'duration_s' => (float)($now - (int)$ev['occurred_at']),
|
||||||
|
'metrics' => [
|
||||||
|
'origin_event_id' => $originId,
|
||||||
|
'origin_event_type' => $ev['event_type'],
|
||||||
|
'unacked_s' => $now - (int)$ev['occurred_at'],
|
||||||
|
],
|
||||||
|
'spatial_x' => $ev['spatial_x'] ?? null,
|
||||||
|
'spatial_y' => $ev['spatial_y'] ?? null,
|
||||||
|
]);
|
||||||
|
$escalated[] = $originId;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// A unique-key collision means a concurrent run won the race; anything else is
|
||||||
|
// worth surfacing but must not stop the remaining events from escalating.
|
||||||
|
if (!str_contains($e->getMessage(), 'uq_events_uuid')
|
||||||
|
&& !str_contains($e->getMessage(), 'UNIQUE')
|
||||||
|
&& !str_contains($e->getMessage(), 'Duplicate')) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $escalated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ namespace App\Api;
|
||||||
use App\Config;
|
use App\Config;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Security\Hmac;
|
use App\Security\Hmac;
|
||||||
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
|
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository,
|
||||||
|
RateLimiterRepository, AlertRepository};
|
||||||
|
|
||||||
final class ConfigController
|
final class ConfigController
|
||||||
{
|
{
|
||||||
|
|
@ -13,6 +14,7 @@ final class ConfigController
|
||||||
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
|
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
|
||||||
private NonceRepository $nonces, private Config $cfg,
|
private NonceRepository $nonces, private Config $cfg,
|
||||||
private ?RateLimiterRepository $rateLimiter = null,
|
private ?RateLimiterRepository $rateLimiter = null,
|
||||||
|
private ?AlertRepository $alerts = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function get(Request $req, int $now, string $clientIp = ''): Response
|
public function get(Request $req, int $now, string $clientIp = ''): Response
|
||||||
|
|
@ -40,6 +42,33 @@ final class ConfigController
|
||||||
'version' => $this->devices->configVersion($deviceId),
|
'version' => $this->devices->configVersion($deviceId),
|
||||||
'zones' => $this->zones->forDevice($deviceId),
|
'zones' => $this->zones->forDevice($deviceId),
|
||||||
'thresholds' => $this->thresholds->forDevice($deviceId),
|
'thresholds' => $this->thresholds->forDevice($deviceId),
|
||||||
|
// Local beacon wiring (GPIO pin, latch/pulse, hold time). The node fires its own
|
||||||
|
// beacon the instant its detector produces a qualifying event — no server round trip,
|
||||||
|
// so it still works with the uplink down, which is the whole point of having it.
|
||||||
|
// Carried here because /api/config is the only authenticated channel the device has.
|
||||||
|
'beacons' => $this->beaconsFor($deviceId),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array> local-kind channels applicable to this device's site. */
|
||||||
|
private function beaconsFor(string $deviceId): array
|
||||||
|
{
|
||||||
|
if ($this->alerts === null) return [];
|
||||||
|
$site = $this->devices->siteFor($deviceId);
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->alerts->channels($site) as $ch) {
|
||||||
|
if ($ch['kind'] !== 'local') continue;
|
||||||
|
$config = json_decode($ch['config_json'], true);
|
||||||
|
if (!is_array($config)) continue;
|
||||||
|
$out[] = [
|
||||||
|
'name' => $ch['name'],
|
||||||
|
'min_severity' => $ch['min_severity'],
|
||||||
|
'gpio_pin' => (int)($config['gpio_pin'] ?? 0),
|
||||||
|
'mode' => (string)($config['mode'] ?? 'latch'),
|
||||||
|
'hold_s' => (float)($config['hold_s'] ?? 30.0),
|
||||||
|
'active_high' => (bool)($config['active_high'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ namespace App\Api;
|
||||||
use App\Config;
|
use App\Config;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Security\Hmac;
|
use App\Security\Hmac;
|
||||||
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository};
|
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository, DeviceMetricRepository};
|
||||||
|
|
||||||
final class HeartbeatController
|
final class HeartbeatController
|
||||||
{
|
{
|
||||||
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces,
|
public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces,
|
||||||
private ?RateLimiterRepository $rateLimiter = null) {}
|
private ?RateLimiterRepository $rateLimiter = null,
|
||||||
|
private ?DeviceMetricRepository $metrics = null) {}
|
||||||
|
|
||||||
public function post(Request $req, int $now, string $clientIp = ''): Response
|
public function post(Request $req, int $now, string $clientIp = ''): Response
|
||||||
{
|
{
|
||||||
|
|
@ -46,6 +47,25 @@ final class HeartbeatController
|
||||||
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
|
'config_version' => ($reportedVersion !== null && $reportedVersion > 0) ? $reportedVersion : null,
|
||||||
'at' => $now,
|
'at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Append the same beat to the time series. move_devices only ever holds the LATEST
|
||||||
|
// reading, which cannot answer "is this node getting worse?" — a device whose fps has
|
||||||
|
// been sliding for a week looks identical to a healthy one right up until it stops.
|
||||||
|
// Optional dependency so the many tests that construct this controller with four
|
||||||
|
// arguments keep working, and so a site that has not migrated yet still ingests.
|
||||||
|
//
|
||||||
|
// Absent readings stay absent: only keys the device actually sent are forwarded, because
|
||||||
|
// the repository stores a missing metric as NULL and coercing one to 0 here would make a
|
||||||
|
// device that cannot read its temperature look like a device sitting at 0°C.
|
||||||
|
if ($this->metrics !== null) {
|
||||||
|
$series = [];
|
||||||
|
foreach (['fps', 'temp', 'throttle', 'queue_pending', 'cameras_total',
|
||||||
|
'cameras_erroring', 'errors'] as $k) {
|
||||||
|
if (array_key_exists($k, $b)) $series[$k] = $b[$k];
|
||||||
|
}
|
||||||
|
if ($series !== []) $this->metrics->append($deviceId, $series, $now);
|
||||||
|
}
|
||||||
|
|
||||||
return Response::json(['ok' => true], 200);
|
return Response::json(['ok' => true], 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
server/src/Api/ThresholdCatalog.php
Normal file
101
server/src/Api/ThresholdCatalog.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tunables an operator may change, and the bounds they may change them within.
|
||||||
|
*
|
||||||
|
* This catalogue is the whole reason the threshold editor is safe to expose. The values it
|
||||||
|
* describes decide what the system will and will not detect: `fall_prone_s` too high and a fall
|
||||||
|
* is never confirmed; `cooldown_s` at 0 and one event re-fires every tick until the queue fills.
|
||||||
|
* The edge already rejects non-finite and non-positive values at startup (edge/config.py
|
||||||
|
* `_positive`), but by then the device is in a restart loop in a factory — the bounds below stop
|
||||||
|
* the same mistake at the keyboard, where somebody can still read the error.
|
||||||
|
*
|
||||||
|
* Keys mirror the edge's env names in lower case, which is the contract GET /api/config carries
|
||||||
|
* (see edge/zones.py thresholds_from_config and edge/runtime.py apply_thresholds).
|
||||||
|
*
|
||||||
|
* A key NOT listed here cannot be written through the API. That is deliberate: an unknown key
|
||||||
|
* would be delivered to the device, silently ignored, and look configured.
|
||||||
|
*/
|
||||||
|
final class ThresholdCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string,array{label:string,unit:string,min:float,max:float,default:float,
|
||||||
|
* step:float,integer:bool,help:string}>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'fall_prone_s' => [
|
||||||
|
'label' => '넘어짐 확정 대기', 'unit' => '초',
|
||||||
|
'min' => 1.0, 'max' => 30.0, 'default' => 3.0, 'step' => 0.5, 'integer' => false,
|
||||||
|
'help' => '쓰러진 자세가 이 시간 이상 지속되어야 넘어짐으로 확정합니다. 낮추면 반응이 빨라지지만 앉거나 웅크린 자세를 오탐할 수 있습니다.',
|
||||||
|
],
|
||||||
|
'fall_no_motion_s' => [
|
||||||
|
'label' => '넘어짐 후 미동 판정', 'unit' => '초',
|
||||||
|
'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false,
|
||||||
|
'help' => '넘어진 뒤 이 시간 동안 움직임이 없으면 응급 상황(미동 없음)으로 올립니다.',
|
||||||
|
],
|
||||||
|
'immobile_s' => [
|
||||||
|
'label' => '장시간 부동 판정', 'unit' => '초',
|
||||||
|
'min' => 30.0, 'max' => 1800.0, 'default' => 120.0, 'step' => 10.0, 'integer' => false,
|
||||||
|
'help' => '서 있든 앉아 있든 이 시간 동안 움직임이 없으면 이상으로 봅니다. 정적인 작업이 많은 공정에서는 올리세요.',
|
||||||
|
],
|
||||||
|
'ghost_reappear_s' => [
|
||||||
|
'label' => '소실 후 재출현 대기', 'unit' => '초',
|
||||||
|
'min' => 3.0, 'max' => 120.0, 'default' => 10.0, 'step' => 1.0, 'integer' => false,
|
||||||
|
'help' => '위험 상태였던 사람이 화면에서 사라진 뒤 이 시간 안에 다시 나타나지 않으면 경보합니다.',
|
||||||
|
],
|
||||||
|
'zone_debounce_s' => [
|
||||||
|
'label' => '구역 진입 디바운스', 'unit' => '초',
|
||||||
|
'min' => 0.2, 'max' => 10.0, 'default' => 0.75, 'step' => 0.05, 'integer' => false,
|
||||||
|
'help' => '경계선을 스치듯 지나가는 것을 침입으로 세지 않기 위한 최소 체류 시간입니다.',
|
||||||
|
],
|
||||||
|
'cooldown_s' => [
|
||||||
|
'label' => '동일 이벤트 재발행 억제', 'unit' => '초',
|
||||||
|
'min' => 5.0, 'max' => 600.0, 'default' => 30.0, 'step' => 5.0, 'integer' => false,
|
||||||
|
'help' => '같은 사람·같은 유형의 이벤트를 이 시간 안에는 다시 보내지 않습니다. 너무 낮추면 한 사건이 수십 건으로 쪼개집니다.',
|
||||||
|
],
|
||||||
|
'motion_thr' => [
|
||||||
|
'label' => '움직임 임계', 'unit' => '',
|
||||||
|
'min' => 0.05, 'max' => 2.0, 'default' => 0.25, 'step' => 0.05, 'integer' => false,
|
||||||
|
'help' => '이 값보다 작은 키포인트 변화는 "정지"로 봅니다. 카메라가 흔들리는 현장은 올리고, 미세한 움직임까지 살리려면 내리세요.',
|
||||||
|
],
|
||||||
|
'infer_every' => [
|
||||||
|
'label' => '추론 주기', 'unit' => '틱마다 1회',
|
||||||
|
'min' => 1.0, 'max' => 10.0, 'default' => 1.0, 'step' => 1.0, 'integer' => true,
|
||||||
|
'help' => '장비가 전 카메라를 full-rate 로 처리하지 못할 때만 올립니다. 반응 정밀도를 내주고 모든 카메라를 살리는 절충입니다.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function has(string $key): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($key, self::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a submitted value against the catalogue.
|
||||||
|
*
|
||||||
|
* @return array{0:bool,1:string} [ok, normalized-or-error-code]
|
||||||
|
*/
|
||||||
|
public static function normalize(string $key, mixed $raw): array
|
||||||
|
{
|
||||||
|
$spec = self::all()[$key] ?? null;
|
||||||
|
if ($spec === null) return [false, 'unknown_key'];
|
||||||
|
if (!is_numeric($raw)) return [false, 'not_numeric'];
|
||||||
|
$v = (float)$raw;
|
||||||
|
// NaN/INF slip past is_numeric for the strings "nan"/"inf" on some platforms, and both
|
||||||
|
// are exactly the values edge/config.py exists to reject.
|
||||||
|
if (!is_finite($v)) return [false, 'not_finite'];
|
||||||
|
if ($v < $spec['min'] || $v > $spec['max']) return [false, 'out_of_range'];
|
||||||
|
if ($spec['integer']) {
|
||||||
|
if (floor($v) !== $v) return [false, 'not_integer'];
|
||||||
|
return [true, (string)(int)$v];
|
||||||
|
}
|
||||||
|
// Normalize the textual form so the same value always stores identically (the column is
|
||||||
|
// a VARCHAR and "3" vs "3.0" would otherwise read as two different settings).
|
||||||
|
return [true, rtrim(rtrim(number_format($v, 3, '.', ''), '0'), '.') ?: '0'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,8 +45,12 @@ 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' => 'locked'], 423);
|
return Response::json(['error' => 'invalid_credentials'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -73,8 +77,17 @@ final class AuthController
|
||||||
return Response::json(['ok' => true], 200);
|
return Response::json(['ok' => true], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout(SessionStore $sess): Response
|
public function logout(Request $req, 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.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,13 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,43 @@ 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); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
server/src/Dashboard/ActionsController.php
Normal file
107
server/src/Dashboard/ActionsController.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
server/src/Dashboard/AdminConfigController.php
Normal file
247
server/src/Dashboard/AdminConfigController.php
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Api\ThresholdCatalog;
|
||||||
|
use App\Http\{Request, Response};
|
||||||
|
use App\Auth\{SessionStore, Rbac};
|
||||||
|
use App\Security\Csrf;
|
||||||
|
use App\Repository\{ThresholdRepository, AlertRepository, DeviceRepository, AuditRepository};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System configuration an admin can change from the browser: detection tunables and the
|
||||||
|
* notification channels.
|
||||||
|
*
|
||||||
|
* Both used to be unreachable. Thresholds existed as a table, were delivered to devices by
|
||||||
|
* GET /api/config, and the schema comment told the operator to edit them in phpMyAdmin — which
|
||||||
|
* in a delivered, air-gapped install means the customer could not retune a camera at all, even
|
||||||
|
* though the console's own false-positive panel exists to tell them which one needs it.
|
||||||
|
* Channels did not exist in any form: a detection reached a human only if somebody happened to
|
||||||
|
* be looking at the screen.
|
||||||
|
*
|
||||||
|
* Every write here is audited. A threshold change alters what the system is capable of
|
||||||
|
* detecting, and an investigation has to be able to ask when that changed and who changed it.
|
||||||
|
*/
|
||||||
|
final class AdminConfigController
|
||||||
|
{
|
||||||
|
private const MAX_NAME = 190;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private ThresholdRepository $thresholds,
|
||||||
|
private AlertRepository $alerts,
|
||||||
|
private DeviceRepository $devices,
|
||||||
|
private AuditRepository $audit,
|
||||||
|
private SessionStore $sess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private function guard(Request $req, bool $write): ?Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
if (!Rbac::can((string)$this->sess->get('role'), 'manage_thresholds')) {
|
||||||
|
return Response::json(['error' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
if ($write && !Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
||||||
|
return Response::json(['error' => 'csrf'], 403);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function site(): ?string
|
||||||
|
{
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
return is_string($scope) ? $scope : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- thresholds
|
||||||
|
public function thresholds(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($r = $this->guard($req, false)) return $r;
|
||||||
|
return Response::json([
|
||||||
|
// The catalogue travels with the values so the editor can render a bounded control
|
||||||
|
// (a slider with the real min/max and the real explanation) instead of a free-text
|
||||||
|
// key/value box that invites a typo the device will silently ignore.
|
||||||
|
'catalog' => ThresholdCatalog::all(),
|
||||||
|
'thresholds' => $this->thresholds->listAll($this->site()),
|
||||||
|
'devices' => $this->devices->listStatus($this->site()),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveThreshold(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($r = $this->guard($req, true)) return $r;
|
||||||
|
$b = $req->json();
|
||||||
|
$deviceId = trim((string)($b['device_id'] ?? ''));
|
||||||
|
$key = trim((string)($b['key'] ?? ''));
|
||||||
|
$cameraId = trim((string)($b['camera_id'] ?? ''));
|
||||||
|
$cameraId = $cameraId === '' ? null : $cameraId;
|
||||||
|
|
||||||
|
if ($deviceId === '' || mb_strlen($deviceId) > 64) return Response::json(['error' => 'invalid_device'], 400);
|
||||||
|
if ($cameraId !== null && mb_strlen($cameraId) > 64) return Response::json(['error' => 'invalid_camera'], 400);
|
||||||
|
[$ok, $value] = ThresholdCatalog::normalize($key, $b['value'] ?? null);
|
||||||
|
if (!$ok) return Response::json(['error' => $value], 400);
|
||||||
|
|
||||||
|
// Scope BEFORE the write: a scoped admin must not retune another site's device.
|
||||||
|
if (!$this->devices->exists($deviceId)) return Response::json(['error' => 'unknown_device'], 400);
|
||||||
|
$site = $this->site();
|
||||||
|
if ($site !== null && $this->devices->siteFor($deviceId) !== $site) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$actor = (string)$this->sess->get('username');
|
||||||
|
// Capture the old value first — the audit entry is worthless without it.
|
||||||
|
$before = $this->thresholds->forDevice($deviceId)[$key] ?? null;
|
||||||
|
$id = $this->thresholds->set($deviceId, $cameraId, $key, $value, $actor, $now);
|
||||||
|
$this->audit->append($actor, 'threshold_set', "threshold:$deviceId:$key",
|
||||||
|
['value' => $before], ['value' => $value, 'camera_id' => $cameraId]);
|
||||||
|
|
||||||
|
// Bumping the device's config version is what makes the change actually reach the node:
|
||||||
|
// without it the daemon's next sync sees the same version and the new value sits in the
|
||||||
|
// database looking applied. This is the failure the config-drift indicator exists for.
|
||||||
|
$this->devices->bumpConfigVersion($deviceId);
|
||||||
|
|
||||||
|
return Response::json(['id' => $id, 'value' => $value], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteThreshold(Request $req, array $params): Response
|
||||||
|
{
|
||||||
|
if ($r = $this->guard($req, true)) return $r;
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$row = $this->thresholds->find($id);
|
||||||
|
if ($row === null) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
$site = $this->site();
|
||||||
|
if ($site !== null && ($row['site'] ?? null) !== $site) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
|
||||||
|
if (!$this->thresholds->remove($id, $site)) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
$actor = (string)$this->sess->get('username');
|
||||||
|
$this->audit->append($actor, 'threshold_delete', 'threshold:' . $row['device_id'] . ':' . $row['key'],
|
||||||
|
['value' => $row['value']], []);
|
||||||
|
$this->devices->bumpConfigVersion($row['device_id']);
|
||||||
|
return Response::json(['ok' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- alert channels
|
||||||
|
public function channels(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($r = $this->guard($req, false)) return $r;
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->alerts->allChannels($this->site()) as $c) {
|
||||||
|
$config = json_decode($c['config_json'], true);
|
||||||
|
if (!is_array($config)) $config = [];
|
||||||
|
// Never echo a shared secret back to the browser. It is write-only from the UI's
|
||||||
|
// point of view: the form can set a new token, but the page can never display the
|
||||||
|
// current one (a screen-shared config page must not leak the integration key).
|
||||||
|
if (isset($config['token'])) $config['token'] = '••••••••';
|
||||||
|
$c['config'] = $config;
|
||||||
|
unset($c['config_json']);
|
||||||
|
$out[] = $c;
|
||||||
|
}
|
||||||
|
return Response::json(['channels' => $out], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveChannel(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($r = $this->guard($req, true)) return $r;
|
||||||
|
$b = $req->json();
|
||||||
|
$kind = (string)($b['kind'] ?? '');
|
||||||
|
$name = trim((string)($b['name'] ?? ''));
|
||||||
|
$minSeverity = (string)($b['min_severity'] ?? 'high');
|
||||||
|
$step = (int)($b['step'] ?? 0);
|
||||||
|
$after = (int)($b['escalate_after_s'] ?? 0);
|
||||||
|
|
||||||
|
if (!in_array($kind, ['local', 'email', 'webhook'], true)) return Response::json(['error' => 'invalid_kind'], 400);
|
||||||
|
if ($name === '' || mb_strlen($name) > self::MAX_NAME) return Response::json(['error' => 'invalid_name'], 400);
|
||||||
|
if (!in_array($minSeverity, ['critical', 'high', 'warning', 'info'], true)) {
|
||||||
|
return Response::json(['error' => 'invalid_severity'], 400);
|
||||||
|
}
|
||||||
|
if ($step < 0 || $step > 5) return Response::json(['error' => 'invalid_step'], 400);
|
||||||
|
// An escalation step with no delay would fire together with the immediate channels and
|
||||||
|
// wake the second person for every event — the opposite of an escalation.
|
||||||
|
if ($step > 0 && ($after < 30 || $after > 86400)) return Response::json(['error' => 'invalid_escalate_after'], 400);
|
||||||
|
|
||||||
|
$config = $this->normalizeChannelConfig($kind, is_array($b['config'] ?? null) ? $b['config'] : []);
|
||||||
|
if ($config === null) return Response::json(['error' => 'invalid_config'], 400);
|
||||||
|
|
||||||
|
$id = (int)($b['channel_id'] ?? 0);
|
||||||
|
if ($id > 0) {
|
||||||
|
$existing = null;
|
||||||
|
foreach ($this->alerts->allChannels($this->site()) as $c) {
|
||||||
|
if ((int)$c['channel_id'] === $id) { $existing = $c; break; }
|
||||||
|
}
|
||||||
|
if ($existing === null) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
// A masked token means "leave it alone" — the GET never revealed the real one.
|
||||||
|
if (($config['token'] ?? null) === '••••••••') {
|
||||||
|
$old = json_decode($existing['config_json'], true);
|
||||||
|
if (is_array($old) && isset($old['token'])) $config['token'] = $old['token'];
|
||||||
|
else unset($config['token']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newId = $this->alerts->saveChannel([
|
||||||
|
'channel_id' => $id > 0 ? $id : null,
|
||||||
|
'site' => $this->site(),
|
||||||
|
'kind' => $kind,
|
||||||
|
'name' => $name,
|
||||||
|
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||||
|
'min_severity' => $minSeverity,
|
||||||
|
'enabled' => !empty($b['enabled']) ? 1 : 0,
|
||||||
|
'step' => $step,
|
||||||
|
'escalate_after_s' => $step > 0 ? $after : 0,
|
||||||
|
'created_at' => time(),
|
||||||
|
]);
|
||||||
|
$actor = (string)$this->sess->get('username');
|
||||||
|
// The audit entry records the shape, never the token.
|
||||||
|
$this->audit->append($actor, $id > 0 ? 'alert_channel_update' : 'alert_channel_create',
|
||||||
|
"alert_channel:$newId", [], ['kind' => $kind, 'name' => $name, 'step' => $step,
|
||||||
|
'min_severity' => $minSeverity]);
|
||||||
|
return Response::json(['channel_id' => $newId], $id > 0 ? 200 : 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteChannel(Request $req, array $params): Response
|
||||||
|
{
|
||||||
|
if ($r = $this->guard($req, true)) return $r;
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
if (!$this->alerts->deleteChannel($id, $this->site())) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
$this->audit->append((string)$this->sess->get('username'), 'alert_channel_delete', "alert_channel:$id", [], []);
|
||||||
|
return Response::json(['ok' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array|null null = invalid for this kind. */
|
||||||
|
private function normalizeChannelConfig(string $kind, array $in): ?array
|
||||||
|
{
|
||||||
|
if ($kind === 'email') {
|
||||||
|
$raw = $in['to'] ?? '';
|
||||||
|
$list = is_array($raw) ? $raw : array_map('trim', explode(',', (string)$raw));
|
||||||
|
$list = array_values(array_filter($list, static fn ($a) => filter_var($a, FILTER_VALIDATE_EMAIL)));
|
||||||
|
if ($list === []) return null;
|
||||||
|
$out = ['to' => $list];
|
||||||
|
if (!empty($in['from']) && filter_var((string)$in['from'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$out['from'] = (string)$in['from'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
if ($kind === 'webhook') {
|
||||||
|
$url = (string)($in['url'] ?? '');
|
||||||
|
if (!filter_var($url, FILTER_VALIDATE_URL)) return null;
|
||||||
|
// http(s) only — a stored file:// or gopher:// would turn the dispatcher into a
|
||||||
|
// local file reader running as the web user.
|
||||||
|
if (!in_array(strtolower((string)parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) return null;
|
||||||
|
$out = ['url' => $url];
|
||||||
|
if (!empty($in['token']) && is_string($in['token'])) $out['token'] = $in['token'];
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
// local: the edge fires this itself; the config is the GPIO wiring it should use.
|
||||||
|
$pin = isset($in['gpio_pin']) ? (int)$in['gpio_pin'] : 0;
|
||||||
|
if ($pin < 0 || $pin > 40) return null; // Pi header is 40 pins; 0 = unset/disabled
|
||||||
|
$mode = (string)($in['mode'] ?? 'latch');
|
||||||
|
if (!in_array($mode, ['latch', 'pulse'], true)) return null;
|
||||||
|
$out = ['gpio_pin' => $pin, 'mode' => $mode];
|
||||||
|
// How long a pulse lasts / how long a latch holds before auto-release. A latch that
|
||||||
|
// never releases needs somebody to walk to the node, which is not always possible.
|
||||||
|
$hold = isset($in['hold_s']) ? (float)$in['hold_s'] : 30.0;
|
||||||
|
if ($hold <= 0 || $hold > 3600) return null;
|
||||||
|
$out['hold_s'] = $hold;
|
||||||
|
$out['active_high'] = !empty($in['active_high']);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
server/src/Dashboard/ComplianceController.php
Normal file
225
server/src/Dashboard/ComplianceController.php
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Http\{Request, Response};
|
||||||
|
use App\Auth\{SessionStore, Rbac};
|
||||||
|
use App\Security\{Csrf, SiteScope};
|
||||||
|
use App\Repository\{EventRepository, SnapshotRepository, ActionRepository, AlertRepository,
|
||||||
|
RiskControlRepository, HandoverRepository, AuditRepository};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The evidence side of the product: what an inquiry, an insurer or a regulator asks for.
|
||||||
|
*
|
||||||
|
* - 사고조사 패키지 (package): one incident, reconstructed. The event, its evidence frames, the
|
||||||
|
* same camera's timeline either side of it, who was notified and whether the notification
|
||||||
|
* actually went out, what the operator did, and which risk control was supposed to cover it.
|
||||||
|
* Assembling that by hand after an accident is exactly when nobody has time to.
|
||||||
|
* - 위험성평가 (risk controls): the mapping from each detection type to the hazard it stands for
|
||||||
|
* and the control currently in place. This is the artefact a safety management system is
|
||||||
|
* audited on; without it the console is a gadget rather than part of a management system.
|
||||||
|
* - 교대 인수인계 (handover): a record that a named person accepted a named backlog. The 24h
|
||||||
|
* brief says what happened; this says who took responsibility for it.
|
||||||
|
*/
|
||||||
|
final class ComplianceController
|
||||||
|
{
|
||||||
|
/** ±30 minutes of the same camera's activity around the incident. */
|
||||||
|
private const CONTEXT_WINDOW_S = 1800;
|
||||||
|
private const CONTEXT_LIMIT = 200;
|
||||||
|
private const MAX_NOTE = 1000;
|
||||||
|
private const HANDOVER_LIST = 20;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EventRepository $events,
|
||||||
|
private SnapshotRepository $snaps,
|
||||||
|
private ActionRepository $actions,
|
||||||
|
private AlertRepository $alerts,
|
||||||
|
private RiskControlRepository $risks,
|
||||||
|
private HandoverRepository $handovers,
|
||||||
|
private AuditRepository $audit,
|
||||||
|
private SessionStore $sess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private function site(): ?string
|
||||||
|
{
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
return is_string($scope) ? $scope : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- incident package
|
||||||
|
public function package(Request $req, array $params): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$event = $this->events->find($id);
|
||||||
|
// 404 (not 403) for an out-of-scope id, so a cross-site id is indistinguishable from a
|
||||||
|
// non-existent one — the same rule the rest of the dashboard follows.
|
||||||
|
if ($event === null || !SiteScope::allows($this->site(), $event['site'] ?? null)) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$at = (float)$event['occurred_at'];
|
||||||
|
$site = $this->site();
|
||||||
|
$timeline = $this->events->windowForCamera(
|
||||||
|
(string)$event['camera_id'], $at - self::CONTEXT_WINDOW_S, $at + self::CONTEXT_WINDOW_S,
|
||||||
|
$site, self::CONTEXT_LIMIT
|
||||||
|
);
|
||||||
|
|
||||||
|
// Improvement actions recorded against this camera, so the package shows whether the
|
||||||
|
// hazard had already been raised before the incident — the first question asked.
|
||||||
|
$cameraActions = [];
|
||||||
|
foreach ($this->actions->recent(50, $site) as $a) {
|
||||||
|
if (($a['camera_id'] ?? '') === $event['camera_id']) $cameraActions[] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
$risk = null;
|
||||||
|
foreach ($this->risks->listAll($site) as $r) {
|
||||||
|
if ($r['event_type'] === $event['event_type']) { $risk = $r; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json([
|
||||||
|
'event' => $event,
|
||||||
|
'snapshots' => $this->snaps->forEvent($id),
|
||||||
|
'timeline' => $timeline,
|
||||||
|
// Whether anyone was actually told. A package that only proves the system SAW the
|
||||||
|
// incident, without showing whether it reached a human, answers half the question.
|
||||||
|
'notifications' => $this->alerts->forEvent($id),
|
||||||
|
'actions' => $cameraActions,
|
||||||
|
'risk_control' => $risk,
|
||||||
|
'context_window_s' => self::CONTEXT_WINDOW_S,
|
||||||
|
'generated_at' => time(),
|
||||||
|
'generated_by' => (string)$this->sess->get('username'),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- risk controls
|
||||||
|
public function risks(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
return Response::json(['controls' => $this->risks->listAll($this->site())], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveRisk(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
|
||||||
|
return Response::json(['error' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
||||||
|
return Response::json(['error' => 'csrf'], 403);
|
||||||
|
}
|
||||||
|
$b = $req->json();
|
||||||
|
$type = trim((string)($b['event_type'] ?? ''));
|
||||||
|
$hazard = trim((string)($b['hazard'] ?? ''));
|
||||||
|
$control = trim((string)($b['control'] ?? ''));
|
||||||
|
$sev = (int)($b['severity_rating'] ?? 0);
|
||||||
|
$like = (int)($b['likelihood_rating'] ?? 0);
|
||||||
|
|
||||||
|
if ($type === '' || mb_strlen($type) > 32) return Response::json(['error' => 'invalid_type'], 400);
|
||||||
|
if ($hazard === '' || mb_strlen($hazard) > 300) return Response::json(['error' => 'invalid_hazard'], 400);
|
||||||
|
if ($control === '' || mb_strlen($control) > 500) return Response::json(['error' => 'invalid_control'], 400);
|
||||||
|
if ($sev < 1 || $sev > 5 || $like < 1 || $like > 5) return Response::json(['error' => 'invalid_rating'], 400);
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$actor = (string)$this->sess->get('username');
|
||||||
|
$this->risks->upsert([
|
||||||
|
'site' => $this->site(), 'event_type' => $type, 'hazard' => $hazard, 'control' => $control,
|
||||||
|
'severity_rating' => $sev, 'likelihood_rating' => $like,
|
||||||
|
'reviewed_by' => $actor, 'reviewed_at' => $now, 'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$this->audit->append($actor, 'risk_control_save', "risk:$type", [],
|
||||||
|
['severity' => $sev, 'likelihood' => $like]);
|
||||||
|
return Response::json(['ok' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteRisk(Request $req, array $params): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
|
||||||
|
return Response::json(['error' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
||||||
|
return Response::json(['error' => 'csrf'], 403);
|
||||||
|
}
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
if (!$this->risks->delete($id, $this->site())) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
$this->audit->append((string)$this->sess->get('username'), 'risk_control_delete', "risk:$id", [], []);
|
||||||
|
return Response::json(['ok' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- shift handover
|
||||||
|
public function handovers(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
return Response::json([
|
||||||
|
'handovers' => $this->handovers->recent($this->site(), self::HANDOVER_LIST),
|
||||||
|
'pending' => $this->pendingHandover(),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the next handover would certify. Computed here and used by BOTH the read endpoint and
|
||||||
|
* the accept endpoint, so the number that is displayed is by construction the number that
|
||||||
|
* gets recorded — a browser-supplied count could understate the very backlog the record
|
||||||
|
* exists to pin down.
|
||||||
|
*/
|
||||||
|
private function pendingHandover(): array
|
||||||
|
{
|
||||||
|
$site = $this->site();
|
||||||
|
$last = $this->handovers->latest($site);
|
||||||
|
$now = time();
|
||||||
|
// The period runs from the last handover to now; on the very first one fall back to 12
|
||||||
|
// hours (one shift) rather than to the beginning of time.
|
||||||
|
$periodStart = $last !== null ? (int)$last['created_at'] : $now - 12 * 3600;
|
||||||
|
|
||||||
|
$unacked = 0;
|
||||||
|
$severe = 0;
|
||||||
|
foreach ($this->events->page(200, null, null, null, $site) as $ev) {
|
||||||
|
if ((float)$ev['occurred_at'] < $periodStart) continue;
|
||||||
|
if (!empty($ev['acknowledged_by']) || !empty($ev['false_positive_by'])) continue;
|
||||||
|
$unacked++;
|
||||||
|
if (in_array($ev['severity'], ['critical', 'high'], true)) $severe++;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'period_start' => $periodStart,
|
||||||
|
'period_end' => $now,
|
||||||
|
'unacked_count' => $unacked,
|
||||||
|
'severe_count' => $severe,
|
||||||
|
'from_user' => $last['to_user'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptHandover(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) {
|
||||||
|
return Response::json(['error' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
||||||
|
return Response::json(['error' => 'csrf'], 403);
|
||||||
|
}
|
||||||
|
$b = $req->json();
|
||||||
|
$note = trim((string)($b['note'] ?? ''));
|
||||||
|
if (mb_strlen($note) > self::MAX_NOTE) return Response::json(['error' => 'invalid_note'], 400);
|
||||||
|
|
||||||
|
// Recomputed server-side, never taken from the request body (see pendingHandover).
|
||||||
|
$p = $this->pendingHandover();
|
||||||
|
|
||||||
|
$actor = (string)$this->sess->get('username');
|
||||||
|
$now = time();
|
||||||
|
$id = $this->handovers->create([
|
||||||
|
'site' => $this->site(),
|
||||||
|
'from_user' => $p['from_user'] ?? null,
|
||||||
|
'to_user' => $actor,
|
||||||
|
'period_start' => (int)$p['period_start'],
|
||||||
|
'period_end' => (int)$p['period_end'],
|
||||||
|
'unacked_count' => (int)$p['unacked_count'],
|
||||||
|
'severe_count' => (int)$p['severe_count'],
|
||||||
|
'note' => $note !== '' ? $note : null,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
$this->audit->append($actor, 'shift_handover', "handover:$id", [],
|
||||||
|
['unacked' => (int)$p['unacked_count'], 'severe' => (int)$p['severe_count']]);
|
||||||
|
return Response::json(['handover_id' => $id], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
namespace App\Dashboard;
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\SessionStore;
|
use App\Auth\SessionStore;
|
||||||
use App\Repository\DeviceRepository;
|
use App\Security\SiteScope;
|
||||||
|
use App\Repository\{DeviceRepository, DeviceMetricRepository};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
|
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
|
||||||
|
|
@ -25,7 +27,14 @@ final class DeviceStatusController
|
||||||
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
|
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
|
||||||
private const OFFLINE_AFTER_S = 120;
|
private const OFFLINE_AFTER_S = 120;
|
||||||
|
|
||||||
public function __construct(private DeviceRepository $devices, private SessionStore $sess) {}
|
/** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */
|
||||||
|
private const SERIES_WINDOW_S = 86400;
|
||||||
|
private const SERIES_LIMIT = 3000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private DeviceRepository $devices, private SessionStore $sess,
|
||||||
|
private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null
|
||||||
|
) {}
|
||||||
|
|
||||||
public function json(Request $req, int $now): Response
|
public function json(Request $req, int $now): Response
|
||||||
{
|
{
|
||||||
|
|
@ -54,6 +63,63 @@ final class DeviceStatusController
|
||||||
];
|
];
|
||||||
}, $this->devices->listStatus($site));
|
}, $this->devices->listStatus($site));
|
||||||
|
|
||||||
return Response::json(['devices' => $devices], 200);
|
return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/devices/{id}/metrics — the heartbeat time series for one device.
|
||||||
|
*
|
||||||
|
* The status panel answers "is it alive right now"; this answers "has it been getting
|
||||||
|
* worse", which is the question that catches a node before it dies. A thermally throttling
|
||||||
|
* Pi loses fps for days before it stops.
|
||||||
|
*/
|
||||||
|
public function metrics(Request $req, array $params, int $now): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
if ($this->metrics === null) return Response::json(['error' => 'not_available'], 404);
|
||||||
|
|
||||||
|
$deviceId = (string)($params['id'] ?? '');
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
$site = is_string($scope) ? $scope : null;
|
||||||
|
// Scope before reading: telemetry reveals that a device exists and how it is doing.
|
||||||
|
if (!$this->devices->exists($deviceId)
|
||||||
|
|| !SiteScope::allows($site, $this->devices->siteFor($deviceId))) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hours = (int)($req->query('hours') ?? 24);
|
||||||
|
$window = max(3600, min(self::SERIES_WINDOW_S * 7, $hours * 3600));
|
||||||
|
return Response::json([
|
||||||
|
'device_id' => $deviceId,
|
||||||
|
'from' => $now - $window,
|
||||||
|
'points' => $this->metrics->series($deviceId, $now - $window, self::SERIES_LIMIT),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free space on the snapshot volume.
|
||||||
|
*
|
||||||
|
* On shared hosting a full disk does not announce itself: image writes start failing, the
|
||||||
|
* ingest path logs and moves on (by design — an event without its picture still beats no
|
||||||
|
* event), and the board keeps looking healthy while the evidence quietly stops being kept.
|
||||||
|
* Surfacing the number is what turns that into something somebody notices.
|
||||||
|
*/
|
||||||
|
private function storage(): ?array
|
||||||
|
{
|
||||||
|
if ($this->cfg === null) return null;
|
||||||
|
$minFreeMb = $this->cfg->storageMinFreeMb();
|
||||||
|
if ($minFreeMb <= 0) return null; // explicitly disabled for this host
|
||||||
|
$dir = $this->cfg->snapshotDir();
|
||||||
|
if (!is_dir($dir)) return null;
|
||||||
|
$free = @disk_free_space($dir);
|
||||||
|
$total = @disk_total_space($dir);
|
||||||
|
if ($free === false || $free === null) return null; // not meaningful on this host
|
||||||
|
$freeMb = (int)floor($free / 1048576);
|
||||||
|
return [
|
||||||
|
'free_mb' => $freeMb,
|
||||||
|
'total_mb' => ($total !== false && $total !== null) ? (int)floor($total / 1048576) : null,
|
||||||
|
'min_free_mb' => $minFreeMb,
|
||||||
|
'low' => $freeMb < $minFreeMb,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,24 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
server/src/Dashboard/ExportController.php
Normal file
80
server/src/Dashboard/ExportController.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
39
server/src/Dashboard/FpStatsController.php
Normal file
39
server/src/Dashboard/FpStatsController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
server/src/Dashboard/HeatmapController.php
Normal file
43
server/src/Dashboard/HeatmapController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
165
server/src/Dashboard/ReportController.php
Normal file
165
server/src/Dashboard/ReportController.php
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
136
server/src/Dashboard/SuppressionController.php
Normal file
136
server/src/Dashboard/SuppressionController.php
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Http\{Request, Response};
|
||||||
|
use App\Auth\{SessionStore, Rbac};
|
||||||
|
use App\Security\{Csrf, SiteScope};
|
||||||
|
use App\Repository\{SuppressionRepository, EventRepository, DeviceRepository, AuditRepository};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정비 모드 / 작업허가 알람 억제.
|
||||||
|
*
|
||||||
|
* The single largest source of false alarms on a real site is planned work: a maintenance crew
|
||||||
|
* inside a restricted zone trips every intrusion rule they walk past. A console that cries wolf
|
||||||
|
* through every shutdown is a console the site turns off, and then the next real fall goes
|
||||||
|
* unseen. So the system needs a way to say "we know, it's planned".
|
||||||
|
*
|
||||||
|
* Three constraints make that safe rather than dangerous:
|
||||||
|
* - Detection is never paused. Events are ingested, listed and reported exactly as always;
|
||||||
|
* only NOTIFICATION is withheld, and the withholding is itself recorded (status
|
||||||
|
* 'suppressed' in the alert ledger). An investigator can always reconstruct what the
|
||||||
|
* cameras saw during the window.
|
||||||
|
* - A reason is mandatory. "Somebody silenced it" is not an answer anyone can act on later.
|
||||||
|
* - There is a hard expiry, capped at MAX_WINDOW_S. The failure mode of this feature is a
|
||||||
|
* zone muted "just for now" that nobody ever unmutes.
|
||||||
|
*/
|
||||||
|
final class SuppressionController
|
||||||
|
{
|
||||||
|
private const MAX_REASON = 500;
|
||||||
|
/** 8 hours: one shift. Longer work needs a deliberate re-issue, which is the point. */
|
||||||
|
private const MAX_WINDOW_S = 8 * 3600;
|
||||||
|
private const MAX_LEAD_S = 7 * 86400; // may be scheduled up to a week ahead
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SuppressionRepository $suppressions,
|
||||||
|
private EventRepository $events,
|
||||||
|
private DeviceRepository $devices,
|
||||||
|
private AuditRepository $audit,
|
||||||
|
private SessionStore $sess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private function site(): ?string
|
||||||
|
{
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
return is_string($scope) ? $scope : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function json(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
$now = time();
|
||||||
|
$includeExpired = $req->query('all') === '1';
|
||||||
|
return Response::json([
|
||||||
|
'now' => $now,
|
||||||
|
'suppressions' => $this->suppressions->listAll($this->site(), $now, $includeExpired),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $req): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
// Same permission as zone editing: issuing a maintenance window is an operational act by
|
||||||
|
// somebody who is on site, not an administrative one.
|
||||||
|
if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403);
|
||||||
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
||||||
|
return Response::json(['error' => 'csrf'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$b = $req->json();
|
||||||
|
$scopeType = (string)($b['scope_type'] ?? '');
|
||||||
|
$scopeId = trim((string)($b['scope_id'] ?? ''));
|
||||||
|
$reason = trim((string)($b['reason'] ?? ''));
|
||||||
|
$minutes = (int)($b['minutes'] ?? 0);
|
||||||
|
$startsAt = isset($b['starts_at']) && is_numeric($b['starts_at']) ? (int)$b['starts_at'] : time();
|
||||||
|
|
||||||
|
if (!in_array($scopeType, ['device', 'camera', 'zone'], true)) return Response::json(['error' => 'invalid_scope'], 400);
|
||||||
|
if ($scopeId === '' || mb_strlen($scopeId) > 64) return Response::json(['error' => 'invalid_scope_id'], 400);
|
||||||
|
if ($reason === '' || mb_strlen($reason) > self::MAX_REASON) return Response::json(['error' => 'reason_required'], 400);
|
||||||
|
if ($minutes < 1 || $minutes * 60 > self::MAX_WINDOW_S) return Response::json(['error' => 'invalid_window'], 400);
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
if ($startsAt < $now - 3600 || $startsAt > $now + self::MAX_LEAD_S) {
|
||||||
|
return Response::json(['error' => 'invalid_start'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the site from the scoped object, and refuse anything outside the caller's own
|
||||||
|
// scope BEFORE writing — a suppression is a licence to stop alarming, and it must not be
|
||||||
|
// possible to issue one for another site by guessing a camera name.
|
||||||
|
$site = $this->resolveSite($scopeType, $scopeId);
|
||||||
|
if ($site === false) return Response::json(['error' => 'unknown_scope_id'], 400);
|
||||||
|
if (!SiteScope::allows($this->site(), $site)) return Response::json(['error' => 'not_found'], 404);
|
||||||
|
|
||||||
|
$actor = (string)$this->sess->get('username');
|
||||||
|
$id = $this->suppressions->create([
|
||||||
|
'scope_type' => $scopeType, 'scope_id' => $scopeId, 'site' => $site,
|
||||||
|
'reason' => $reason, 'starts_at' => $startsAt, 'ends_at' => $startsAt + $minutes * 60,
|
||||||
|
'created_by' => $actor, 'created_at' => $now,
|
||||||
|
]);
|
||||||
|
$this->audit->append($actor, 'suppression_create', "suppression:$id", [],
|
||||||
|
['scope' => "$scopeType:$scopeId", 'minutes' => $minutes, 'reason' => $reason]);
|
||||||
|
return Response::json(['suppression_id' => $id, 'ends_at' => $startsAt + $minutes * 60], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(Request $req, array $params): Response
|
||||||
|
{
|
||||||
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403);
|
||||||
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
||||||
|
return Response::json(['error' => 'csrf'], 403);
|
||||||
|
}
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$now = time();
|
||||||
|
if (!$this->suppressions->revoke($id, (string)$this->sess->get('username'), $now, $this->site())) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
$this->audit->append((string)$this->sess->get('username'), 'suppression_revoke', "suppression:$id", [], []);
|
||||||
|
return Response::json(['ok' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string|null|false site, or false when the scope target does not exist. */
|
||||||
|
private function resolveSite(string $scopeType, string $scopeId): string|null|false
|
||||||
|
{
|
||||||
|
if ($scopeType === 'device') {
|
||||||
|
if (!$this->devices->exists($scopeId)) return false;
|
||||||
|
return $this->devices->siteFor($scopeId);
|
||||||
|
}
|
||||||
|
if ($scopeType === 'camera') {
|
||||||
|
// Cameras have no registry (see schema.sql); their events are the ownership record.
|
||||||
|
if (!$this->events->cameraExists($scopeId)) return false;
|
||||||
|
return $this->events->siteForCamera($scopeId);
|
||||||
|
}
|
||||||
|
// A zone id is opaque here; scope is inherited from the caller so a scoped operator can
|
||||||
|
// only ever create one inside their own site, and a global operator may suppress any.
|
||||||
|
return $this->site();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,13 @@ 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,
|
||||||
|
|
@ -62,6 +69,33 @@ 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;
|
||||||
|
|
@ -74,6 +108,8 @@ 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
|
||||||
|
|
@ -102,6 +138,7 @@ 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
|
||||||
|
|
@ -110,6 +147,11 @@ final class ZoneConfigController
|
||||||
return Response::json(['error' => 'invalid_zone'], 400);
|
return Response::json(['error' => 'invalid_zone'], 400);
|
||||||
}
|
}
|
||||||
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
|
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
|
||||||
|
// Bump the device's config version so the drift indicator becomes meaningful: it now
|
||||||
|
// reads "edited, not yet picked up" until the node's next sync echoes the new version
|
||||||
|
// back. Nothing ever incremented it before, so the indicator could never light up and an
|
||||||
|
// edit that failed to reach a device was indistinguishable from one that arrived.
|
||||||
|
$this->devices->bumpConfigVersion($deviceId);
|
||||||
return Response::json(['zone_id' => $zoneId], 201);
|
return Response::json(['zone_id' => $zoneId], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +170,7 @@ final class ZoneConfigController
|
||||||
}
|
}
|
||||||
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
|
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
|
||||||
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
|
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
|
||||||
|
$this->devices->bumpConfigVersion((string)($existing['device_id'] ?? ''));
|
||||||
return Response::json(['ok' => true], 200);
|
return Response::json(['ok' => true], 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ 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): self {
|
public static function text(string $s, int $status = 200, array $headers = []): self {
|
||||||
return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
|
// Content-Type stays authoritative; extra headers (e.g. Content-Security-Policy) are added.
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ 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'];
|
||||||
|
|
||||||
|
|
@ -31,6 +32,16 @@ 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.
|
||||||
|
|
@ -58,6 +69,8 @@ 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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,17 @@ 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();
|
||||||
if ($days > 0 && ($this->purgeGate)()) {
|
$eventDays = $this->cfg->eventRetentionDays();
|
||||||
|
if (($days > 0 || $eventDays > 0) && ($this->purgeGate)()) {
|
||||||
|
if ($days > 0) {
|
||||||
$this->snapshots->purgeOlderThan($now - $days * 86400);
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
15
server/src/Repository/ActionRepository.php
Normal file
15
server/src/Repository/ActionRepository.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
34
server/src/Repository/AlertRepository.php
Normal file
34
server/src/Repository/AlertRepository.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
/** Notification channels and the delivery ledger. */
|
||||||
|
interface AlertRepository
|
||||||
|
{
|
||||||
|
/** @return array<int,array> enabled channels, ordered by step then id. */
|
||||||
|
public function channels(?string $site): array;
|
||||||
|
|
||||||
|
public function allChannels(?string $site): array;
|
||||||
|
|
||||||
|
public function saveChannel(array $row): int;
|
||||||
|
|
||||||
|
public function deleteChannel(int $id, ?string $site): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim (event, channel) for delivery. Returns false when it was already claimed —
|
||||||
|
* that is the duplicate-send guard, enforced by a UNIQUE key rather than by a read.
|
||||||
|
*/
|
||||||
|
public function claim(int $eventId, int $channelId, int $now): bool;
|
||||||
|
|
||||||
|
public function markSent(int $eventId, int $channelId, int $now): void;
|
||||||
|
|
||||||
|
public function markFailed(int $eventId, int $channelId, string $error, int $now): void;
|
||||||
|
|
||||||
|
public function markSuppressed(int $eventId, int $channelId, int $now): void;
|
||||||
|
|
||||||
|
/** Delivery rows for one event, for the incident record. */
|
||||||
|
public function forEvent(int $eventId): array;
|
||||||
|
|
||||||
|
/** Rows still worth retrying (status=failed, attempts under the cap). */
|
||||||
|
public function retryable(int $maxAttempts, int $limit): array;
|
||||||
|
}
|
||||||
15
server/src/Repository/DeviceMetricRepository.php
Normal file
15
server/src/Repository/DeviceMetricRepository.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
/** Heartbeat telemetry as a time series, so slow degradation is visible. */
|
||||||
|
interface DeviceMetricRepository
|
||||||
|
{
|
||||||
|
public function append(string $deviceId, array $metrics, int $now): void;
|
||||||
|
|
||||||
|
/** @return array<int,array> ascending by ts. */
|
||||||
|
public function series(string $deviceId, int $sinceTs, int $limit): array;
|
||||||
|
|
||||||
|
/** Retention: drop points older than $before. Returns rows removed. */
|
||||||
|
public function prune(int $before): int;
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,14 @@ interface DeviceRepository
|
||||||
public function siteFor(string $deviceId): ?string;
|
public function siteFor(string $deviceId): ?string;
|
||||||
public function touchHeartbeat(string $deviceId, array $meta): void;
|
public function touchHeartbeat(string $deviceId, array $meta): void;
|
||||||
public function configVersion(string $deviceId): int;
|
public function configVersion(string $deviceId): int;
|
||||||
|
/**
|
||||||
|
* Increment config_version so the device's next sync sees a NEW version and applies what
|
||||||
|
* changed. Without this a zone or threshold edit sits in the database looking applied while
|
||||||
|
* the node keeps running the old value — the exact drift the dashboard's config-drift
|
||||||
|
* indicator exists to expose, except nothing would ever have caused it to light up.
|
||||||
|
* Returns the new version (or 0 when the device does not exist).
|
||||||
|
*/
|
||||||
|
public function bumpConfigVersion(string $deviceId): int;
|
||||||
|
|
||||||
/** All devices with their health telemetry, for the dashboard's device-status panel.
|
/** All devices with their health telemetry, for the dashboard's device-status panel.
|
||||||
* Site-scoped: a non-null $site returns only that site's devices (a scoped operator);
|
* Site-scoped: a non-null $site returns only that site's devices (a scoped operator);
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,83 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
server/src/Repository/HandoverRepository.php
Normal file
13
server/src/Repository/HandoverRepository.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
/** Shift handover records: who accepted responsibility for which backlog. */
|
||||||
|
interface HandoverRepository
|
||||||
|
{
|
||||||
|
public function recent(?string $site, int $limit): array;
|
||||||
|
|
||||||
|
public function latest(?string $site): ?array;
|
||||||
|
|
||||||
|
public function create(array $row): int;
|
||||||
|
}
|
||||||
58
server/src/Repository/Pdo/PdoActionRepository.php
Normal file
58
server/src/Repository/Pdo/PdoActionRepository.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
184
server/src/Repository/Pdo/PdoAlertRepository.php
Normal file
184
server/src/Repository/Pdo/PdoAlertRepository.php
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository\Pdo;
|
||||||
|
|
||||||
|
use App\Repository\AlertRepository;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification channels and the delivery ledger.
|
||||||
|
*
|
||||||
|
* The ledger is not bookkeeping — it is the duplicate-send guard. The dispatcher runs from cron
|
||||||
|
* on shared hosting, where an overrunning job and the next scheduled one routinely overlap, so
|
||||||
|
* "read whether we sent it, then send it" is a race that phones the same fall in five times.
|
||||||
|
* claim() instead INSERTs a (event_id, channel_id) row and lets the UNIQUE key decide: exactly
|
||||||
|
* one caller wins, and it is the one that then does the sending.
|
||||||
|
*/
|
||||||
|
final class PdoAlertRepository implements AlertRepository
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
public function channels(?string $site): array
|
||||||
|
{
|
||||||
|
// A NULL-site channel is a global one (the single-site default install); it applies
|
||||||
|
// everywhere. A scoped channel applies only to its own site.
|
||||||
|
$sql = 'SELECT * FROM move_alert_channels WHERE enabled = 1';
|
||||||
|
$args = [];
|
||||||
|
if ($site !== null) { $sql .= ' AND (site IS NULL OR site = ?)'; $args[] = $site; }
|
||||||
|
$sql .= ' ORDER BY step ASC, channel_id ASC';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return array_map([self::class, 'mapChannel'], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allChannels(?string $site): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT * FROM move_alert_channels';
|
||||||
|
$args = [];
|
||||||
|
if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; }
|
||||||
|
$sql .= ' ORDER BY step ASC, channel_id ASC';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return array_map([self::class, 'mapChannel'], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveChannel(array $row): int
|
||||||
|
{
|
||||||
|
if (!empty($row['channel_id'])) {
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'UPDATE move_alert_channels
|
||||||
|
SET site = ?, kind = ?, name = ?, config_json = ?, min_severity = ?,
|
||||||
|
enabled = ?, step = ?, escalate_after_s = ?
|
||||||
|
WHERE channel_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([
|
||||||
|
$row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'],
|
||||||
|
$row['min_severity'], (int)$row['enabled'], (int)$row['step'],
|
||||||
|
(int)$row['escalate_after_s'], (int)$row['channel_id'],
|
||||||
|
]);
|
||||||
|
return (int)$row['channel_id'];
|
||||||
|
}
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_alert_channels (site, kind, name, config_json, min_severity, enabled, step, escalate_after_s, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$st->execute([
|
||||||
|
$row['site'] ?? null, $row['kind'], $row['name'], $row['config_json'],
|
||||||
|
$row['min_severity'], (int)$row['enabled'], (int)$row['step'],
|
||||||
|
(int)$row['escalate_after_s'], $row['created_at'],
|
||||||
|
]);
|
||||||
|
return (int)$this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteChannel(int $id, ?string $site): bool
|
||||||
|
{
|
||||||
|
$sql = 'DELETE FROM move_alert_channels WHERE channel_id = ?';
|
||||||
|
$args = [$id];
|
||||||
|
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return $st->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function claim(int $eventId, int $channelId, int $now): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_alert_log (event_id, channel_id, status, attempts, queued_at)
|
||||||
|
VALUES (?, ?, ?, 0, ?)'
|
||||||
|
);
|
||||||
|
$st->execute([$eventId, $channelId, 'pending', $now]);
|
||||||
|
return true;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Duplicate key = another dispatcher run already owns this delivery. That is the
|
||||||
|
// designed outcome, not an error: losing the race means someone else is sending.
|
||||||
|
if (self::isDuplicate($e)) return false;
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markSent(int $eventId, int $channelId, int $now): void
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'UPDATE move_alert_log SET status = ?, sent_at = ?, attempts = attempts + 1, last_error = NULL
|
||||||
|
WHERE event_id = ? AND channel_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute(['sent', $now, $eventId, $channelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markFailed(int $eventId, int $channelId, string $error, int $now): void
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'UPDATE move_alert_log SET status = ?, attempts = attempts + 1, last_error = ?
|
||||||
|
WHERE event_id = ? AND channel_id = ?'
|
||||||
|
);
|
||||||
|
// Truncate: a curl error body can be long and the column is not the place to store it.
|
||||||
|
$st->execute(['failed', mb_substr($error, 0, 500), $eventId, $channelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markSuppressed(int $eventId, int $channelId, int $now): void
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'UPDATE move_alert_log SET status = ?, sent_at = ? WHERE event_id = ? AND channel_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute(['suppressed', $now, $eventId, $channelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forEvent(int $eventId): array
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'SELECT l.*, c.name AS channel_name, c.kind
|
||||||
|
FROM move_alert_log l
|
||||||
|
LEFT JOIN move_alert_channels c ON c.channel_id = l.channel_id
|
||||||
|
WHERE l.event_id = ? ORDER BY l.alert_id ASC'
|
||||||
|
);
|
||||||
|
$st->execute([$eventId]);
|
||||||
|
return array_map(static fn (array $r): array => [
|
||||||
|
'channel_id' => (int)$r['channel_id'],
|
||||||
|
'channel_name' => $r['channel_name'] !== null ? (string)$r['channel_name'] : null,
|
||||||
|
'kind' => $r['kind'] !== null ? (string)$r['kind'] : null,
|
||||||
|
'status' => (string)$r['status'],
|
||||||
|
'attempts' => (int)$r['attempts'],
|
||||||
|
'last_error' => $r['last_error'] !== null ? (string)$r['last_error'] : null,
|
||||||
|
'queued_at' => (int)$r['queued_at'],
|
||||||
|
'sent_at' => $r['sent_at'] !== null ? (int)$r['sent_at'] : null,
|
||||||
|
], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryable(int $maxAttempts, int $limit): array
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'SELECT event_id, channel_id, attempts FROM move_alert_log
|
||||||
|
WHERE status = ? AND attempts < ? ORDER BY queued_at ASC LIMIT ' . max(1, min(500, $limit))
|
||||||
|
);
|
||||||
|
$st->execute(['failed', $maxAttempts]);
|
||||||
|
return array_map(static fn (array $r): array => [
|
||||||
|
'event_id' => (int)$r['event_id'],
|
||||||
|
'channel_id' => (int)$r['channel_id'],
|
||||||
|
'attempts' => (int)$r['attempts'],
|
||||||
|
], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isDuplicate(PDOException $e): bool
|
||||||
|
{
|
||||||
|
// MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class
|
||||||
|
// so this holds for both drivers the project runs (MySQL in production, sqlite in tests).
|
||||||
|
return ($e->getCode() === '23000') || str_contains($e->getMessage(), 'UNIQUE constraint failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mapChannel(array $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'channel_id' => (int)$r['channel_id'],
|
||||||
|
'site' => $r['site'] !== null ? (string)$r['site'] : null,
|
||||||
|
'kind' => (string)$r['kind'],
|
||||||
|
'name' => (string)$r['name'],
|
||||||
|
'config_json' => (string)$r['config_json'],
|
||||||
|
'min_severity' => (string)$r['min_severity'],
|
||||||
|
'enabled' => (int)$r['enabled'],
|
||||||
|
'step' => (int)$r['step'],
|
||||||
|
'escalate_after_s' => (int)$r['escalate_after_s'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,23 +5,79 @@ namespace App\Repository\Pdo;
|
||||||
use App\Repository\AuditRepository;
|
use App\Repository\AuditRepository;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tamper-EVIDENT audit log.
|
||||||
|
*
|
||||||
|
* Shared hosting cannot give the app an append-only table: the same DB user that inserts rows
|
||||||
|
* can also UPDATE and DELETE them, and there is no way to drop that privilege from inside the
|
||||||
|
* application. So the goal is not to make tampering impossible — it is to make it impossible to
|
||||||
|
* do QUIETLY. Each row carries the previous row's hash, and its own hash covers its contents
|
||||||
|
* plus that link. Change one row, or remove one, and every hash after it stops matching; the
|
||||||
|
* verifier (tools/verify_audit_chain.php) prints exactly where the chain forks.
|
||||||
|
*
|
||||||
|
* `id` is deliberately NOT part of the hash. Excluding it lets the whole row be written in a
|
||||||
|
* single INSERT (the hash is computed before the id exists), which keeps the append atomic;
|
||||||
|
* deletion is still detected, because the row after the hole carries a prev_hash that matches
|
||||||
|
* nothing that remains.
|
||||||
|
*/
|
||||||
final class PdoAuditRepository implements AuditRepository
|
final class PdoAuditRepository implements AuditRepository
|
||||||
{
|
{
|
||||||
public function __construct(private PDO $pdo) {}
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
public function append(string $actor, string $action, string $target, array $before, array $after): void
|
public function append(string $actor, string $action, string $target, array $before, array $after): void
|
||||||
{
|
{
|
||||||
|
$beforeJson = json_encode($before, JSON_THROW_ON_ERROR);
|
||||||
|
$afterJson = json_encode($after, JSON_THROW_ON_ERROR);
|
||||||
|
$ts = time();
|
||||||
|
|
||||||
|
// The read of the tail and the write of the new row must not interleave with another
|
||||||
|
// append, or two rows would claim the same predecessor and the chain would fork. When
|
||||||
|
// this repository owns the transaction it also takes a row lock (MySQL); sqlite's
|
||||||
|
// single-writer model makes the lock unnecessary and it has no FOR UPDATE.
|
||||||
|
$ownTransaction = !$this->pdo->inTransaction();
|
||||||
|
if ($ownTransaction) $this->pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$prev = $this->tailHash();
|
||||||
|
$entry = self::hash($actor, $action, $target, $beforeJson, $afterJson, $ts, $prev);
|
||||||
$st = $this->pdo->prepare(
|
$st = $this->pdo->prepare(
|
||||||
'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts)
|
'INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts, prev_hash, entry_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
$st->execute([
|
$st->execute([$actor, $action, $target, $beforeJson, $afterJson, $ts, $prev, $entry]);
|
||||||
$actor,
|
if ($ownTransaction) $this->pdo->commit();
|
||||||
$action,
|
} catch (\Throwable $e) {
|
||||||
$target,
|
if ($ownTransaction && $this->pdo->inTransaction()) $this->pdo->rollBack();
|
||||||
json_encode($before, JSON_THROW_ON_ERROR),
|
throw $e;
|
||||||
json_encode($after, JSON_THROW_ON_ERROR),
|
}
|
||||||
time(),
|
}
|
||||||
]);
|
|
||||||
|
private function tailHash(): ?string
|
||||||
|
{
|
||||||
|
$sql = 'SELECT entry_hash FROM move_audit_log ORDER BY id DESC LIMIT 1';
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||||
|
$sql .= ' FOR UPDATE';
|
||||||
|
}
|
||||||
|
$row = $this->pdo->query($sql)->fetch();
|
||||||
|
if ($row === false) return null; // first entry ever: chain genesis
|
||||||
|
$hash = $row['entry_hash'] ?? null;
|
||||||
|
// NULL here means the tail predates the chain (rows written before the migration).
|
||||||
|
// Linking to NULL restarts the chain from this row, and the verifier reports the
|
||||||
|
// pre-chain rows as an unverifiable prefix rather than as tampering.
|
||||||
|
return is_string($hash) && $hash !== '' ? $hash : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hashed record. Field separator is a unit separator (0x1F), not a printable character:
|
||||||
|
* with a plain '|' an actor literally named "a|b" could be crafted to collide with a
|
||||||
|
* different (actor, action) split, and forging an audit entry is exactly the attack this
|
||||||
|
* chain exists to catch.
|
||||||
|
*/
|
||||||
|
public static function hash(
|
||||||
|
string $actor, string $action, string $target,
|
||||||
|
string $beforeJson, string $afterJson, int $ts, ?string $prev
|
||||||
|
): string {
|
||||||
|
return hash('sha256', implode("\x1f", [
|
||||||
|
$actor, $action, $target, $beforeJson, $afterJson, (string)$ts, $prev ?? '',
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
server/src/Repository/Pdo/PdoDeviceMetricRepository.php
Normal file
65
server/src/Repository/Pdo/PdoDeviceMetricRepository.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository\Pdo;
|
||||||
|
|
||||||
|
use App\Repository\DeviceMetricRepository;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeat telemetry as a time series.
|
||||||
|
*
|
||||||
|
* move_devices keeps only the newest value, which cannot answer "is this node getting worse?".
|
||||||
|
* At a 30s heartbeat one device writes ~2,880 rows/day, so prune() is not optional housekeeping
|
||||||
|
* — on a shared-hosting DB quota it is what keeps this table from becoming the reason ingest
|
||||||
|
* itself starts failing.
|
||||||
|
*/
|
||||||
|
final class PdoDeviceMetricRepository implements DeviceMetricRepository
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
public function append(string $deviceId, array $metrics, int $now): void
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_device_metrics (device_id, ts, fps, temp, throttle, queue_pending,
|
||||||
|
cameras_total, cameras_erroring, errors)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
// A missing metric stays NULL. Never coerce an absent reading to 0: a device that cannot
|
||||||
|
// read its temperature would then look like a device sitting at 0°C.
|
||||||
|
$num = static fn (string $k) => isset($metrics[$k]) && is_numeric($metrics[$k]) ? $metrics[$k] + 0 : null;
|
||||||
|
$st->execute([
|
||||||
|
$deviceId, $now,
|
||||||
|
$num('fps'), $num('temp'),
|
||||||
|
isset($metrics['throttle']) ? (int)(bool)$metrics['throttle'] : null,
|
||||||
|
$num('queue_pending'), $num('cameras_total'), $num('cameras_erroring'), $num('errors'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function series(string $deviceId, int $sinceTs, int $limit): array
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'SELECT ts, fps, temp, throttle, queue_pending, cameras_total, cameras_erroring, errors
|
||||||
|
FROM move_device_metrics
|
||||||
|
WHERE device_id = ? AND ts >= ?
|
||||||
|
ORDER BY ts ASC LIMIT ' . max(1, min(5000, $limit))
|
||||||
|
);
|
||||||
|
$st->execute([$deviceId, $sinceTs]);
|
||||||
|
return array_map(static fn (array $r): array => [
|
||||||
|
'ts' => (int)$r['ts'],
|
||||||
|
'fps' => $r['fps'] !== null ? (float)$r['fps'] : null,
|
||||||
|
'temp' => $r['temp'] !== null ? (float)$r['temp'] : null,
|
||||||
|
'throttle' => $r['throttle'] !== null ? (bool)$r['throttle'] : null,
|
||||||
|
'queue_pending' => $r['queue_pending'] !== null ? (int)$r['queue_pending'] : null,
|
||||||
|
'cameras_total' => $r['cameras_total'] !== null ? (int)$r['cameras_total'] : null,
|
||||||
|
'cameras_erroring' => $r['cameras_erroring'] !== null ? (int)$r['cameras_erroring'] : null,
|
||||||
|
'errors' => $r['errors'] !== null ? (int)$r['errors'] : null,
|
||||||
|
], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prune(int $before): int
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare('DELETE FROM move_device_metrics WHERE ts < ?');
|
||||||
|
$st->execute([$before]);
|
||||||
|
return $st->rowCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,18 @@ final class PdoDeviceRepository implements DeviceRepository
|
||||||
return $version === false ? 1 : (int)$version;
|
return $version === false ? 1 : (int)$version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function bumpConfigVersion(string $deviceId): int
|
||||||
|
{
|
||||||
|
// Increment in SQL, not read-modify-write: two admins saving different zones at the same
|
||||||
|
// moment would otherwise both write version+1 and one edit would never reach the device.
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'UPDATE move_devices SET config_version = config_version + 1 WHERE device_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$deviceId]);
|
||||||
|
if ($st->rowCount() === 0) return 0;
|
||||||
|
return $this->configVersion($deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
public function listStatus(?string $site): array
|
public function listStatus(?string $site): array
|
||||||
{
|
{
|
||||||
// Site-scope access control, same rule as the event list: a non-null scope filters to
|
// Site-scope access control, same rule as the event list: a non-null scope filters to
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ 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, acknowledged_by, acknowledged_at';
|
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at,
|
||||||
|
false_positive_by, false_positive_at';
|
||||||
|
|
||||||
public function __construct(private PDO $pdo) {}
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
|
@ -18,8 +19,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, received_at)
|
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, 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)
|
||||||
|
|
@ -33,6 +34,8 @@ 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();
|
||||||
|
|
@ -131,6 +134,311 @@ 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 [
|
||||||
|
|
@ -146,6 +454,10 @@ 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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
66
server/src/Repository/Pdo/PdoHandoverRepository.php
Normal file
66
server/src/Repository/Pdo/PdoHandoverRepository.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository\Pdo;
|
||||||
|
|
||||||
|
use App\Repository\HandoverRepository;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift handover records.
|
||||||
|
*
|
||||||
|
* unacked_count/severe_count are stored as a SNAPSHOT taken at handover time rather than
|
||||||
|
* recomputed on read. They are the number the incoming operator actually accepted, and if a
|
||||||
|
* later query disagrees with it that disagreement is itself the finding — recomputing would
|
||||||
|
* silently erase it.
|
||||||
|
*/
|
||||||
|
final class PdoHandoverRepository implements HandoverRepository
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
public function recent(?string $site, int $limit): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT * FROM move_shift_handovers';
|
||||||
|
$args = [];
|
||||||
|
if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; }
|
||||||
|
$sql .= ' ORDER BY created_at DESC, handover_id DESC LIMIT ' . max(1, min(100, $limit));
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return array_map([self::class, 'mapRow'], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latest(?string $site): ?array
|
||||||
|
{
|
||||||
|
return $this->recent($site, 1)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $row): int
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_shift_handovers (site, from_user, to_user, period_start, period_end,
|
||||||
|
unacked_count, severe_count, note, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$st->execute([
|
||||||
|
$row['site'] ?? null, $row['from_user'] ?? null, $row['to_user'],
|
||||||
|
$row['period_start'], $row['period_end'], $row['unacked_count'], $row['severe_count'],
|
||||||
|
$row['note'] ?? null, $row['created_at'],
|
||||||
|
]);
|
||||||
|
return (int)$this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mapRow(array $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'handover_id' => (int)$r['handover_id'],
|
||||||
|
'site' => $r['site'] !== null ? (string)$r['site'] : null,
|
||||||
|
'from_user' => $r['from_user'] !== null ? (string)$r['from_user'] : null,
|
||||||
|
'to_user' => (string)$r['to_user'],
|
||||||
|
'period_start' => (int)$r['period_start'],
|
||||||
|
'period_end' => (int)$r['period_end'],
|
||||||
|
'unacked_count' => (int)$r['unacked_count'],
|
||||||
|
'severe_count' => (int)$r['severe_count'],
|
||||||
|
'note' => $r['note'] !== null ? (string)$r['note'] : null,
|
||||||
|
'created_at' => (int)$r['created_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
92
server/src/Repository/Pdo/PdoRiskControlRepository.php
Normal file
92
server/src/Repository/Pdo/PdoRiskControlRepository.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository\Pdo;
|
||||||
|
|
||||||
|
use App\Repository\RiskControlRepository;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위험성평가 매핑: one row per (site, detection type).
|
||||||
|
*
|
||||||
|
* The UNIQUE key is (site, event_type) and MySQL treats NULL sites as distinct, so a plain
|
||||||
|
* "INSERT ... ON DUPLICATE KEY UPDATE" would append a second global row instead of updating the
|
||||||
|
* first. upsert() therefore looks the row up explicitly, with an IS NULL predicate for the
|
||||||
|
* global case.
|
||||||
|
*/
|
||||||
|
final class PdoRiskControlRepository implements RiskControlRepository
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
public function listAll(?string $site): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT * FROM move_risk_controls';
|
||||||
|
$args = [];
|
||||||
|
if ($site !== null) { $sql .= ' WHERE site = ?'; $args[] = $site; }
|
||||||
|
// Highest risk score first: the table is read to decide what to fix next.
|
||||||
|
$sql .= ' ORDER BY (severity_rating * likelihood_rating) DESC, event_type ASC';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return array_map([self::class, 'mapRow'], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upsert(array $row): void
|
||||||
|
{
|
||||||
|
$site = $row['site'] ?? null;
|
||||||
|
$sql = 'SELECT control_id FROM move_risk_controls WHERE event_type = ? AND site '
|
||||||
|
. ($site === null ? 'IS NULL' : '= ?');
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($site === null ? [$row['event_type']] : [$row['event_type'], $site]);
|
||||||
|
$id = $st->fetchColumn();
|
||||||
|
|
||||||
|
if ($id !== false) {
|
||||||
|
$up = $this->pdo->prepare(
|
||||||
|
'UPDATE move_risk_controls
|
||||||
|
SET hazard = ?, control = ?, severity_rating = ?, likelihood_rating = ?,
|
||||||
|
reviewed_by = ?, reviewed_at = ?, updated_at = ?
|
||||||
|
WHERE control_id = ?'
|
||||||
|
);
|
||||||
|
$up->execute([
|
||||||
|
$row['hazard'], $row['control'], (int)$row['severity_rating'], (int)$row['likelihood_rating'],
|
||||||
|
$row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'], (int)$id,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ins = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_risk_controls (site, event_type, hazard, control, severity_rating,
|
||||||
|
likelihood_rating, reviewed_by, reviewed_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$ins->execute([
|
||||||
|
$site, $row['event_type'], $row['hazard'], $row['control'],
|
||||||
|
(int)$row['severity_rating'], (int)$row['likelihood_rating'],
|
||||||
|
$row['reviewed_by'] ?? null, $row['reviewed_at'] ?? null, $row['updated_at'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id, ?string $site): bool
|
||||||
|
{
|
||||||
|
$sql = 'DELETE FROM move_risk_controls WHERE control_id = ?';
|
||||||
|
$args = [$id];
|
||||||
|
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return $st->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mapRow(array $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'control_id' => (int)$r['control_id'],
|
||||||
|
'site' => $r['site'] !== null ? (string)$r['site'] : null,
|
||||||
|
'event_type' => (string)$r['event_type'],
|
||||||
|
'hazard' => (string)$r['hazard'],
|
||||||
|
'control' => (string)$r['control'],
|
||||||
|
'severity_rating' => (int)$r['severity_rating'],
|
||||||
|
'likelihood_rating' => (int)$r['likelihood_rating'],
|
||||||
|
'risk_score' => (int)$r['severity_rating'] * (int)$r['likelihood_rating'],
|
||||||
|
'reviewed_by' => $r['reviewed_by'] !== null ? (string)$r['reviewed_by'] : null,
|
||||||
|
'reviewed_at' => $r['reviewed_at'] !== null ? (int)$r['reviewed_at'] : null,
|
||||||
|
'updated_at' => (int)$r['updated_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,22 @@ 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
|
||||||
|
|
|
||||||
109
server/src/Repository/Pdo/PdoSuppressionRepository.php
Normal file
109
server/src/Repository/Pdo/PdoSuppressionRepository.php
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository\Pdo;
|
||||||
|
|
||||||
|
use App\Repository\SuppressionRepository;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Planned-work alarm suppression.
|
||||||
|
*
|
||||||
|
* A suppression never stops detection or ingest — the events still land, still show in the
|
||||||
|
* console and still count in the report. It withholds NOTIFICATION only. That distinction is
|
||||||
|
* the whole design: an investigation after an accident must be able to see what the cameras saw
|
||||||
|
* during the maintenance window, and a "quiet mode" that dropped events would destroy exactly
|
||||||
|
* the evidence someone would later be prosecuted over.
|
||||||
|
*
|
||||||
|
* Every row has a hard expiry. There is no open-ended suppression, because the failure mode of
|
||||||
|
* this feature is somebody silencing a zone "for now" and nobody ever turning it back on.
|
||||||
|
*/
|
||||||
|
final class PdoSuppressionRepository implements SuppressionRepository
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
/** @param array<int,array{0:string,1:string}> $scopes [[type, id], ...] */
|
||||||
|
public function activeFor(array $scopes, int $now): array
|
||||||
|
{
|
||||||
|
if ($scopes === []) return [];
|
||||||
|
$where = [];
|
||||||
|
$args = [];
|
||||||
|
foreach ($scopes as [$type, $id]) {
|
||||||
|
$where[] = '(scope_type = ? AND scope_id = ?)';
|
||||||
|
$args[] = $type;
|
||||||
|
$args[] = (string)$id;
|
||||||
|
}
|
||||||
|
$sql = 'SELECT * FROM move_suppressions
|
||||||
|
WHERE revoked_at IS NULL AND starts_at <= ? AND ends_at > ?
|
||||||
|
AND (' . implode(' OR ', $where) . ')';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute(array_merge([$now, $now], $args));
|
||||||
|
return array_map([self::class, 'mapRow'], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listAll(?string $site, int $now, bool $includeExpired = false): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT * FROM move_suppressions WHERE 1=1';
|
||||||
|
$args = [];
|
||||||
|
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
|
||||||
|
if (!$includeExpired) {
|
||||||
|
$sql .= ' AND revoked_at IS NULL AND ends_at > ?';
|
||||||
|
$args[] = $now;
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY created_at DESC LIMIT 200';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return array_map([self::class, 'mapRow'], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $row): int
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_suppressions (scope_type, scope_id, site, reason, starts_at, ends_at, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$st->execute([
|
||||||
|
$row['scope_type'], $row['scope_id'], $row['site'] ?? null, $row['reason'],
|
||||||
|
$row['starts_at'], $row['ends_at'], $row['created_by'], $row['created_at'],
|
||||||
|
]);
|
||||||
|
return (int)$this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(int $id, string $user, int $now, ?string $site): bool
|
||||||
|
{
|
||||||
|
// The site predicate is part of the UPDATE, not a prior read: a scoped operator must not
|
||||||
|
// be able to lift another site's suppression by guessing an id, and a check-then-write
|
||||||
|
// would leave a window between the two.
|
||||||
|
$sql = 'UPDATE move_suppressions SET revoked_by = ?, revoked_at = ?
|
||||||
|
WHERE suppression_id = ? AND revoked_at IS NULL';
|
||||||
|
$args = [$user, $now, $id];
|
||||||
|
if ($site !== null) { $sql .= ' AND site = ?'; $args[] = $site; }
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return $st->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?array
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare('SELECT * FROM move_suppressions WHERE suppression_id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
return $r === false ? null : self::mapRow($r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mapRow(array $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'suppression_id' => (int)$r['suppression_id'],
|
||||||
|
'scope_type' => (string)$r['scope_type'],
|
||||||
|
'scope_id' => (string)$r['scope_id'],
|
||||||
|
'site' => $r['site'] !== null ? (string)$r['site'] : null,
|
||||||
|
'reason' => (string)$r['reason'],
|
||||||
|
'starts_at' => (int)$r['starts_at'],
|
||||||
|
'ends_at' => (int)$r['ends_at'],
|
||||||
|
'created_by' => (string)$r['created_by'],
|
||||||
|
'created_at' => (int)$r['created_at'],
|
||||||
|
'revoked_by' => $r['revoked_by'] !== null ? (string)$r['revoked_by'] : null,
|
||||||
|
'revoked_at' => $r['revoked_at'] !== null ? (int)$r['revoked_at'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,31 @@ namespace App\Repository\Pdo;
|
||||||
use App\Repository\ThresholdRepository;
|
use App\Repository\ThresholdRepository;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-device (optionally per-camera) tunables, served to the edge by GET /api/config.
|
||||||
|
*
|
||||||
|
* These rows used to be editable only by hand in phpMyAdmin — which meant that after delivery
|
||||||
|
* the customer had no way to retune a camera at all, even though the console's false-positive
|
||||||
|
* panel exists precisely to tell them which camera needs it. The write methods below back the
|
||||||
|
* admin editor; every write records who made it, because a threshold change is a change to what
|
||||||
|
* the system will and will not detect, and an incident investigation has to be able to ask when
|
||||||
|
* it happened.
|
||||||
|
*
|
||||||
|
* Site scope is resolved through move_devices: a scoped operator must never see, let alone
|
||||||
|
* retune, another site's device.
|
||||||
|
*/
|
||||||
final class PdoThresholdRepository implements ThresholdRepository
|
final class PdoThresholdRepository implements ThresholdRepository
|
||||||
{
|
{
|
||||||
public function __construct(private PDO $pdo) {}
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
public function forDevice(string $deviceId): array
|
public function forDevice(string $deviceId): array
|
||||||
{
|
{
|
||||||
$st = $this->pdo->prepare('SELECT `key`, `value` FROM move_thresholds WHERE device_id = ?');
|
// Device-wide rows only (camera_id IS NULL). Per-camera overrides are addressed by the
|
||||||
|
// edge through its own camera id and are not flattened into this map, which the daemon
|
||||||
|
// applies globally — merging them here would silently apply one camera's tuning to all.
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'SELECT `key`, `value` FROM move_thresholds WHERE device_id = ? AND camera_id IS NULL'
|
||||||
|
);
|
||||||
$st->execute([$deviceId]);
|
$st->execute([$deviceId]);
|
||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
|
|
@ -20,4 +38,92 @@ final class PdoThresholdRepository implements ThresholdRepository
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listAll(?string $site): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at,
|
||||||
|
d.name AS device_name, d.site
|
||||||
|
FROM move_thresholds t
|
||||||
|
LEFT JOIN move_devices d ON d.device_id = t.device_id';
|
||||||
|
$args = [];
|
||||||
|
if ($site !== null) { $sql .= ' WHERE d.site = ?'; $args[] = $site; }
|
||||||
|
$sql .= ' ORDER BY t.device_id, t.camera_id, t.`key`';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return array_map(static fn (array $r): array => [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'device_id' => (string)$r['device_id'],
|
||||||
|
'device_name' => $r['device_name'] !== null ? (string)$r['device_name'] : null,
|
||||||
|
'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null,
|
||||||
|
'key' => (string)$r['key'],
|
||||||
|
'value' => (string)$r['value'],
|
||||||
|
'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null,
|
||||||
|
'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null,
|
||||||
|
], $st->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $deviceId, ?string $cameraId, string $key, string $value, string $actor, int $now): int
|
||||||
|
{
|
||||||
|
// The unique key is (device_id, camera_id, zone_id, key), and in MySQL a UNIQUE index
|
||||||
|
// treats NULLs as distinct — so "ON DUPLICATE KEY" would NOT collide for the common
|
||||||
|
// camera_id IS NULL row and every save would append a duplicate. Look it up explicitly.
|
||||||
|
$existing = $this->findScoped($deviceId, $cameraId, $key);
|
||||||
|
if ($existing !== null) {
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'UPDATE move_thresholds SET `value` = ?, updated_by = ?, updated_at = ? WHERE id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$value, $actor, $now, $existing]);
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'INSERT INTO move_thresholds (device_id, camera_id, zone_id, `key`, `value`, updated_by, updated_at)
|
||||||
|
VALUES (?, ?, NULL, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$st->execute([$deviceId, $cameraId, $key, $value, $actor, $now]);
|
||||||
|
return (int)$this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findScoped(string $deviceId, ?string $cameraId, string $key): ?int
|
||||||
|
{
|
||||||
|
$sql = 'SELECT id FROM move_thresholds WHERE device_id = ? AND `key` = ? AND camera_id '
|
||||||
|
. ($cameraId === null ? 'IS NULL' : '= ?');
|
||||||
|
$args = $cameraId === null ? [$deviceId, $key] : [$deviceId, $key, $cameraId];
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
$id = $st->fetchColumn();
|
||||||
|
return $id === false ? null : (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(int $id, ?string $site): bool
|
||||||
|
{
|
||||||
|
$row = $this->find($id);
|
||||||
|
if ($row === null) return false;
|
||||||
|
if ($site !== null && ($row['site'] ?? null) !== $site) return false;
|
||||||
|
$st = $this->pdo->prepare('DELETE FROM move_thresholds WHERE id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
return $st->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?array
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'SELECT t.id, t.device_id, t.camera_id, t.`key`, t.`value`, t.updated_by, t.updated_at, d.site
|
||||||
|
FROM move_thresholds t
|
||||||
|
LEFT JOIN move_devices d ON d.device_id = t.device_id
|
||||||
|
WHERE t.id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$id]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
if ($r === false) return null;
|
||||||
|
return [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'device_id' => (string)$r['device_id'],
|
||||||
|
'camera_id' => $r['camera_id'] !== null ? (string)$r['camera_id'] : null,
|
||||||
|
'key' => (string)$r['key'],
|
||||||
|
'value' => (string)$r['value'],
|
||||||
|
'updated_by' => $r['updated_by'] !== null ? (string)$r['updated_by'] : null,
|
||||||
|
'updated_at' => $r['updated_at'] !== null ? (int)$r['updated_at'] : null,
|
||||||
|
'site' => $r['site'] !== null ? (string)$r['site'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,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 FROM move_zones WHERE zone_id = ? LIMIT 1'
|
'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_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 FROM move_zones WHERE device_id = ?'
|
'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_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 FROM move_zones WHERE camera_id = ?'
|
'SELECT zone_id, device_id, camera_id, level, polygon_json, rules_json FROM move_zones WHERE camera_id = ?'
|
||||||
);
|
);
|
||||||
$st->execute([$cameraId]);
|
$st->execute([$cameraId]);
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
|
|
@ -45,17 +45,19 @@ 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);
|
return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson, $rulesJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
$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, updated_at)
|
'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, rules_json, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]);
|
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, $rulesJson, time()]);
|
||||||
return $newId;
|
return $newId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +89,7 @@ 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'
|
||||||
|
|
@ -97,10 +100,10 @@ final class PdoZoneRepository implements ZoneRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
$st = $this->pdo->prepare(
|
$st = $this->pdo->prepare(
|
||||||
'UPDATE move_zones SET level = ?, polygon_json = ?, updated_at = ?
|
'UPDATE move_zones SET level = ?, polygon_json = ?, rules_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, time(), $zoneId, $deviceId, $cameraId]);
|
$st->execute([$level, $polygonJson, $rulesJson, 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
|
||||||
|
|
|
||||||
13
server/src/Repository/RiskControlRepository.php
Normal file
13
server/src/Repository/RiskControlRepository.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
/** 위험성평가: one row per (site, detection type) — the hazard it stands for and its control. */
|
||||||
|
interface RiskControlRepository
|
||||||
|
{
|
||||||
|
public function listAll(?string $site): array;
|
||||||
|
|
||||||
|
public function upsert(array $row): void;
|
||||||
|
|
||||||
|
public function delete(int $id, ?string $site): bool;
|
||||||
|
}
|
||||||
|
|
@ -25,4 +25,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
server/src/Repository/SuppressionRepository.php
Normal file
23
server/src/Repository/SuppressionRepository.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Planned-work alarm suppression. Detection and ingest are untouched — only notification is
|
||||||
|
* withheld — so an investigation after the fact still sees everything that happened.
|
||||||
|
*/
|
||||||
|
interface SuppressionRepository
|
||||||
|
{
|
||||||
|
/** Active (not expired, not revoked) suppressions covering any of the given scopes. */
|
||||||
|
public function activeFor(array $scopes, int $now): array;
|
||||||
|
|
||||||
|
/** @return array<int,array> newest first; $site null = all sites (global operator). */
|
||||||
|
public function listAll(?string $site, int $now, bool $includeExpired = false): array;
|
||||||
|
|
||||||
|
public function create(array $row): int;
|
||||||
|
|
||||||
|
/** @return bool false when the id does not exist or is already revoked. */
|
||||||
|
public function revoke(int $id, string $user, int $now, ?string $site): bool;
|
||||||
|
|
||||||
|
public function find(int $id): ?array;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue