/** * Events dashboard: polls GET /api/events every 5s, renders the list with * severity text+icon+color (never color alone, per NF9), and wires the acknowledge * button to POST /events/{id}/ack. * * ORDERING (deliberate): strictly newest-first by occurred_at, ALWAYS. The list used to pin * unacknowledged rows above acknowledged ones, which made the stream look like a timeline * while actually being two concatenated queues — an incident's own follow-up landed pages * away from it, and acknowledging a row teleported it out of view (the next click then hit a * different event, because every row below had shifted up). Triage is served by the 미확인 * view mode instead, which FILTERS rather than re-sorts, so the chronology never lies. * * 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: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트', // 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들. camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하', unacked_escalation: '미확인 경보 에스컬레이션', // 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' }; 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()); } // --- day grouping ------------------------------------------------------------------ // A strictly chronological stream still needs a visible day boundary: without it the only // signal that the list crossed midnight is two digits inside a mono stamp, and a row from // last Tuesday reads as if it happened this morning. var DOW = ['일', '월', '화', '수', '목', '금', '토']; function dayKey(occurredAt) { var d = new Date(Number(occurredAt) * 1000); if (isNaN(d.getTime())) return ''; return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } function dayLabel(occurredAt) { var d = new Date(Number(occurredAt) * 1000); if (isNaN(d.getTime())) return ''; var today = new Date(); var key = dayKey(occurredAt); var rel = ''; if (key === dayKey(today.getTime() / 1000)) rel = ' · 오늘'; else if (key === dayKey(today.getTime() / 1000 - 86400)) rel = ' · 어제'; return d.getFullYear() + '. ' + (d.getMonth() + 1) + '. ' + d.getDate() + '. (' + DOW[d.getDay()] + ')' + rel; } // --- action feedback ----------------------------------------------------------------- // 확인/오탐 are audit-backed and NOT reversible from this console (acknowledged_by is an // append-only record; a silent un-ack would be a worse property for a safety system than a // visible one). So the affordance is confirmation, not undo: the row transitions in place // and this line states exactly which event was just resolved, by type and camera. var toastEl = null; var toastTimer = null; function showToast(text) { if (!toastEl) { toastEl = document.createElement('div'); toastEl.className = 'action-toast'; toastEl.setAttribute('role', 'status'); toastEl.setAttribute('aria-live', 'polite'); document.body.appendChild(toastEl); } toastEl.textContent = text; toastEl.classList.add('is-on'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { toastEl.classList.remove('is-on'); }, 4000); } // Device-scope events (device_fault / monitoring_degraded) carry this sentinel: the server // files them about the NODE, and move_events.camera_id is NOT NULL. Rendering it raw would put // "__device__" in front of an operator, and letting it into a camera list would offer a camera // that does not exist to a person trying to draw zones on it. function isDeviceScope(ev) { return DeviceScope.is(ev); } function cameraLabel(id) { return DeviceScope.label(id); } function eventSummary(ev) { var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || ''); return what + (ev.camera_id ? ' · ' + cameraLabel(ev.camera_id) : ''); } 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 // Session-scoped mute. A repeating tone that cannot be silenced is how a safety console gets // switched off entirely, so the operator gets a control — but a deliberately narrow one: it // lives for this page only (no persistence), it is announced on screen while active, and a // NEWLY arriving critical re-arms it, so muting a known backlog can never hide the next fall. var muted = false; var muteBtn = document.getElementById('alarm-mute'); var muteState = document.getElementById('alarm-mute-state'); function renderMute() { if (muteBtn) { muteBtn.setAttribute('aria-pressed', muted ? 'true' : 'false'); muteBtn.textContent = muted ? '음소거 해제' : '경보음 끄기'; } if (muteState) { muteState.hidden = !muted; muteState.textContent = muted ? '경보음 꺼짐 — 새 위급 발생 시 자동 해제' : ''; } } function setMuted(on) { muted = on; if (muted && alarmTimer) { clearInterval(alarmTimer); alarmTimer = null; } renderMute(); } function unlockAudio() { if (!audioCtx) { var AC = window.AudioContext || window.webkitAudioContext; if (AC) audioCtx = new AC(); } document.removeEventListener('click', unlockAudio); } function beep() { if (muted) return; 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; // A critical this session has never seen re-arms a muted console: the mute is for a // known backlog, never for the next fall. if (muted) setMuted(false); beep(); } } }); if (hasUnackedCritical && !alarmTimer && !muted) { alarmTimer = setInterval(beep, 3000); } else if ((!hasUnackedCritical || muted) && alarmTimer) { clearInterval(alarmTimer); alarmTimer = null; } // Nothing left alarming: drop the mute so the control never sits "on" without a reason. if (!hasUnackedCritical && muted) setMuted(false); if (muteBtn) muteBtn.hidden = !hasUnackedCritical && !muted; } // 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 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); // "확인 필요" on EVERY unacknowledged row, not just criticals. The chip used to be a // critical-only flourish, which left 경고/정보 rows with no textual state at all: whether // they were handled could only be inferred from the presence of the action buttons in a // far-right column. The chip is toned by severity so a critical still shouts and an info // row only murmurs — but the FACT is always written, never inferred. if (!isAcked) { var chip = document.createElement('span'); chip.className = 'chip chip-' + sevRow; 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', cameraLabel(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'; // data-focus keys the row's controls so renderList can hand focus back to the SAME // button after a poll rebuild (see renderList). Without it a keyboard operator loses // focus to every 5 seconds, mid-triage. btn.setAttribute('data-focus', 'ack:' + String(ev.event_id)); 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.setAttribute('data-focus', 'fp:' + String(ev.event_id)); 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); // Patch BOTH windows immediately. The row must resolve in place on this frame, not on // the next 5s poll: a row that keeps saying 미확인 for five seconds invites a second // click that overwrites the original acknowledged_by audit record. var ev = patchEventLocally(eventId, false); renderList(); showToast('확인 처리 — ' + (ev ? eventSummary(ev) : '이벤트 ' + eventId)); return loadEvents(); }) .catch(function () { btn.disabled = false; }); } /** Apply the resolution to the live window AND the paged tail, and return the patched event. * loadEvents() only refreshes the live window, so without the tail patch an event resolved * from a "더 보기" page snaps back to 미확인 on the next render. */ function patchEventLocally(eventId, alsoFp) { var patch = { acknowledged_by: window.APP_USER || '확인' }; if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐'; var found = null; [liveEvents, olderEvents].forEach(function (list) { for (var i = 0; i < list.length; i++) { var ev = list[i]; if (ev && ev.event_id === eventId) { // New object, never mutate the existing one (keeps combinedEvents' dedup predictable). list[i] = Object.assign({}, ev, isAcked(ev) && !alsoFp ? {} : patch); found = list[i]; } } }); return found; } // 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. // // Two-step on purpose: unlike 확인, this one asserts the DETECTOR was wrong. It moves a // per-camera statistic that a human later re-tunes a threshold from, and there is no undo. // The first click arms, a second within 4s commits; anything else disarms. var fpArmTimer = null; function disarmFp(fpBtn) { if (fpArmTimer) { clearTimeout(fpArmTimer); fpArmTimer = null; } if (fpBtn) { fpBtn.classList.remove('is-armed'); fpBtn.textContent = '오탐'; } } function markFalsePositive(eventId, fpBtn, ackBtn) { if (!fpBtn.classList.contains('is-armed')) { fpBtn.classList.add('is-armed'); fpBtn.textContent = '오탐 확정?'; if (fpArmTimer) clearTimeout(fpArmTimer); fpArmTimer = setTimeout(function () { disarmFp(fpBtn); }, 4000); return; } disarmFp(fpBtn); 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); var ev = patchEventLocally(eventId, true); renderList(); showToast('오탐 태그 — ' + (ev ? eventSummary(ev) : '이벤트 ' + eventId)); 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 !== ''; } // The strip's scope has to be written down. It counts the newest window (up to 200 events), // which is an unbounded stretch of time — hours on a busy site, months on a quiet one. Left // captioned as a bare "미확인" it sat directly above the 24h brief's own 미확인 count and the // two numbers disagreed with nothing to explain why. var kpiCap = document.getElementById('kpi-cap'); function renderStatusCaption(events) { if (!kpiCap) return; if (events.length === 0) { kpiCap.textContent = '미확인'; kpiCap.removeAttribute('title'); return; } var oldest = events[events.length - 1]; var newest = events[0]; kpiCap.textContent = '미확인 · 최근 ' + events.length + '건'; kpiCap.title = '집계 범위: ' + formatTime(oldest.occurred_at) + ' ~ ' + formatTime(newest.occurred_at) + ' (아래 배너의 "지난 24시간"과는 기준 구간이 다릅니다)'; } function renderStatus(events) { renderStatusCaption(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 var lastListSig = null; // last rendered list fingerprint; unchanged -> keep the DOM (and focus) var eventsInFlight = false; // guards against overlapping polls on a slow shared host var loadedOnce = false; // distinguishes "cold start failed" from "refresh went stale" // 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: '', type: '', device: '', camera: '', unackedOnly: false }; var moreBtn = document.getElementById('load-more'); // --- day-query mode ---------------------------------------------------------------- // Accident investigation asks "what happened on the 24th", and the live console could only // answer it by pressing 더 보기 until that day scrolled past. The keyset cursor is // ":", so a specific day is reachable directly: seed the cursor at the // end of that day and page backwards until the window covers it. While a query is active the // stream shows ONLY that day; the poll (and therefore the KPI strip and the alarm) keeps // running against the live window underneath, so investigating never blinds the console. var QUERY_MAX_PAGES = 6; var query = null; // null = live stream; else {sinceTs, untilTs, label, events, loading, truncated} function inQueryMode() { return query !== null; } function matchesFilters(ev) { if (filters.severity && ev.severity !== filters.severity) return false; if (filters.type && ev.event_type !== filters.type) 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 renderQueryBanner(); // First paint waits for /api/devices to resolve (success OR failure, and at most // DEVICE_GATE_MS). Rows print the device NAME; painting before the names are known showed // raw ids that swapped a beat later — the operator's first read of the board was of values // that then changed under them. if (!devicesResolved && !deviceGateExpired) return; var all = inQueryMode() ? query.events : combinedEvents(); // Strictly newest-first. See the ordering note at the top of this file: acknowledgement // state must never reorder a chronology. var shown = all.filter(matchesFilters).sort(function (a, b) { var at = Number(a.occurred_at) || 0, bt = Number(b.occurred_at) || 0; if (at !== bt) return bt - at; return (b.event_id || 0) - (a.event_id || 0); }); // Skip the rebuild when nothing visible changed. #events-root is an aria-live region and // this runs every 5s: rebuilding it unconditionally made a screen reader re-announce all // 200 rows on every poll, and threw keyboard focus to mid-triage. Same signature + // refocus pattern device-tree.js already uses for the rail. var anyFilter = filters.severity || filters.type || filters.camera || filters.unackedOnly; var sig = shown.map(function (ev) { return [ev.event_id, ev.acknowledged_by || '', ev.false_positive_by || '', ev.thumb_uuid || ''].join('|'); }).join(',') + '#' + String(moreCursor === null) + '#' + String(!!anyFilter) + '#' + (inQueryMode() ? query.label + (query.loading ? ':loading' : '') : 'live'); if (sig === lastListSig) return; lastListSig = sig; // Rows are rebuilt, not patched, so remember which control held focus and restore it. var active = document.activeElement; var refocus = (active && root.contains(active)) ? active.getAttribute('data-focus') : null; root.textContent = ''; root.removeAttribute('aria-busy'); if (shown.length === 0) { var empty = document.createElement('div'); empty.className = 'stream-empty'; if (inQueryMode()) { empty.textContent = query.loading ? '해당 날짜를 조회하는 중…' : query.label + '에는 ' + (anyFilter ? '조건에 맞는 ' : '') + '감지 이벤트가 없습니다.'; } else if (anyFilter) { empty.textContent = '조건에 맞는 이벤트가 없습니다. 왼쪽에서 필터를 해제하면 전체 스트림으로 돌아갑니다.'; } else if (devicesLoaded && deviceList.length === 0) { // 갓 설치된 시스템. "표시할 이벤트가 없습니다"는 여기서 최악의 문장이다 — 조용하다는 // 뜻으로 읽히지만 실제로는 아무것도 감시하고 있지 않다. empty.textContent = '등록된 장치가 없습니다. 이 시스템은 아직 아무것도 감시하고 있지 않습니다. '; var setup = document.createElement('a'); setup.href = B + '/setup'; setup.textContent = '설치 점검 →'; empty.appendChild(setup); } else { empty.textContent = '표시할 이벤트가 없습니다.'; } root.appendChild(empty); } else { // Day dividers: emitted whenever the calendar day changes going down the (strictly // chronological) list, so crossing midnight is a visible event and not two digits // buried in a mono timestamp. var frag = document.createDocumentFragment(); var lastDay = null; shown.forEach(function (ev) { var key = dayKey(ev.occurred_at); if (key && key !== lastDay) { lastDay = key; var sep = document.createElement('div'); sep.className = 'day-sep'; sep.setAttribute('role', 'separator'); var lbl = document.createElement('span'); lbl.textContent = dayLabel(ev.occurred_at); sep.appendChild(lbl); frag.appendChild(sep); } frag.appendChild(buildRow(ev)); }); root.appendChild(frag); } if (refocus) { var back = root.querySelector('[data-focus="' + refocus + '"]'); if (back) back.focus(); } renderBulk(); // The keyboard cursor is index-based and the list was just rebuilt; clamp it rather than // leaving it pointing past the end (where A would silently do nothing). var rowCount = root.querySelectorAll('.event-row').length; if (cursor >= rowCount) cursor = rowCount - 1; if (cursor >= 0) { var rows = [].slice.call(root.querySelectorAll('.event-row')); rows.forEach(function (r, i) { r.classList.toggle('is-cursor', i === cursor); }); } // "더 보기" pages the LIVE stream only: in day-query mode the range is already fully // fetched, so offering it there would page past the day the operator asked for. if (moreBtn) moreBtn.hidden = inQueryMode() || (moreCursor === null); updatePageGapHint(); } // --- day query: fetch + banner ------------------------------------------------------- var queryBanner = null; function renderQueryBanner() { if (!queryBanner) { queryBanner = document.createElement('div'); queryBanner.className = 'query-banner'; var text = document.createElement('span'); text.className = 'query-text'; var back = document.createElement('button'); back.type = 'button'; back.textContent = '실시간 스트림으로'; back.addEventListener('click', exitQuery); queryBanner.appendChild(text); queryBanner.appendChild(back); if (root.parentNode) root.parentNode.insertBefore(queryBanner, root); } queryBanner.hidden = !inQueryMode(); if (!inQueryMode()) return; var t = queryBanner.querySelector('.query-text'); if (query.loading) { t.textContent = query.label + ' 조회 중…'; } else { t.textContent = query.label + ' 조회 · ' + query.events.length + '건' + (query.truncated ? ' (표시 상한에 도달해 이 날의 가장 이른 구간이 잘렸습니다)' : ''); } queryBanner.classList.toggle('is-truncated', !!query.truncated); } function exitQuery() { query = null; lastListSig = null; if (dateInput) dateInput.value = ''; renderList(); } function runDayQuery(dateStr) { var parts = String(dateStr || '').split('-'); if (parts.length !== 3) return; var start = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]), 0, 0, 0, 0); if (isNaN(start.getTime())) return; var sinceTs = Math.floor(start.getTime() / 1000); var untilTs = sinceTs + 86400; query = { sinceTs: sinceTs, untilTs: untilTs, label: dayLabel(sinceTs + 1), events: [], loading: true, truncated: false }; lastListSig = null; renderList(); // Page backwards from the end of the requested day until the window passes its start. var page = 0; function fetchPage(cursor) { page += 1; return fetch(B + '/api/events?limit=200&cursor=' + encodeURIComponent(cursor), { headers: { Accept: 'application/json' } }) .then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; return null; } if (!res.ok) throw new Error('query_failed:' + res.status); return res.json(); }) .then(function (data) { if (!data || !query || query.sinceTs !== sinceTs) return; // superseded/cancelled var rows = data.events || []; var reachedStart = false; rows.forEach(function (ev) { var at = Number(ev.occurred_at) || 0; if (at < sinceTs) { reachedStart = true; return; } if (at < untilTs) query.events.push(ev); }); if (!reachedStart && data.next_cursor && page < QUERY_MAX_PAGES) { return fetchPage(data.next_cursor); } query.truncated = !reachedStart && !!data.next_cursor; query.loading = false; lastListSig = null; renderList(); }); } fetchPage(untilTs + ':2147483647').catch(function () { if (!query) return; query.loading = false; query.truncated = false; lastListSig = null; renderList(); showToast('해당 날짜를 불러오지 못했습니다.'); }); } 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() { // In-flight guard. This runs on a fixed 5s interval against Cafe24 shared hosting; if a // response takes longer than the interval, unguarded polls stack up and — worse — an older // response can land after a newer one and overwrite the list with stale rows. if (eventsInFlight) return Promise.resolve(); eventsInFlight = true; // 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(); } loadedOnce = true; setWatch(true); }) .catch(function () { // Leave the last rendered state on transient poll failure, but flag the staleness. setWatch(false); // Cold start is different from a stale refresh: if the FIRST load never succeeded there // is no last-good list, and the skeleton "불러오는 중…" would sit there forever looking // like a slow page rather than a broken one — on a safety console, silence must not be // mistaken for calm. if (!loadedOnce && root) { root.removeAttribute('aria-busy'); root.textContent = ''; var fail = document.createElement('div'); fail.className = 'stream-empty'; fail.textContent = '이벤트를 불러오지 못했습니다. 서버 연결을 확인하세요 — 재시도 중입니다.'; root.appendChild(fail); lastListSig = null; // let the next successful poll rebuild the list } }) .then(function () { eventsInFlight = 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 // First-paint gate (see renderList): resolved on the first /api/devices outcome, success or // failure. The timer is the backstop — a hung devices endpoint must delay the stream by a // moment, never withhold it, because the events are the safety-critical half of this page. var devicesResolved = false; var deviceGateExpired = false; var DEVICE_GATE_MS = 2500; setTimeout(function () { if (devicesResolved) return; deviceGateExpired = true; lastListSig = null; renderList(); }, DEVICE_GATE_MS); 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 || isDeviceScope(ev)) 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 || isDeviceScope(ev) || 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 && data.storage) renderStorage(data.storage); if (data && Array.isArray(data.devices)) { deviceList = data.devices; devicesLoaded = true; devicesStale = false; devicesResolved = true; // 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; // A failed devices poll still RESOLVES the first-paint gate: the stream must not be // held hostage to the rail. Rows then print the raw device_id, which is the honest // answer while no name is known. if (!devicesResolved) { devicesResolved = true; lastListSig = null; renderList(); } else renderTree(); }); } // --- storage headroom -------------------------------------------------------------- // A full snapshot volume does not announce itself: image writes start failing, ingest logs // and carries on (an event without its picture still beats no event), and the board stays // green while the evidence quietly stops being kept. Say it out loud instead. var storageWarn = document.getElementById('storage-warn'); function renderStorage(s) { if (!storageWarn) return; if (!s || !s.low) { storageWarn.hidden = true; return; } storageWarn.hidden = false; storageWarn.textContent = ''; storageWarn.appendChild(spanWith('warn-lead', '저장 공간 부족')); storageWarn.appendChild(document.createTextNode( ' — 스냅샷 볼륨 여유 ' + s.free_mb + 'MB (기준 ' + s.min_free_mb + 'MB). ' + '이 상태가 지속되면 새 사건의 증거 이미지가 저장되지 않습니다. ' + '보존기간 설정을 줄이거나 용량을 확보하세요.')); } // --- bulk acknowledge ---------------------------------------------------------------- // For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall // is exactly the row a human must look at, and a control that can clear one in a sweep is a // control that will eventually clear one nobody read. var bulkBtn = document.getElementById('bulk-ack'); var bulkArmed = false; var bulkTimer = null; function bulkTargets() { if (!filters.unackedOnly) return []; var src = inQueryMode() ? query.events : combinedEvents(); return src.filter(function (ev) { return matchesFilters(ev) && !isAcked(ev) && ev.severity !== 'critical'; }); } function renderBulk() { if (!bulkBtn) return; var n = bulkTargets().length; // Only offered in the triage view; in the chronological view a sweep has no clear scope. bulkBtn.hidden = !filters.unackedOnly || n === 0; if (bulkBtn.hidden) { disarmBulk(); return; } if (!bulkArmed) bulkBtn.textContent = '위급 제외 ' + n + '건 일괄 확인'; } function disarmBulk() { if (bulkTimer) { clearTimeout(bulkTimer); bulkTimer = null; } bulkArmed = false; if (bulkBtn) bulkBtn.classList.remove('is-armed'); } if (bulkBtn) { bulkBtn.addEventListener('click', function () { var targets = bulkTargets(); if (!targets.length) return; if (!bulkArmed) { bulkArmed = true; bulkBtn.classList.add('is-armed'); bulkBtn.textContent = targets.length + '건을 확인 처리합니다. 한 번 더 누르세요'; bulkTimer = setTimeout(function () { disarmBulk(); renderBulk(); }, 5000); return; } disarmBulk(); bulkBtn.disabled = true; var done = 0; var queue = targets.slice(); // Bounded concurrency. Firing one fetch per target looked fine at 7 rows, but a real // backlog is hundreds, and over HTTP/2 the browser does NOT serialise them — the whole // sweep lands on shared hosting at once and the later writes come back 503. Four at a // time keeps it well inside any process limit and still clears 200 rows in seconds. var CONCURRENCY = 4; function worker() { var ev = queue.shift(); if (!ev) return Promise.resolve(); return fetch(B + '/events/' + encodeURIComponent(ev.event_id) + '/ack', { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } }) .then(function (res) { if (res.ok) { patchEventLocally(ev.event_id, false); done++; } }) .catch(function () {}) .then(function () { // Repaint as it goes: a sweep that shows nothing until it finishes reads as frozen. lastListSig = null; renderList(); return worker(); }); } var lanes = []; for (var i = 0; i < Math.min(CONCURRENCY, targets.length); i++) lanes.push(worker()); Promise.all(lanes).then(function () { bulkBtn.disabled = false; lastListSig = null; renderList(); showToast('일괄 확인 ' + done + '건 / ' + targets.length + '건 (위급은 제외되었습니다)'); loadEvents(); }); }); } // --- keyboard triage ----------------------------------------------------------------- // A desk operator working a backlog should not have to move a mouse between rows. The // shortcuts are deliberately non-destructive except for A: F still arms the two-step 오탐. var cursor = -1; function rowsOnScreen() { return [].slice.call(root.querySelectorAll('.event-row')); } function moveCursor(delta) { var rows = rowsOnScreen(); if (!rows.length) return; cursor = Math.max(0, Math.min(rows.length - 1, cursor < 0 ? 0 : cursor + delta)); rows.forEach(function (r, i) { r.classList.toggle('is-cursor', i === cursor); }); rows[cursor].scrollIntoView({ block: 'nearest' }); } function cursorRow() { var rows = rowsOnScreen(); return cursor >= 0 && cursor < rows.length ? rows[cursor] : null; } document.addEventListener('keydown', function (e) { // Never steal a key from a field, and never from a modified chord (browser shortcuts). var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return; if (e.ctrlKey || e.metaKey || e.altKey) return; var key = e.key.toLowerCase(); if (key === 'j') { e.preventDefault(); moveCursor(1); return; } if (key === 'k') { e.preventDefault(); moveCursor(-1); return; } var row = cursorRow(); if (!row) return; if (key === 'a') { var ack = row.querySelector('.ack-btn'); if (ack) { e.preventDefault(); ack.click(); } } else if (key === 'f') { var fp = row.querySelector('.fp-btn'); if (fp) { e.preventDefault(); fp.click(); } } else if (e.key === 'Enter') { var link = row.querySelector('.event-detail-link'); if (link) { e.preventDefault(); window.location.href = link.href; } } }); // --- 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; var FP_MIN_SAMPLE = 5; // below this a ratio is noise, not a rate — see renderFpStats 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); // Sample gate. "100% (1/1)" is not a false-positive RATE, it is one tagged event — and // printed in the same column as "33% (5/15)" it outranked it. Below the minimum sample // the raw counts are shown instead, with no percentage and no hot styling, so nothing // reads as a tuning signal until there is enough evidence to be one. if (s.total < FP_MIN_SAMPLE) { rate.className = 'fp-rate is-thin'; rate.textContent = s.fp + '/' + s.total + ' (표본 부족)'; rate.title = '오탐율은 ' + FP_MIN_SAMPLE + '건 이상 감지된 카메라·유형에만 표시합니다.'; } else { // 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다). rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : ''); rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')'; } 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 () { /* 패널은 보조 정보 — 실패 시 마지막 상태 유지 */ }); } // --- 24h catch-up brief ------------------------------------------------------------ // The operator sitting down at 08:00 needs "what happened overnight" in one line before // the live stream takes over. Reuses GET /api/report?days=1; loaded once per page load // (the live KPIs and list carry the present — this line carries the past). var dayBrief = document.getElementById('day-brief'); function briefDuration(s) { if (s === null || s === undefined) return null; if (s < 60) return Math.round(s) + '초'; return Math.floor(s / 60) + '분 ' + Math.round(s % 60) + '초'; } function renderDayBrief(r) { if (!dayBrief) return; var t = r.totals; var severe = t.critical + t.high; dayBrief.textContent = ''; dayBrief.hidden = false; dayBrief.classList.toggle('has-severe', severe > 0); var lead = document.createElement('strong'); lead.textContent = '지난 24시간'; dayBrief.appendChild(lead); var parts = []; if (t.events === 0) { parts.push('감지 이벤트 없음'); } else { // "중대" is this product's word for 위급+높음; spell the two tiers out inline so the // reader never has to guess how it relates to the 위급/높음 tiles right above. parts.push('감지 ' + t.events + '건' + (severe > 0 ? ' (중대 ' + severe + ' = 위급 ' + t.critical + ' + 높음 ' + t.high + ')' : '')); if (r.near_miss.near_fall > 0) parts.push('니어미스 ' + r.near_miss.near_fall); var avg = briefDuration(r.tta.avg_s); if (avg) parts.push('평균 대응 ' + avg); parts.push(t.unacked > 0 ? '이 중 미확인 ' + t.unacked + '건' : '전부 확인됨'); } dayBrief.appendChild(document.createTextNode(' — ' + parts.join(' · ') + ' ')); var link = document.createElement('a'); link.href = B + '/report'; link.textContent = '리포트 보기 →'; dayBrief.appendChild(link); } function loadDayBrief() { if (!dayBrief) return; fetch(B + '/api/report?days=1', { headers: { Accept: 'application/json' } }) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (data) { if (data) renderDayBrief(data); }) .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(); }); var typeSel = document.getElementById('type-filter'); if (typeSel) typeSel.addEventListener('change', function () { filters.type = typeSel.value; renderList(); }); if (cameraReset) cameraReset.addEventListener('click', clearCamera); if (moreBtn) moreBtn.addEventListener('click', loadMore); // View mode — a FILTER, never a re-sort. "전체" is the chronology; "미확인" is the triage // queue over the same chronology. Swapping between them can add or remove rows but can // never move one relative to another, so the operator's mental map of the stream survives. var modeBtns = [].slice.call(document.querySelectorAll('[data-view-mode]')); function setViewMode(mode) { filters.unackedOnly = (mode === 'unacked'); modeBtns.forEach(function (b) { var on = b.getAttribute('data-view-mode') === mode; b.setAttribute('aria-pressed', on ? 'true' : 'false'); b.classList.toggle('is-on', on); }); renderList(); } modeBtns.forEach(function (b) { b.addEventListener('click', function () { setViewMode(b.getAttribute('data-view-mode')); }); }); if (modeBtns.length) setViewMode('all'); // 날짜 조회 (see runDayQuery) var dateInput = document.getElementById('date-jump'); var dateGo = document.getElementById('date-jump-go'); if (dateGo && dateInput) { dateGo.addEventListener('click', function () { if (dateInput.value) runDayQuery(dateInput.value); }); dateInput.addEventListener('keydown', function (e) { if (e.key === 'Enter' && dateInput.value) { e.preventDefault(); runDayQuery(dateInput.value); } }); dateInput.max = dayKey(Date.now() / 1000); } if (muteBtn) muteBtn.addEventListener('click', function () { setMuted(!muted); }); renderMute(); if (muteBtn) muteBtn.hidden = true; loadCsrfToken(); loadEvents(); loadDevices(); loadFpStats(); loadDayBrief(); setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS); setInterval(loadFpStats, FP_STATS_INTERVAL_MS); })();