fix(audit): 1.1.0 3차 재감사 — 확정 결함 14건 전량 해소
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
This commit is contained in:
parent
bebf426c2e
commit
43a3d069a3
32 changed files with 570 additions and 75 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -45,3 +45,7 @@ docs/deck/경영진-보고.html
|
||||||
# 3초 클립은 바이너리다. 저장소가 아니라 발표 노트북에 둔다.
|
# 3초 클립은 바이너리다. 저장소가 아니라 발표 노트북에 둔다.
|
||||||
docs/deck/clips/*
|
docs/deck/clips/*
|
||||||
!docs/deck/clips/.gitkeep
|
!docs/deck/clips/.gitkeep
|
||||||
|
|
||||||
|
# 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다.
|
||||||
|
# dist/ 안에 있으면 폴더째 압축할 때 같이 나간다.
|
||||||
|
/secrets/
|
||||||
|
|
|
||||||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -6,13 +6,30 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1.1.0 — 2026-08-28 · 감시 자신의 고장을 실제로 신고하기
|
## 1.1.0 — 2026-09-05 · 감시 자신의 고장을 실제로 신고하기
|
||||||
|
|
||||||
1.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히
|
1.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 **감시 장비 자신이 멈춘 것**은 여전히
|
||||||
아무도 신고하지 않았습니다 — 타입은 있는데 발행하는 코드가 없었습니다. 1.1.0 이 그것을 채웁니다.
|
아무도 신고하지 않았습니다 — 타입은 있는데 발행하는 코드가 없었습니다. 1.1.0 이 그것을 채웁니다.
|
||||||
|
|
||||||
소개서가 내세우던 "감지 17종"은 그때까지 실제로 15종이었습니다. 이제 17종이 사실입니다.
|
소개서가 내세우던 "감지 17종"은 그때까지 실제로 15종이었습니다. 이제 17종이 사실입니다.
|
||||||
|
|
||||||
|
### 재감사에서 잡은 것 (3차, 14건 전량 해소)
|
||||||
|
|
||||||
|
위 기능을 넣으면서 만든 결함들입니다. 상세는 `docs/delivery-audit.md` 3차 재감사 절.
|
||||||
|
|
||||||
|
- **통보가 통째로 멈출 수 있었습니다.** 장치 건전성 단계가 예외 처리 밖에 있어, 그 단계에서
|
||||||
|
난 오류가 통보와 재시도까지 죽였습니다. 새벽에 넘어진 사람이 통보되지 않고 콘솔은 멀쩡해
|
||||||
|
보이는 상태가 매분 반복됩니다. 단계를 격리했습니다.
|
||||||
|
- **간헐 고장을 못 잡거나, 반대로 쏟아냈습니다.** 카메라가 깜빡이며 살아났다 죽으면
|
||||||
|
(리본 접촉 불량의 전형) 저하 구간이 계속 끊겨 두 시간을 고장 나 있어도 아무것도 올라오지
|
||||||
|
않고, 동시에 같은 고장이 수십 건으로 갈라졌습니다. 회복을 '지속된 정상'으로만 인정하고,
|
||||||
|
재고지 주기당 1건으로 묶었습니다.
|
||||||
|
- **`__device__` 라는 내부 값이 메일·웹훅으로 회사 밖에 나갔습니다.** 이제 어디서나
|
||||||
|
"장치 전체"로 표시됩니다.
|
||||||
|
- 사건 상세에서 **존재하지 않는 카메라에 위험구역을 저장**할 수 있었습니다(저장되면 아무것도
|
||||||
|
지키지 않는 구역이 '활성'으로 남습니다). 막았습니다.
|
||||||
|
- 카메라별 위험 순위·카메라 대수·오탐율에 유령 카메라가 섞이던 것을 제외했습니다.
|
||||||
|
|
||||||
### 수정 — 아무도 발행하지 않던 이벤트 2종
|
### 수정 — 아무도 발행하지 않던 이벤트 2종
|
||||||
|
|
||||||
`monitoring_degraded`(감시 성능 저하)와 `device_fault`(장치 이상)는 타입 정의·서버 검증·
|
`monitoring_degraded`(감시 성능 저하)와 `device_fault`(장치 이상)는 타입 정의·서버 검증·
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
||||||
|
|
||||||
> **판정 (2026-08-18): 적합 — 오프하드웨어 항목 전량 해소.**
|
> **판정 (2026-09-05, 1.1.0): 적합 — 오프하드웨어 항목 전량 해소.**
|
||||||
> 1차 감사 43/43 + 2차 재감사(안전 플랫폼 확장 표면) 12/12 해소.
|
> 1차 43/43 + 2차 재감사 12/12 + **3차 재감사(1.1.0 장치 건전성 표면) 14/14** 해소.
|
||||||
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조.
|
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조.
|
||||||
|
|
||||||
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
|
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 잔여 (실장비 커미셔닝에서만 검증 가능)
|
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
|
||||||
|
|
||||||
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# 프로젝트 소개서 — 이미지 브리프
|
# 프로젝트 소개서 — 이미지 브리프
|
||||||
|
|
||||||
대상 문서: `docs/프로젝트-소개서.md` (v1.0.0)
|
대상 문서: `docs/프로젝트-소개서.md` (v1.1.0)
|
||||||
작성 목적: 소개서에 넣을 이미지를 **무엇을·왜·어떤 사양으로** 만들지 확정하기 위한 발주 명세.
|
작성 목적: 소개서에 넣을 이미지를 **무엇을·왜·어떤 사양으로** 만들지 확정하기 위한 발주 명세.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
||||||
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
|
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
|
||||||
|
|
||||||
버전 1.1.0 · 2026-08-28
|
버전 1.1.0 · 2026-09-05
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
padding:.9rem 1rem 1rem; background:var(--card);
|
||||||
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
|
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-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-empty { margin:0; font-size:.82rem; color:var(--ink-3); }
|
||||||
.detail-foot { margin:.8rem 0 0; font-size:.72rem; color:var(--ink-3); }
|
.detail-foot { margin:.8rem 0 0; font-size:.72rem; color:var(--ink-3); }
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,8 @@
|
||||||
// files them about the NODE, and move_events.camera_id is NOT NULL. Rendering it raw would put
|
// 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
|
// "__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.
|
// that does not exist to a person trying to draw zones on it.
|
||||||
var DEVICE_CAMERA = '__device__';
|
function isDeviceScope(ev) { return DeviceScope.is(ev); }
|
||||||
function isDeviceScope(ev) { return String(ev && ev.camera_id) === DEVICE_CAMERA; }
|
function cameraLabel(id) { return DeviceScope.label(id); }
|
||||||
function cameraLabel(id) { return String(id) === DEVICE_CAMERA ? '장치 전체' : String(id); }
|
|
||||||
|
|
||||||
function eventSummary(ev) {
|
function eventSummary(ev) {
|
||||||
var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
|
var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
|
||||||
|
|
|
||||||
|
|
@ -397,7 +397,7 @@
|
||||||
var seen = {};
|
var seen = {};
|
||||||
cameraRows = [];
|
cameraRows = [];
|
||||||
((res[1] && res[1].events) || []).forEach(function (e) {
|
((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;
|
seen[e.camera_id] = true;
|
||||||
cameraRows.push({ id: e.camera_id, label: e.camera_id });
|
cameraRows.push({ id: e.camera_id, label: e.camera_id });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@
|
||||||
addRow('심각도', buildBadge(ev.severity));
|
addRow('심각도', buildBadge(ev.severity));
|
||||||
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
|
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
|
||||||
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
|
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(formatTime(ev.occurred_at), 'mono'));
|
||||||
addRow('지속', textNode(fmtDuration(ev.duration_s)));
|
addRow('지속', textNode(fmtDuration(ev.duration_s)));
|
||||||
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
|
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
|
||||||
|
|
@ -315,6 +315,10 @@
|
||||||
// --- outbound links + improvement action ---------------------------------------------
|
// --- outbound links + improvement action ---------------------------------------------
|
||||||
function renderLinks() {
|
function renderLinks() {
|
||||||
var ev = currentEvent || {};
|
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');
|
var pkg = document.getElementById('detail-package');
|
||||||
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
|
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
|
||||||
var zones = document.getElementById('detail-zones');
|
var zones = document.getElementById('detail-zones');
|
||||||
|
|
@ -324,10 +328,18 @@
|
||||||
// and asks you anyway" problem the editor's own picker exists to remove.
|
// and asks you anyway" problem the editor's own picker exists to remove.
|
||||||
zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
|
zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
|
||||||
+ '&camera=' + encodeURIComponent(ev.camera_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() {
|
function wireActionForm() {
|
||||||
var form = document.getElementById('detail-action-form');
|
var form = document.getElementById('detail-action-form');
|
||||||
var note = document.getElementById('detail-action-note');
|
var note = document.getElementById('detail-action-note');
|
||||||
|
|
|
||||||
36
server/public/assets/device-scope.js
Normal file
36
server/public/assets/device-scope.js
Normal file
|
|
@ -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);
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
|
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
|
||||||
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
|
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
|
||||||
var sub = el('p', 'pkg-sub');
|
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(' · ');
|
.filter(Boolean).join(' · ');
|
||||||
head.appendChild(sub);
|
head.appendChild(sub);
|
||||||
root.appendChild(head);
|
root.appendChild(head);
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
|
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
|
||||||
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
|
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
|
||||||
kv(dl, '장치', ev.device_id);
|
kv(dl, '장치', ev.device_id);
|
||||||
kv(dl, '카메라', ev.camera_id);
|
kv(dl, '카메라', DeviceScope.label(ev.camera_id));
|
||||||
kv(dl, '현장', ev.site);
|
kv(dl, '현장', ev.site);
|
||||||
kv(dl, '발생 시각', stamp(ev.occurred_at));
|
kv(dl, '발생 시각', stamp(ev.occurred_at));
|
||||||
kv(dl, '지속', (ev.duration_s || 0) + '초');
|
kv(dl, '지속', (ev.duration_s || 0) + '초');
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@
|
||||||
var maxSevere = r.by_camera.reduce(function (m, c) { return Math.max(m, c.critical_high); }, 0);
|
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));
|
var hotFloor = Math.max(1, Math.ceil(maxSevere * 0.6));
|
||||||
r.by_camera.forEach(function (c) {
|
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));
|
c.count + '건 · 중대 ' + c.critical_high, maxSevere > 0 && c.critical_high >= hotFloor));
|
||||||
});
|
});
|
||||||
if (maxSevere > 0) {
|
if (maxSevere > 0) {
|
||||||
|
|
@ -349,7 +349,7 @@
|
||||||
var row = el('div', 'action-row');
|
var row = el('div', 'action-row');
|
||||||
var what = el('div', 'action-what');
|
var what = el('div', 'action-what');
|
||||||
what.appendChild(el('span', 'action-date', fmtDate(a.taken_at)));
|
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));
|
what.appendChild(el('span', 'action-note', a.note));
|
||||||
row.appendChild(what);
|
row.appendChild(what);
|
||||||
row.appendChild(actionEffect(a));
|
row.appendChild(actionEffect(a));
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,10 @@
|
||||||
var online = devices.filter(function (d) { return d.online; });
|
var online = devices.filter(function (d) { return d.online; });
|
||||||
var events = eventsRes.events || [];
|
var events = eventsRes.events || [];
|
||||||
var cameras = {};
|
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 cameraCount = Object.keys(cameras).length;
|
||||||
|
|
||||||
var steps = [];
|
var steps = [];
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,7 @@
|
||||||
evs.forEach(function (ev) {
|
evs.forEach(function (ev) {
|
||||||
// '__device__' is the sentinel on node-scope events (device_fault / monitoring_degraded),
|
// '__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.
|
// 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 || '');
|
var d = String(ev.device_id || '');
|
||||||
if (!camerasByDevice[d]) camerasByDevice[d] = [];
|
if (!camerasByDevice[d]) camerasByDevice[d] = [];
|
||||||
if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id);
|
if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
namespace App\Alert;
|
namespace App\Alert;
|
||||||
|
|
||||||
|
use App\DeviceScope;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The senders. Two of the three channel kinds are delivered from here:
|
* 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'];
|
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
|
||||||
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
|
$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
|
public function body(array $event): string
|
||||||
|
|
@ -97,7 +99,7 @@ final class Channels
|
||||||
"유형 : {$type}",
|
"유형 : {$type}",
|
||||||
"심각도 : {$sev}",
|
"심각도 : {$sev}",
|
||||||
"장치 : " . (string)($event['device_id'] ?? '-'),
|
"장치 : " . (string)($event['device_id'] ?? '-'),
|
||||||
"카메라 : " . (string)($event['camera_id'] ?? '-'),
|
"카메라 : " . ($event['camera_id'] ?? null ? DeviceScope::label($event['camera_id']) : '-'),
|
||||||
"발생 시각: {$when}",
|
"발생 시각: {$when}",
|
||||||
"지속 : " . (string)($event['duration_s'] ?? 0) . "초",
|
"지속 : " . (string)($event['duration_s'] ?? 0) . "초",
|
||||||
"",
|
"",
|
||||||
|
|
@ -152,6 +154,7 @@ final class Channels
|
||||||
'severity' => $event['severity'],
|
'severity' => $event['severity'],
|
||||||
'device_id' => $event['device_id'] ?? null,
|
'device_id' => $event['device_id'] ?? null,
|
||||||
'camera_id' => $event['camera_id'] ?? null,
|
'camera_id' => $event['camera_id'] ?? null,
|
||||||
|
'camera_label' => DeviceScope::label($event['camera_id'] ?? ''),
|
||||||
'site' => $event['site'] ?? null,
|
'site' => $event['site'] ?? null,
|
||||||
'occurred_at' => (int)$event['occurred_at'],
|
'occurred_at' => (int)$event['occurred_at'],
|
||||||
'duration_s' => (float)($event['duration_s'] ?? 0),
|
'duration_s' => (float)($event['duration_s'] ?? 0),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Alert;
|
||||||
|
|
||||||
use App\Repository\DeviceMetricRepository;
|
use App\Repository\DeviceMetricRepository;
|
||||||
use App\Repository\DeviceRepository;
|
use App\Repository\DeviceRepository;
|
||||||
|
use App\DeviceScope;
|
||||||
use App\Repository\EventRepository;
|
use App\Repository\EventRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -21,18 +22,18 @@ use App\Repository\EventRepository;
|
||||||
* operator cannot see for themselves, because the console looks calm either way.
|
* 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
|
* 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
|
* UNIQUE key rather than from a read-then-write race. 다만 장치 건전성은 **연속 상태**라
|
||||||
* a CONTINUING state, not a one-off origin event, so the uuid is anchored to the moment the
|
* uuid 를 두 조각으로 만든다: 장애 구간의 시작(불변) + now 를 재고지 주기로 내림한 버킷.
|
||||||
* current fault run began:
|
|
||||||
*
|
*
|
||||||
* device_fault -> last_heartbeat_at. Perfectly stable for the whole outage: one event per
|
* device_fault -> (last_heartbeat_at, 시간 버킷). 마지막 하트비트는 장애 내내
|
||||||
* 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
|
* 않게. 복구 후 재발은 last_heartbeat_at 이 달라 별건이 된다.
|
||||||
* entire window is degraded the true start is out of sight, so the anchor
|
* monitoring_degraded -> (저하 시작 ts, 시간 버킷). 시작은 짧은 정상 표본을 흡수해
|
||||||
* falls back to a window bucket, which deliberately re-files once per
|
* 구하므로(degradation() 주석 참조) 간헐 고장에도 흔들리지 않는다.
|
||||||
* bucket. An unresolved degradation reminding hourly is intended; a
|
*
|
||||||
* drifting anchor that re-files every cron tick would not be.
|
* 이 조합이 두 실패를 동시에 막는다: 한 주기 안에서는 몇 번을 평가하든 uuid 가 하나라 폭주가
|
||||||
|
* 없고, 주기가 지나면 uuid 가 바뀌어 미해결 장애가 다시 눈에 띈다.
|
||||||
*
|
*
|
||||||
* Suppression (planned maintenance) is NOT consulted here. These are recorded either way and
|
* 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.
|
* 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
|
final class DeviceHealthWatch
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* move_events.camera_id is NOT NULL, but these two events are about the NODE, not about any
|
* move_events.camera_id is NOT NULL, but these two events are about the NODE, not any one
|
||||||
* one camera. A sentinel keeps them out of the per-camera surfaces (the zone editor derives
|
* camera. 값과 표시 라벨은 App\DeviceScope 가 단일 출처다 — 여러 곳에 문자열로 흩어 두었더니
|
||||||
* its camera list from reported events) instead of inventing a phantom camera id that an
|
* 콘솔 스트림에서만 라벨이 붙고 메일·리포트·CSV 로는 원문이 그대로 나갔다.
|
||||||
* operator could then try to draw zones on.
|
|
||||||
*/
|
*/
|
||||||
public const DEVICE_CAMERA = '__device__';
|
public const DEVICE_CAMERA = DeviceScope::CAMERA;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EventRepository $events,
|
private EventRepository $events,
|
||||||
|
|
@ -54,10 +54,20 @@ final class DeviceHealthWatch
|
||||||
private DeviceMetricRepository $metrics,
|
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));
|
$fpsRatio = max(0.0, (float)($opts['fps_ratio'] ?? 0.6));
|
||||||
$targetFps = max(0.0, (float)($opts['target_fps'] ?? 12.0));
|
$targetFps = max(0.0, (float)($opts['target_fps'] ?? 12.0));
|
||||||
$repeatAfter = max(300, (int) ($opts['repeat_after_s'] ?? 3600));
|
$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 = [];
|
$filed = [];
|
||||||
foreach ($this->devices->listStatus(null) as $dev) {
|
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
|
// A node that is not reporting cannot also be meaningfully "degraded" — it is
|
||||||
// simply gone. Filing both would double-alarm a single failure.
|
// simply gone. Filing both would double-alarm a single failure.
|
||||||
$id = $this->file($dev, 'device_fault',
|
$id = $this->file($dev, 'device_fault',
|
||||||
self::uuidFor('device_fault', $deviceId, $last),
|
self::uuidFor('device_fault', $deviceId, $last, $bucket),
|
||||||
(float)$now, (float)$silent,
|
(float)$now, (float)$silent,
|
||||||
['last_heartbeat_at' => $last, 'silent_s' => $silent, 'stale_after_s' => $staleAfter]);
|
['last_heartbeat_at' => $last, 'silent_s' => $silent, 'stale_after_s' => $staleAfter]);
|
||||||
if ($id !== null) {
|
if ($id !== null) {
|
||||||
|
|
@ -99,13 +115,14 @@ final class DeviceHealthWatch
|
||||||
continue;
|
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;
|
if ($verdict === null) continue;
|
||||||
[$anchor, $degradedFor, $metrics] = $verdict;
|
[$onset, $degradedFor, $metrics] = $verdict;
|
||||||
if ($degradedFor < $sustain) continue;
|
if ($degradedFor < $sustain) continue;
|
||||||
|
|
||||||
$id = $this->file($dev, 'monitoring_degraded',
|
$id = $this->file($dev, 'monitoring_degraded',
|
||||||
self::uuidFor('monitoring_degraded', $deviceId, $anchor),
|
self::uuidFor('monitoring_degraded', $deviceId, $onset, $bucket),
|
||||||
(float)$now, (float)$degradedFor,
|
(float)$now, (float)$degradedFor,
|
||||||
$metrics + ['degraded_s' => $degradedFor, 'sustain_s' => $sustain]);
|
$metrics + ['degraded_s' => $degradedFor, 'sustain_s' => $sustain]);
|
||||||
if ($id !== null) {
|
if ($id !== null) {
|
||||||
|
|
@ -118,35 +135,53 @@ final class DeviceHealthWatch
|
||||||
/**
|
/**
|
||||||
* Is this node degraded right now, and since when?
|
* 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,
|
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);
|
$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;
|
if ($series === []) return null;
|
||||||
|
|
||||||
$latest = $series[count($series) - 1];
|
$latest = $series[count($series) - 1];
|
||||||
|
// 최신 표본이 낡았으면 저하를 판정하지 않는다. 낡은 행으로 "지금 저하 중"이라고
|
||||||
|
// 말하는 것은 사실이 아니고, 진짜로 멈춘 노드는 device_fault 가 이미 잡는다.
|
||||||
|
if ($now - (int)$latest['ts'] > $staleAfter) return null;
|
||||||
|
|
||||||
$reasons = $this->reasons($latest, $fpsRatio, $targetFps);
|
$reasons = $this->reasons($latest, $fpsRatio, $targetFps);
|
||||||
if ($reasons === []) return null; // healthy right now — nothing to file
|
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
|
$onset = (int)$latest['ts'];
|
||||||
// anchor: degradation began just after it, and that instant does not move as the window
|
$onsetSeen = false; // 창 안에서 시작을 실제로 봤는가
|
||||||
// slides, so the uuid stays put for the whole run.
|
$healthyRunEnd = null; // 연속 정상 구간의 최신쪽 ts
|
||||||
$anchor = null;
|
|
||||||
$runStart = (int)$latest['ts'];
|
|
||||||
for ($i = count($series) - 2; $i >= 0; $i--) {
|
for ($i = count($series) - 2; $i >= 0; $i--) {
|
||||||
|
$ts = (int)$series[$i]['ts'];
|
||||||
if ($this->reasons($series[$i], $fpsRatio, $targetFps) === []) {
|
if ($this->reasons($series[$i], $fpsRatio, $targetFps) === []) {
|
||||||
$anchor = (int)$series[$i]['ts'];
|
if ($healthyRunEnd === null) $healthyRunEnd = $ts;
|
||||||
break;
|
// 정상 구간이 유예를 넘겼으면 그때 진짜 회복한 것 — 현재 장애는 그 뒤에 시작됐다.
|
||||||
|
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,
|
'reasons' => $reasons,
|
||||||
'fps' => $latest['fps'] ?? null,
|
'fps' => $latest['fps'] ?? null,
|
||||||
'target_fps' => $targetFps,
|
'target_fps' => $targetFps,
|
||||||
|
|
@ -188,7 +223,7 @@ final class DeviceHealthWatch
|
||||||
'event_uuid' => $uuid,
|
'event_uuid' => $uuid,
|
||||||
'device_id' => (string)$dev['device_id'],
|
'device_id' => (string)$dev['device_id'],
|
||||||
'site' => $dev['site'] ?? null,
|
'site' => $dev['site'] ?? null,
|
||||||
'camera_id' => self::DEVICE_CAMERA,
|
'camera_id' => DeviceScope::CAMERA,
|
||||||
'event_type' => $type,
|
'event_type' => $type,
|
||||||
// Matches edge/events.py _SEVERITY for these two types. Kept in step deliberately:
|
// 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.
|
// one event type arriving from two producers must not carry two severities.
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ final class ActionsController
|
||||||
if (!$this->events->cameraExists($camera)) {
|
if (!$this->events->cameraExists($camera)) {
|
||||||
return Response::json(['error' => 'unknown_camera'], 400);
|
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
|
// 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's camera (it would pollute that site's report). 404 hides existence, as everywhere.
|
||||||
$site = $this->events->siteForCamera($camera);
|
$site = $this->events->siteForCamera($camera);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ final class ExportController
|
||||||
$lines = [];
|
$lines = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
if (($r['event_type'] ?? '') === 'ergo_report') continue; // 통계 행: 라벨 대상 아님
|
if (($r['event_type'] ?? '') === 'ergo_report') continue; // 통계 행: 라벨 대상 아님
|
||||||
|
// 장치 단위 이벤트(노드 고장·성능 저하)도 뺀다. 카메라 검출이 아니고 스냅샷이
|
||||||
|
// 없어 프레임으로 라벨링할 수 없다 — 데이터셋에 unreviewed 로 영원히 남는다.
|
||||||
|
if (\App\DeviceScope::isDevice($r['camera_id'] ?? null)) continue;
|
||||||
$isFp = ($r['false_positive_by'] ?? null) !== null;
|
$isFp = ($r['false_positive_by'] ?? null) !== null;
|
||||||
$isAcked = ($r['acknowledged_by'] ?? null) !== null;
|
$isAcked = ($r['acknowledged_by'] ?? null) !== null;
|
||||||
$lines[] = json_encode([
|
$lines[] = json_encode([
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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.
|
// 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);
|
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;
|
$zoneId = $b['zone_id'] ?? null;
|
||||||
if (is_string($zoneId) && $zoneId !== '') {
|
if (is_string($zoneId) && $zoneId !== '') {
|
||||||
$existing = $this->zones->find($zoneId);
|
$existing = $this->zones->find($zoneId);
|
||||||
|
|
|
||||||
50
server/src/DeviceScope.php
Normal file
50
server/src/DeviceScope.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장치 단위 이벤트의 camera_id 센티넬.
|
||||||
|
*
|
||||||
|
* `device_fault` / `monitoring_degraded` 는 카메라가 아니라 **노드**에 대한 사건인데
|
||||||
|
* `move_events.camera_id` 는 NOT NULL 이다. 그래서 서버가 이 센티넬을 넣는다.
|
||||||
|
*
|
||||||
|
* WHY THIS IS A CLASS AND NOT THREE STRING LITERALS
|
||||||
|
* 처음에는 값을 DeviceHealthWatch 와 두 개의 JS 파일에 각각 적어 두었다. 그 결과
|
||||||
|
* 콘솔 스트림에서만 "장치 전체" 로 보이고, 상세 화면·리포트·사고조사 패키지·CSV·
|
||||||
|
* 그리고 **메일과 웹훅** 에는 `__device__` 가 그대로 나갔다. 통보는 회사 밖으로
|
||||||
|
* 나가는 유일한 경로다 — 담당자 휴대폰에 `[경고] 장치 이상 — __device__` 가 찍힌다.
|
||||||
|
* 라벨과 판별을 한곳에 모아, 표시하는 쪽이 늘어나도 같은 실수를 반복하지 않게 한다.
|
||||||
|
*
|
||||||
|
* WHY INGEST MUST REJECT IT
|
||||||
|
* 이 값은 **서버 소유**다. 엣지가 이 camera_id 로 `fall` 을 올리면 콘솔은 그것을
|
||||||
|
* "장치 전체" 로 이름 붙이고 카메라 목록·구역·통계에서 제외한다 — 즉 진짜 낙상이
|
||||||
|
* 화면에서 더 찾기 어려워진다. 경계에서 막는다 (App\Ingest\EventValidator).
|
||||||
|
*/
|
||||||
|
final class DeviceScope
|
||||||
|
{
|
||||||
|
/** 노드 단위 이벤트의 camera_id. 실제 카메라 id 와 겹치지 않도록 밑줄 2개로 감쌌다. */
|
||||||
|
public const CAMERA = '__device__';
|
||||||
|
|
||||||
|
/** 사람에게 보여줄 이름. 화면·메일·리포트·CSV 어디서나 이 함수를 통과시킨다. */
|
||||||
|
public const LABEL = '장치 전체';
|
||||||
|
|
||||||
|
public static function isDevice(mixed $cameraId): bool
|
||||||
|
{
|
||||||
|
return is_string($cameraId) && $cameraId === self::CAMERA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 표시용 이름. 센티넬이면 한국어 라벨, 아니면 원래 camera_id. */
|
||||||
|
public static function label(mixed $cameraId): string
|
||||||
|
{
|
||||||
|
return self::isDevice($cameraId) ? self::LABEL : (string)($cameraId ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이벤트 배열의 camera_id 를 표시용으로 바꾼 사본. 원본은 건드리지 않는다. */
|
||||||
|
public static function labelled(array $event): array
|
||||||
|
{
|
||||||
|
if (isset($event['camera_id'])) {
|
||||||
|
$event['camera_id'] = self::label($event['camera_id']);
|
||||||
|
}
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,12 @@ final class EventValidator
|
||||||
// Type guards for required fields
|
// Type guards for required fields
|
||||||
if (isset($p['device_id']) && !is_string($p['device_id'])) $errors[] = 'bad:device_id';
|
if (isset($p['device_id']) && !is_string($p['device_id'])) $errors[] = 'bad:device_id';
|
||||||
if (isset($p['camera_id']) && !is_string($p['camera_id'])) $errors[] = 'bad:camera_id';
|
if (isset($p['camera_id']) && !is_string($p['camera_id'])) $errors[] = 'bad:camera_id';
|
||||||
|
// camera_id 센티넬은 서버 소유다(App\DeviceScope). 엣지가 이 값으로 fall 을 올리면
|
||||||
|
// 콘솔이 그것을 "장치 전체" 로 이름 붙이고 카메라 목록·구역·통계에서 빼버린다 —
|
||||||
|
// 진짜 낙상이 화면에서 더 찾기 어려워지므로 경계에서 막는다.
|
||||||
|
if (isset($p['camera_id']) && $p['camera_id'] === \App\DeviceScope::CAMERA) {
|
||||||
|
$errors[] = 'reserved:camera_id';
|
||||||
|
}
|
||||||
if (isset($p['occurred_at']) && !is_numeric($p['occurred_at'])) $errors[] = 'bad:occurred_at';
|
if (isset($p['occurred_at']) && !is_numeric($p['occurred_at'])) $errors[] = 'bad:occurred_at';
|
||||||
|
|
||||||
// Type guards for optional fields
|
// Type guards for optional fields
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@ use PDO;
|
||||||
|
|
||||||
final class PdoEventRepository implements EventRepository
|
final class PdoEventRepository implements EventRepository
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 카메라별 집계에서 장치 단위 이벤트를 빼는 조건.
|
||||||
|
*
|
||||||
|
* device_fault / monitoring_degraded 는 카메라가 아니라 노드에 대한 사건인데 camera_id 가
|
||||||
|
* NOT NULL 이라 센티넬이 들어간다(App\DeviceScope). 그대로 두면 "카메라별 위험 순위"에
|
||||||
|
* 존재하지 않는 카메라가 오르고, "카메라 N대"가 한 대씩 부풀고, 오탐율 패널에도 나타난다.
|
||||||
|
* SQL 리터럴에는 PHP 상수를 넣을 수 없어 값이 여기 한 번 더 적히므로,
|
||||||
|
* PdoEventRepositoryTest 가 이 조각과 DeviceScope::CAMERA 가 일치하는지 검사한다.
|
||||||
|
*/
|
||||||
|
private const NOT_DEVICE = "camera_id <> '__device__'";
|
||||||
|
|
||||||
private const SELECT_COLUMNS =
|
private const SELECT_COLUMNS =
|
||||||
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
|
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
|
||||||
occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at,
|
occurred_at, duration_s, metrics_json, 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,
|
$sql = 'SELECT camera_id, event_type, COUNT(*) AS total,
|
||||||
SUM(false_positive_by IS NOT NULL) AS fp
|
SUM(false_positive_by IS NOT NULL) AS fp
|
||||||
FROM move_events
|
FROM move_events
|
||||||
WHERE occurred_at >= ?';
|
WHERE occurred_at >= ? AND ' . self::NOT_DEVICE;
|
||||||
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
|
$binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
|
||||||
if ($site !== null) {
|
if ($site !== null) {
|
||||||
$sql .= ' AND site = ?';
|
$sql .= ' AND site = ?';
|
||||||
|
|
@ -230,7 +241,7 @@ final class PdoEventRepository implements EventRepository
|
||||||
$byCamera = $this->pdo->prepare(
|
$byCamera = $this->pdo->prepare(
|
||||||
"SELECT camera_id, COUNT(*) AS count,
|
"SELECT camera_id, COUNT(*) AS count,
|
||||||
SUM(severity IN ('critical','high')) AS critical_high
|
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 ?"
|
GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?"
|
||||||
);
|
);
|
||||||
$pos = 1;
|
$pos = 1;
|
||||||
|
|
@ -344,7 +355,7 @@ final class PdoEventRepository implements EventRepository
|
||||||
COUNT(*) AS count,
|
COUNT(*) AS count,
|
||||||
SUM(CASE WHEN severity IN ('critical','high') THEN 1 ELSE 0 END) AS critical_high,
|
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,
|
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
|
FROM move_events
|
||||||
WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
|
WHERE occurred_at >= ? AND event_type <> 'ergo_report'";
|
||||||
$binds = [sprintf('%.6f', $sinceOccurredAt)];
|
$binds = [sprintf('%.6f', $sinceOccurredAt)];
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ render_header('안전 관리');
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/compliance.js') ?>"></script>
|
<script src="<?= view_url('/assets/compliance.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ render_header('이벤트 상세');
|
||||||
</div>
|
</div>
|
||||||
<!-- 사건에서 조치로 바로 이어지게 한다. 조치를 리포트 화면에서만 기록할 수 있으면
|
<!-- 사건에서 조치로 바로 이어지게 한다. 조치를 리포트 화면에서만 기록할 수 있으면
|
||||||
"무엇이 계기였나"가 끊기고, 나중에 그 대책이 어느 사고에서 나왔는지 알 수 없다. -->
|
"무엇이 계기였나"가 끊기고, 나중에 그 대책이 어느 사고에서 나왔는지 알 수 없다. -->
|
||||||
<div class="detail-card detail-action-card">
|
<div class="detail-card detail-action-card" id="detail-action-card">
|
||||||
<h2 class="detail-card-title">개선 조치 기록</h2>
|
<h2 class="detail-card-title">개선 조치 기록</h2>
|
||||||
<p class="detail-card-hint">이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.</p>
|
<p class="detail-card-hint">이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.</p>
|
||||||
<form id="detail-action-form">
|
<form id="detail-action-form">
|
||||||
|
|
@ -48,6 +48,7 @@ render_header('이벤트 상세');
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/detail.js') ?>"></script>
|
<script src="<?= view_url('/assets/detail.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ render_header('이벤트');
|
||||||
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
|
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/device-tree.js') ?>"></script>
|
<script src="<?= view_url('/assets/device-tree.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/app.js') ?>"></script>
|
<script src="<?= view_url('/assets/app.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ render_header('사고조사 패키지');
|
||||||
<p class="package-loading">불러오는 중…</p>
|
<p class="package-loading">불러오는 중…</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/package.js') ?>"></script>
|
<script src="<?= view_url('/assets/package.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ render_header('안전 리포트');
|
||||||
<p class="report-foot"><a id="export-labels" href="#">라벨 데이터셋 내려받기 (JSONL, 관리자)</a>
|
<p class="report-foot"><a id="export-labels" href="#">라벨 데이터셋 내려받기 (JSONL, 관리자)</a>
|
||||||
— 확인/오탐 판정이 붙은 감지 이력. 평가셋·임계 튜닝의 원료입니다.</p>
|
— 확인/오탐 판정이 붙은 감지 이력. 평가셋·임계 튜닝의 원료입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/report.js') ?>"></script>
|
<script src="<?= view_url('/assets/report.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ render_header('설치 점검');
|
||||||
문제가 있는 단계만 남기려면 <a href="<?= view_url('/setup') ?>">새로고침</a>하세요.
|
문제가 있는 단계만 남기려면 <a href="<?= view_url('/setup') ?>">새로고침</a>하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/setup.js') ?>"></script>
|
<script src="<?= view_url('/assets/setup.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ render_header('구역 설정');
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
|
||||||
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
|
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
|
||||||
<?php
|
<?php
|
||||||
render_footer();
|
render_footer();
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,121 @@ final class DeviceHealthWatchTest extends TestCase
|
||||||
self::assertSame(['pi-slow' => 'monitoring_degraded', 'pi-dead' => 'device_fault'], $byDevice);
|
self::assertSame(['pi-slow' => 'monitoring_degraded', 'pi-dead' => 'device_fault'], $byDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 간헐 고장(플래핑) -------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// 아래 네 건이 이 클래스에서 가장 중요한 테스트다. edge/daemon.py 는 성공 프레임 하나만
|
||||||
|
// 들어와도 streak 를 지워 cameras_erroring 를 0 으로 되돌린다 — 리본 접촉 불량이나 USB
|
||||||
|
// 브라운아웃이면 30초 하트비트마다 1↔0 이 오간다. 정상 표본 하나로 저하 구간을 끊는
|
||||||
|
// 구현은 이 입력에서 (a) 두 시간을 고장 나 있어도 아무것도 못 올리고 (b) 동시에 같은
|
||||||
|
// 고장을 수십 건으로 쏟아낸다. 기존 테스트가 전부 단조 시퀀스라 그 버그가 CI를 통과했다.
|
||||||
|
|
||||||
|
/** @param array<int,bool> $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
|
public function testUuidIsStableAndDistinctPerKindAndDevice(): void
|
||||||
{
|
{
|
||||||
$a = DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100);
|
$a = DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100);
|
||||||
|
|
|
||||||
73
server/tests/DeviceScopeTest.php
Normal file
73
server/tests/DeviceScopeTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\DeviceScope;
|
||||||
|
use App\Ingest\EventValidator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 센티넬 값이 흩어지는 것을 막는 테스트.
|
||||||
|
*
|
||||||
|
* 이 프로젝트에서 실제로 난 사고: 값을 DeviceHealthWatch 와 app.js 두 곳에만 적어 두었더니
|
||||||
|
* 콘솔 스트림에서만 "장치 전체" 로 보이고 상세·리포트·사고조사 패키지·CSV, 그리고 **메일과
|
||||||
|
* 웹훅** 에는 `__device__` 가 그대로 나갔다. 값은 PHP 상수 하나, JS 파일 하나, 그리고 SQL
|
||||||
|
* 리터럴 하나(상수를 넣을 수 없으므로) — 이 셋이 어긋나면 여기서 잡는다.
|
||||||
|
*/
|
||||||
|
final class DeviceScopeTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testLabelIsUsedForTheSentinelAndPassesEverythingElseThrough(): void
|
||||||
|
{
|
||||||
|
self::assertSame('장치 전체', DeviceScope::label(DeviceScope::CAMERA));
|
||||||
|
self::assertSame('cam-01', DeviceScope::label('cam-01'));
|
||||||
|
self::assertSame('', DeviceScope::label(null));
|
||||||
|
self::assertTrue(DeviceScope::isDevice(DeviceScope::CAMERA));
|
||||||
|
self::assertFalse(DeviceScope::isDevice('cam-01'));
|
||||||
|
self::assertFalse(DeviceScope::isDevice(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFrontEndSharedScriptCarriesTheSameValue(): void
|
||||||
|
{
|
||||||
|
$js = file_get_contents(__DIR__ . '/../public/assets/device-scope.js');
|
||||||
|
self::assertNotFalse($js);
|
||||||
|
self::assertStringContainsString("var CAMERA = '" . DeviceScope::CAMERA . "';", $js);
|
||||||
|
self::assertStringContainsString("var LABEL = '" . DeviceScope::LABEL . "';", $js);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSqlExclusionFragmentCarriesTheSameValue(): void
|
||||||
|
{
|
||||||
|
// PdoEventRepository 는 카메라별 집계에서 센티넬을 빼는데, SQL 리터럴에는 PHP 상수를
|
||||||
|
// 넣을 수 없어 값이 한 번 더 적힌다. 그 사본이 진짜와 같은지 본다.
|
||||||
|
$php = file_get_contents(__DIR__ . '/../src/Repository/Pdo/PdoEventRepository.php');
|
||||||
|
self::assertNotFalse($php);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'private const NOT_DEVICE = "camera_id <> ' . "'" . 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'] ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,23 +60,44 @@ $channels = new Channels($baseUrl, 8, $cfg->timezone());
|
||||||
// --- 1. 장치 건전성 발행 --------------------------------------------------------------
|
// --- 1. 장치 건전성 발행 --------------------------------------------------------------
|
||||||
// 이 두 유형은 서버만 발행할 수 있다. 노드가 응답하지 않는다는 사실은 응답하지 않는 노드가
|
// 이 두 유형은 서버만 발행할 수 있다. 노드가 응답하지 않는다는 사실은 응답하지 않는 노드가
|
||||||
// 아니라 그것을 기다리는 쪽에서만 관측된다.
|
// 아니라 그것을 기다리는 쪽에서만 관측된다.
|
||||||
|
//
|
||||||
|
// 이 단계는 반드시 격리한다. 장치 건전성은 move_devices·move_device_metrics 를 읽는데, 통보
|
||||||
|
// 경로는 그 두 표를 건드리지 않는다 — 즉 여기서 나는 예외(마이그레이션 미적용, 공유호스팅
|
||||||
|
// 커넥션 한도, lock timeout)는 원래대로면 정상 발송됐을 실행을 통째로 죽인다. 그러면 새벽 3시
|
||||||
|
// 넘어짐이 통보되지 않고, 원인이 지속형이라 매분 반복되면서 콘솔은 멀쩡해 보인다.
|
||||||
|
// 장치 건전성은 부가 기능이고 통보는 이 제품의 존재 이유다 — 실패하면 로그만 남기고 계속 간다.
|
||||||
|
$healthFiled = [];
|
||||||
|
if (!$dryRun) {
|
||||||
|
try {
|
||||||
$health = new DeviceHealthWatch(
|
$health = new DeviceHealthWatch(
|
||||||
$events, new PdoDeviceRepository($pdo), new PdoDeviceMetricRepository($pdo));
|
$events, new PdoDeviceRepository($pdo), new PdoDeviceMetricRepository($pdo));
|
||||||
$healthFiled = $dryRun ? [] : $health->run($now, [
|
$healthFiled = $health->run($now, [
|
||||||
'stale_after_s' => $cfg->deviceStaleSeconds(),
|
'stale_after_s' => $cfg->deviceStaleSeconds(),
|
||||||
'sustain_s' => $cfg->degradedSustainSeconds(),
|
'sustain_s' => $cfg->degradedSustainSeconds(),
|
||||||
'fps_ratio' => $cfg->degradedFpsRatio(),
|
'fps_ratio' => $cfg->degradedFpsRatio(),
|
||||||
'target_fps' => $cfg->targetFps(),
|
'target_fps' => $cfg->targetFps(),
|
||||||
]);
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
fwrite(STDERR, date('c') . ' device-health error (통보는 계속 진행): '
|
||||||
|
. $e->getMessage() . PHP_EOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
foreach ($healthFiled as $f) {
|
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']));
|
date('c'), $f['event_type'], $f['device_id'], $f['event_id']));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. 에스컬레이션 발행 -------------------------------------------------------------
|
// --- 2. 에스컬레이션 발행 -------------------------------------------------------------
|
||||||
$watch = new EscalationWatch($events);
|
$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 !== []) {
|
if ($escalated !== []) {
|
||||||
fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n");
|
fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n");
|
||||||
}
|
}
|
||||||
|
|
@ -113,8 +134,8 @@ try {
|
||||||
$stats = $dispatcher->run($now, $window, $limit, $send);
|
$stats = $dispatcher->run($now, $window, $limit, $send);
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
fwrite(STDERR, date('c') . ' dispatcher error: ' . $e->getMessage() . "\n");
|
fwrite(STDERR, date('c') . ' dispatcher error (재시도는 계속 진행): ' . $e->getMessage() . PHP_EOL);
|
||||||
exit(1);
|
// exit 하지 않는다: 여기 도달하는 예외는 저장소·DB 장애이고, 4단계 재시도 패스가 바로 그 전이 장애에서 회복하려고 존재한다.
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 4. 실패 재시도 -------------------------------------------------------------------
|
// --- 4. 실패 재시도 -------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue