Two independent adversarial reviews (codex CLI + a 6-dimension verification workflow) of the delivery-hardening branch surfaced ~40 findings; this closes the confirmed correctness/security/deploy blockers plus a codex re-review pass. edge: - geometry: track_displacement per-second threshold recalibrated (0.02->0.25, env-tunable via MOTION_THR, validated finite/positive); confidence-filter keypoints with a bbox-center fallback so hallucinated points don't fake motion - fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident (freeze + reset recovery timer so recovery needs a fresh confident streak); baseline height only seeded from trusted frames - queue: PostClient.send() returns a tri-state SendResult; only definite 4xx count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx) never deletes queued safety events; legacy rows persist their minted uuid - config: reject placeholder / http / short DEVICE_SECRET at startup - zones: warn+skip legacy pixel-space polygons that would silently match nothing cloud: - zone endpoints wired into P3-2b site-scope (cross-site read/write/delete closed) + role gate on list; catch narrowed so DB faults surface as 500 - snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan) - ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE (event_id, content_hash) makes recovery idempotent under retry/race - event list cursor carries full-precision occurred_at (no float rounding at the page boundary); string keyset end-to-end - SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap - runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit reap fails open deploy: - offline bundle hashes EVERY file (fatal on missing/failed/injected; models in SBOM; arch in tarball name; REQUIRE_SIGNATURE option) - installer verifies before any mutation, Hailo arch preflight before install, atomic src swap, backend reconcile preserves a working operator choice - systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert) Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4 skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings + fixes) with the second-pass findings folded in.
226 lines
7.9 KiB
JavaScript
226 lines
7.9 KiB
JavaScript
/**
|
||
* Events dashboard: polls GET /api/events every 5s, renders the list with
|
||
* severity text+icon+color (never color alone, per NF9), pins unacknowledged
|
||
* events to the top, and wires the acknowledge button to POST /events/{id}/ack.
|
||
*
|
||
* XSS-safe: all dynamic values are set via textContent, never innerHTML.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
|
||
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME. Every URL below must
|
||
// go through it — a bare '/api/...' would address the other site instead.
|
||
var B = window.APP_BASE || '';
|
||
|
||
var POLL_INTERVAL_MS = 5000;
|
||
|
||
var SEVERITY_LABELS = {
|
||
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' }, // ⛔
|
||
high: { icon: '⚠', text: '높음', cls: 'sev-high' }, // ⚠
|
||
warning: { icon: '△', text: '경고', cls: 'sev-warning' }, // △
|
||
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' } // ℹ
|
||
};
|
||
|
||
var root = document.getElementById('events-root');
|
||
if (!root) return;
|
||
|
||
var csrfToken = null;
|
||
function loadCsrfToken() {
|
||
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
|
||
.then(function (res) { return res.ok ? res.json() : null; })
|
||
.then(function (data) { if (data && data.token) csrfToken = data.token; })
|
||
.catch(function () { /* ack will fail with 403 and re-enable the button */ });
|
||
}
|
||
|
||
function severityInfo(sev) {
|
||
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
||
}
|
||
|
||
function formatTime(occurredAt) {
|
||
if (!occurredAt && occurredAt !== 0) return '';
|
||
var d = new Date(Number(occurredAt) * 1000);
|
||
if (isNaN(d.getTime())) return '';
|
||
return d.toLocaleString('ko-KR');
|
||
}
|
||
|
||
function buildBadge(sev) {
|
||
var info = severityInfo(sev);
|
||
var badge = document.createElement('span');
|
||
badge.className = 'severity-badge ' + info.cls;
|
||
var icon = document.createElement('span');
|
||
icon.className = 'icon';
|
||
icon.setAttribute('aria-hidden', 'true');
|
||
icon.textContent = info.icon;
|
||
var label = document.createElement('span');
|
||
label.className = 'label';
|
||
label.textContent = info.text;
|
||
badge.appendChild(icon);
|
||
badge.appendChild(label);
|
||
return badge;
|
||
}
|
||
|
||
// --- desk audio alert: repeating beep while any unacknowledged critical exists ---
|
||
// No binary asset (air-gapped friendly): a WebAudio oscillator tone. The browser
|
||
// blocks audio until a user gesture, so the context is created on first click.
|
||
var audioCtx = null;
|
||
var alarmTimer = null;
|
||
var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately
|
||
|
||
function unlockAudio() {
|
||
if (!audioCtx) {
|
||
var AC = window.AudioContext || window.webkitAudioContext;
|
||
if (AC) audioCtx = new AC();
|
||
}
|
||
document.removeEventListener('click', unlockAudio);
|
||
}
|
||
|
||
function beep() {
|
||
if (!audioCtx) return;
|
||
try {
|
||
var osc = audioCtx.createOscillator();
|
||
var gain = audioCtx.createGain();
|
||
osc.type = 'sine';
|
||
osc.frequency.value = 880;
|
||
gain.gain.value = 0.2;
|
||
osc.connect(gain);
|
||
gain.connect(audioCtx.destination);
|
||
osc.start();
|
||
osc.stop(audioCtx.currentTime + 0.4);
|
||
} catch (e) {
|
||
// audio unavailable; the visual list still surfaces the critical event
|
||
}
|
||
}
|
||
|
||
function updateAlarm(events) {
|
||
var hasUnackedCritical = false;
|
||
events.forEach(function (ev) {
|
||
var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||
if (ev.severity === 'critical' && !acked) {
|
||
hasUnackedCritical = true;
|
||
if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); }
|
||
}
|
||
});
|
||
if (hasUnackedCritical && !alarmTimer) {
|
||
alarmTimer = setInterval(beep, 3000);
|
||
} else if (!hasUnackedCritical && alarmTimer) {
|
||
clearInterval(alarmTimer);
|
||
alarmTimer = null;
|
||
}
|
||
}
|
||
|
||
function buildRow(ev) {
|
||
var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== '';
|
||
|
||
var row = document.createElement('div');
|
||
row.className = 'event-row' + (isAcked ? '' : ' is-unacknowledged');
|
||
|
||
row.appendChild(buildBadge(ev.severity));
|
||
|
||
var meta = document.createElement('div');
|
||
meta.className = 'event-meta';
|
||
|
||
var type = document.createElement('div');
|
||
type.className = 'event-type';
|
||
var typeLink = document.createElement('a');
|
||
typeLink.className = 'event-detail-link';
|
||
typeLink.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
|
||
typeLink.textContent = String(ev.event_type || '');
|
||
type.appendChild(typeLink);
|
||
meta.appendChild(type);
|
||
|
||
var sub = document.createElement('div');
|
||
sub.className = 'event-time';
|
||
var cam = ev.camera_id ? String(ev.camera_id) : '';
|
||
var time = formatTime(ev.occurred_at);
|
||
sub.textContent = [cam, time].filter(Boolean).join(' · ');
|
||
meta.appendChild(sub);
|
||
|
||
row.appendChild(meta);
|
||
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'ack-btn';
|
||
if (isAcked) {
|
||
btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
|
||
btn.disabled = true;
|
||
} else {
|
||
btn.textContent = '확인';
|
||
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
|
||
}
|
||
row.appendChild(btn);
|
||
|
||
return row;
|
||
}
|
||
|
||
function acknowledge(eventId, btn) {
|
||
btn.disabled = true;
|
||
fetch(B + '/events/' + encodeURIComponent(eventId) + '/ack', {
|
||
method: 'POST',
|
||
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||
})
|
||
.then(function (res) {
|
||
if (!res.ok) throw new Error('ack_failed:' + res.status);
|
||
return loadEvents();
|
||
})
|
||
.catch(function () {
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
|
||
function render(events) {
|
||
var sorted = events.slice().sort(function (a, b) {
|
||
var aAcked = a.acknowledged_by !== null && a.acknowledged_by !== undefined && a.acknowledged_by !== '';
|
||
var bAcked = b.acknowledged_by !== null && b.acknowledged_by !== undefined && b.acknowledged_by !== '';
|
||
if (aAcked === bAcked) return 0;
|
||
return aAcked ? 1 : -1; // unacknowledged first
|
||
});
|
||
|
||
updateAlarm(sorted); // before any early return, so an emptied list also silences the alarm
|
||
|
||
root.textContent = '';
|
||
root.removeAttribute('aria-busy');
|
||
if (sorted.length === 0) {
|
||
root.textContent = '표시할 이벤트가 없습니다.';
|
||
return;
|
||
}
|
||
var frag = document.createDocumentFragment();
|
||
sorted.forEach(function (ev) { frag.appendChild(buildRow(ev)); });
|
||
root.appendChild(frag);
|
||
}
|
||
|
||
function loadEvents() {
|
||
// Request the full server page cap (MAX_LIMIT=200), not the default 50: the audible alarm
|
||
// and the "unacknowledged first" pinning only consider the rows fetched here. During an event
|
||
// burst, a default page of 50 could push an unacknowledged critical off the fetched set, and
|
||
// updateAlarm() would then clear the alarm while a critical fall is still unacknowledged.
|
||
return fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
|
||
.then(function (res) {
|
||
if (res.status === 401) {
|
||
window.location.href = B + '/login';
|
||
return null;
|
||
}
|
||
if (!res.ok) throw new Error('list_failed:' + res.status);
|
||
return res.json();
|
||
})
|
||
.then(function (data) {
|
||
if (data && Array.isArray(data.events)) render(data.events);
|
||
})
|
||
.catch(function () {
|
||
// Leave the last rendered state on transient poll failure.
|
||
});
|
||
}
|
||
|
||
var logoutBtn = document.getElementById('logout-btn');
|
||
if (logoutBtn) {
|
||
logoutBtn.addEventListener('click', function () {
|
||
fetch(B + '/logout', { method: 'POST' }).then(function () { window.location.href = B + '/login'; });
|
||
});
|
||
}
|
||
|
||
document.addEventListener('click', unlockAudio);
|
||
|
||
loadCsrfToken();
|
||
loadEvents();
|
||
setInterval(loadEvents, POLL_INTERVAL_MS);
|
||
})();
|