move_detect/server/public/assets/app.js
유창욱 3aca674a78
Some checks failed
verify / edge (push) Has been cancelled
verify / server (push) Has been cancelled
feat: 이해관계자 페르소나 검증 4건 — 열람 감사 · 증거 보존 · 크론 생존 · 증거 정책 (1.3.0)
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.

읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.

크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.

증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.

증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.

마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).

검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-25 18:34:26 +09:00

1352 lines
62 KiB
JavaScript
Raw Permalink 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), 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: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
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 <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);
// "확인 필요" 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 <body> 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
// "<occurred_at>:<event_id>", 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 <body> 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) renderDispatcher(data.dispatcher);
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). '
+ '이 상태가 지속되면 새 사건의 증거 이미지가 저장되지 않습니다. '
+ '보존기간 설정을 줄이거나 용량을 확보하세요.'));
}
// --- notification dispatcher liveness -----------------------------------------------
// 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 크론이
// 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이 전부 그 한 프로세스에
// 걸려 있어서, 멈춘 순간부터 이 화면은 조용해지고 그 침묵은 "사고 없음"과 똑같이 보인다.
// 감시자의 생존은 감시자가 아닌 쪽이 읽어야 해서, 그 판정이 여기 있다.
var dispatchWarn = document.getElementById('dispatch-warn');
function renderDispatcher(d) {
if (!dispatchWarn) return;
if (!d || !d.stale) { dispatchWarn.hidden = true; return; }
dispatchWarn.hidden = false;
dispatchWarn.textContent = '';
var never = d.last_run_at === null || d.last_run_at === undefined;
dispatchWarn.appendChild(spanWith('warn-lead', never ? '통보가 설정되지 않았습니다' : '통보가 멈췄습니다'));
dispatchWarn.appendChild(document.createTextNode(never
? ' — 통보 디스패처가 한 번도 실행된 적이 없습니다. 크론에 tools/dispatch_alerts.php 를 1분 주기로 등록하세요. '
+ '등록 전까지 메일·웹훅·5분 미확인 확대·장치 이상 알림이 나가지 않습니다 (현장 경광등은 영향 없음).'
: ' — 마지막 실행 ' + fmtAgo(d.seconds_since) + ' 전 (기준 '
+ Math.round(d.stale_after_s / 60) + '분). 이 상태에서는 화면이 조용한 것이 "사고 없음"이라는 뜻이 아닙니다. '
+ '메일·웹훅·미확인 확대·장치 이상 알림이 전부 멈춰 있습니다 (현장 경광등은 영향 없음).'));
}
function fmtAgo(s) {
if (typeof s !== 'number') return '?';
if (s < 120) return s + '초';
if (s < 7200) return Math.floor(s / 60) + '분';
return Math.floor(s / 3600) + '시간';
}
// --- 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);
})();