move_detect/server/public/assets/app.js
유창욱 e00ad69aa3 feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
  뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
  밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
  '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
  (전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
  무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
  플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
  통째로 꺼지는 경로다.

전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
  KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
  (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
  시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
  넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.

시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
  납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
  캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.

━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━

통보 — 이 제품의 정의에 해당하는 구멍
  지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
  넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
  어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
  없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
  아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
  '아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
  인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.

조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
  전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
  초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.

끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
  /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
  장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
  없었다. 지표는 있는데 그것을 켜는 사건이 없었다.

관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
  엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
  목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
  통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
  의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).

증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
  없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
  exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
  매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
  조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).

운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
  모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
  백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.

미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
  감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
  (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
  명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
2026-08-18 07:51:23 +09:00

1291 lines
58 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: '작업자세 부담 리포트'
};
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;
Promise.all(targets.map(function (ev) {
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 () {
bulkBtn.disabled = false;
lastListSig = null;
renderList();
showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)');
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);
})();