From 43a3d069a31fe67f91891bbdf61c62dfe316cd99 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?=
Date: Sat, 5 Sep 2026 00:06:35 +0900
Subject: [PATCH] =?UTF-8?q?fix(audit):=201.1.0=203=EC=B0=A8=20=EC=9E=AC?=
=?UTF-8?q?=EA=B0=90=EC=82=AC=20=E2=80=94=20=ED=99=95=EC=A0=95=20=EA=B2=B0?=
=?UTF-8?q?=ED=95=A8=2014=EA=B1=B4=20=EC=A0=84=EB=9F=89=20=ED=95=B4?=
=?UTF-8?q?=EC=86=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증된 4건은 감사 문서에
REFUTED 로 남겼다(히트맵 왜곡·에스컬레이션 전파·스트림 필터 고정·설정 미배선).
CRITICAL 2건 — 둘 다 이번 기능이 만든 회귀다:
R3-1 dispatch_alerts.php 의 장치 건전성 단계가 try 밖에 있었다. 이 단계만
move_devices·move_device_metrics 를 읽는데 통보 경로는 그 두 표를 건드리지
않는다 — 즉 마이그레이션 미적용·커넥션 한도·lock timeout 이 원래대로면 정상
발송됐을 실행을 통째로 죽인다. 원인이 지속형이라 매분 반복되고 콘솔은 멀쩡해
보인다. 1·2단계를 각각 격리했고, 3단계의 exit(1) 도 없앴다 — 거기 도달하는
예외는 저장소 장애이고 4단계 재시도가 바로 그것에서 회복하려고 존재한다.
R3-2 간헐 고장에 감지기가 양방향으로 무너졌다. edge/daemon.py:105 는 성공
프레임 하나에 streak 를 지워 cameras_erroring 를 0 으로 되돌린다. 정상 표본
하나로 저하 구간을 끊으니 (a) 두 시간 고장에도 sustain 을 못 넘어 아무것도
못 올리고 (b) 끊긴 구간마다 새 앵커가 생겨 6시간에 54건을 쏟아냈다. 엣지
낙상 감지기의 recover_grace_s 와 같은 규율로 짧은 정상을 흡수하고, uuid 에
재고지 버킷을 더해 주기당 1건으로 묶었다(같은 입력 ≤7건). 기존 16개 테스트가
전부 단조 시퀀스라 이 결함이 CI를 통과했다 — 그래서 플래핑·폭주·회복·미래시각
테스트를 추가했다.
HIGH 5건: 센티넬이 메일·웹훅으로 회사 밖에 나갔고(라벨을 app.js 에만 넣은 것이
원인), 사건 상세 링크로 존재하지 않는 카메라에 구역을 저장할 수 있었고, 인제스트가
센티넬을 예약값으로 안 막아 엣지가 그 camera_id 로 올린 진짜 낙상이 화면에서
더 안 보이게 됐고, BUILD-INFO 가 릴리스 이전 커밋을 가리켰고, 실제 자격증명
파일이 납품 폴더 안에 있었다.
값이 흩어진 것이 센티넬 결함의 근본 원인이라 단일 출처를 만들었다:
App\DeviceScope(서버) + assets/device-scope.js(프런트) + PdoEventRepository 의
NOT_DEVICE SQL 조각. 셋이 어긋나면 DeviceScopeTest 가 잡는다.
MEDIUM 7건: 카메라별 집계 3곳 제외, 설정 점검 오판정, 개선조치 대상 거부,
라벨 CSV 제외, 낡은 지표로 판정 금지, 미래 시각 표본 제거, 죽은 노드 재고지.
검증: phpunit 250 passed (신규 10) · pytest 는 dist 재생성 후 확인.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
---
.gitignore | 4 +
CHANGELOG.md | 19 ++-
docs/delivery-audit.md | 88 +++++++++++++-
docs/소개서-이미지-브리프.md | 2 +-
docs/프로젝트-소개서.md | 2 +-
server/public/assets/app.css | 3 +
server/public/assets/app.js | 5 +-
server/public/assets/compliance.js | 2 +-
server/public/assets/detail.js | 16 ++-
server/public/assets/device-scope.js | 36 ++++++
server/public/assets/package.js | 4 +-
server/public/assets/report.js | 4 +-
server/public/assets/setup.js | 5 +-
server/public/assets/zone-draw.js | 2 +-
server/src/Alert/Channels.php | 7 +-
server/src/Alert/DeviceHealthWatch.php | 113 +++++++++++------
server/src/Dashboard/ActionsController.php | 6 +
server/src/Dashboard/ExportController.php | 3 +
server/src/Dashboard/ZoneConfigController.php | 7 ++
server/src/DeviceScope.php | 50 ++++++++
server/src/Ingest/EventValidator.php | 6 +
.../src/Repository/Pdo/PdoEventRepository.php | 17 ++-
server/src/View/compliance.php | 1 +
server/src/View/event_detail.php | 3 +-
server/src/View/events.php | 1 +
server/src/View/incident_package.php | 1 +
server/src/View/report.php | 1 +
server/src/View/setup.php | 1 +
server/src/View/zones.php | 1 +
server/tests/DeviceHealthWatchTest.php | 115 ++++++++++++++++++
server/tests/DeviceScopeTest.php | 73 +++++++++++
server/tools/dispatch_alerts.php | 47 +++++--
32 files changed, 570 insertions(+), 75 deletions(-)
create mode 100644 server/public/assets/device-scope.js
create mode 100644 server/src/DeviceScope.php
create mode 100644 server/tests/DeviceScopeTest.php
diff --git a/.gitignore b/.gitignore
index f13c0f5..88b297b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,7 @@ docs/deck/경영진-보고.html
# 3초 클립은 바이너리다. 저장소가 아니라 발표 노트북에 둔다.
docs/deck/clips/*
!docs/deck/clips/.gitkeep
+
+# 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다.
+# dist/ 안에 있으면 폴더째 압축할 때 같이 나간다.
+/secrets/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4c43b7..2b273ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,13 +6,30 @@
---
-## 1.1.0 — 2026-08-28 · 감시 자신의 고장을 실제로 신고하기
+## 1.1.0 — 2026-09-05 · 감시 자신의 고장을 실제로 신고하기
1.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히
아무도 신고하지 않았습니다 — 타입은 있는데 발행하는 코드가 없었습니다. 1.1.0 이 그것을 채웁니다.
소개서가 내세우던 "감지 17종"은 그때까지 실제로 15종이었습니다. 이제 17종이 사실입니다.
+### 재감사에서 잡은 것 (3차, 14건 전량 해소)
+
+위 기능을 넣으면서 만든 결함들입니다. 상세는 `docs/delivery-audit.md` 3차 재감사 절.
+
+- **통보가 통째로 멈출 수 있었습니다.** 장치 건전성 단계가 예외 처리 밖에 있어, 그 단계에서
+ 난 오류가 통보와 재시도까지 죽였습니다. 새벽에 넘어진 사람이 통보되지 않고 콘솔은 멀쩡해
+ 보이는 상태가 매분 반복됩니다. 단계를 격리했습니다.
+- **간헐 고장을 못 잡거나, 반대로 쏟아냈습니다.** 카메라가 깜빡이며 살아났다 죽으면
+ (리본 접촉 불량의 전형) 저하 구간이 계속 끊겨 두 시간을 고장 나 있어도 아무것도 올라오지
+ 않고, 동시에 같은 고장이 수십 건으로 갈라졌습니다. 회복을 '지속된 정상'으로만 인정하고,
+ 재고지 주기당 1건으로 묶었습니다.
+- **`__device__` 라는 내부 값이 메일·웹훅으로 회사 밖에 나갔습니다.** 이제 어디서나
+ "장치 전체"로 표시됩니다.
+- 사건 상세에서 **존재하지 않는 카메라에 위험구역을 저장**할 수 있었습니다(저장되면 아무것도
+ 지키지 않는 구역이 '활성'으로 남습니다). 막았습니다.
+- 카메라별 위험 순위·카메라 대수·오탐율에 유령 카메라가 섞이던 것을 제외했습니다.
+
### 수정 — 아무도 발행하지 않던 이벤트 2종
`monitoring_degraded`(감시 성능 저하)와 `device_fault`(장치 이상)는 타입 정의·서버 검증·
diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md
index 8f0c26b..d2882f1 100644
--- a/docs/delivery-audit.md
+++ b/docs/delivery-audit.md
@@ -1,7 +1,7 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
-> **판정 (2026-08-18): 적합 — 오프하드웨어 항목 전량 해소.**
-> 1차 감사 43/43 + 2차 재감사(안전 플랫폼 확장 표면) 12/12 해소.
+> **판정 (2026-09-05, 1.1.0): 적합 — 오프하드웨어 항목 전량 해소.**
+> 1차 43/43 + 2차 재감사 12/12 + **3차 재감사(1.1.0 장치 건전성 표면) 14/14** 해소.
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
@@ -349,6 +349,90 @@ misconfig/degradation fail LOUD.
---
+## 3차 재감사 — 1.1.0 장치 건전성 표면 (2026-09-05)
+
+범위: 1.0.0(`9596d1b`) 이후 변경된 서버 코드 전량 — `DeviceHealthWatch`(신규),
+`dispatch_alerts.php` 1단계 배선, `Config.php` 설정 4종, `__device__` 센티넬 렌더 경로,
+config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정.
+
+### CRITICAL
+
+- [x] **R3-1 [B]** `dispatch_alerts.php` 1단계가 try 밖에 있어, 장치 건전성에서 난 예외가
+ **통보와 재시도까지 통째로 죽였다.** 이 단계만 `move_devices`·`move_device_metrics` 를
+ 읽으므로(통보 경로는 두 표를 안 건드림), 마이그레이션 미적용·커넥션 한도·lock timeout 이
+ 원래대로면 정상 발송됐을 실행을 매분 죽인다 — 새벽 3시 낙상이 통보되지 않고 콘솔은 멀쩡해
+ 보인다. DONE — 1·2단계를 각각 격리하고 3단계의 `exit(1)` 을 제거해 4단계 재시도가 계속 돈다.
+- [x] **R3-2 [B]** 간헐 고장에 감지기가 **양방향으로** 무너졌다. `edge/daemon.py:105` 는 성공
+ 프레임 하나에 streak 를 지워 `cameras_erroring` 를 0 으로 되돌린다(리본 접촉 불량·USB
+ 브라운아웃의 전형). 정상 표본 하나로 저하 구간을 끊는 구현은 (a) 두 시간 고장에도 지속
+ 시간이 sustain 을 못 넘어 아무것도 못 올리고, (b) 끊긴 구간마다 새 앵커가 생겨 같은 고장을
+ 수십 건으로 쏟아냈다(6시간 시뮬레이션 54건). DONE — 엣지 낙상 감지기의 `recover_grace_s`
+ 와 같은 규율로 짧은 정상 표본을 흡수하고, uuid 에 재고지 버킷을 더해 주기당 1건으로 묶었다
+ (같은 입력 6시간 ≤7건). 기존 16개 테스트가 전부 단조 시퀀스라 이 결함이 CI를 통과했다 —
+ 플래핑·폭주·회복·미래시각 테스트 5건 추가.
+
+### HIGH
+
+- [x] **R3-3 [H]** 센티넬 `__device__` 가 **메일·웹훅으로 회사 밖에 나갔다**
+ (`[경고] 장치 이상 — __device__`). 라벨을 `app.js` 에만 넣은 것이 원인.
+ DONE — `App\DeviceScope`(서버)·`assets/device-scope.js`(프런트) 단일 출처로 모으고
+ 통보·상세·리포트·사고조사 패키지·설정 점검·컴플라이언스 전 경로에 적용.
+- [x] **R3-4 [H]** 사건 상세의 "이 카메라 구역 설정" 링크가 `?camera=__device__` 를 넘겨,
+ **어떤 카메라와도 매칭되지 않는 구역**이 '활성'으로 저장될 수 있었다(아무것도 지키지 않음).
+ DONE — 링크를 감추고 `ZoneConfigController` 가 400 으로 거부.
+- [x] **R3-5 [H]** 인제스트가 센티넬을 예약값으로 다루지 않아, 엣지가 그 `camera_id` 로 올린
+ **진짜 낙상**이 "장치 전체" 로 이름 붙고 카메라 목록·구역·통계에서 빠졌다 — 변경이 실제
+ 사고를 화면에서 더 안 보이게 만들었다. DONE — `EventValidator` 가 `reserved:camera_id` 로 거부.
+- [x] **R3-6 [H]** `BUILD-INFO.txt` 의 `git_commit` 이 릴리스 직전 커밋을 가리켰다(패키지는
+ 1.1.0 내용, 스탬프는 1.0.0 트리). DONE — 릴리스 커밋 이후 재생성.
+- [x] **R3-7 [H]** 실제 자격증명 파일(`PROVISION_SECRETS.md`)이 납품 폴더 `dist/` 안에 있었다.
+ zip 에는 안 들어갔지만 폴더째 압축하면 그대로 나간다. DONE — `secrets/` 로 이동(gitignore).
+ **잔여 조치: 해당 계정 비밀번호와 장치 HMAC 키 교체 권고** (아래 잔여 절).
+
+### MEDIUM
+
+- [x] **R3-8 [M]** 카메라별 집계 3곳에 센티넬이 섞였다 — "카메라별 위험 순위"에 유령 카메라가
+ 오르고, 사이트별 "카메라 N대"가 한 대씩 부풀고("카메라 한 대로 지키는 현장"을 드러내려는
+ 숫자라 목적 자체가 훼손), 오탐율 패널에도 나타났다. DONE — `NOT_DEVICE` 조각으로 제외.
+- [x] **R3-9 [M]** 설정 점검(`/setup`)이 노드 장애 1건을 카메라로 세어 ③을 초록으로 통과시키고,
+ 그 값으로 ④의 구역을 조회해 정상 현장에 "구역 없음"을 띄웠다. DONE.
+- [x] **R3-10 [M]** 개선 조치를 센티넬에 기록할 수 있어, "조치 전후 감지 밀도"가 노드 장애
+ 건수로 계산됐다. DONE — 폼을 감추고 `ActionsController` 가 거부.
+- [x] **R3-11 [M]** 라벨 데이터셋(CSV)에 노드 사건이 `unreviewed` 로 영구히 쌓였다(스냅샷이
+ 없어 프레임으로 라벨링 불가). DONE — 내보내기에서 제외.
+- [x] **R3-12 [M]** 낡은 지표 행으로 "지금 저하 중"을 판정할 수 있었다. DONE — 최신 표본이
+ `device_stale_seconds` 보다 낡으면 저하 판정을 하지 않는다(죽은 노드는 device_fault 가 잡음).
+- [x] **R3-13 [M]** 미래 시각 지표 표본 하나가 영원히 "최신"으로 고정돼 실제 저하를 가리거나
+ 없던 장애를 sustain 없이 통과시켰다. DONE — `ts > now` 표본 제거.
+- [x] **R3-14 [M]** 죽은 노드가 첫 시각 이후 영원히 조용했다(`device_fault` 는 warning 이라
+ 에스컬레이션 대상도 아님) — 더 가벼운 `monitoring_degraded` 는 주기적으로 다시 올라오는데
+ 방향이 반대였다. DONE — 재고지 버킷을 두 유형에 동일 적용.
+
+### 반증되어 채택하지 않은 것 (REFUTED)
+
+- 센티넬이 히트맵을 왜곡한다 → `spatial_x/y IS NOT NULL` 조건에 걸려 점이 하나도 안 들어간다.
+- 센티넬이 에스컬레이션으로 번진다 → `EscalationWatch` 는 critical/high 만 올리고 장치 이벤트는
+ warning 이다. (심각도를 올리면 살아나므로 주석으로 표시.)
+- 스트림 카메라 필터가 유령 카메라로 고정될 수 있다 → 필터는 레일 트리에서만 설정되고
+ 센티넬은 트리에 들어가지 않는다.
+- 설정 4종이 배선되지 않았다 → 키 이름까지 일치하며, 템플릿 누락은 기존 테스트가 자동으로 잡는다.
+
+### 이번 재감사에서 확인한 잔여 (수정하지 않음, 사유 명시)
+
+- **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는
+ 6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다.
+ 운영 계정 조작이라 이 저장소에서 수행하지 않는다.
+- **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만,
+ 파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드
+ 가능하므로 Pi 납품 시 그 머신에서 수행한다.
+- **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다
+ (1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지
+ 않았다. 다음 매뉴얼 개정에서 함께 넣는다.
+- **크론 중복 실행 잠금 없음** — 재시도 패스에 claim 이 없어 1분을 넘기는 실행이 겹치면
+ 중복 발송이 가능하다. 1.0.0 부터 있던 성질이고 이번 변경이 만든 것이 아니다.
+
+---
+
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
diff --git a/docs/소개서-이미지-브리프.md b/docs/소개서-이미지-브리프.md
index 17162da..11fd041 100644
--- a/docs/소개서-이미지-브리프.md
+++ b/docs/소개서-이미지-브리프.md
@@ -1,6 +1,6 @@
# 프로젝트 소개서 — 이미지 브리프
-대상 문서: `docs/프로젝트-소개서.md` (v1.0.0)
+대상 문서: `docs/프로젝트-소개서.md` (v1.1.0)
작성 목적: 소개서에 넣을 이미지를 **무엇을·왜·어떤 사양으로** 만들지 확정하기 위한 발주 명세.
---
diff --git a/docs/프로젝트-소개서.md b/docs/프로젝트-소개서.md
index 4d3c5fd..bb67383 100644
--- a/docs/프로젝트-소개서.md
+++ b/docs/프로젝트-소개서.md
@@ -6,7 +6,7 @@
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
-버전 1.1.0 · 2026-08-28
+버전 1.1.0 · 2026-09-05
---
diff --git a/server/public/assets/app.css b/server/public/assets/app.css
index 9e2e641..85896c4 100644
--- a/server/public/assets/app.css
+++ b/server/public/assets/app.css
@@ -571,6 +571,9 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
padding:.9rem 1rem 1rem; background:var(--card);
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
}
+/* 이 파일의 다른 [hidden] 규칙과 같은 이유로 명시한다 — 카드에 display 를 주는 규칙이
+ 나중에 붙으면 UA 기본값을 이겨 감춰야 할 카드가 그대로 보인다. */
+.detail-action-card[hidden] { display:none; }
.detail-card-title { margin:0 0 .75rem; font-size:.72rem; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-3); }
.detail-empty { margin:0; font-size:.82rem; color:var(--ink-3); }
.detail-foot { margin:.8rem 0 0; font-size:.72rem; color:var(--ink-3); }
diff --git a/server/public/assets/app.js b/server/public/assets/app.js
index eaa7148..b599db5 100644
--- a/server/public/assets/app.js
+++ b/server/public/assets/app.js
@@ -133,9 +133,8 @@
// files them about the NODE, and move_events.camera_id is NOT NULL. Rendering it raw would put
// "__device__" in front of an operator, and letting it into a camera list would offer a camera
// that does not exist to a person trying to draw zones on it.
- var DEVICE_CAMERA = '__device__';
- function isDeviceScope(ev) { return String(ev && ev.camera_id) === DEVICE_CAMERA; }
- function cameraLabel(id) { return String(id) === DEVICE_CAMERA ? '장치 전체' : String(id); }
+ function isDeviceScope(ev) { return DeviceScope.is(ev); }
+ function cameraLabel(id) { return DeviceScope.label(id); }
function eventSummary(ev) {
var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
diff --git a/server/public/assets/compliance.js b/server/public/assets/compliance.js
index 33a8e0a..6eed194 100644
--- a/server/public/assets/compliance.js
+++ b/server/public/assets/compliance.js
@@ -397,7 +397,7 @@
var seen = {};
cameraRows = [];
((res[1] && res[1].events) || []).forEach(function (e) {
- if (!e.camera_id || seen[e.camera_id]) return;
+ if (!e.camera_id || DeviceScope.is(e.camera_id) || seen[e.camera_id]) return;
seen[e.camera_id] = true;
cameraRows.push({ id: e.camera_id, label: e.camera_id });
});
diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js
index 31dc7ca..b76a751 100644
--- a/server/public/assets/detail.js
+++ b/server/public/assets/detail.js
@@ -250,7 +250,7 @@
addRow('심각도', buildBadge(ev.severity));
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
- addRow('카메라', textNode(ev.camera_id, 'mono'));
+ addRow('카메라', textNode(DeviceScope.label(ev.camera_id), 'mono'));
addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono'));
addRow('지속', textNode(fmtDuration(ev.duration_s)));
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
@@ -315,6 +315,10 @@
// --- outbound links + improvement action ---------------------------------------------
function renderLinks() {
var ev = currentEvent || {};
+ var actionCard = document.getElementById('detail-action-card')
+ || document.getElementById('detail-action-form');
+ if (actionCard) actionCard.hidden = !isCameraEvent(ev);
+
var pkg = document.getElementById('detail-package');
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
var zones = document.getElementById('detail-zones');
@@ -324,10 +328,18 @@
// 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;
+ // 노드 단위 사건(장치 이상·감시 성능 저하)에는 카메라가 없다. 링크를 남겨 두면
+ // ?camera=__device__ 로 편집기가 열리고, 그 값이 그대로 저장되면 어떤 카메라와도
+ // 매칭되지 않는 구역이 '활성'으로 남는다. 서버도 막지만 여기서도 내보내지 않는다.
+ zones.hidden = !ev.camera_id || DeviceScope.is(ev.camera_id);
}
}
+ /** 이 사건이 카메라에 대한 것인가. 개선 조치는 카메라 단위 개념이라 노드 사건에는 없다. */
+ function isCameraEvent(ev) {
+ return !!(ev && ev.camera_id) && !DeviceScope.is(ev.camera_id);
+ }
+
function wireActionForm() {
var form = document.getElementById('detail-action-form');
var note = document.getElementById('detail-action-note');
diff --git a/server/public/assets/device-scope.js b/server/public/assets/device-scope.js
new file mode 100644
index 0000000..1c8ab04
--- /dev/null
+++ b/server/public/assets/device-scope.js
@@ -0,0 +1,36 @@
+/**
+ * 장치 단위 이벤트의 camera_id 센티넬 — 프런트 공용.
+ *
+ * device_fault / monitoring_degraded 는 카메라가 아니라 노드에 대한 사건인데
+ * move_events.camera_id 가 NOT NULL 이라 서버가 '__device__' 를 넣는다(App\DeviceScope).
+ *
+ * WHY A SHARED FILE
+ * 처음에는 이 값을 app.js 와 zone-draw.js 에만 적어 두었다. 그 결과 이벤트 스트림에서만
+ * "장치 전체" 로 보이고 상세 화면·리포트·사고조사 패키지·설정 점검·컴플라이언스에는
+ * `__device__` 가 그대로 노출됐다. 값이 흩어진 것이 원인이었으므로 한 파일로 모은다.
+ * 서버측 단일 출처는 App\DeviceScope 이고, 두 값이 어긋나면 테스트가 잡는다.
+ */
+(function (global) {
+ 'use strict';
+
+ var CAMERA = '__device__';
+ var LABEL = '장치 전체';
+
+ global.DeviceScope = {
+ CAMERA: CAMERA,
+ LABEL: LABEL,
+
+ /** 이 이벤트(또는 camera_id)가 노드 단위인가. */
+ is: function (cameraIdOrEvent) {
+ if (cameraIdOrEvent && typeof cameraIdOrEvent === 'object') {
+ return String(cameraIdOrEvent.camera_id) === CAMERA;
+ }
+ return String(cameraIdOrEvent) === CAMERA;
+ },
+
+ /** 사람에게 보여줄 이름. 표시하는 모든 자리에서 이 함수를 통과시킨다. */
+ label: function (cameraId) {
+ return String(cameraId) === CAMERA ? LABEL : String(cameraId == null ? '' : cameraId);
+ }
+ };
+})(window);
diff --git a/server/public/assets/package.js b/server/public/assets/package.js
index 2049cd3..6b6025f 100644
--- a/server/public/assets/package.js
+++ b/server/public/assets/package.js
@@ -75,7 +75,7 @@
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)]
+ sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, DeviceScope.label(ev.camera_id), stamp(ev.occurred_at)]
.filter(Boolean).join(' · ');
head.appendChild(sub);
root.appendChild(head);
@@ -87,7 +87,7 @@
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, '카메라', DeviceScope.label(ev.camera_id));
kv(dl, '현장', ev.site);
kv(dl, '발생 시각', stamp(ev.occurred_at));
kv(dl, '지속', (ev.duration_s || 0) + '초');
diff --git a/server/public/assets/report.js b/server/public/assets/report.js
index e5484da..09c4573 100644
--- a/server/public/assets/report.js
+++ b/server/public/assets/report.js
@@ -208,7 +208,7 @@
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,
+ camerasEl.appendChild(barRow(DeviceScope.label(c.camera_id), c.count, maxCam,
c.count + '건 · 중대 ' + c.critical_high, maxSevere > 0 && c.critical_high >= hotFloor));
});
if (maxSevere > 0) {
@@ -349,7 +349,7 @@
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-cam', DeviceScope.label(a.camera_id)));
what.appendChild(el('span', 'action-note', a.note));
row.appendChild(what);
row.appendChild(actionEffect(a));
diff --git a/server/public/assets/setup.js b/server/public/assets/setup.js
index 3a2e4b0..b3aaae0 100644
--- a/server/public/assets/setup.js
+++ b/server/public/assets/setup.js
@@ -73,7 +73,10 @@
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; });
+ events.forEach(function (e) {
+ if (!e.camera_id || DeviceScope.is(e.camera_id)) return; // 노드 단위 사건은 카메라가 아니다
+ cameras[e.camera_id] = true;
+ });
var cameraCount = Object.keys(cameras).length;
var steps = [];
diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js
index 1b7117b..7fa3af7 100644
--- a/server/public/assets/zone-draw.js
+++ b/server/public/assets/zone-draw.js
@@ -355,7 +355,7 @@
evs.forEach(function (ev) {
// '__device__' is the sentinel on node-scope events (device_fault / monitoring_degraded),
// not a camera. Offering it here would let an operator draw zones onto nothing.
- if (!ev.camera_id || ev.camera_id === '__device__') return;
+ if (!ev.camera_id || DeviceScope.is(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);
diff --git a/server/src/Alert/Channels.php b/server/src/Alert/Channels.php
index 6e725c5..548b80b 100644
--- a/server/src/Alert/Channels.php
+++ b/server/src/Alert/Channels.php
@@ -2,6 +2,8 @@
declare(strict_types=1);
namespace App\Alert;
+use App\DeviceScope;
+
/**
* The senders. Two of the three channel kinds are delivered from here:
*
@@ -82,7 +84,7 @@ final class Channels
{
$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'] ?? '');
+ return "[{$sev}] {$type} — " . DeviceScope::label($event['camera_id'] ?? '');
}
public function body(array $event): string
@@ -97,7 +99,7 @@ final class Channels
"유형 : {$type}",
"심각도 : {$sev}",
"장치 : " . (string)($event['device_id'] ?? '-'),
- "카메라 : " . (string)($event['camera_id'] ?? '-'),
+ "카메라 : " . ($event['camera_id'] ?? null ? DeviceScope::label($event['camera_id']) : '-'),
"발생 시각: {$when}",
"지속 : " . (string)($event['duration_s'] ?? 0) . "초",
"",
@@ -152,6 +154,7 @@ final class Channels
'severity' => $event['severity'],
'device_id' => $event['device_id'] ?? null,
'camera_id' => $event['camera_id'] ?? null,
+ 'camera_label' => DeviceScope::label($event['camera_id'] ?? ''),
'site' => $event['site'] ?? null,
'occurred_at' => (int)$event['occurred_at'],
'duration_s' => (float)($event['duration_s'] ?? 0),
diff --git a/server/src/Alert/DeviceHealthWatch.php b/server/src/Alert/DeviceHealthWatch.php
index 9c52dae..e3dc77f 100644
--- a/server/src/Alert/DeviceHealthWatch.php
+++ b/server/src/Alert/DeviceHealthWatch.php
@@ -4,6 +4,7 @@ namespace App\Alert;
use App\Repository\DeviceMetricRepository;
use App\Repository\DeviceRepository;
+use App\DeviceScope;
use App\Repository\EventRepository;
/**
@@ -21,18 +22,18 @@ use App\Repository\EventRepository;
* operator cannot see for themselves, because the console looks calm either way.
*
* IDEMPOTENCY, as in EscalationWatch, comes from a deterministic event_uuid colliding on the
- * UNIQUE key rather than from a read-then-write race. The subtlety here is that device health is
- * a CONTINUING state, not a one-off origin event, so the uuid is anchored to the moment the
- * current fault run began:
+ * UNIQUE key rather than from a read-then-write race. 다만 장치 건전성은 **연속 상태**라
+ * uuid 를 두 조각으로 만든다: 장애 구간의 시작(불변) + now 를 재고지 주기로 내림한 버킷.
*
- * device_fault -> last_heartbeat_at. Perfectly stable for the whole outage: one event per
- * outage, not one per cron tick. A node that recovers and dies again has
- * a new last_heartbeat_at, so the second outage is a second event.
- * monitoring_degraded -> the timestamp of the last HEALTHY heartbeat inside the window. If the
- * entire window is degraded the true start is out of sight, so the anchor
- * falls back to a window bucket, which deliberately re-files once per
- * bucket. An unresolved degradation reminding hourly is intended; a
- * drifting anchor that re-files every cron tick would not be.
+ * device_fault -> (last_heartbeat_at, 시간 버킷). 마지막 하트비트는 장애 내내
+ * 불변이라 한 장애가 한 사건으로 묶이고, 버킷이 넘어갈 때마다 한 번씩
+ * 다시 올라온다 — 한 달째 죽은 노드가 첫 시각 이후 영원히 조용해지지
+ * 않게. 복구 후 재발은 last_heartbeat_at 이 달라 별건이 된다.
+ * monitoring_degraded -> (저하 시작 ts, 시간 버킷). 시작은 짧은 정상 표본을 흡수해
+ * 구하므로(degradation() 주석 참조) 간헐 고장에도 흔들리지 않는다.
+ *
+ * 이 조합이 두 실패를 동시에 막는다: 한 주기 안에서는 몇 번을 평가하든 uuid 가 하나라 폭주가
+ * 없고, 주기가 지나면 uuid 가 바뀌어 미해결 장애가 다시 눈에 띈다.
*
* Suppression (planned maintenance) is NOT consulted here. These are recorded either way and
* AlertDispatcher decides whether anyone is woken — the same layering every other event type uses.
@@ -41,12 +42,11 @@ use App\Repository\EventRepository;
final class DeviceHealthWatch
{
/**
- * move_events.camera_id is NOT NULL, but these two events are about the NODE, not about any
- * one camera. A sentinel keeps them out of the per-camera surfaces (the zone editor derives
- * its camera list from reported events) instead of inventing a phantom camera id that an
- * operator could then try to draw zones on.
+ * move_events.camera_id is NOT NULL, but these two events are about the NODE, not any one
+ * camera. 값과 표시 라벨은 App\DeviceScope 가 단일 출처다 — 여러 곳에 문자열로 흩어 두었더니
+ * 콘솔 스트림에서만 라벨이 붙고 메일·리포트·CSV 로는 원문이 그대로 나갔다.
*/
- public const DEVICE_CAMERA = '__device__';
+ public const DEVICE_CAMERA = DeviceScope::CAMERA;
public function __construct(
private EventRepository $events,
@@ -54,10 +54,20 @@ final class DeviceHealthWatch
private DeviceMetricRepository $metrics,
) {}
- /** Deterministic 32-hex uuid for one fault run. Prefixed so it is recognisable in the DB. */
- public static function uuidFor(string $kind, string $deviceId, int $anchorTs): string
+ /**
+ * Deterministic 32-hex uuid for one fault run. Prefixed so it is recognisable in the DB.
+ *
+ * $anchorTs 는 장애 구간의 시작(고장이 계속되는 동안 불변)이고, $reminderBucket 은
+ * now 를 재고지 주기로 내림한 값이다. 둘을 함께 쓰면:
+ * · 같은 장애가 한 주기 안에서 몇 번 평가되든 uuid 가 하나 -> 폭주 없음
+ * · 주기가 넘어가면 uuid 가 바뀌어 한 번 더 올라옴 -> 죽은 채로 영원히 조용해지지 않음
+ * · 서로 다른 장애는 $anchorTs 가 달라 구분됨
+ */
+ public static function uuidFor(string $kind, string $deviceId, int $anchorTs,
+ int $reminderBucket = 0): string
{
- return substr('d0c70401' . substr(hash('sha256', "$kind:$deviceId:$anchorTs"), 0, 24), 0, 32);
+ $seed = "$kind:$deviceId:$anchorTs:$reminderBucket";
+ return substr('d0c70401' . substr(hash('sha256', $seed), 0, 24), 0, 32);
}
/**
@@ -72,7 +82,13 @@ final class DeviceHealthWatch
$fpsRatio = max(0.0, (float)($opts['fps_ratio'] ?? 0.6));
$targetFps = max(0.0, (float)($opts['target_fps'] ?? 12.0));
$repeatAfter = max(300, (int) ($opts['repeat_after_s'] ?? 3600));
- $limit = max(1, (int) ($opts['limit'] ?? 500));
+ $limit = max(1, (int) ($opts['limit'] ?? 2000));
+ // 회복으로 인정할 최소 정상 지속. 하트비트 30초 기준 120초면 짧은 깜빡임은 흡수하고
+ // 진짜 회복은 4비트 안에 인정한다.
+ $recoverGrace = max(0, (int)($opts['recover_grace_s'] ?? 120));
+ // 같은 장애를 이 주기당 한 번만 다시 올린다. 죽은 노드가 영원히 조용해지지도,
+ // 매분 쏟아지지도 않는 지점.
+ $bucket = intdiv($now, $repeatAfter);
$filed = [];
foreach ($this->devices->listStatus(null) as $dev) {
@@ -90,7 +106,7 @@ final class DeviceHealthWatch
// A node that is not reporting cannot also be meaningfully "degraded" — it is
// simply gone. Filing both would double-alarm a single failure.
$id = $this->file($dev, 'device_fault',
- self::uuidFor('device_fault', $deviceId, $last),
+ self::uuidFor('device_fault', $deviceId, $last, $bucket),
(float)$now, (float)$silent,
['last_heartbeat_at' => $last, 'silent_s' => $silent, 'stale_after_s' => $staleAfter]);
if ($id !== null) {
@@ -99,13 +115,14 @@ final class DeviceHealthWatch
continue;
}
- $verdict = $this->degradation($deviceId, $now, $repeatAfter, $limit, $fpsRatio, $targetFps);
+ $verdict = $this->degradation($deviceId, $now, $repeatAfter, $limit,
+ $fpsRatio, $targetFps, $recoverGrace, $staleAfter);
if ($verdict === null) continue;
- [$anchor, $degradedFor, $metrics] = $verdict;
+ [$onset, $degradedFor, $metrics] = $verdict;
if ($degradedFor < $sustain) continue;
$id = $this->file($dev, 'monitoring_degraded',
- self::uuidFor('monitoring_degraded', $deviceId, $anchor),
+ self::uuidFor('monitoring_degraded', $deviceId, $onset, $bucket),
(float)$now, (float)$degradedFor,
$metrics + ['degraded_s' => $degradedFor, 'sustain_s' => $sustain]);
if ($id !== null) {
@@ -118,35 +135,53 @@ final class DeviceHealthWatch
/**
* Is this node degraded right now, and since when?
*
- * @return array{0:int,1:int,2:array}|null [anchorTs, degradedForSeconds, metrics], or null
+ * 회복은 **지속된** 정상이어야 인정한다. 이 규율이 없으면 간헐 고장에 감지기가 양쪽으로
+ * 무너진다: `edge/daemon.py` 는 성공 프레임 하나만 들어와도 streak 를 지워
+ * `cameras_erroring` 를 0 으로 되돌린다(리본 접촉 불량·USB 브라운아웃의 전형적 양상).
+ * 정상 표본 하나로 런을 끊으면
+ * (a) 지속 시간이 영원히 sustain 을 못 넘어 두 시간을 고장 나 있어도 아무것도 안 올라오고,
+ * (b) 끊긴 런마다 새 앵커가 생겨 같은 고장이 수십 건으로 쏟아진다.
+ * 엣지 낙상 감지기의 recover_grace_s 와 같은 판단이다.
+ *
+ * @return array{0:int,1:int,2:array}|null [anchorTs, degradedForSeconds, metrics], or null.
+ * anchorTs 는 저하가 시작된 시각이되, **그 시작이 창 안에서 실제로 관측될 때만** 그렇다.
+ * 창 전체가 저하면 시작이 시야 밖이므로 0(=미상)을 돌려준다 — 그때 시작 시각을 앵커로
+ * 쓰면 오래된 표본이 창 밖으로 밀릴 때마다 값이 흔들려 같은 장애가 여러 건으로 갈라진다.
*/
private function degradation(string $deviceId, int $now, int $window, int $limit,
- float $fpsRatio, float $targetFps): ?array
+ float $fpsRatio, float $targetFps, int $recoverGrace,
+ int $staleAfter): ?array
{
$series = $this->metrics->series($deviceId, $now - $window, $limit);
+ // 미래 시각 표본은 버린다. NTP 보정·수동 백필로 들어오면 그 하나가 영원히 "최신"으로
+ // 고정돼, 실제로 멈춘 노드가 정상으로 보이거나 없던 장애가 sustain 을 건너뛴다.
+ $series = array_values(array_filter($series, static fn (array $p): bool => (int)$p['ts'] <= $now));
if ($series === []) return null;
$latest = $series[count($series) - 1];
+ // 최신 표본이 낡았으면 저하를 판정하지 않는다. 낡은 행으로 "지금 저하 중"이라고
+ // 말하는 것은 사실이 아니고, 진짜로 멈춘 노드는 device_fault 가 이미 잡는다.
+ if ($now - (int)$latest['ts'] > $staleAfter) return null;
+
$reasons = $this->reasons($latest, $fpsRatio, $targetFps);
if ($reasons === []) return null; // healthy right now — nothing to file
- // Walk backwards while every point stays degraded. The first healthy point we meet is the
- // anchor: degradation began just after it, and that instant does not move as the window
- // slides, so the uuid stays put for the whole run.
- $anchor = null;
- $runStart = (int)$latest['ts'];
+ $onset = (int)$latest['ts'];
+ $onsetSeen = false; // 창 안에서 시작을 실제로 봤는가
+ $healthyRunEnd = null; // 연속 정상 구간의 최신쪽 ts
for ($i = count($series) - 2; $i >= 0; $i--) {
+ $ts = (int)$series[$i]['ts'];
if ($this->reasons($series[$i], $fpsRatio, $targetFps) === []) {
- $anchor = (int)$series[$i]['ts'];
- break;
+ if ($healthyRunEnd === null) $healthyRunEnd = $ts;
+ // 정상 구간이 유예를 넘겼으면 그때 진짜 회복한 것 — 현재 장애는 그 뒤에 시작됐다.
+ if ($healthyRunEnd - $ts >= $recoverGrace) { $onsetSeen = true; break; }
+ } else {
+ $healthyRunEnd = null; // 유예 안쪽의 짧은 정상은 흡수한다
+ $onset = $ts;
}
- $runStart = (int)$series[$i]['ts'];
}
- // Every point in the window is degraded: the true start is out of sight, so bucket the
- // anchor. This re-files once per window rather than drifting with every new heartbeat.
- if ($anchor === null) $anchor = intdiv($now, $window) * $window;
- return [$anchor, max(0, (int)$latest['ts'] - $runStart), [
+ return [$onsetSeen ? $onset : 0, max(0, (int)$latest['ts'] - $onset), [
'reasons' => $reasons,
'fps' => $latest['fps'] ?? null,
'target_fps' => $targetFps,
@@ -188,7 +223,7 @@ final class DeviceHealthWatch
'event_uuid' => $uuid,
'device_id' => (string)$dev['device_id'],
'site' => $dev['site'] ?? null,
- 'camera_id' => self::DEVICE_CAMERA,
+ 'camera_id' => DeviceScope::CAMERA,
'event_type' => $type,
// Matches edge/events.py _SEVERITY for these two types. Kept in step deliberately:
// one event type arriving from two producers must not carry two severities.
diff --git a/server/src/Dashboard/ActionsController.php b/server/src/Dashboard/ActionsController.php
index 9131028..c2dff58 100644
--- a/server/src/Dashboard/ActionsController.php
+++ b/server/src/Dashboard/ActionsController.php
@@ -51,6 +51,12 @@ final class ActionsController
if (!$this->events->cameraExists($camera)) {
return Response::json(['error' => 'unknown_camera'], 400);
}
+ // 센티넬은 카메라가 아니다(App\DeviceScope). cameraExists 는 이벤트 존재로 판단하므로
+ // 장치 장애가 한 건이라도 있으면 통과해 버린다. 그대로 두면 "조치 전후 감지 밀도"가
+ // 노드 장애 건수로 계산되어, 카메라 안전 투자 효과라는 이름의 엉뚱한 숫자가 나온다.
+ if ($camera === \App\DeviceScope::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);
diff --git a/server/src/Dashboard/ExportController.php b/server/src/Dashboard/ExportController.php
index e541cdb..701a8c5 100644
--- a/server/src/Dashboard/ExportController.php
+++ b/server/src/Dashboard/ExportController.php
@@ -47,6 +47,9 @@ final class ExportController
$lines = [];
foreach ($rows as $r) {
if (($r['event_type'] ?? '') === 'ergo_report') continue; // 통계 행: 라벨 대상 아님
+ // 장치 단위 이벤트(노드 고장·성능 저하)도 뺀다. 카메라 검출이 아니고 스냅샷이
+ // 없어 프레임으로 라벨링할 수 없다 — 데이터셋에 unreviewed 로 영원히 남는다.
+ if (\App\DeviceScope::isDevice($r['camera_id'] ?? null)) continue;
$isFp = ($r['false_positive_by'] ?? null) !== null;
$isAcked = ($r['acknowledged_by'] ?? null) !== null;
$lines[] = json_encode([
diff --git a/server/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php
index 80e5a74..0fa0fc8 100644
--- a/server/src/Dashboard/ZoneConfigController.php
+++ b/server/src/Dashboard/ZoneConfigController.php
@@ -124,6 +124,13 @@ final class ZoneConfigController
// site's device. For an update, the EXISTING zone's device must also be in scope, so a
// caller cannot re-home a cross-site zone under one of their own device ids.
if (!$this->inScope($deviceId)) return Response::json(['error' => 'not_found'], 404);
+ // 장치 단위 이벤트의 camera_id 센티넬은 카메라가 아니다(App\DeviceScope). 사건 상세
+ // 화면의 "이 카메라 구역 설정" 링크가 그 값을 그대로 넘길 수 있는데, 저장되면 어떤
+ // 카메라와도 매칭되지 않는 구역이 목록에 '활성'으로 남아 아무것도 지키지 않는다 —
+ // 이 컨트롤러가 device_id 에 대해 막고 있는 것과 같은 종류의 조용한 안전 구멍이다.
+ if (($b['camera_id'] ?? '') === \App\DeviceScope::CAMERA) {
+ return Response::json(['error' => 'reserved_camera'], 400);
+ }
$zoneId = $b['zone_id'] ?? null;
if (is_string($zoneId) && $zoneId !== '') {
$existing = $this->zones->find($zoneId);
diff --git a/server/src/DeviceScope.php b/server/src/DeviceScope.php
new file mode 100644
index 0000000..c448022
--- /dev/null
+++ b/server/src/DeviceScope.php
@@ -0,0 +1,50 @@
+ '__device__'";
+
private const SELECT_COLUMNS =
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at,
@@ -188,7 +199,7 @@ final class PdoEventRepository implements EventRepository
$sql = 'SELECT camera_id, event_type, COUNT(*) AS total,
SUM(false_positive_by IS NOT NULL) AS fp
FROM move_events
- WHERE occurred_at >= ?';
+ WHERE occurred_at >= ? AND ' . self::NOT_DEVICE;
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
if ($site !== null) {
$sql .= ' AND site = ?';
@@ -230,7 +241,7 @@ final class PdoEventRepository implements EventRepository
$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
+ FROM move_events WHERE occurred_at >= ?$until$scope AND " . self::NOT_DEVICE . "
GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?"
);
$pos = 1;
@@ -344,7 +355,7 @@ final class PdoEventRepository implements EventRepository
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
+ COUNT(DISTINCT CASE WHEN " . self::NOT_DEVICE . " THEN camera_id END) AS cameras
FROM move_events
WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
$binds = [sprintf('%.6f', $sinceOccurredAt)];
diff --git a/server/src/View/compliance.php b/server/src/View/compliance.php
index 21030ef..7a7e721 100644
--- a/server/src/View/compliance.php
+++ b/server/src/View/compliance.php
@@ -102,6 +102,7 @@ render_header('안전 관리');
+
-
+
개선 조치 기록
이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.
+
+
불러오는 중…
+
라벨 데이터셋 내려받기 (JSONL, 관리자)
— 확인/오탐 판정이 붙은 감지 이력. 평가셋·임계 튜닝의 원료입니다.
+
">새로고침하세요.
+
+
'monitoring_degraded', 'pi-dead' => 'device_fault'], $byDevice);
}
+ // --- 간헐 고장(플래핑) -------------------------------------------------------------
+ //
+ // 아래 네 건이 이 클래스에서 가장 중요한 테스트다. edge/daemon.py 는 성공 프레임 하나만
+ // 들어와도 streak 를 지워 cameras_erroring 를 0 으로 되돌린다 — 리본 접촉 불량이나 USB
+ // 브라운아웃이면 30초 하트비트마다 1↔0 이 오간다. 정상 표본 하나로 저하 구간을 끊는
+ // 구현은 이 입력에서 (a) 두 시간을 고장 나 있어도 아무것도 못 올리고 (b) 동시에 같은
+ // 고장을 수십 건으로 쏟아낸다. 기존 테스트가 전부 단조 시퀀스라 그 버그가 CI를 통과했다.
+
+ /** @param array $pattern [초 전 => 저하 여부] */
+ private function flapping(string $deviceId, array $pattern): InMemoryDeviceMetricRepository
+ {
+ $repo = new InMemoryDeviceMetricRepository();
+ foreach ($pattern as $offset => $bad) {
+ $repo->append($deviceId, ['fps' => $bad ? 2.0 : 12.0], self::NOW - $offset);
+ }
+ return $repo;
+ }
+
+ public function testFlappingCameraStillFilesDegraded(): void
+ {
+ // 30초 간격 40분: 대체로 저하인데 중간중간 정상 프레임이 한 번씩 낀다.
+ $pattern = [];
+ for ($off = 2400; $off >= 0; $off -= 30) {
+ $pattern[$off] = !(($off / 30) % 4 === 0); // 4비트에 한 번 정상
+ }
+ $pattern[0] = true; // 지금은 저하
+
+ $events = new InMemoryEventRepository();
+ $watch = new DeviceHealthWatch(
+ $events,
+ $this->devices([$this->device('pi-01', self::NOW - 10)]),
+ $this->flapping('pi-01', $pattern)
+ );
+
+ $filed = $watch->run(self::NOW);
+ self::assertCount(1, $filed, '깜빡이는 정상 표본에 저하 구간이 끊겨 아무것도 올라오지 않았다');
+ self::assertGreaterThanOrEqual(
+ 300, $events->find($filed[0]['event_id'])['metrics']['degraded_s']);
+ }
+
+ public function testFlappingDoesNotStorm(): void
+ {
+ // 6시간 내내 간헐 고장이 이어지고 하트비트도 계속 들어오는 상황을 그대로 재현한다.
+ // 고정 표본으로 now 만 밀면 낡은 행으로 판정하게 되어 실제와 다른 것을 재는 셈이다.
+ $events = new InMemoryEventRepository();
+ $devices = $this->devices([$this->device('pi-01', self::NOW)]);
+ $repo = new InMemoryDeviceMetricRepository();
+
+ $total = 0;
+ for ($t = 0; $t <= 6 * 3600; $t += 30) {
+ $now = self::NOW + $t;
+ // 4비트에 한 번 정상이 끼는 저하 — edge/daemon.py 의 streak 리셋과 같은 양상
+ $repo->append('pi-01', ['fps' => (($t / 30) % 4 === 0) ? 12.0 : 2.0], $now);
+ $devices->statusRows = [$this->device('pi-01', $now)];
+ if ($t % 60 === 0) { // 크론은 1분 주기
+ $total += count((new DeviceHealthWatch($events, $devices, $repo))->run($now));
+ }
+ }
+
+ self::assertGreaterThanOrEqual(1, $total, '6시간 내내 고장인데 한 건도 올라오지 않았다');
+ // 재고지 주기가 1시간이므로 6~7건이 상한이다. 수정 전 구현은 같은 입력에서 수십 건을 냈다.
+ self::assertLessThanOrEqual(7, $total, "6시간 동안 $total 건 — 폭주");
+ }
+
+ public function testSustainedRecoveryStartsANewRun(): void
+ {
+ // 긴 정상 구간(유예 초과)은 진짜 회복이다 — 그 이전의 저하는 현재 구간에 포함되지 않는다.
+ $events = new InMemoryEventRepository();
+ $watch = new DeviceHealthWatch(
+ $events,
+ $this->devices([$this->device('pi-01', self::NOW - 10)]),
+ $this->metrics('pi-01', [
+ 3000 => ['fps' => 2.0], // 옛 저하
+ 2700 => ['fps' => 2.0],
+ 2400 => ['fps' => 12.0], // 정상이 600초 지속 = 회복
+ 2100 => ['fps' => 12.0],
+ 1800 => ['fps' => 12.0],
+ 120 => ['fps' => 2.0], // 새 저하 — 아직 120초뿐
+ 0 => ['fps' => 2.0],
+ ])
+ );
+ // 새 구간은 sustain(300)에 못 미치므로 아무것도 올라오지 않아야 한다.
+ self::assertSame([], $watch->run(self::NOW));
+ }
+
+ public function testDeadNodeIsRaisedAgainInTheNextWindow(): void
+ {
+ // 한 달째 죽은 노드가 첫 시각 이후 영원히 조용해지면 안 된다.
+ $events = new InMemoryEventRepository();
+ $devices = $this->devices([$this->device('pi-01', self::NOW - 600)]);
+ $watch = new DeviceHealthWatch($events, $devices, new InMemoryDeviceMetricRepository());
+
+ self::assertCount(1, $watch->run(self::NOW));
+ self::assertSame([], $watch->run(self::NOW + 600)); // 같은 주기 — 조용
+ self::assertCount(1, $watch->run(self::NOW + 3600)); // 다음 주기 — 한 번 더
+ }
+
+ public function testFutureTimestampedMetricIsIgnored(): void
+ {
+ // NTP 보정이나 수동 백필로 들어온 미래 표본 하나가 "최신"으로 고정되면, 실제로 멈춘
+ // 노드가 정상으로 보이거나 없던 장애가 sustain 을 건너뛴다.
+ $events = new InMemoryEventRepository();
+ $repo = new InMemoryDeviceMetricRepository();
+ $repo->append('pi-01', ['fps' => 12.0], self::NOW + 86400); // 미래·정상
+ foreach ([900, 600, 300, 0] as $off) {
+ $repo->append('pi-01', ['fps' => 1.0], self::NOW - $off); // 실제로는 계속 저하
+ }
+ $watch = new DeviceHealthWatch(
+ $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $repo);
+
+ $filed = $watch->run(self::NOW);
+ self::assertCount(1, $filed, '미래 표본이 최신으로 잡혀 실제 저하를 가렸다');
+ self::assertSame('monitoring_degraded', $filed[0]['event_type']);
+ }
+
public function testUuidIsStableAndDistinctPerKindAndDevice(): void
{
$a = DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100);
diff --git a/server/tests/DeviceScopeTest.php b/server/tests/DeviceScopeTest.php
new file mode 100644
index 0000000..4584f07
--- /dev/null
+++ b/server/tests/DeviceScopeTest.php
@@ -0,0 +1,73 @@
+ ' . "'" . DeviceScope::CAMERA . "'" . '";', $php);
+ }
+
+ public function testIngestRejectsTheReservedCameraId(): void
+ {
+ // 센티넬은 서버 소유다. 엣지가 이 값으로 낙상을 올리면 콘솔이 그것을 "장치 전체" 로
+ // 이름 붙이고 카메라 목록·구역·통계에서 빼버려, 진짜 사고가 화면에서 더 안 보이게 된다.
+ $res = (new EventValidator())->validate([
+ 'device_id' => 'pi-01',
+ 'camera_id' => DeviceScope::CAMERA,
+ 'event_type' => 'fall',
+ 'severity' => 'high',
+ 'occurred_at' => 1_700_000_000,
+ ]);
+ self::assertFalse($res['ok']);
+ self::assertContains('reserved:camera_id', $res['errors']);
+ }
+
+ public function testIngestStillAcceptsAnOrdinaryCameraId(): void
+ {
+ $res = (new EventValidator())->validate([
+ 'device_id' => 'pi-01',
+ 'camera_id' => 'cam-01',
+ 'event_type' => 'fall',
+ 'severity' => 'high',
+ 'occurred_at' => 1_700_000_000,
+ ]);
+ self::assertTrue($res['ok'], implode(',', $res['errors'] ?? []));
+ }
+}
diff --git a/server/tools/dispatch_alerts.php b/server/tools/dispatch_alerts.php
index 6ecc359..45559de 100644
--- a/server/tools/dispatch_alerts.php
+++ b/server/tools/dispatch_alerts.php
@@ -60,23 +60,44 @@ $channels = new Channels($baseUrl, 8, $cfg->timezone());
// --- 1. 장치 건전성 발행 --------------------------------------------------------------
// 이 두 유형은 서버만 발행할 수 있다. 노드가 응답하지 않는다는 사실은 응답하지 않는 노드가
// 아니라 그것을 기다리는 쪽에서만 관측된다.
-$health = new DeviceHealthWatch(
- $events, new PdoDeviceRepository($pdo), new PdoDeviceMetricRepository($pdo));
-$healthFiled = $dryRun ? [] : $health->run($now, [
- 'stale_after_s' => $cfg->deviceStaleSeconds(),
- 'sustain_s' => $cfg->degradedSustainSeconds(),
- 'fps_ratio' => $cfg->degradedFpsRatio(),
- 'target_fps' => $cfg->targetFps(),
-]);
+//
+// 이 단계는 반드시 격리한다. 장치 건전성은 move_devices·move_device_metrics 를 읽는데, 통보
+// 경로는 그 두 표를 건드리지 않는다 — 즉 여기서 나는 예외(마이그레이션 미적용, 공유호스팅
+// 커넥션 한도, lock timeout)는 원래대로면 정상 발송됐을 실행을 통째로 죽인다. 그러면 새벽 3시
+// 넘어짐이 통보되지 않고, 원인이 지속형이라 매분 반복되면서 콘솔은 멀쩡해 보인다.
+// 장치 건전성은 부가 기능이고 통보는 이 제품의 존재 이유다 — 실패하면 로그만 남기고 계속 간다.
+$healthFiled = [];
+if (!$dryRun) {
+ try {
+ $health = new DeviceHealthWatch(
+ $events, new PdoDeviceRepository($pdo), new PdoDeviceMetricRepository($pdo));
+ $healthFiled = $health->run($now, [
+ 'stale_after_s' => $cfg->deviceStaleSeconds(),
+ 'sustain_s' => $cfg->degradedSustainSeconds(),
+ 'fps_ratio' => $cfg->degradedFpsRatio(),
+ 'target_fps' => $cfg->targetFps(),
+ ]);
+ } catch (Throwable $e) {
+ fwrite(STDERR, date('c') . ' device-health error (통보는 계속 진행): '
+ . $e->getMessage() . PHP_EOL);
+ }
+}
foreach ($healthFiled as $f) {
- fwrite(STDOUT, sprintf("%s %s: %s (event=%d)
-",
+ fwrite(STDOUT, sprintf('%s %s: %s (event=%d)' . PHP_EOL,
date('c'), $f['event_type'], $f['device_id'], $f['event_id']));
}
// --- 2. 에스컬레이션 발행 -------------------------------------------------------------
$watch = new EscalationWatch($events);
-$escalated = $dryRun ? [] : $watch->run($now, $escalateAfter, $window, $limit);
+$escalated = [];
+if (!$dryRun) {
+ try {
+ $escalated = $watch->run($now, $escalateAfter, $window, $limit);
+ } catch (Throwable $e) {
+ fwrite(STDERR, date('c') . ' escalation error (통보는 계속 진행): '
+ . $e->getMessage() . PHP_EOL);
+ }
+}
if ($escalated !== []) {
fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n");
}
@@ -113,8 +134,8 @@ try {
$stats = $dispatcher->run($now, $window, $limit, $send);
}
} catch (Throwable $e) {
- fwrite(STDERR, date('c') . ' dispatcher error: ' . $e->getMessage() . "\n");
- exit(1);
+ fwrite(STDERR, date('c') . ' dispatcher error (재시도는 계속 진행): ' . $e->getMessage() . PHP_EOL);
+ // exit 하지 않는다: 여기 도달하는 예외는 저장소·DB 장애이고, 4단계 재시도 패스가 바로 그 전이 장애에서 회복하려고 존재한다.
}
// --- 4. 실패 재시도 -------------------------------------------------------------------