move_detect/server/public/assets/app.js
유창욱 9d25e0a2d1 feat(dashboard): redesign as a dense AI CCTV operations console
Rework the events dashboard from the sparse light-minimal look (which read as
empty / unprofessional) into a professional, information-dense operations
console — keeping the LIGHT direction (the dark HMI was previously rejected).

- App shell: top bar with an "AI SAFETY CCTV" brand mark + product identity, and a
  device/camera rail (a second neutral layer) beside the detection stream.
- Density: device health + filters move into the rail; the four hero KPI cards
  become a compact inline pill strip; the stream is snapshot-forward.
- Every event row is a camera slot (evidence thumbnail, or a "무영상" placeholder)
  so the stream reads as a CCTV grid and rows stay aligned regardless of snapshots.
- Restrained near-monochrome; indigo for interaction only; severity colour spent on
  the unacknowledged-critical alarm only; contrast-checked ink; reduced-motion honoured.
- Pure CSS, system fonts, zero external assets (air-gap). DOM contract (ids/classes)
  unchanged, so polling/ack/alarm/filter logic is untouched. Verified in-browser.
2026-07-25 23:58:09 +09:00

562 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 slot — EVERY row is a camera slot so rows stay aligned and the stream reads as a
// CCTV grid. When a snapshot exists it fills the slot; otherwise (or after a PIPA-retention
// purge) the slot shows an empty "무영상" frame instead of collapsing the row. The <img> goes
// through /snap/{uuid}, which re-checks session + site scope, so the uuid grants nothing.
var slot = document.createElement('div');
slot.className = 'event-thumb-slot';
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';
thumb.onerror = function () { slot.classList.add('is-empty'); if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
slot.appendChild(thumb);
} else {
slot.classList.add('is-empty');
}
row.appendChild(slot);
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);
})();