move_detect/server/public/assets/app.js
유창욱 47ab1143e1 feat(fp): operator false-positive tag + per-camera FP-rate report
The feedback loop, per product decision (option a): tags feed STATISTICS only.
A human reads the rate and re-tunes a camera deliberately - the tag never
adjusts edge sensitivity on its own (no silent behavior change in a safety
system).

- POST /events/{id}/false-positive: same RBAC ('acknowledge') + CSRF + site
  scope as ack. Tagging also acknowledges (an FP is a handled event - leaving
  it unacknowledged would keep it alarming). COALESCE keeps the FIRST tagger:
  a second tag never rewrites who made the original audit-relevant call.
- move_events.false_positive_by/at (migration included), audit-logged.
- GET /api/fp-stats?days=N: per-(camera, event_type) totals + FP counts,
  FP>0 groups only, fp-desc, fail-closed site scoping.
- Console: each open row gets a visually-secondary 오탐 button beside 확인
  (can't be fat-fingered as the default); resolved rows show a mono verdict
  ("오탐 (user)" struck through vs "확인됨 (user)"). New rail panel "오탐율 ·
  30일" lists top offenders - 30%+ highlighted as re-tuning candidates -
  refreshed on a slow 60s cadence plus immediately after a tag. Detail view
  shows the tag.

Verified: PHPUnit 135 pass / pytest unchanged + Chrome real-render (tag ->
row flips to struck-through verdict + drops out of unacked KPI/tree counts ->
FP panel updates immediately; no console errors).
2026-08-06 20:38:36 +09:00

709 lines
32 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;
// Event types arrive as wire codes ("fall") but the console reads in Korean. An unknown
// code (a newer edge than this dashboard) falls through as the raw code — never blank.
var TYPE_LABELS = {
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)'
};
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 pad2(n) { return n < 10 ? '0' + n : String(n); }
// Compact 24h stamp in the row's mono column. toLocaleString('ko-KR') renders
// "2026. 7. 26. 오후 2:22:31": long, 12-hour, and non-tabular, so a column of them cannot be
// scanned. The year is shown only when the event is not from the current year, so a deep
// paged-in row is never mistaken for a recent one.
function formatTime(occurredAt) {
if (!occurredAt && occurredAt !== 0) return '';
var d = new Date(Number(occurredAt) * 1000);
if (isNaN(d.getTime())) return '';
var year = d.getFullYear() !== new Date().getFullYear() ? d.getFullYear() + '-' : '';
return year + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' '
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
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;
}
}
// No snapshot, or the file is gone (PIPA retention purge / a broken store). Both are the same
// fact for the operator — the row has no evidence — and the label has to say so, because the
// "무영상" the slot draws is CSS content and never reaches a screen reader.
function markEmptySlot(slot) {
slot.classList.add('is-empty');
slot.setAttribute('aria-label', '무영상 · 상세 보기');
}
function spanWith(cls, text) {
var s = document.createElement('span');
s.className = cls;
s.textContent = text;
return s;
}
// Human name for the event's device, from the last /api/devices payload. Falls back to the
// raw device_id: a row must still say where it came from when the device is unregistered.
function deviceLabel(deviceId) {
var id = String(deviceId || '');
if (!id) return '';
for (var i = 0; i < deviceList.length; i++) {
if (String(deviceList[i].device_id) === id) return deviceList[i].name || id;
}
return id;
}
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
// contact sheet. 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.
// The slot opens the incident — the same destination as the title link below, so it is
// skipped by Tab (a mouse shortcut, not a second stop) but kept in the accessibility tree:
// whether an event has evidence at all is part of reading the row. The link carries the
// label, so the image itself is decorative (alt="").
var slot = document.createElement('a');
slot.className = 'event-thumb-slot';
slot.href = B + '/events/' + encodeURIComponent(ev.event_id) + '/view';
slot.tabIndex = -1;
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 () { markEmptySlot(slot); if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
slot.appendChild(thumb);
slot.setAttribute('aria-label', '스냅샷 · 상세 보기');
} else {
markEmptySlot(slot);
}
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 = TYPE_LABELS[ev.event_type] || 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);
// Provenance line: WHERE (device, then camera) and WHEN. The device name is prose and the
// ids/stamp are mono, so the three read apart on typeface alone — a "·" chain would blur
// into the device names, which contain "·" themselves ("1공장 · 프레스 라인").
var sub = document.createElement('div');
sub.className = 'event-time';
var src = deviceLabel(ev.device_id);
if (src) sub.appendChild(spanWith('ev-src', src));
if (ev.camera_id) sub.appendChild(spanWith('ev-cam', String(ev.camera_id)));
var time = formatTime(ev.occurred_at);
if (time) sub.appendChild(spanWith('ev-at', time));
meta.appendChild(sub);
row.appendChild(meta);
// Actions column. Resolution states are terminal and mutually exclusive in display:
// an FP tag wins (it is the more specific verdict — the event was also acknowledged
// server-side), then a plain acknowledgement, else the two action buttons.
var actions = document.createElement('div');
actions.className = 'row-actions';
var isFp = ev.false_positive_by !== null && ev.false_positive_by !== undefined && ev.false_positive_by !== '';
if (isFp) {
var fpDone = document.createElement('span');
fpDone.className = 'fp-done';
fpDone.textContent = '오탐 (' + String(ev.false_positive_by) + ')';
actions.appendChild(fpDone);
} else if (isAcked) {
var ackDone = document.createElement('span');
ackDone.className = 'ack-done';
ackDone.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
actions.appendChild(ackDone);
} else {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ack-btn';
btn.textContent = '확인';
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
actions.appendChild(btn);
var fpBtn = document.createElement('button');
fpBtn.type = 'button';
fpBtn.className = 'fp-btn';
fpBtn.textContent = '오탐';
fpBtn.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
fpBtn.addEventListener('click', function () { markFalsePositive(ev.event_id, fpBtn, btn); });
actions.appendChild(fpBtn);
}
row.appendChild(actions);
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, alsoFp) {
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).
var patch = { acknowledged_by: window.APP_USER || '확인' };
if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐';
olderEvents[i] = Object.assign({}, ev, patch);
}
}
}
// The 오탐 tag: same terminality as an ack (the server also acknowledges), plus the event
// feeds the per-camera FP-rate panel. By product decision this is where the loop ENDS —
// the tag never auto-tunes edge thresholds; a human reads the rate and adjusts deliberately.
function markFalsePositive(eventId, fpBtn, ackBtn) {
fpBtn.disabled = true;
if (ackBtn) ackBtn.disabled = true;
fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
})
.then(function (res) {
if (!res.ok) throw new Error('fp_failed:' + res.status);
patchOlderAck(eventId, true);
loadFpStats(); // the panel should reflect the tag immediately, not in 60s
return loadEvents();
})
.catch(function () {
fpBtn.disabled = false;
if (ackBtn) ackBtn.disabled = false;
});
}
// 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;
// The strip is captioned "미확인" once (see the view), so each tile carries only its tier —
// four repetitions of the same word is noise in a strip meant to be read at a glance.
var text = document.createElement('span');
text.textContent = info.text;
label.appendChild(led);
label.appendChild(icon);
label.appendChild(text);
tile.setAttribute('aria-label', '미확인 ' + info.text + ' ' + n + '건');
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
// The camera filter is a (device, camera) PAIR, not a bare camera_id: camera ids are free-form
// strings from the edge, so two devices may both run a "cam-01". Selecting the pair is what the
// rail tree hands us, and it keeps one device's events out of another's filtered view.
var filters = { severity: '', device: '', camera: '', unackedOnly: false };
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.device && String(ev.device_id || '') !== filters.device) 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;
}
function renderList() {
renderStatus(liveEvents); // counts always from the full live window, never the filtered view
updateAlarm(liveEvents); // alarm too — before any early return
renderTree(); // rail: camera stats + the current selection
var all = combinedEvents();
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;
var empty = document.createElement('div');
empty.className = 'stream-empty';
empty.textContent = anyFilter
? '조건에 맞는 이벤트가 없습니다. 왼쪽에서 필터를 해제하면 전체 스트림으로 돌아갑니다.'
: '표시할 이벤트가 없습니다.';
root.appendChild(empty);
} 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.
// --- rail: device → camera tree ---------------------------------------------------
// GET /api/devices surfaces the heartbeat telemetry the edge already reports (online/offline,
// fps, temp, throttle, config drift); the cameras seen in the event stream nest under their
// device. 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". DeviceTree (device-tree.js) owns the DOM.
var devicesRoot = document.getElementById('devices-root');
var railSummary = document.getElementById('rail-summary');
var cameraReset = document.getElementById('camera-reset');
var deviceList = []; // last successful /api/devices payload
var devicesLoaded = false; // false until the first success, so "empty" ≠ "not known yet"
var devicesStale = false; // last poll failed: show the old state, but say that it is old
var deviceLabelSig = ''; // device_id=name pairs last painted into the stream rows
var SEV_RANK = { info: 0, warning: 1, high: 2, critical: 3 };
var CAM_SEP = '\u001f'; // (device, camera) key separator — a unit separator, never in an id
function cameraKey(deviceId, cameraId) {
return String(deviceId || '') + CAM_SEP + String(cameraId || '');
}
// Per-camera rail stats. MEMBERSHIP and "last seen" come from everything this session has seen
// (live window + any paged tail), so a camera keeps its place in the tree during a quiet spell
// instead of blinking out between polls. The UNACKNOWLEDGED counts come from the live window
// only — the same set the KPI strip and the alarm use, so the rail can never show a backlog
// that the strip contradicts.
function cameraStats() {
var byKey = {}, out = [];
combinedEvents().forEach(function (ev) {
if (!ev.camera_id) return;
var key = cameraKey(ev.device_id, ev.camera_id);
var cam = byKey[key];
if (!cam) {
cam = byKey[key] = { key: key, device_id: String(ev.device_id || ''), camera_id: String(ev.camera_id),
last_at: 0, unacked: 0, worst: null };
out.push(cam);
}
var at = Number(ev.occurred_at) || 0;
if (at > cam.last_at) cam.last_at = at;
});
liveEvents.forEach(function (ev) {
if (!ev.camera_id || isAcked(ev)) return;
var cam = byKey[cameraKey(ev.device_id, ev.camera_id)];
if (!cam) return;
cam.unacked += 1;
var sev = SEV_RANK.hasOwnProperty(ev.severity) ? ev.severity : 'info';
if (cam.worst === null || SEV_RANK[sev] > SEV_RANK[cam.worst]) cam.worst = sev;
});
return out;
}
function renderTree() {
if (cameraReset) cameraReset.hidden = !filters.camera;
if (!devicesRoot || !window.DeviceTree) return;
window.DeviceTree.render(devicesRoot, railSummary, {
devices: deviceList,
cameras: cameraStats(),
now: Date.now() / 1000,
loaded: devicesLoaded,
stale: devicesStale,
selectedKey: filters.camera ? cameraKey(filters.device, filters.camera) : '',
onSelect: selectCamera
});
}
// Clicking the selected camera again clears the filter — the same affordance as the
// "전체 보기" button, so the operator never gets stuck inside one camera.
function selectCamera(key) {
var pair = key.split(CAM_SEP);
var isSame = filters.device === pair[0] && filters.camera === pair[1];
filters.device = isSame ? '' : pair[0];
filters.camera = isSame ? '' : pair[1];
renderList();
}
function clearCamera() {
filters.device = '';
filters.camera = '';
renderList();
}
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)) {
deviceList = data.devices;
devicesLoaded = true;
devicesStale = false;
// The stream rows print the device's NAME, not its id, so a changed name set — the
// first load above all, which lands after the first event render — has to repaint the
// list too. Unchanged names (the steady state) only repaint the rail.
var sig = deviceList.map(function (d) { return d.device_id + '=' + (d.name || ''); }).join('|');
if (sig !== deviceLabelSig) { deviceLabelSig = sig; renderList(); }
else renderTree();
}
})
.catch(function () {
// A silently frozen panel (poll 500 / WAF-blocked while events still flow) is worse than
// an empty one: a camera that dies afterwards would keep showing "온라인". Keep the last
// state, but flag it. Cleared automatically by the next successful poll.
devicesStale = true;
renderTree();
});
}
// --- rail: FP-rate panel ----------------------------------------------------------
// Per-(camera, type) false-positive rates over 30 days. Refreshed on its own slow cadence
// (60s) — tag volume changes slowly — plus immediately after the operator tags one.
var fpStatsRoot = document.getElementById('fp-stats-root');
var FP_STATS_INTERVAL_MS = 60000;
var FP_STATS_SHOWN = 5;
function renderFpStats(stats) {
if (!fpStatsRoot) return;
fpStatsRoot.textContent = '';
if (!stats || stats.length === 0) {
fpStatsRoot.textContent = '오탐 태그가 없습니다.';
fpStatsRoot.className = 'fp-stats is-empty';
return;
}
fpStatsRoot.className = 'fp-stats';
stats.slice(0, FP_STATS_SHOWN).forEach(function (s) {
var row = document.createElement('div');
row.className = 'fp-row';
var what = document.createElement('span');
what.className = 'fp-what';
var cam = document.createElement('span');
cam.className = 'fp-cam';
cam.textContent = s.camera_id;
what.appendChild(cam);
what.appendChild(document.createTextNode(' ' + (TYPE_LABELS[s.event_type] || s.event_type)));
row.appendChild(what);
var rate = document.createElement('span');
var pct = Math.round((s.fp / Math.max(1, s.total)) * 100);
rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : '');
rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')';
// 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
row.appendChild(rate);
fpStatsRoot.appendChild(row);
});
}
function loadFpStats() {
if (!fpStatsRoot) return;
fetch(B + '/api/fp-stats?days=30', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data) renderFpStats(data.stats); })
.catch(function () { /* 패널은 보조 정보 — 실패 시 마지막 상태 유지 */ });
}
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 (cameraReset) cameraReset.addEventListener('click', clearCamera);
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();
loadFpStats();
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
setInterval(loadFpStats, FP_STATS_INTERVAL_MS);
})();