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.
1291 lines
58 KiB
JavaScript
1291 lines
58 KiB
JavaScript
/**
|
||
* 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);
|
||
})();
|