/** * 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; // Live-monitoring pill: honest about poll health — green "실시간 감시 중" while events // are flowing, muted "연결 지연" when a poll fails, so a stale board never looks live. var watchEl = document.getElementById('watch-status'); function setWatch(ok) { if (!watchEl) return; watchEl.textContent = ok ? '실시간 감시 중' : '연결 지연'; watchEl.classList.toggle('is-stale', !ok); } 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 !== ''; // sev-row-* drives the severity-colored left rail (and the critical glow) on // unacknowledged rows. Constrain to the known tiers so the class can't carry an // arbitrary server value into the DOM. var sevRow = SEVERITY_LABELS.hasOwnProperty(ev.severity) ? ev.severity : 'info'; var row = document.createElement('div'); row.className = 'event-row sev-row-' + sevRow + (isAcked ? '' : ' is-unacknowledged'); // Evidence thumbnail (newest snapshot for this event), when one exists. The goes // through /snap/{uuid}, which re-checks session + site scope, so the uuid grants nothing. if (ev.thumb_uuid) { var thumb = document.createElement('img'); thumb.className = 'event-thumb'; thumb.src = B + '/snap/' + encodeURIComponent(ev.thumb_uuid); thumb.alt = '스냅샷'; thumb.loading = 'lazy'; // A snapshot can be purged (PIPA retention) after the row was fetched; drop the // rather than leave a broken-image box. thumb.onerror = function () { if (thumb.parentNode) thumb.parentNode.removeChild(thumb); }; row.appendChild(thumb); } 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); // The one loud cue: an unacknowledged critical (a fall) gets a "확인 필요" chip — // secondary encoding alongside the red tint, so it never rides on color alone. if (!isAcked && ev.severity === 'critical') { var chip = document.createElement('span'); chip.className = 'chip'; chip.textContent = '확인 필요'; type.appendChild(chip); } 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); // Reflect the ack on a "더 보기"-loaded (older-window) row too. loadEvents() only refreshes // the live window, so without this an event acked from the paged tail snaps straight back // to unacknowledged on the next render — inviting a duplicate ack that overwrites the // original acknowledged_by audit record. patchOlderAck(eventId); return loadEvents(); }) .catch(function () { btn.disabled = false; }); } function patchOlderAck(eventId) { for (var i = 0; i < olderEvents.length; i++) { var ev = olderEvents[i]; if (ev && ev.event_id === eventId && !isAcked(ev)) { // New object, never mutate the existing one (keeps combinedEvents' dedup predictable). olderEvents[i] = Object.assign({}, ev, { acknowledged_by: window.APP_USER || '확인' }); } } } // Status strip: count UNACKNOWLEDGED events per severity from the same array the // list renders. Stat tiles (not a chart): small integers a desk operator scans. // Every tier tile is always drawn (zero -> muted) so the strip never reflows. var statusRoot = document.getElementById('events-status'); var STATUS_TIERS = [ { key: 'critical', cls: 'tile-critical' }, { key: 'high', cls: 'tile-high' }, { key: 'warning', cls: 'tile-warning' }, { key: 'info', cls: 'tile-info' } ]; function isAcked(ev) { return ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; } function renderStatus(events) { if (!statusRoot) return; var counts = { critical: 0, high: 0, warning: 0, info: 0 }; events.forEach(function (ev) { if (isAcked(ev)) return; var sev = counts.hasOwnProperty(ev.severity) ? ev.severity : 'info'; counts[sev] += 1; }); statusRoot.textContent = ''; var frag = document.createDocumentFragment(); STATUS_TIERS.forEach(function (tier) { var info = severityInfo(tier.key); var n = counts[tier.key]; var tile = document.createElement('div'); tile.className = 'stat-tile ' + tier.cls + (n === 0 ? ' is-zero' : ''); var label = document.createElement('span'); label.className = 'stat-label'; var led = document.createElement('span'); led.className = 'led'; led.setAttribute('aria-hidden', 'true'); var icon = document.createElement('span'); icon.className = 'icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = info.icon; var text = document.createElement('span'); text.textContent = '미확인 ' + info.text; label.appendChild(led); label.appendChild(icon); label.appendChild(text); var count = document.createElement('span'); count.className = 'stat-count'; count.textContent = String(n); tile.appendChild(label); tile.appendChild(count); frag.appendChild(tile); }); statusRoot.appendChild(frag); } // --- filter + pagination state --------------------------------------------------- // Filters are CLIENT-SIDE (they narrow only the displayed list); the status strip and the // audible alarm keep considering the full live set, so filtering the view can never silence a // real unacknowledged critical. Pagination is SERVER-SIDE (keyset cursor): "더 보기" pulls // events older than the live window into an accumulated tail that the 5s poll does not wipe. var liveEvents = []; // newest window from the poll (drives status + alarm) var olderEvents = []; // events pulled in by "더 보기", older than the live window var moreCursor = null; // cursor for the NEXT "더 보기" (null = no older page) var pagedByUser = false; // once true, polls stop resetting moreCursor to the live boundary var newestIdAtPaging = null; // newest live event_id at the moment paging began (gap detection) var pageGapHint = null; // lazily-built "new events arrived while paging" reload affordance var filters = { severity: '', camera: '', unackedOnly: false }; var cameraSel = document.getElementById('camera-filter'); var moreBtn = document.getElementById('load-more'); function matchesFilters(ev) { if (filters.severity && ev.severity !== filters.severity) return false; if (filters.camera && String(ev.camera_id || '') !== filters.camera) return false; if (filters.unackedOnly && isAcked(ev)) return false; return true; } function combinedEvents() { var seen = {}, out = []; liveEvents.concat(olderEvents).forEach(function (ev) { if (seen[ev.event_id]) return; // dedup: a poll may overlap the paged tail by an event seen[ev.event_id] = true; out.push(ev); }); return out; } // Populate the camera dropdown from the camera_ids actually present, rebuilding only when that // set changes so a poll never resets the operator's current selection. function refreshCameraOptions(events) { if (!cameraSel) return; var cams = {}; events.forEach(function (ev) { if (ev.camera_id) cams[String(ev.camera_id)] = true; }); var list = Object.keys(cams).sort(); var want = list.join('|'); if (cameraSel.getAttribute('data-opts') === want) return; cameraSel.setAttribute('data-opts', want); var current = cameraSel.value; cameraSel.textContent = ''; var all = document.createElement('option'); all.value = ''; all.textContent = '전체 카메라'; cameraSel.appendChild(all); list.forEach(function (c) { var o = document.createElement('option'); o.value = c; o.textContent = c; cameraSel.appendChild(o); }); // Preserve the operator's active camera filter even if that camera has no event in the current // window. Silently resetting to "전체 카메라" floods the list with other cameras' events, which // reads as the filtered camera's activity. Keep the selection as an option so the filter stays // applied (the list then honestly shows "none" for a quiet camera). if (current && !cams[current]) { var keep = document.createElement('option'); keep.value = current; keep.textContent = current + ' (최근 이벤트 없음)'; cameraSel.appendChild(keep); } cameraSel.value = current; // always preserve the selection ('' = 전체 카메라 is preserved too) } function renderList() { renderStatus(liveEvents); // counts always from the full live window, never the filtered view updateAlarm(liveEvents); // alarm too — before any early return var all = combinedEvents(); refreshCameraOptions(all); var shown = all.filter(matchesFilters).sort(function (a, b) { var aA = isAcked(a), bA = isAcked(b); if (aA !== bA) return aA ? 1 : -1; // unacknowledged first var at = Number(a.occurred_at) || 0, bt = Number(b.occurred_at) || 0; if (at !== bt) return bt - at; // then newest first return (b.event_id || 0) - (a.event_id || 0); }); root.textContent = ''; root.removeAttribute('aria-busy'); if (shown.length === 0) { var anyFilter = filters.severity || filters.camera || filters.unackedOnly; root.textContent = anyFilter ? '조건에 맞는 이벤트가 없습니다.' : '표시할 이벤트가 없습니다.'; } else { var frag = document.createDocumentFragment(); shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); }); root.appendChild(frag); } if (moreBtn) moreBtn.hidden = (moreCursor === null); updatePageGapHint(); } function updatePageGapHint() { // A gap exists once new events have arrived since the operator paged: some events have slid out // of the live window into a band that was never fetched. Surface it honestly with a reload, // rather than let the operator scroll a timeline with a silent hole in the middle. var gap = pagedByUser && liveEvents.length > 0 && newestIdAtPaging !== null && (liveEvents[0].event_id || 0) > newestIdAtPaging; if (!pageGapHint) { pageGapHint = document.createElement('div'); pageGapHint.className = 'page-gap-hint'; var span = document.createElement('span'); span.textContent = '페이징 이후 새 이벤트가 도착해 목록 일부가 밀렸을 수 있습니다.'; var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = '새로고침'; btn.addEventListener('click', function () { window.location.reload(); }); pageGapHint.appendChild(span); pageGapHint.appendChild(btn); if (root.parentNode) root.parentNode.insertBefore(pageGapHint, root); // sibling above the list, survives root clears } pageGapHint.hidden = !gap; } function loadEvents() { // Newest window (server MAX_LIMIT=200). The audible alarm and the "unacknowledged first" // pinning only consider this set, so it stays the full cap — during an event burst a smaller // page could push an unacknowledged critical off it and updateAlarm() would clear the alarm // while a 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)) { liveEvents = data.events; // Track the live boundary as the next-page cursor only until the operator starts paging; // after that "더 보기" advances moreCursor itself and a poll must not reset it. if (!pagedByUser) moreCursor = data.next_cursor || null; renderList(); } setWatch(true); }) .catch(function () { // Leave the last rendered state on transient poll failure, but flag the staleness. setWatch(false); }); } function loadMore() { if (moreCursor === null) return; if (moreBtn) { moreBtn.disabled = true; moreBtn.textContent = '불러오는 중...'; } fetch(B + '/api/events?limit=200&cursor=' + encodeURIComponent(moreCursor), { headers: { Accept: 'application/json' } }) .then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; return null; } if (!res.ok) throw new Error('more_failed:' + res.status); return res.json(); }) .then(function (data) { if (data && Array.isArray(data.events)) { // Capture the newest live id at the moment paging begins. If newer events arrive after // this, the live window (newest 200) slides forward and events between its new bottom and // the frozen paged boundary belong to neither array — combinedEvents() drops them // silently. We cannot re-fetch that middle band cheaply, so we at least SURFACE it: the // gap hint (renderList) tells the operator to reload for the complete timeline. if (!pagedByUser) newestIdAtPaging = (liveEvents[0] && liveEvents[0].event_id) || 0; pagedByUser = true; olderEvents = olderEvents.concat(data.events); moreCursor = data.next_cursor || null; renderList(); } }) .catch(function () { /* leave state; the button re-enables below */ }) .then(function () { if (moreBtn) { moreBtn.disabled = false; moreBtn.textContent = '더 보기'; } }); } // Logout is wired in the shared layout footer (src/View/layout.php) so it works on every page, // not just this one — app.js bails early on pages without #events-root. // --- device / camera health panel ------------------------------------------------- // GET /api/devices surfaces the heartbeat telemetry the edge already reports (online/offline, // fps, temp, throttle, config drift). A dead camera is a safety blind spot, so this is the // panel that turns "the board looks calm" into "the board is actually watching". var devicesRoot = document.getElementById('devices-root'); function fmtAgo(secs) { if (secs === null || secs === undefined) return '응답 없음'; if (secs < 60) return secs + '초 전'; if (secs < 3600) return Math.floor(secs / 60) + '분 전'; return Math.floor(secs / 3600) + '시간 전'; } function renderDevices(devices) { if (!devicesRoot) return; devicesRoot.textContent = ''; if (!devices || devices.length === 0) { devicesRoot.className = 'device-strip is-empty'; devicesRoot.textContent = '등록된 장치가 없습니다.'; return; } devicesRoot.className = 'device-strip'; var frag = document.createDocumentFragment(); devices.forEach(function (d) { var card = document.createElement('div'); card.className = 'device-card' + (d.online ? '' : ' is-offline'); var name = document.createElement('div'); name.className = 'device-name'; var dot = document.createElement('span'); dot.className = 'dot'; dot.setAttribute('aria-hidden', 'true'); var nm = document.createElement('span'); nm.textContent = d.name || d.device_id; // never color alone: the "오프라인" text below carries state too name.appendChild(dot); name.appendChild(nm); card.appendChild(name); var meta = document.createElement('div'); meta.className = 'device-meta'; var parts = []; if (d.online) parts.push(fmtAgo(d.seconds_since)); else if (d.seconds_since === null) parts.push('응답 없음'); else parts.push('오프라인 · ' + fmtAgo(d.seconds_since)); if (d.fps !== null && d.fps !== undefined) parts.push((Math.round(d.fps * 10) / 10) + ' fps'); if (d.temp !== null && d.temp !== undefined && d.temp > 0) parts.push(Math.round(d.temp) + '°'); meta.textContent = parts.join(' · '); card.appendChild(meta); if (d.throttle || d.config_drift) { var warn = document.createElement('div'); warn.className = 'device-warn'; var w = []; if (d.throttle) w.push('과열 스로틀'); if (d.config_drift) w.push('설정 미적용'); warn.textContent = w.join(' · '); card.appendChild(warn); } frag.appendChild(card); }); devicesRoot.appendChild(frag); } function markDevicesStale() { // The device panel's whole job is a trustworthy online/offline verdict, so a silently frozen // panel (poll 500 / WAF-blocked while events still flow) is worse than an empty one: a camera // that dies afterwards keeps showing "온라인". Flag the staleness instead of hiding it. Cleared // automatically on the next successful poll (renderDevices resets the class + note). if (!devicesRoot) return; devicesRoot.classList.add('is-stale'); if (!devicesRoot.querySelector('.device-stale-note')) { var note = document.createElement('div'); note.className = 'device-stale-note'; note.textContent = '상태 갱신 지연 — 아래는 마지막으로 확인된 상태입니다'; devicesRoot.insertBefore(note, devicesRoot.firstChild); } } function loadDevices() { return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } }) .then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; return null; } if (!res.ok) throw new Error('devices_failed:' + res.status); return res.json(); }) .then(function (data) { if (data && Array.isArray(data.devices)) renderDevices(data.devices); }) .catch(function () { markDevicesStale(); }); // keep the last state, but say it is stale } document.addEventListener('click', unlockAudio); // Filter bar (client-side) + "더 보기" (server-side cursor). var sevSel = document.getElementById('severity-filter'); if (sevSel) sevSel.addEventListener('change', function () { filters.severity = sevSel.value; renderList(); }); if (cameraSel) cameraSel.addEventListener('change', function () { filters.camera = cameraSel.value; renderList(); }); var unackedChk = document.getElementById('unacked-only'); if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); }); if (moreBtn) moreBtn.addEventListener('click', loadMore); loadCsrfToken(); loadEvents(); loadDevices(); setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS); })();