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:
유창욱 2026-09-05 00:06:35 +09:00
parent bebf426c2e
commit 43a3d069a3
32 changed files with 570 additions and 75 deletions

4
.gitignore vendored
View file

@ -45,3 +45,7 @@ docs/deck/경영진-보고.html
# 3초 클립은 바이너리다. 저장소가 아니라 발표 노트북에 둔다.
docs/deck/clips/*
!docs/deck/clips/.gitkeep
# 프로비저닝 자격증명 — 납품 폴더(dist/) 밖에 둔다.
# dist/ 안에 있으면 폴더째 압축할 때 같이 나간다.
/secrets/

View file

@ -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`(장치 이상)는 타입 정의·서버 검증·

View file

@ -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 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.

View file

@ -1,6 +1,6 @@
# 프로젝트 소개서 — 이미지 브리프
대상 문서: `docs/프로젝트-소개서.md` (v1.0.0)
대상 문서: `docs/프로젝트-소개서.md` (v1.1.0)
작성 목적: 소개서에 넣을 이미지를 **무엇을·왜·어떤 사양으로** 만들지 확정하기 위한 발주 명세.
---

View file

@ -6,7 +6,7 @@
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
버전 1.1.0 · 2026-08-28
버전 1.1.0 · 2026-09-05
---

View file

@ -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); }

View file

@ -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 || '');

View file

@ -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 });
});

View file

@ -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');

View 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);

View file

@ -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) + '초');

View file

@ -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));

View file

@ -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 = [];

View file

@ -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);

View file

@ -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),

View file

@ -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.

View file

@ -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);

View file

@ -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([

View file

@ -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);

View 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;
}
}

View file

@ -46,6 +46,12 @@ final class EventValidator
// Type guards for required fields
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';
// 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';
// Type guards for optional fields

View file

@ -7,6 +7,17 @@ use PDO;
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 =
'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)];

View file

@ -102,6 +102,7 @@ render_header('안전 관리');
</section>
</div>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/compliance.js') ?>"></script>
<?php
render_footer();

View file

@ -34,7 +34,7 @@ render_header('이벤트 상세');
</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>
<p class="detail-card-hint">이 사건을 계기로 취한 조치를 남기면 전후 감지 밀도를 자동 비교합니다.</p>
<form id="detail-action-form">
@ -48,6 +48,7 @@ render_header('이벤트 상세');
</aside>
</div>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/detail.js') ?>"></script>
<?php
render_footer();

View file

@ -115,6 +115,7 @@ render_header('이벤트');
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
</section>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/device-tree.js') ?>"></script>
<script src="<?= view_url('/assets/app.js') ?>"></script>
<?php

View file

@ -14,6 +14,7 @@ render_header('사고조사 패키지');
<p class="package-loading">불러오는 중…</p>
</article>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/package.js') ?>"></script>
<?php
render_footer();

View file

@ -66,6 +66,7 @@ render_header('안전 리포트');
<p class="report-foot"><a id="export-labels" href="#">라벨 데이터셋 내려받기 (JSONL, 관리자)</a>
— 확인/오탐 판정이 붙은 감지 이력. 평가셋·임계 튜닝의 원료입니다.</p>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/report.js') ?>"></script>
<?php
render_footer();

View file

@ -20,6 +20,7 @@ render_header('설치 점검');
문제가 있는 단계만 남기려면 <a href="<?= view_url('/setup') ?>">새로고침</a>하세요.
</p>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/setup.js') ?>"></script>
<?php
render_footer();

View file

@ -88,6 +88,7 @@ render_header('구역 설정');
</section>
</div>
</div>
<script src="<?= view_url('/assets/device-scope.js') ?>"></script>
<script src="<?= view_url('/assets/zone-draw.js') ?>"></script>
<?php
render_footer();

View file

@ -302,6 +302,121 @@ final class DeviceHealthWatchTest extends TestCase
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
{
$a = DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100);

View 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'] ?? []));
}
}

View file

@ -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. 실패 재시도 -------------------------------------------------------------------