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('안전 관리'); + -
+

개선 조치 기록

이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.

@@ -48,6 +48,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. 실패 재시도 -------------------------------------------------------------------