move_detect/server/public/assets/app.js
유창욱 7daee0b372 fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.

CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
  edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
  끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
  검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
  허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
  (tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
  프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
  지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
  한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
  식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
  (/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
  전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
  경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.

HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
  그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
  공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
  04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
  Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
  DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
  이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.

MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
  크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
  claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
  정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
  발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
  공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.

LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
  읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
  PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.

수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.

부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.

검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
2026-08-18 10:33:02 +09:00

1315 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Events dashboard: polls GET /api/events every 5s, renders the list with
* severity text+icon+color (never color alone, per NF9), 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);
}
function eventSummary(ev) {
var what = TYPE_LABELS[ev.event_type] || String(ev.event_type || '');
return what + (ev.camera_id ? ' · ' + 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', String(ev.camera_id)));
var time = formatTime(ev.occurred_at);
if (time) sub.appendChild(spanWith('ev-at', time));
meta.appendChild(sub);
row.appendChild(meta);
// Actions column. Resolution states are terminal and mutually exclusive in display:
// an FP tag wins (it is the more specific verdict — the event was also acknowledged
// server-side), then a plain acknowledgement, else the two action buttons.
var actions = document.createElement('div');
actions.className = 'row-actions';
var isFp = ev.false_positive_by !== null && ev.false_positive_by !== undefined && ev.false_positive_by !== '';
if (isFp) {
var fpDone = document.createElement('span');
fpDone.className = 'fp-done';
fpDone.textContent = '오탐 (' + String(ev.false_positive_by) + ')';
actions.appendChild(fpDone);
} else if (isAcked) {
var ackDone = document.createElement('span');
ackDone.className = 'ack-done';
ackDone.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
actions.appendChild(ackDone);
} else {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ack-btn';
// 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) return;
var key = cameraKey(ev.device_id, ev.camera_id);
var cam = byKey[key];
if (!cam) {
cam = byKey[key] = { key: key, device_id: String(ev.device_id || ''), camera_id: String(ev.camera_id),
last_at: 0, unacked: 0, worst: null };
out.push(cam);
}
var at = Number(ev.occurred_at) || 0;
if (at > cam.last_at) cam.last_at = at;
});
liveEvents.forEach(function (ev) {
if (!ev.camera_id || isAcked(ev)) return;
var cam = byKey[cameraKey(ev.device_id, ev.camera_id)];
if (!cam) return;
cam.unacked += 1;
var sev = SEV_RANK.hasOwnProperty(ev.severity) ? ev.severity : 'info';
if (cam.worst === null || SEV_RANK[sev] > SEV_RANK[cam.worst]) cam.worst = sev;
});
return out;
}
function renderTree() {
if (cameraReset) cameraReset.hidden = !filters.camera;
if (!devicesRoot || !window.DeviceTree) return;
window.DeviceTree.render(devicesRoot, railSummary, {
devices: deviceList,
cameras: cameraStats(),
now: Date.now() / 1000,
loaded: devicesLoaded,
stale: devicesStale,
selectedKey: filters.camera ? cameraKey(filters.device, filters.camera) : '',
onSelect: selectCamera
});
}
// Clicking the selected camera again clears the filter — the same affordance as the
// "전체 보기" button, so the operator never gets stuck inside one camera.
function selectCamera(key) {
var pair = key.split(CAM_SEP);
var isSame = filters.device === pair[0] && filters.camera === pair[1];
filters.device = isSame ? '' : pair[0];
filters.camera = isSame ? '' : pair[1];
renderList();
}
function clearCamera() {
filters.device = '';
filters.camera = '';
renderList();
}
function loadDevices() {
return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('devices_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (data && 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);
})();