/** * 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); })();