feat(alert): 아무도 발행하지 않던 device_fault·monitoring_degraded 구현
두 유형은 타입 정의(edge/events.py)·서버 검증(EventValidator)·콘솔 필터· 통보 라벨까지 전 구간에 있었으나 생성하는 코드가 어디에도 없었다. 실제로 값을 넣는 것은 seed_demo.php 뿐이었고, 소개서가 내세운 "17종"은 15종이었다. 앞서 고친 EventValidator 누락과 정확히 반대 방향의 같은 병이다. 그때는 감지기가 있는데 서버가 막았고, 이번엔 서버·UI가 열려 있는데 감지기가 없다. 어느 쪽이든 화면은 멀쩡해 보인다. 엣지가 낼 수 없는 유형이라 서버에 둔다 — 전원·회선이 끊긴 노드는 자기가 죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다. App\Alert\DeviceHealthWatch: - dispatch_alerts.php 1분 크론의 1단계. 에스컬레이션·통보보다 앞에 두어 같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 되게 했다. - 멱등은 EscalationWatch와 같은 방식(event_uuid UNIQUE 충돌)이되, 장치 건전성은 연속 상태라 uuid 앵커를 '장애 구간의 시작'으로 잡았다. device_fault는 last_heartbeat_at(장애 내내 불변) → 장애당 정확히 1건. monitoring_degraded는 창 안의 마지막 정상 하트비트 ts. 창 전체가 저하면 시작을 볼 수 없으므로 창 버킷으로 폴백해 시간당 1회 재고지한다 — 의도된 리마인더이고, 매 틱 재발행하는 앵커 드리프트가 아니다. - 성능 저하는 지속을 요구(기본 5분). 한 번 튄 값에 울리면 운영자가 이 유형을 무시하게 되고, 그러면 감시가 멈춘 날에도 아무도 보지 않는다. 엣지 감지기들이 쓰는 규율과 같다. - 하트비트를 한 번도 보낸 적 없는 장치는 장애가 아니다(미설치 노드). 새 현장 첫날이 빨간 벽이 되면 그 사이트는 이 유형을 영영 신뢰하지 않는다. - 죽은 노드에 degraded를 겹쳐 내지 않는다 — 한 고장에 두 알람은 소음이다. - fps NULL을 0으로 강압하지 않는다(측정 실패 ≠ 정지). PdoDeviceMetric Repository가 NULL을 지키는 이유와 같다. - 억제(정비 모드)는 여기서 보지 않는다. 기록은 남기고 통보 여부는 AlertDispatcher가 정한다 — 정비 창은 전화를 끄는 것이지 노드가 죽었던 기록을 지우는 것이 아니다. camera_id는 NOT NULL인데 장치 단위 이벤트에는 카메라가 없다. '__device__' 센티넬을 쓰고, 콘솔은 "장치 전체"로 표시, 구역 편집기 카메라 목록에서는 제외한다(카메라 목록은 보고된 이벤트에서 파생되므로, 두면 존재하지 않는 카메라에 구역을 그리려는 일이 생긴다). 설정 4종 추가(device_stale_seconds 180 · degraded_sustain_seconds 300 · degraded_fps_ratio 0.6 · target_fps 12) — 두 config 템플릿에 한국어 설명과 함께. 낮은 프레임으로 운용하는 현장이 target_fps를 안 맞추면 전 노드가 영구 저하로 보고되므로 그 경고를 템플릿에 적었다. 검증: pytest 308 passed(3 skip: 모델 미동봉·shellcheck 미설치·AF_UNIX, 기존과 동일) · phpunit 240 passed(신규 16). dist 재생성으로 패리티 회복.
This commit is contained in:
parent
46d45c9a6e
commit
9c0cfd2fea
13 changed files with 657 additions and 19 deletions
29
CHANGELOG.md
29
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 은 **"감지에 대응하는 시스템"** 입니다.
|
||||
|
|
|
|||
|
|
@ -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회, 보존기간) ·
|
||||
|
|
|
|||
|
|
@ -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초가 남는 전부**입니다.
|
||||
|
||||
**얼굴 블러는 엣지에서, 업로드 전에 겁니다.** 블러 안 된 이미지는 네트워크로 나가지
|
||||
않고, 서버에 존재한 적이 없습니다. 과거 프레임은 *그 프레임 자신의* 머리 위치로
|
||||
블러합니다(현재 위치로 블러하면 과거 얼굴이 그대로 남으므로).
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@
|
|||
|--------|-------------|
|
||||
| **카메라 화각 변경** | 청소하다 삼각대를 스치면 위험구역이 엉뚱한 바닥을 가리킵니다. 그런데 프레임은 계속 들어오고 화면은 정상으로 보입니다 — 이 **조용한 실패**를 감지합니다 |
|
||||
| 카메라 가림 / 조도 저하 | 렌즈가 덮이거나 조명이 죽어 검출력이 떨어진 상태 |
|
||||
| 장치 이상 / 감시 성능 저하 | 노드·카메라 자체의 장애 |
|
||||
| **장치 이상** | 노드가 응답을 멈춘 상태. **서버가 하트비트의 부재로 판정합니다** — 전원이나 회선이 끊긴 노드는 자기가 죽었다고 신고할 수 없기 때문입니다 |
|
||||
| **감시 성능 저하** | 노드는 살아 있으나 프레임률이 떨어지거나, 카메라 하나가 오류이거나, 발열로 성능이 깎인 상태. 한 번 튄 값이 아니라 **지속될 때만** 사건이 됩니다 |
|
||||
| **미확인 경보 확대** | 위급 사건을 정해진 시간 동안 아무도 확인하지 않았다는 **사실 자체를 사건으로** 만듭니다 |
|
||||
|
||||
> 보호구 미착용 · 중장비 근접 · 화재/연기 3종은 감지기 배선과 이벤트 규격이 준비되어 있으나
|
||||
|
|
|
|||
|
|
@ -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 로 적으면 응급 대응에서 그대로 사고가 된다.
|
||||
|
|
|
|||
|
|
@ -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 로 접속했을 때
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
|
||||
// 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
213
server/src/Alert/DeviceHealthWatch.php
Normal file
213
server/src/Alert/DeviceHealthWatch.php
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Alert;
|
||||
|
||||
use App\Repository\DeviceMetricRepository;
|
||||
use App\Repository\DeviceRepository;
|
||||
use App\Repository\EventRepository;
|
||||
|
||||
/**
|
||||
* Makes the monitoring system report its OWN failures as events.
|
||||
*
|
||||
* `monitoring_degraded` and `device_fault` existed everywhere except where it mattered: the event
|
||||
* type was declared in edge/events.py, accepted by EventValidator, filterable in the console and
|
||||
* labelled in the notification channels — and nothing on either side ever produced one. The edge
|
||||
* cannot: a node that has crashed, lost power or lost its network is precisely the node that
|
||||
* cannot file a report about itself. So the observation has to happen here, from the ABSENCE of a
|
||||
* heartbeat rather than the presence of one.
|
||||
*
|
||||
* That asymmetry is the whole point. Every other event in this product means "something happened
|
||||
* in front of a camera". These two mean "we may not be watching at all" — the failure mode the
|
||||
* 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:
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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<int,array{device_id:string,event_type:string,event_id:int}> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
314
server/tests/DeviceHealthWatchTest.php
Normal file
314
server/tests/DeviceHealthWatchTest.php
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Alert\DeviceHealthWatch;
|
||||
use App\Tests\Support\{InMemoryEventRepository, InMemoryDeviceRepository, InMemoryDeviceMetricRepository};
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The two events nobody produced.
|
||||
*
|
||||
* These tests pin the behaviour that makes them trustworthy rather than noisy: a fault must be
|
||||
* filed ONCE per outage (not once per cron tick), a slow node must be degraded for a sustained
|
||||
* period before it counts, and a node that has never been installed must stay silent.
|
||||
*/
|
||||
final class DeviceHealthWatchTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_700_000_000;
|
||||
|
||||
private function devices(array $rows): InMemoryDeviceRepository
|
||||
{
|
||||
return new InMemoryDeviceRepository([], [], [], $rows);
|
||||
}
|
||||
|
||||
private function device(string $id, ?int $heartbeat, ?string $site = 'site-a'): array
|
||||
{
|
||||
return ['device_id' => $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<int,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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue