move_detect/server/public/assets/device-tree.js
유창욱 eaff1765a4 feat(dashboard): device -> camera tree rail with pair-scoped camera filter
- Rail rework: the flat device strip + camera <select> becomes one device->camera
  tree (device-tree.js owns the DOM). A device row carries the heartbeat verdict
  (online/offline, fps, temp, throttle, config drift); the cameras seen in the
  event stream nest under it and double as the stream's camera filter.
- The camera filter is a (device, camera) PAIR: camera ids are free-form edge
  strings, so two devices may both run a "cam-01" - one device's events no longer
  leak into another's filtered view. Clicking the selected camera (or 전체 보기)
  clears the filter.
- A camera whose device is missing from /api/devices renders as its own
  unknown-device group ("장치 목록에 없음 - 등록 확인") instead of being dropped.
- Offline rows show liveness only - fps/temp are the LAST values a dead device
  reported and would read as current telemetry.
- Poll-failure staleness, collapse state, and focus survive the 5s re-render;
  an unchanged poll never rebuilds the DOM (render signature).
- Stream rows: device NAME + mono camera id + compact 24h stamp (year only when
  not current); thumb slot links to the incident and is honest about 무영상 for
  screen readers; KPI strip captioned "미확인" once.

Verified: PHPUnit 119 pass (4 skipped, PDO gate) + real-render harness in Chrome
(tree states, pair filter, collapse, reset, no console errors).
2026-08-06 16:54:44 +09:00

248 lines
11 KiB
JavaScript

/**
* 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 };
})();