Three intertwined bodies of work developed and tested together this session. Full test suite green: python (edge) + phpunit (cloud). ## Edge/Pi install path - setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit + StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection. - onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the real backend and hard-fails if none works. - Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess 8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix. - install_offline.sh: mirrors the backend gate; onnxruntime optional. ## Cafe24 cloud package (cloud/deploy/) - build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/ (docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess. - Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie scoped to the mount. ## Delivery audit — silent-failure & correctness (docs/delivery-audit.md) All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness: - Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and allow_redirects=False. - Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions; heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera; offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated. - Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING). - Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person); ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
222 lines
7.5 KiB
JavaScript
222 lines
7.5 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() {
|
||
return fetch(B + '/api/events', { 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);
|
||
})();
|