diff --git a/CHANGELOG.md b/CHANGELOG.md index 6164d2e..e740d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ --- +## 미출시 (main) + +### 수정 — 아무도 발행하지 않던 이벤트 2종 + +`monitoring_degraded`(감시 성능 저하)와 `device_fault`(장치 이상)는 타입 정의·서버 검증· +콘솔 필터·통보 라벨까지 전 구간에 있었으나 **생성하는 코드가 어디에도 없었습니다.** 실제로 +값을 넣는 것은 데모 시더뿐이었고, 소개서가 내세우던 "17종"은 실제로 15종이었습니다. + +엣지가 낼 수 없는 유형입니다 — 전원이나 회선이 끊긴 노드는 자기가 죽었다고 신고할 수 +없습니다. 그래서 서버가 하트비트의 **부재**로 판정합니다. + +- `App\Alert\DeviceHealthWatch` 추가. 1분 크론(`dispatch_alerts.php`)의 첫 단계로 실행되어, + 같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 됩니다. +- 장애당 1건. event_uuid 앵커가 장애 구간의 시작(`device_fault` 는 마지막 하트비트 시각)이라 + 크론이 60번 돌아도 이벤트는 하나입니다. 복구 후 재발은 별건으로 잡힙니다. +- 성능 저하는 **지속**을 요구합니다(기본 5분). 한 번 튄 프레임률로 알람이 울리면 운영자는 + 이 유형을 무시하게 되고, 그러면 정작 감시가 멈춘 날에도 아무도 보지 않습니다. +- 한 번도 하트비트를 보낸 적 없는 장치는 **장애가 아닙니다**(미설치 노드). 새 현장 첫날이 + 빨간 벽이 되지 않도록. +- 설정: `device_stale_seconds`(180) · `degraded_sustain_seconds`(300) · + `degraded_fps_ratio`(0.6) · `target_fps`(12). 노드를 낮은 프레임으로 운용하는 현장은 + `target_fps` 를 실제 값으로 맞춰야 합니다. + +장치 단위 이벤트는 카메라가 없으므로 `camera_id` 에 `__device__` 센티넬을 씁니다. 콘솔은 +"장치 전체"로 표시하고, 구역 편집기의 카메라 목록에서는 제외합니다(존재하지 않는 카메라에 +구역을 그리려는 일이 없도록). + +--- + ## 1.0.0 — 2026-08-18 · 첫 정식 납품 버전 0.9.0 까지가 "감지하는 시스템"이었다면, 1.0.0 은 **"감지에 대응하는 시스템"** 입니다. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 506e405..63e8534 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -36,7 +36,9 @@ 뷰: `src/View/{layout,events,login,zones,report,event_detail,admin,compliance,setup,incident_package}.php` - `src/Alert/`: `AlertDispatcher`(어떤 통보를 낼지 결정 — 순수 로직, I/O 없음) · `Channels`(email/webhook 발송; `local` 은 엣지가 자체 GPIO 로) · `EscalationWatch`(미확인 위급 → - `unacked_escalation` 이벤트 발행, event_uuid 로 멱등) + `unacked_escalation` 이벤트 발행, event_uuid 로 멱등) · + `DeviceHealthWatch`(하트비트 부재 → `device_fault`, 지속 성능저하 → `monitoring_degraded`; + 엣지가 낼 수 없는 두 유형을 서버가 발행. uuid 앵커 = 장애 구간 시작이라 장애당 1건) - `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명. 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게) - `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · diff --git a/docs/영상보관-노조대응.md b/docs/영상보관-노조대응.md index 0928dfe..ea32bc7 100644 --- a/docs/영상보관-노조대응.md +++ b/docs/영상보관-노조대응.md @@ -11,11 +11,16 @@ | 무엇 | 어디에 | 얼마나 | 근거 | |---|---|---|---| -| 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임**(수 초). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)` | -| 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 3장**, 얼굴 블러 후 JPEG | `edge/snapshot.py` `Snapshotter.capture` | +| 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임 = 7.5초**(기본 12fps). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)`, `TARGET_FPS=12` | +| 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 3장** — 이벤트 **2.5초 전·1초 전·그 순간**, 얼굴 블러 후 JPEG | `edge/app.py` `SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)` | | 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제** | `Config::snapshotRetentionDays() = 7` | | 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `Config::eventRetentionDays() = 0` | +> **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다 +> 덮어씁니다. 이벤트가 나야 3장이 뽑히고, 나지 않으면 사라집니다. 버퍼가 존재하는 이유는 +> 보관이 아니라 **판정 지연의 보상**입니다: 넘어짐 확정에 3초가 걸리므로, 확정 시점에서 +> 2.5초 전을 되짚으려면 그만큼은 손에 들고 있어야 합니다. 즉 **사고 직전 2.5초가 남는 전부**입니다. + **얼굴 블러는 엣지에서, 업로드 전에 겁니다.** 블러 안 된 이미지는 네트워크로 나가지 않고, 서버에 존재한 적이 없습니다. 과거 프레임은 *그 프레임 자신의* 머리 위치로 블러합니다(현재 위치로 블러하면 과거 얼굴이 그대로 남으므로). diff --git a/docs/프로젝트-소개서.md b/docs/프로젝트-소개서.md index bb1760d..0a822ce 100644 --- a/docs/프로젝트-소개서.md +++ b/docs/프로젝트-소개서.md @@ -97,7 +97,8 @@ |--------|-------------| | **카메라 화각 변경** | 청소하다 삼각대를 스치면 위험구역이 엉뚱한 바닥을 가리킵니다. 그런데 프레임은 계속 들어오고 화면은 정상으로 보입니다 — 이 **조용한 실패**를 감지합니다 | | 카메라 가림 / 조도 저하 | 렌즈가 덮이거나 조명이 죽어 검출력이 떨어진 상태 | -| 장치 이상 / 감시 성능 저하 | 노드·카메라 자체의 장애 | +| **장치 이상** | 노드가 응답을 멈춘 상태. **서버가 하트비트의 부재로 판정합니다** — 전원이나 회선이 끊긴 노드는 자기가 죽었다고 신고할 수 없기 때문입니다 | +| **감시 성능 저하** | 노드는 살아 있으나 프레임률이 떨어지거나, 카메라 하나가 오류이거나, 발열로 성능이 깎인 상태. 한 번 튄 값이 아니라 **지속될 때만** 사건이 됩니다 | | **미확인 경보 확대** | 위급 사건을 정해진 시간 동안 아무도 확인하지 않았다는 **사실 자체를 사건으로** 만듭니다 | > 보호구 미착용 · 중장비 근접 · 화재/연기 3종은 감지기 배선과 이벤트 규격이 준비되어 있으나 diff --git a/server/config.example.php b/server/config.example.php index 1cedf5b..440218a 100644 --- a/server/config.example.php +++ b/server/config.example.php @@ -21,6 +21,12 @@ return [ 'public_base_url' => '', // 예: 'https://example.com/move_detect' // 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다. 'escalate_after_seconds' => 300, + // 장치 건전성 판정 (App\Alert\DeviceHealthWatch). 엣지는 이 두 이벤트를 스스로 못 낸다 — + // 죽은 노드는 자기가 죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다. + 'device_stale_seconds' => 180, // 이만큼 무소식이면 device_fault + 'degraded_sustain_seconds' => 300, // 성능 저하가 이만큼 지속되면 monitoring_degraded + 'degraded_fps_ratio' => 0.6, // target_fps 의 이 비율 미만이면 저하 + 'target_fps' => 12.0, // 엣지 TARGET_FPS 와 맞출 것 // 통보 메시지의 시각 표기 기준. 대시보드는 브라우저 시각을 쓰지만 알림 메일은 PHP 가 // 만들고, 공유호스팅은 date.timezone 이 UTC 인 경우가 흔하다 — 콘솔이 13:01 로 보여준 // 넘어짐을 메일이 04:01 로 적으면 응급 대응에서 그대로 사고가 된다. diff --git a/server/deploy/README-UPLOAD.md b/server/deploy/README-UPLOAD.md index 1fa93fa..d7c83af 100644 --- a/server/deploy/README-UPLOAD.md +++ b/server/deploy/README-UPLOAD.md @@ -186,7 +186,7 @@ VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기', | 주기 | 실행할 명령 | 하는 일 | |------|-------------|---------| -| 1분마다 | `/usr/bin/php /home/<계정>/move_detect_app/tools/dispatch_alerts.php` | 알림 발송 · 미확인 경보 상위 통보 · 실패 재시도 | +| 1분마다 | `/usr/bin/php /home/<계정>/move_detect_app/tools/dispatch_alerts.php` | **장치 이상·감시 성능 저하 판정** · 알림 발송 · 미확인 경보 상위 통보 · 실패 재시도 | | 하루 1회 (새벽) | `/usr/bin/php /home/<계정>/move_detect_app/tools/maintain.php` | 사진 보존기간 정리 · 기록 용량 관리 | `<계정>` 자리에는 카페24 FTP 계정명을 넣습니다. 경로가 헷갈리면 FTP 로 접속했을 때 diff --git a/server/deploy/config.template.php b/server/deploy/config.template.php index 38940e9..a11db51 100644 --- a/server/deploy/config.template.php +++ b/server/deploy/config.template.php @@ -41,6 +41,18 @@ return [ // 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를 // 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분. 'escalate_after_seconds' => 300, + // 노드가 이 시간(초) 동안 아무 소식이 없으면 '장치 이상' 이벤트를 만듭니다. 하트비트는 + // 30초 간격이라 180 이면 몇 번 걸러 먹어도 견딥니다. 짧게 줄이면 잠깐의 네트워크 + // 끊김마다 알람이 울려, 정작 노드가 정말 죽었을 때 아무도 쳐다보지 않게 됩니다. + 'device_stale_seconds' => 180, + // 노드 성능 저하(프레임 저하·카메라 오류·발열 감속)가 이 시간(초) 이상 계속되면 + // '감시 성능 저하' 이벤트를 만듭니다. 한 번 튄 값으로는 만들지 않습니다. + 'degraded_sustain_seconds' => 300, + // 성능 저하 판정 기준: 실제 프레임률이 target_fps 의 이 비율 아래로 떨어지면 저하로 봅니다. + // 노드를 일부러 낮은 프레임으로 돌리는 현장은 target_fps 를 실제 값으로 맞추세요. + // 그러지 않으면 모든 노드가 계속 '감시 성능 저하' 로 보고됩니다. + 'degraded_fps_ratio' => 0.6, + 'target_fps' => 12.0, // 알림 메일·웹훅에 찍히는 시각의 기준 시간대입니다. 대시보드 화면은 보는 사람의 PC // 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이 // 서로 다르게 나옵니다. 한국 현장은 그대로 두세요. diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 0d7084c..eaa7148 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -129,9 +129,17 @@ toastTimer = setTimeout(function () { toastEl.classList.remove('is-on'); }, 4000); } + // Device-scope events (device_fault / monitoring_degraded) carry this sentinel: the server + // 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 eventSummary(ev) { var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || ''); - return what + (ev.camera_id ? ' · ' + ev.camera_id : ''); + return what + (ev.camera_id ? ' · ' + cameraLabel(ev.camera_id) : ''); } function buildBadge(sev) { @@ -327,7 +335,7 @@ sub.className = 'event-time'; var src = deviceLabel(ev.device_id); if (src) sub.appendChild(spanWith('ev-src', src)); - if (ev.camera_id) sub.appendChild(spanWith('ev-cam', String(ev.camera_id))); + if (ev.camera_id) sub.appendChild(spanWith('ev-cam', cameraLabel(ev.camera_id))); var time = formatTime(ev.occurred_at); if (time) sub.appendChild(spanWith('ev-at', time)); meta.appendChild(sub); @@ -924,7 +932,7 @@ function cameraStats() { var byKey = {}, out = []; combinedEvents().forEach(function (ev) { - if (!ev.camera_id) return; + if (!ev.camera_id || isDeviceScope(ev)) return; var key = cameraKey(ev.device_id, ev.camera_id); var cam = byKey[key]; if (!cam) { @@ -936,7 +944,7 @@ if (at > cam.last_at) cam.last_at = at; }); liveEvents.forEach(function (ev) { - if (!ev.camera_id || isAcked(ev)) return; + if (!ev.camera_id || isDeviceScope(ev) || isAcked(ev)) return; var cam = byKey[cameraKey(ev.device_id, ev.camera_id)]; if (!cam) return; cam.unacked += 1; diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js index 81a2877..1b7117b 100644 --- a/server/public/assets/zone-draw.js +++ b/server/public/assets/zone-draw.js @@ -353,7 +353,9 @@ var devs = (res[0] && res[0].devices) || []; var evs = (res[1] && res[1].events) || []; evs.forEach(function (ev) { - if (!ev.camera_id) return; + // '__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; var d = String(ev.device_id || ''); if (!camerasByDevice[d]) camerasByDevice[d] = []; if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id); diff --git a/server/src/Alert/DeviceHealthWatch.php b/server/src/Alert/DeviceHealthWatch.php new file mode 100644 index 0000000..9c52dae --- /dev/null +++ b/server/src/Alert/DeviceHealthWatch.php @@ -0,0 +1,213 @@ + 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. + * + * 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. + * A maintenance window should silence the phone, not erase the record that a node was down. + */ +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. + */ + public const DEVICE_CAMERA = '__device__'; + + public function __construct( + private EventRepository $events, + private DeviceRepository $devices, + 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 + { + return substr('d0c70401' . substr(hash('sha256', "$kind:$deviceId:$anchorTs"), 0, 24), 0, 32); + } + + /** + * @param array{stale_after_s?:int,sustain_s?:int,fps_ratio?:float,target_fps?:float, + * repeat_after_s?:int,limit?:int} $opts + * @return array what was filed + */ + public function run(int $now, array $opts = []): array + { + $staleAfter = max(60, (int) ($opts['stale_after_s'] ?? 180)); + $sustain = max(0, (int) ($opts['sustain_s'] ?? 300)); + $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)); + + $filed = []; + foreach ($this->devices->listStatus(null) as $dev) { + $deviceId = (string)($dev['device_id'] ?? ''); + if ($deviceId === '') continue; + + $last = (int)($dev['last_heartbeat_at'] ?? 0); + // A device row that has NEVER reported is a provisioned-but-not-yet-installed node, + // not a fault. Alarming on it would make every new site's first day a wall of red and + // teach the operator to ignore this event type before it ever means anything. + if ($last <= 0) continue; + + $silent = $now - $last; + if ($silent >= $staleAfter) { + // 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), + (float)$now, (float)$silent, + ['last_heartbeat_at' => $last, 'silent_s' => $silent, 'stale_after_s' => $staleAfter]); + if ($id !== null) { + $filed[] = ['device_id' => $deviceId, 'event_type' => 'device_fault', 'event_id' => $id]; + } + continue; + } + + $verdict = $this->degradation($deviceId, $now, $repeatAfter, $limit, $fpsRatio, $targetFps); + if ($verdict === null) continue; + [$anchor, $degradedFor, $metrics] = $verdict; + if ($degradedFor < $sustain) continue; + + $id = $this->file($dev, 'monitoring_degraded', + self::uuidFor('monitoring_degraded', $deviceId, $anchor), + (float)$now, (float)$degradedFor, + $metrics + ['degraded_s' => $degradedFor, 'sustain_s' => $sustain]); + if ($id !== null) { + $filed[] = ['device_id' => $deviceId, 'event_type' => 'monitoring_degraded', 'event_id' => $id]; + } + } + return $filed; + } + + /** + * Is this node degraded right now, and since when? + * + * @return array{0:int,1:int,2:array}|null [anchorTs, degradedForSeconds, metrics], or null + */ + private function degradation(string $deviceId, int $now, int $window, int $limit, + float $fpsRatio, float $targetFps): ?array + { + $series = $this->metrics->series($deviceId, $now - $window, $limit); + if ($series === []) return null; + + $latest = $series[count($series) - 1]; + $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']; + for ($i = count($series) - 2; $i >= 0; $i--) { + if ($this->reasons($series[$i], $fpsRatio, $targetFps) === []) { + $anchor = (int)$series[$i]['ts']; + break; + } + $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), [ + 'reasons' => $reasons, + 'fps' => $latest['fps'] ?? null, + 'target_fps' => $targetFps, + 'cameras_total' => $latest['cameras_total'] ?? null, + 'cameras_erroring' => $latest['cameras_erroring'] ?? null, + 'throttle' => isset($latest['throttle']) ? (bool)$latest['throttle'] : null, + ]]; + } + + /** Why this heartbeat counts as degraded. Empty = healthy. */ + private function reasons(array $point, float $fpsRatio, float $targetFps): array + { + $out = []; + // A camera erroring out is the most direct form of "not watching": the node is alive and + // reporting, and one of its eyes is shut. + if (isset($point['cameras_erroring']) && (int)$point['cameras_erroring'] > 0) { + $out[] = 'cameras_erroring'; + } + if (!empty($point['throttle'])) { + $out[] = 'thermal_throttle'; + } + // Frame rate is the detector's sampling rate. Detector timers are clock-based, so a slow + // node still judges correctly — it just reacts later, which is exactly what is worth + // reporting before someone relies on a 3-second confirmation that now takes eight. + if (isset($point['fps']) && $point['fps'] !== null && $targetFps > 0.0 + && (float)$point['fps'] < $targetFps * $fpsRatio) { + $out[] = 'low_fps'; + } + return $out; + } + + /** Insert, tolerating the UNIQUE collision that means a concurrent run already filed it. */ + private function file(array $dev, string $type, string $uuid, float $at, + float $duration, array $metrics): ?int + { + if ($this->events->idByUuid($uuid) !== null) return null; + try { + return $this->events->insert([ + 'event_uuid' => $uuid, + 'device_id' => (string)$dev['device_id'], + 'site' => $dev['site'] ?? null, + 'camera_id' => self::DEVICE_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. + 'severity' => 'warning', + 'track_id' => null, + 'zone_id' => null, + 'occurred_at' => $at, + 'duration_s' => $duration, + 'metrics' => $metrics, + 'spatial_x' => null, + 'spatial_y' => null, + ]); + } catch (\Throwable $e) { + $m = $e->getMessage(); + if (!str_contains($m, 'uq_events_uuid') && !str_contains($m, 'UNIQUE') + && !str_contains($m, 'Duplicate')) { + throw $e; + } + return null; + } + } +} diff --git a/server/src/Config.php b/server/src/Config.php index bc41556..eb49ce2 100644 --- a/server/src/Config.php +++ b/server/src/Config.php @@ -68,6 +68,29 @@ final class Config */ public function metricRetentionDays(): int { return (int)($this->a['metric_retention_days'] ?? 30); } + /** + * How long a node may stay silent before App\Alert\DeviceHealthWatch files a `device_fault`. + * The heartbeat is 30s, so 180 tolerates several consecutive misses (a brief network blip, + * a cron overrun) before declaring a node down — a false "장치 이상" at every hiccup would + * train the operator to ignore the one event that means nobody is watching. + */ + public function deviceStaleSeconds(): int { return (int)($this->a['device_stale_seconds'] ?? 180); } + + /** + * How long a node must stay degraded (low fps / erroring camera / thermal throttle) before it + * files `monitoring_degraded`. Same discipline the edge detectors use: a sustained state, not + * a single bad sample. Five minutes. + */ + public function degradedSustainSeconds(): int { return (int)($this->a['degraded_sustain_seconds'] ?? 300); } + + /** + * Fraction of TARGET_FPS below which a node counts as degraded, and the target it is measured + * against. The edge's own default is 12; a site that runs its nodes slower must set this or + * every node reports itself degraded forever. + */ + public function degradedFpsRatio(): float { return (float)($this->a['degraded_fps_ratio'] ?? 0.6); } + public function targetFps(): float { return (float)($this->a['target_fps'] ?? 12.0); } + /** * Free-space floor for the snapshot volume, in megabytes. Below it the system raises a * `device_fault` notice instead of discovering the problem as silently-failing image writes. diff --git a/server/tests/DeviceHealthWatchTest.php b/server/tests/DeviceHealthWatchTest.php new file mode 100644 index 0000000..1c2ff80 --- /dev/null +++ b/server/tests/DeviceHealthWatchTest.php @@ -0,0 +1,314 @@ + $id, 'name' => $id, 'site' => $site, + 'last_fps' => null, 'last_temp' => null, 'last_throttle' => null, + 'config_version' => 1, 'reported_config_version' => 1, + 'last_heartbeat_at' => $heartbeat]; + } + + /** @param array $points [tsOffsetFromNow => metric fields] */ + private function metrics(string $deviceId, array $points): InMemoryDeviceMetricRepository + { + $repo = new InMemoryDeviceMetricRepository(); + foreach ($points as $offset => $fields) { + $repo->append($deviceId, $fields, self::NOW - $offset); + } + return $repo; + } + + // --- device_fault --------------------------------------------------------------------- + + public function testSilentDeviceFilesDeviceFault(): void + { + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 600)]), + new InMemoryDeviceMetricRepository() + ); + + $filed = $watch->run(self::NOW); + + self::assertCount(1, $filed); + self::assertSame('device_fault', $filed[0]['event_type']); + $row = $events->find($filed[0]['event_id']); + self::assertSame('warning', $row['severity']); + self::assertSame(DeviceHealthWatch::DEVICE_CAMERA, $row['camera_id']); + self::assertSame('site-a', $row['site']); + self::assertSame(600, $row['metrics']['silent_s']); + } + + public function testHealthyHeartbeatFilesNothing(): void + { + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 20)]), + $this->metrics('pi-01', [20 => ['fps' => 12.0, 'cameras_erroring' => 0]]) + ); + + self::assertSame([], $watch->run(self::NOW)); + } + + public function testOutageFilesOnceAcrossRepeatedRuns(): void + { + // The cron runs every minute. An hour-long outage must leave ONE event, not sixty. + $events = new InMemoryEventRepository(); + $devices = $this->devices([$this->device('pi-01', self::NOW - 600)]); + $watch = new DeviceHealthWatch($events, $devices, new InMemoryDeviceMetricRepository()); + + $first = $watch->run(self::NOW); + $second = $watch->run(self::NOW + 60); + $third = $watch->run(self::NOW + 120); + + self::assertCount(1, $first); + self::assertSame([], $second); + self::assertSame([], $third); + } + + public function testRecoveredThenFailedAgainIsASecondEvent(): 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)); + + // The node came back (new heartbeat) and then went silent again. + $devices->statusRows = [$this->device('pi-01', self::NOW + 1000)]; + self::assertCount(1, $watch->run(self::NOW + 2000)); + } + + public function testNeverInstalledDeviceIsNotAFault(): void + { + // A provisioned row that has never reported is an uninstalled node. Alarming here would + // make every new site's first day a wall of red. + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-new', null), $this->device('pi-zero', 0)]), + new InMemoryDeviceMetricRepository() + ); + + self::assertSame([], $watch->run(self::NOW)); + } + + public function testSilentDeviceDoesNotAlsoFileDegraded(): void + { + // A node that is gone must not be reported twice for one failure. + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 600)]), + $this->metrics('pi-01', [900 => ['fps' => 1.0], 700 => ['fps' => 1.0]]) + ); + + $filed = $watch->run(self::NOW); + self::assertCount(1, $filed); + self::assertSame('device_fault', $filed[0]['event_type']); + } + + // --- monitoring_degraded -------------------------------------------------------------- + + public function testSustainedLowFpsFilesDegraded(): void + { + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 10)]), + $this->metrics('pi-01', [ + 800 => ['fps' => 12.0], // healthy anchor + 600 => ['fps' => 3.0], + 300 => ['fps' => 3.0], + 10 => ['fps' => 3.0], + ]) + ); + + $filed = $watch->run(self::NOW); + + self::assertCount(1, $filed); + self::assertSame('monitoring_degraded', $filed[0]['event_type']); + $row = $events->find($filed[0]['event_id']); + self::assertSame(['low_fps'], $row['metrics']['reasons']); + self::assertSame(590, $row['metrics']['degraded_s']); + } + + public function testBriefDipDoesNotFileDegraded(): void + { + // One slow heartbeat is a hiccup. Requiring a sustained run is what separates this event + // from noise the operator learns to ignore. + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 10)]), + $this->metrics('pi-01', [ + 200 => ['fps' => 12.0], + 100 => ['fps' => 12.0], + 10 => ['fps' => 3.0], + ]) + ); + + self::assertSame([], $watch->run(self::NOW)); + } + + public function testRecoveredFpsFilesNothingEvenAfterALongBadRun(): void + { + // The latest heartbeat is what decides. A node that already recovered must not alarm. + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 10)]), + $this->metrics('pi-01', [ + 900 => ['fps' => 2.0], + 600 => ['fps' => 2.0], + 10 => ['fps' => 12.0], + ]) + ); + + self::assertSame([], $watch->run(self::NOW)); + } + + public function testErroringCameraIsDegradedEvenAtFullFps(): void + { + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 10)]), + $this->metrics('pi-01', [ + 800 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 0], + 600 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 1], + 10 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 1], + ]) + ); + + $filed = $watch->run(self::NOW); + + self::assertCount(1, $filed); + $row = $events->find($filed[0]['event_id']); + self::assertSame(['cameras_erroring'], $row['metrics']['reasons']); + self::assertSame(1, $row['metrics']['cameras_erroring']); + } + + public function testThermalThrottleCountsAsDegraded(): void + { + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 10)]), + $this->metrics('pi-01', [ + 800 => ['fps' => 12.0, 'throttle' => 0], + 600 => ['fps' => 12.0, 'throttle' => 1], + 10 => ['fps' => 12.0, 'throttle' => 1], + ]) + ); + + $filed = $watch->run(self::NOW); + + self::assertCount(1, $filed); + self::assertSame(['thermal_throttle'], + $events->find($filed[0]['event_id'])['metrics']['reasons']); + } + + public function testOngoingDegradationFilesOncePerRunNotPerTick(): void + { + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 10)]), + $this->metrics('pi-01', [ + 800 => ['fps' => 12.0], + 600 => ['fps' => 3.0], + 10 => ['fps' => 3.0], + ]) + ); + + self::assertCount(1, $watch->run(self::NOW)); + self::assertSame([], $watch->run(self::NOW + 60)); + self::assertSame([], $watch->run(self::NOW + 120)); + } + + public function testMissingFpsIsNotTreatedAsZero(): void + { + // A node that cannot read a metric reports NULL. Coercing that to 0 would invent an + // outage out of a missing reading. + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 10)]), + $this->metrics('pi-01', [ + 600 => ['fps' => null, 'cameras_erroring' => 0], + 10 => ['fps' => null, 'cameras_erroring' => 0], + ]) + ); + + self::assertSame([], $watch->run(self::NOW)); + } + + public function testNoHeartbeatSeriesFilesNothingWhileDeviceIsFresh(): void + { + // Fresh heartbeat but no metric rows yet (just installed): nothing to judge. + $events = new InMemoryEventRepository(); + $watch = new DeviceHealthWatch( + $events, + $this->devices([$this->device('pi-01', self::NOW - 5)]), + new InMemoryDeviceMetricRepository() + ); + + self::assertSame([], $watch->run(self::NOW)); + } + + public function testEachDeviceIsJudgedIndependently(): void + { + $events = new InMemoryEventRepository(); + $metrics = new InMemoryDeviceMetricRepository(); + $metrics->append('pi-ok', ['fps' => 12.0], self::NOW - 10); + $metrics->append('pi-slow', ['fps' => 12.0], self::NOW - 800); + $metrics->append('pi-slow', ['fps' => 2.0], self::NOW - 600); + $metrics->append('pi-slow', ['fps' => 2.0], self::NOW - 10); + + $watch = new DeviceHealthWatch($events, $this->devices([ + $this->device('pi-ok', self::NOW - 10), + $this->device('pi-slow', self::NOW - 10), + $this->device('pi-dead', self::NOW - 3600), + ]), $metrics); + + $filed = $watch->run(self::NOW); + $byDevice = []; + foreach ($filed as $f) $byDevice[$f['device_id']] = $f['event_type']; + + self::assertSame(['pi-slow' => 'monitoring_degraded', 'pi-dead' => 'device_fault'], $byDevice); + } + + public function testUuidIsStableAndDistinctPerKindAndDevice(): void + { + $a = DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100); + self::assertSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100)); + self::assertSame(32, strlen($a)); + self::assertNotSame($a, DeviceHealthWatch::uuidFor('monitoring_degraded', 'pi-01', 100)); + self::assertNotSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-02', 100)); + self::assertNotSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 101)); + } +} diff --git a/server/tools/dispatch_alerts.php b/server/tools/dispatch_alerts.php index c5e6fb5..6ecc359 100644 --- a/server/tools/dispatch_alerts.php +++ b/server/tools/dispatch_alerts.php @@ -9,10 +9,15 @@ * * 정확한 크론 표기는 server/docs/server-deploy.md §11 을 보십시오. * - * 하는 일 3가지: - * 1. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다. - * 2. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널). - * 3. 실패한 전달을 재시도한다. + * 하는 일 4가지: + * 1. 하트비트가 끊긴 노드에 `device_fault`, 성능이 지속 저하된 노드에 `monitoring_degraded` + * 이벤트를 발행한다. 엣지는 이걸 스스로 못 한다 — 죽은 노드는 자기가 죽었다고 신고할 수 없다. + * 2. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다. + * 3. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널). + * 4. 실패한 전달을 재시도한다. + * + * 1번이 2·3번보다 먼저 오는 것은 의도적이다: 같은 실행 안에서 발행된 장치 이벤트가 곧바로 + * 통보 대상이 되어야 한다. 뒤에 두면 노드가 죽은 사실이 다음 크론까지 1분 늦게 전달된다. * * 이 파일이 없으면 이 제품은 "사고를 기록하는 시스템"이지 "사고에 대응하는 시스템"이 아니다. * 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞는 상황이 정확히 이 구멍이었다. @@ -25,8 +30,9 @@ declare(strict_types=1); require __DIR__ . '/bootstrap.php'; -use App\Alert\{AlertDispatcher, Channels, EscalationWatch}; -use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository}; +use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch}; +use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository, + PdoDeviceRepository, PdoDeviceMetricRepository}; [$cfg, $pdo] = tool_boot(); $args = tool_args($argv); @@ -51,14 +57,31 @@ if ($baseUrl === '') { } $channels = new Channels($baseUrl, 8, $cfg->timezone()); -// --- 1. 에스컬레이션 발행 ------------------------------------------------------------- +// --- 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(), +]); +foreach ($healthFiled as $f) { + fwrite(STDOUT, sprintf("%s %s: %s (event=%d) +", + date('c'), $f['event_type'], $f['device_id'], $f['event_id'])); +} + +// --- 2. 에스컬레이션 발행 ------------------------------------------------------------- $watch = new EscalationWatch($events); $escalated = $dryRun ? [] : $watch->run($now, $escalateAfter, $window, $limit); if ($escalated !== []) { fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n"); } -// --- 2. 통보 -------------------------------------------------------------------------- +// --- 3. 통보 -------------------------------------------------------------------------- $dispatcher = new AlertDispatcher($events, $alerts, $suppressions); // local 채널은 디스패처가 계획 단계에서 제외한다 (엣지가 자체 GPIO 로 즉시 울린다 — @@ -94,7 +117,7 @@ try { exit(1); } -// --- 3. 실패 재시도 ------------------------------------------------------------------- +// --- 4. 실패 재시도 ------------------------------------------------------------------- $retried = 0; if (!$dryRun) { foreach ($alerts->retryable($maxAttempts, 100) as $row) {