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