/** * Rail: the device → camera tree of the events console. * * A device row carries the heartbeat verdict (online/offline, fps, temp, throttle, config * drift); the cameras that have reported events nest under it on hairline connectors, and a * camera node doubles as the stream's camera filter. app.js owns the polling, the filter state * and the per-camera aggregation — this file owns only the rail's DOM. * * XSS-safe: every dynamic value goes through textContent / setAttribute, never innerHTML. */ (function () { 'use strict'; var LEVELS = { critical: 'lv-critical', high: 'lv-high', warning: 'lv-warning', info: 'lv-info' }; var LEVEL_TEXT = { critical: '위급', high: '높음', warning: '경고', info: '정보' }; var collapsed = {}; // device_id -> true; survives the 5s re-render var lastSig = null; // rendered signature; an unchanged poll must not rebuild the DOM var last = null; // {root, summaryEl, model} for the collapse toggle's own re-render function ago(seconds) { if (seconds === null || seconds === undefined) return '응답 없음'; if (seconds < 60) return Math.max(0, Math.round(seconds)) + '초 전'; if (seconds < 3600) return Math.floor(seconds / 60) + '분 전'; if (seconds < 86400) return Math.floor(seconds / 3600) + '시간 전'; return Math.floor(seconds / 86400) + '일 전'; } function deviceMeta(d) { // Liveness first, spelled out in words — the status dot is colour, and colour is never // allowed to carry state on its own (NF9). The verdict itself is the server's (computed // against the request clock), never re-derived here from a local clock. if (d.seconds_since === null || d.seconds_since === undefined) return '응답 없음'; // fps/temp are the LAST values a dead device reported, so on an offline row they would read // as current telemetry ("0 fps" for a device that simply stopped talking). Liveness only. if (!d.online) return '오프라인 · ' + ago(d.seconds_since); var parts = ['온라인 · ' + ago(d.seconds_since)]; if (d.fps !== null && d.fps !== undefined) parts.push((Math.round(d.fps * 10) / 10) + ' fps'); if (d.temp !== null && d.temp !== undefined && d.temp > 0) parts.push(Math.round(d.temp) + '°'); return parts.join(' · '); } function deviceWarn(d) { var w = []; if (d.throttle) w.push('과열 스로틀'); if (d.config_drift) w.push('설정 미적용'); return w.join(' · '); } /** Devices from the heartbeat API, each with its observed cameras nested underneath. */ function groupsOf(model) { var groups = [], byId = {}; (model.devices || []).forEach(function (d) { var id = String(d.device_id); var g = { id: id, label: d.name || id, state: d.online ? 'online' : 'offline', meta: deviceMeta(d), warn: deviceWarn(d), cams: [] }; byId[id] = g; groups.push(g); }); (model.cameras || []).forEach(function (c) { var g = byId[c.device_id]; if (!g) { // A camera whose device is missing from /api/devices — it was deleted, or it is sending // events under an id nobody provisioned. Show it as its own unknown-device group: an // unattributed camera is exactly the thing an operator must be told about, never dropped. g = byId[c.device_id] = { id: c.device_id, label: c.device_id || '(장치 ID 없음)', state: 'unknown', meta: '', warn: '장치 목록에 없음 — 등록 확인', cams: [] }; groups.push(g); } g.cams.push({ key: c.key, id: c.camera_id, when: c.last_at ? ago(model.now - c.last_at) : '감지 없음', count: c.unacked, level: c.worst }); }); groups.forEach(function (g) { // Stable order: the rail is a target the operator clicks, so a camera must not jump // position because its event count changed. g.cams.sort(function (a, b) { return a.id < b.id ? -1 : (a.id > b.id ? 1 : 0); }); }); return groups; } /** Collapsed, unless this branch holds the camera the stream is currently filtered by — * hiding that node would leave the operator filtered with no visible sign of by what. */ function isCollapsed(g, selectedKey) { if (!collapsed[g.id]) return false; for (var i = 0; i < g.cams.length; i++) if (g.cams[i].key === selectedKey) return false; return true; } function signature(groups, model) { var parts = [model.stale ? 'S' : '-', model.loaded ? 'L' : '-', model.selectedKey || '']; groups.forEach(function (g) { parts.push(g.id + '|' + g.label + '|' + g.state + '|' + g.meta + '|' + g.warn + '|' + (isCollapsed(g, model.selectedKey) ? 'c' : 'o')); g.cams.forEach(function (c) { parts.push(' ' + c.id + '|' + c.when + '|' + c.count + '|' + (c.level || '')); }); }); return parts.join('\n'); } function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined && text !== null) n.textContent = text; return n; } function buildDeviceHead(g, shut) { var head = el('button', 'device-head'); head.type = 'button'; head.setAttribute('data-node', 'd' + g.id); head.setAttribute('aria-expanded', shut ? 'false' : 'true'); var line = el('span', 'device-line'); line.appendChild(el('span', 'twisty')).setAttribute('aria-hidden', 'true'); line.appendChild(el('span', 'dot')).setAttribute('aria-hidden', 'true'); line.appendChild(el('span', 'device-name', g.label)); line.appendChild(el('span', 'device-tally', g.cams.length + '대')); head.appendChild(line); if (g.meta) head.appendChild(el('span', 'device-meta', g.meta)); if (g.warn) head.appendChild(el('span', 'device-warn', g.warn)); // Spoken name: everything the row shows, in reading order and without the visual // separators. It must stay a superset of the visible text — a shorter label would hide // exactly the fields (오프라인, 과열 스로틀) that this rail exists to surface. var spoken = [g.label]; if (g.meta) spoken.push(g.meta.split(' · ').join(', ')); spoken.push('카메라 ' + g.cams.length + '대'); if (g.warn) spoken.push(g.warn); head.setAttribute('aria-label', spoken.join(', ')); return head; } function buildCamNode(c, onSelect, selectedKey) { var node = el('button', 'cam-node'); node.type = 'button'; node.setAttribute('data-node', 'c' + c.key); node.setAttribute('aria-pressed', c.key === selectedKey ? 'true' : 'false'); node.appendChild(el('span', 'cam-id', c.id)); node.appendChild(el('span', 'cam-when', c.when)); if (c.count > 0) { node.appendChild(el('span', 'cam-count ' + (LEVELS[c.level] || 'lv-info'), String(c.count))); } node.setAttribute('aria-label', c.id + ' 카메라, 마지막 감지 ' + c.when + (c.count > 0 ? ', 미확인 ' + (LEVEL_TEXT[c.level] || '') + ' 포함 ' + c.count + '건' : ', 미확인 없음') + ' — 이 카메라만 보기'); node.addEventListener('click', function () { onSelect(c.key); }); return node; } function renderSummary(summaryEl, groups, model) { if (!summaryEl) return; summaryEl.textContent = ''; if (!model.loaded) { summaryEl.textContent = '장치 상태 확인 중…'; return; } // "장치" counts REGISTERED devices only — an unknown-device group is not a device the // operator has, it is a discrepancy, and it gets its own count rather than padding this one. var cams = 0, known = 0, down = 0, unknown = 0; groups.forEach(function (g) { cams += g.cams.length; if (g.state === 'unknown') { unknown += 1; return; } known += 1; if (g.state === 'offline') down += 1; }); summaryEl.appendChild(document.createTextNode('장치 ' + known + ' · 카메라 ' + cams)); if (down > 0) summaryEl.appendChild(el('span', 'is-down', ' · 오프라인 ' + down)); if (unknown > 0) summaryEl.appendChild(el('span', 'is-down', ' · 미등록 ' + unknown)); } function render(root, summaryEl, model) { if (!root) return; last = { root: root, summaryEl: summaryEl, model: model }; var groups = groupsOf(model); var sig = signature(groups, model); if (sig === lastSig) return; // nothing visible changed: keep the DOM, and the focus in it lastSig = sig; // The tree is rebuilt, not patched, so a node the operator is tabbing through would lose // focus on any change. Remember which node held it and hand focus back to its replacement. var active = document.activeElement; var refocus = (active && root.contains(active)) ? active.getAttribute('data-node') : null; renderSummary(summaryEl, groups, model); root.textContent = ''; root.className = 'device-tree' + (model.stale ? ' is-stale' : '') + (groups.length === 0 ? ' is-empty' : ''); if (model.stale) { // A frozen panel is worse than an empty one: a camera that dies while the poll is down // would keep showing "온라인". Say the state is old instead of quietly implying it is live. root.appendChild(el('div', 'device-stale-note', '상태 갱신 지연 — 마지막으로 확인된 상태입니다')); } if (groups.length === 0) { root.appendChild(document.createTextNode( model.loaded ? '등록된 장치가 없습니다. 관리자가 장치를 등록하면 여기에 표시됩니다.' : '불러오는 중…')); return; } var frag = document.createDocumentFragment(); groups.forEach(function (g) { var shut = isCollapsed(g, model.selectedKey); var node = el('div', 'device-node' + (g.state === 'offline' ? ' is-offline' : '') + (g.state === 'unknown' ? ' is-unknown' : '') + (shut ? ' is-collapsed' : '')); var head = buildDeviceHead(g, shut); head.addEventListener('click', function () { toggle(g.id); }); node.appendChild(head); if (!shut) { if (g.cams.length === 0) { node.appendChild(el('div', 'cam-empty', '수신된 감지 이벤트 없음')); } else { var list = el('ul', 'cam-list'); g.cams.forEach(function (c) { var item = el('li', 'cam-item'); item.appendChild(buildCamNode(c, model.onSelect, model.selectedKey)); list.appendChild(item); }); node.appendChild(list); } } frag.appendChild(node); }); root.appendChild(frag); if (refocus) { var nodes = root.querySelectorAll('[data-node]'); for (var i = 0; i < nodes.length; i++) { if (nodes[i].getAttribute('data-node') === refocus) { nodes[i].focus(); break; } } } } function toggle(deviceId) { if (collapsed[deviceId]) delete collapsed[deviceId]; else collapsed[deviceId] = true; if (!last) return; lastSig = null; // collapse state is part of the signature; force the rebuild render(last.root, last.summaryEl, last.model); } window.DeviceTree = { render: render }; })();