feat: render scalable Alta site and group hierarchy

This commit is contained in:
2026-08-20 01:15:01 +00:00
parent c65a813e5f
commit 8d256cd4ab
12 changed files with 614 additions and 164 deletions
+6 -1
View File
@@ -35,6 +35,10 @@ npm start
Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication.
## Camera hierarchy and large deployments
The sidebar presents collapsed Alta sites, then device groups, then cameras, including explicit fallback nodes for ungrouped, orphaned, unknown, or conflicting metadata. Search opens only matching ancestors and supports full tree keyboard navigation. A fixed-height virtual window keeps the mounted DOM bounded even when thousands of cameras are visible; selection and active-proxy state survive expansion, search, and scrolling. If site or group metadata cannot be loaded, every valid camera remains selectable in the fallback hierarchy and APT shows a warning.
## Development and verification
```bash
@@ -54,7 +58,8 @@ Core files:
- `src/proxy-launch.js` — fixed shell-free helper process management
- `src/update-policy.js` — exact GitPeji check-only release policy
- `preload.js` — narrow context bridge
- `renderer.js`, `index.html`, `styles.css` — non-secret UI
- `device-tree.js`, `sidebar-controller.js`, `sidebar-view.js` — pure hierarchy, interaction state, and bounded DOM adapter
- `renderer.js`, `index.html`, `styles.css` — non-secret UI integration and Alta styling
- `chrome-extension/` — stable-ID paired cookie sender
- `test/` — pure and end-to-end contract tests
+2 -2
View File
@@ -75,7 +75,7 @@
const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics);
const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
canonicalId: identity.canonicalId,
pendingDeletion: safe.pendingDeletion === true,
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
source: safe,
});
sites.push(site);
@@ -105,7 +105,7 @@
const group = makeGroup(site, identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
canonicalId: identity.canonicalId,
rawParentId: parentId,
pendingDeletion: safe.pendingDeletion === true,
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
source: safe,
});
site.groups.push(group);
+12 -3
View File
@@ -12,15 +12,21 @@
<div class="main-layout">
<aside class="devices-sidebar">
<div class="sidebar-header"><h2>Available Devices</h2></div>
<div id="deviceStatus" class="status-message"></div>
<div id="deviceStatus" class="status-message" role="status" aria-live="polite"></div>
<div class="device-search-container">
<input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input" autocomplete="off">
<label class="visually-hidden" for="deviceSearch">Search cameras</label>
<input type="search" id="deviceSearch" placeholder="Search cameras..." class="device-search-input" autocomplete="off" aria-controls="deviceList">
<p id="deviceResults" class="tree-results" role="status" aria-live="polite">Connect to load cameras</p>
</div>
<div class="device-list-container">
<div id="deviceList" class="device-list">
<div id="deviceList" class="device-list virtual-tree" role="tree" tabindex="0" aria-label="Alta camera hierarchy" aria-activedescendant="" aria-describedby="deviceResults">
<div id="treeSpacer" class="virtual-tree-spacer">
<div id="treeWindow" class="virtual-tree-window">
<p class="placeholder-text">Connect through the paired Chrome extension to load devices</p>
</div>
</div>
</div>
</div>
</aside>
<main class="main-content">
@@ -103,6 +109,9 @@
</div>
<script src="renderer-controller.js"></script>
<script src="device-tree.js"></script>
<script src="sidebar-controller.js"></script>
<script src="sidebar-view.js"></script>
<script src="renderer.js"></script>
</body>
</html>
+3
View File
@@ -25,6 +25,9 @@
"preload.js",
"renderer.js",
"renderer-controller.js",
"device-tree.js",
"sidebar-controller.js",
"sidebar-view.js",
"index.html",
"styles.css",
"src/**/*",
+178 -149
View File
@@ -3,6 +3,9 @@
const connectionStatus = document.getElementById('connectionStatus');
const deviceStatus = document.getElementById('deviceStatus');
const deviceList = document.getElementById('deviceList');
const treeSpacer = document.getElementById('treeSpacer');
const treeWindow = document.getElementById('treeWindow');
const deviceResults = document.getElementById('deviceResults');
const statusIndicator = document.getElementById('statusIndicator');
const deviceSearch = document.getElementById('deviceSearch');
const disconnectBtn = document.getElementById('disconnectBtn');
@@ -21,12 +24,14 @@ const pairingSecretRow = document.getElementById('pairingSecretRow');
const rotatePairingBtn = document.getElementById('rotatePairingBtn');
const revokePairingBtn = document.getElementById('revokePairingBtn');
const ROW_HEIGHT = window.AptSidebarView.ROW_HEIGHT;
let connection = { connected: false, origin: null, activeProxies: [] };
let selectedDevice = null;
let allDevices = [];
let allSites = {};
let collapsedSites = new Set();
let loadingDevices = false;
let hierarchyModel = null;
let sidebarController = null;
let loadGeneration = 0;
let searchTimer = null;
let scrollFrame = null;
function showStatus(element, message, type) {
element.textContent = message;
@@ -35,7 +40,20 @@ function showStatus(element, message, type) {
}
function activeDeviceIds() {
return new Set((connection.activeProxies || []).map((proxy) => proxy.deviceId));
return new Set((connection.activeProxies || []).map((proxy) => String(proxy.deviceId || '').toLowerCase()));
}
function deviceStatusFor(device) {
const value = String(device && (device.displayStatus ?? device.status ?? device.online) || '').toLowerCase();
return ['green', 'online', 'live', 'connected', 'true'].includes(value);
}
function updateProxyButtons() {
const id = selectedDevice && selectedDevice.id;
const active = id ? activeDeviceIds().has(id.toLowerCase()) : false;
const hasUsername = altaUsername.value.trim().length > 0;
startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active;
stopProxyBtn.disabled = !id || !active;
}
function renderConnectionState(state) {
@@ -50,171 +68,141 @@ function renderConnectionState(state) {
text.textContent = connection.connected ? 'Connected' : 'Disconnected';
disconnectBtn.disabled = !connection.connected;
updateProxyButtons();
if (sidebarController) sidebarController.refresh();
}
function updateProxyButtons() {
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id);
const active = id ? activeDeviceIds().has(id.toLowerCase()) : false;
const hasUsername = altaUsername.value.trim().length > 0;
startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active;
stopProxyBtn.disabled = !id || !active;
}
function clearDeviceList() {
deviceList.textContent = '';
function showTreePlaceholder(message) {
treeSpacer.style.height = '0px';
treeWindow.style.transform = '';
const placeholder = document.createElement('p');
placeholder.className = 'placeholder-text';
placeholder.textContent = connection.connected ? 'Loading devices...' : 'Connect through the paired Chrome extension to load devices';
deviceList.appendChild(placeholder);
placeholder.textContent = message;
treeWindow.replaceChildren(placeholder);
deviceList.removeAttribute('aria-activedescendant');
}
function clearHierarchy() {
loadGeneration += 1;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = null;
if (sidebarController) sidebarController.destroy();
sidebarController = null;
hierarchyModel = null;
selectedDevice = null;
selectedDeviceId.value = '';
deviceSearch.value = '';
deviceResults.textContent = connection.connected ? 'Loading cameras' : 'Connect to load cameras';
showTreePlaceholder(connection.connected
? 'Loading devices...'
: 'Connect through the paired Chrome extension to load devices');
updateProxyButtons();
}
function deviceStatusFor(device) {
const raw = device && device.live && device.live.display_status !== undefined
? device.live.display_status
: device && device.status !== undefined
? device.status
: device && device.online;
const value = String(raw ?? '').toLowerCase();
return ['green', 'online', 'live', 'connected', 'true'].includes(value);
}
function selectDevice(device, item) {
const previous = deviceList.querySelector('.device-item.selected');
if (previous) previous.classList.remove('selected');
item.classList.add('selected');
selectedDevice = device;
const id = device.guid || device.id || '';
selectedDeviceId.value = id;
showStatus(connectionStatus, `Selected device: ${device.name || 'Unnamed Device'}`, 'info');
function syncSelection(snapshot) {
const selectedKey = snapshot && snapshot.state.selectedKey;
const camera = selectedKey && hierarchyModel ? hierarchyModel.cameraByKey.get(selectedKey) : null;
if (!camera) return;
if (selectedDevice && selectedDevice.id === camera.source.id) return;
selectedDevice = camera.source;
selectedDeviceId.value = selectedDevice.id;
showStatus(connectionStatus, `Selected device: ${selectedDevice.name || 'Unnamed Device'}`, 'info');
updateProxyButtons();
}
function createDeviceItem(device) {
const item = document.createElement('div');
const id = device.guid || device.id || '';
item.className = 'device-item';
item.dataset.deviceId = id;
if (activeDeviceIds().has(String(id).toLowerCase())) item.classList.add('proxy-active');
const name = document.createElement('div');
name.className = 'device-name';
name.textContent = device.name || 'Unnamed Device';
const dot = document.createElement('div');
dot.className = `device-status-dot ${deviceStatusFor(device) ? 'online' : 'offline'}`;
item.append(name, dot);
item.addEventListener('click', () => selectDevice(device, item));
return item;
function syncTreeScroll(snapshot) {
if (!snapshot) return;
const target = snapshot.state.scrollTop;
if (Math.abs(deviceList.scrollTop - target) >= ROW_HEIGHT) deviceList.scrollTop = target;
}
function groupDevicesBySite(devices) {
const groups = new Map();
for (const device of devices) {
const siteId = device.server_group_id;
const siteName = siteId && allSites[siteId];
const key = siteName ? siteId : '__ungrouped';
if (!groups.has(key)) groups.set(key, { name: siteName || 'Ungrouped', devices: [] });
groups.get(key).devices.push(device);
}
return [...groups.entries()].sort((left, right) => left[1].name.localeCompare(right[1].name));
function describeHierarchyWarning(hierarchy, model) {
const diagnostics = hierarchy && hierarchy.diagnostics && typeof hierarchy.diagnostics === 'object'
? hierarchy.diagnostics
: {};
const metadataErrors = hierarchy && hierarchy.metadataErrors && typeof hierarchy.metadataErrors === 'object'
? hierarchy.metadataErrors
: {};
const unavailable = ['sites', 'groups'].filter((kind) => metadataErrors[kind] || (diagnostics[kind] && diagnostics[kind].error));
const issueCount = model.diagnostics.length;
if (!unavailable.length && issueCount === 0) return '';
const parts = [];
if (unavailable.length) parts.push(`${unavailable.join(' and ')} metadata unavailable`);
if (issueCount) parts.push(`${issueCount} hierarchy warning${issueCount === 1 ? '' : 's'}`);
return parts.join('; ');
}
function displayDevices(devices) {
deviceList.textContent = '';
if (!devices.length) {
const empty = document.createElement('p');
empty.className = 'no-devices';
empty.textContent = deviceSearch.value.trim() ? 'No devices match your search' : 'No local cameras found';
deviceList.appendChild(empty);
return;
}
const grouped = Object.keys(allSites).length > 0;
if (!grouped) {
for (const device of devices) deviceList.appendChild(createDeviceItem(device));
return;
}
for (const [siteId, group] of groupDevicesBySite(devices)) {
const header = document.createElement('div');
const collapsed = collapsedSites.has(siteId);
header.className = `site-group-header${collapsed ? ' collapsed' : ''}`;
const arrow = document.createElement('span');
arrow.className = 'site-group-arrow';
arrow.textContent = collapsed ? '\u25B6' : '\u25BC';
const name = document.createElement('span');
name.className = 'site-group-name';
name.textContent = group.name;
const count = document.createElement('span');
count.className = 'site-group-count';
count.textContent = String(group.devices.length);
header.append(arrow, name, count);
header.addEventListener('click', () => {
if (collapsedSites.has(siteId)) collapsedSites.delete(siteId);
else collapsedSites.add(siteId);
filterDevices();
function installHierarchy(hierarchy) {
hierarchyModel = window.AptDeviceTree.buildDeviceTreeModel(hierarchy);
const view = window.AptSidebarView.createSidebarView({
document,
tree: deviceList,
spacer: treeSpacer,
windowElement: treeWindow,
model: hierarchyModel,
getActiveDeviceIds: activeDeviceIds,
isOnline: deviceStatusFor,
onCommit: (snapshot) => {
const count = snapshot.state.searchQuery && snapshot.state.searchCameraKeys
? snapshot.state.searchCameraKeys.size
: hierarchyModel.cameraCount;
const hidden = snapshot.rows.hiddenSelected ? ' Selected camera is hidden by the current view.' : '';
deviceResults.textContent = snapshot.state.searchQuery
? `${count} exact search result${count === 1 ? '' : 's'}.${hidden}`
: `${count} camera${count === 1 ? '' : 's'}.${hidden}`;
},
});
sidebarController = window.AptSidebarController.createSidebarController({
model: hierarchyModel,
view,
rowHeight: ROW_HEIGHT,
overscan: 16,
initialState: { viewportHeight: deviceList.clientHeight },
});
deviceList.appendChild(header);
if (!collapsed) for (const device of group.devices) deviceList.appendChild(createDeviceItem(device));
}
}
function filterDevices() {
const query = deviceSearch.value.toLowerCase().trim();
if (!query) {
displayDevices(allDevices);
return;
}
displayDevices(allDevices.filter((device) => {
const values = [
device.name, device.guid, device.id, device.type, device.model, device.ipAddress,
device.server_group_id && allSites[device.server_group_id],
];
return values.some((value) => String(value || '').toLowerCase().includes(query));
}));
}
async function loadDevices() {
if (!connection.connected || loadingDevices) return;
loadingDevices = true;
showStatus(deviceStatus, 'Fetching devices...', 'info');
if (!connection.connected) return;
const generation = ++loadGeneration;
const origin = connection.origin;
showStatus(deviceStatus, 'Fetching camera hierarchy...', 'info');
try {
const [devicesResult, sitesResult] = await Promise.all([
window.electronAPI.getDevices(),
window.electronAPI.getDeviceSites(),
]);
allSites = {};
if (sitesResult.success && Array.isArray(sitesResult.sites)) {
for (const site of sitesResult.sites) if (site.id) allSites[site.id] = site.name || 'Unnamed Site';
}
if (!devicesResult.success) {
allDevices = [];
showStatus(deviceStatus, devicesResult.message || 'Failed to load devices', 'error');
clearDeviceList();
const result = await window.electronAPI.getDeviceHierarchy();
if (generation !== loadGeneration || !connection.connected || connection.origin !== origin) return;
if (!result.success) {
showStatus(deviceStatus, result.message || 'Failed to load cameras', 'error');
showTreePlaceholder('Could not load cameras');
return;
}
allDevices = devicesResult.devices.filter((device) =>
!device.capabilities || device.capabilities.localStorage === undefined || device.capabilities.localStorage === false
const hierarchy = result.hierarchy || { devices: [], sites: [], groups: [] };
installHierarchy(hierarchy);
const warning = describeHierarchyWarning(hierarchy, hierarchyModel);
const count = hierarchyModel.cameraCount;
showStatus(
deviceStatus,
warning ? `Found ${count} camera${count === 1 ? '' : 's'}; ${warning}. Cameras remain available in fallback hierarchy.` : `Found ${count} camera${count === 1 ? '' : 's'}`,
warning ? 'warning' : 'success',
);
showStatus(deviceStatus, `Found ${allDevices.length} local camera${allDevices.length === 1 ? '' : 's'}`, 'success');
filterDevices();
} catch {
showStatus(deviceStatus, 'Could not load devices.', 'error');
} finally {
loadingDevices = false;
if (generation !== loadGeneration) return;
showStatus(deviceStatus, 'Could not load cameras.', 'error');
showTreePlaceholder('Could not load cameras');
}
}
function dispatchTree(action) {
if (!sidebarController) return null;
const snapshot = sidebarController.dispatch(action);
syncSelection(snapshot);
syncTreeScroll(snapshot);
return snapshot;
}
const rendererController = window.AptRendererController.createRendererController({
disconnect: () => window.electronAPI.disconnect(),
renderConnectionState,
clearDisconnectedState: () => {
allDevices = [];
allSites = {};
collapsedSites.clear();
altaUsername.value = '';
clearDeviceList();
clearHierarchy();
},
showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type),
});
@@ -224,15 +212,13 @@ async function handleDisconnect() {
}
async function handleStartProxy() {
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id);
const id = selectedDevice && selectedDevice.id;
if (!id) return;
startProxyBtn.disabled = true;
showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info');
const result = await window.electronAPI.launchProxy(id, altaUsername.value);
if (result.success) {
connection = await window.electronAPI.getConnectionState();
renderConnectionState(connection);
filterDevices();
renderConnectionState(await window.electronAPI.getConnectionState());
showStatus(connectionStatus, 'Camera proxy started.', 'success');
} else {
showStatus(connectionStatus, result.message || 'Failed to start camera proxy.', 'error');
@@ -241,13 +227,11 @@ async function handleStartProxy() {
}
async function handleStopProxy() {
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id);
const id = selectedDevice && selectedDevice.id;
if (!id) return;
stopProxyBtn.disabled = true;
const result = await window.electronAPI.stopProxy(id);
connection = await window.electronAPI.getConnectionState();
renderConnectionState(connection);
filterDevices();
renderConnectionState(await window.electronAPI.getConnectionState());
showStatus(connectionStatus, result.success ? 'Camera proxy stopped.' : result.message || 'Failed to stop camera proxy.', result.success ? 'success' : 'error');
}
@@ -288,6 +272,17 @@ function renderPairingStatus(result) {
}
}
async function runSearch(query) {
if (!sidebarController) return;
const result = await sidebarController.search(query);
if (result && !result.cancelled) {
const count = result.count;
deviceResults.textContent = query.trim()
? `${count} exact search result${count === 1 ? '' : 's'}.`
: `${hierarchyModel.cameraCount} camera${hierarchyModel.cameraCount === 1 ? '' : 's'}.`;
}
}
async function initialize() {
renderConnectionState(await window.electronAPI.getConnectionState());
renderPairingStatus(await window.electronAPI.getPairingStatus());
@@ -296,10 +291,12 @@ async function initialize() {
const wasConnected = connection.connected;
const previousOrigin = connection.origin;
renderConnectionState(state);
if (!state.connected) {
clearHierarchy();
return;
}
if (state.connected && (!wasConnected || state.origin !== previousOrigin)) {
allDevices = [];
allSites = {};
clearDeviceList();
clearHierarchy();
showStatus(connectionStatus, `Connected to ${state.origin}`, 'success');
await loadDevices();
}
@@ -307,10 +304,42 @@ async function initialize() {
setTimeout(() => checkForUpdates(false), 2000);
}
deviceList.addEventListener('click', (event) => {
const row = event.target.closest('.tree-row[data-row-key]');
if (!row || !deviceList.contains(row)) return;
dispatchTree({ type: 'CLICK', rowKey: row.dataset.rowKey });
deviceList.focus();
});
deviceList.addEventListener('keydown', (event) => {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' ', 'Spacebar', 'Escape'].includes(event.key)) return;
event.preventDefault();
dispatchTree({ type: 'KEY', key: event.key });
if (event.key === 'Escape') {
deviceSearch.value = '';
runSearch('');
}
});
deviceList.addEventListener('scroll', () => {
if (scrollFrame || !sidebarController) return;
scrollFrame = requestAnimationFrame(() => {
scrollFrame = null;
sidebarController.dispatch({ type: 'SET_VIEWPORT', scrollTop: deviceList.scrollTop, viewportHeight: deviceList.clientHeight });
});
});
deviceSearch.addEventListener('input', () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => runSearch(deviceSearch.value), 125);
});
deviceSearch.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
if (searchTimer) clearTimeout(searchTimer);
deviceSearch.value = '';
runSearch('');
});
disconnectBtn.addEventListener('click', handleDisconnect);
startProxyBtn.addEventListener('click', handleStartProxy);
stopProxyBtn.addEventListener('click', handleStopProxy);
deviceSearch.addEventListener('input', filterDevices);
altaUsername.addEventListener('input', updateProxyButtons);
checkUpdateBtn.addEventListener('click', () => checkForUpdates(true));
openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage());
+4 -1
View File
@@ -171,7 +171,10 @@ function verifySource() {
for (const [label, version] of [['lockfile', lock.version], ['lockfile root package', lock.packages?.['']?.version]]) {
if (version !== pkg.version) fail(`${label} version ${version} does not match application version ${pkg.version}`);
}
const productionRoots = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/', 'chrome-extension/'];
const productionRoots = [
'main.js', 'preload.js', 'renderer.js', 'renderer-controller.js', 'device-tree.js',
'sidebar-controller.js', 'sidebar-view.js', 'index.html', 'src/', 'chrome-extension/',
];
const productionFiles = new Map([...files].filter(([name]) =>
productionRoots.some((root) => name === root || name.startsWith(root))
));
+11 -2
View File
@@ -133,7 +133,7 @@
}
const model = options.model;
const view = options.view;
const rowHeight = Number.isFinite(options.rowHeight) ? options.rowHeight : 28;
const rowHeight = Number.isFinite(options.rowHeight) ? options.rowHeight : 30;
const overscan = Number.isInteger(options.overscan) ? options.overscan : 16;
const searchRunner = options.searchRunner || treeApi.createSearchRunner({ scheduler: options.scheduler, chunkSize: options.chunkSize });
let state = createInitialState(options.initialState);
@@ -149,6 +149,15 @@
selectedKey: state.selectedKey,
matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null,
});
const activeIndex = rows.findIndex((row) => row.key === state.activeKey);
if (activeIndex >= 0 && state.viewportHeight > 0) {
const activeTop = activeIndex * rowHeight;
const activeBottom = activeTop + rowHeight;
let scrollTop = state.scrollTop;
if (activeTop < scrollTop) scrollTop = activeTop;
else if (activeBottom > scrollTop + state.viewportHeight) scrollTop = activeBottom - state.viewportHeight;
if (scrollTop !== state.scrollTop) state = Object.assign({}, state, { scrollTop });
}
const window = treeApi.calculateVirtualWindow(rows, {
scrollTop: state.scrollTop, viewportHeight: state.viewportHeight, rowHeight, overscan,
});
@@ -188,7 +197,7 @@
commit();
return Object.freeze({
dispatch, search, getState: () => state, getSnapshot: () => lastSnapshot,
dispatch, search, refresh: commit, getState: () => state, getSnapshot: () => lastSnapshot,
destroy() { searchRunner.cancel(); },
});
}
+142
View File
@@ -0,0 +1,142 @@
'use strict';
(function exposeSidebarView(root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (root) root.AptSidebarView = api;
}(typeof window !== 'undefined' ? window : undefined, function sidebarViewFactory() {
const ROW_HEIGHT = 30;
function stableDomId(key) {
return `apt-tree-row-${Array.from(String(key || '')).map((character) => character.codePointAt(0).toString(16)).join('-')}`;
}
function describeVirtualRows(snapshot, options = {}) {
const activeIds = options.activeDeviceIds instanceof Set ? options.activeDeviceIds : new Set();
const model = options.model;
const isOnline = typeof options.isOnline === 'function' ? options.isOnline : () => false;
const windowRows = snapshot && snapshot.window && Array.isArray(snapshot.window.rows)
? snapshot.window.rows
: [];
return windowRows.map((row, windowIndex) => {
const node = model && model.rowByKey ? model.rowByKey.get(row.key) : null;
const device = node && node.kind === 'camera' ? node.source : null;
const deviceId = device ? device.id : row.id;
const classes = ['tree-row', `tree-${row.kind}`];
if (row.ariaSelected) classes.push('selected');
if (snapshot.state && snapshot.state.activeKey === row.key) classes.push('active');
if (row.pendingDeletion) classes.push('pending-deletion');
if (row.hierarchyStatus && !['assigned', 'inferred-site'].includes(row.hierarchyStatus)) classes.push('hierarchy-warning');
if (row.kind === 'camera') {
classes.push(isOnline(device || row) ? 'online' : 'offline');
if (activeIds.has(String(deviceId || '').toLowerCase())) classes.push('proxy-active');
}
return Object.freeze({
key: row.key,
kind: row.kind,
name: row.name,
device,
deviceId,
role: 'treeitem',
domId: stableDomId(row.key),
top: windowIndex * ROW_HEIGHT,
classes: classes.join(' '),
pendingDeletion: row.pendingDeletion === true,
hierarchyStatus: row.hierarchyStatus,
address: device && device.address ? device.address : '',
aria: {
level: row.ariaLevel,
expanded: row.ariaExpanded,
selected: row.ariaSelected,
posinset: row.ariaPosInSet,
setsize: row.ariaSetSize,
},
});
});
}
function setAria(element, name, value) {
if (value === undefined || value === null) element.removeAttribute(`aria-${name}`);
else element.setAttribute(`aria-${name}`, String(value));
}
function createSidebarView(options = {}) {
const documentRef = options.document;
const tree = options.tree;
const spacer = options.spacer;
const windowElement = options.windowElement;
if (!documentRef || !tree || !spacer || !windowElement) {
throw new TypeError('Sidebar view requires document, tree, spacer, and windowElement.');
}
function createRow(descriptor) {
const element = documentRef.createElement('div');
element.id = descriptor.domId;
element.className = descriptor.classes;
element.dataset.rowKey = descriptor.key;
element.setAttribute('role', descriptor.role);
element.style.top = `${descriptor.top}px`;
setAria(element, 'level', descriptor.aria.level);
setAria(element, 'expanded', descriptor.aria.expanded);
setAria(element, 'selected', descriptor.aria.selected);
setAria(element, 'posinset', descriptor.aria.posinset);
setAria(element, 'setsize', descriptor.aria.setsize);
const disclosure = documentRef.createElement('span');
disclosure.className = 'tree-disclosure';
disclosure.setAttribute('aria-hidden', 'true');
disclosure.textContent = descriptor.kind === 'camera' ? '' : (descriptor.aria.expanded ? '\u25bc' : '\u25b6');
const label = documentRef.createElement('span');
label.className = 'tree-label';
label.textContent = descriptor.name;
element.append(disclosure, label);
if (descriptor.address) {
const address = documentRef.createElement('span');
address.className = 'tree-address';
address.textContent = descriptor.address;
element.appendChild(address);
}
if (descriptor.pendingDeletion) {
const pending = documentRef.createElement('span');
pending.className = 'tree-badge pending';
pending.textContent = 'Pending deletion';
element.appendChild(pending);
}
if (descriptor.hierarchyStatus && !['assigned', 'inferred-site'].includes(descriptor.hierarchyStatus)) {
const warning = documentRef.createElement('span');
warning.className = 'tree-warning';
warning.title = `Hierarchy: ${descriptor.hierarchyStatus}`;
warning.setAttribute('aria-label', `Hierarchy warning: ${descriptor.hierarchyStatus}`);
warning.textContent = '!';
element.appendChild(warning);
}
return element;
}
function commit(snapshot) {
const descriptors = describeVirtualRows(snapshot, {
model: options.model,
activeDeviceIds: typeof options.getActiveDeviceIds === 'function' ? options.getActiveDeviceIds() : new Set(),
isOnline: options.isOnline,
});
spacer.style.height = `${snapshot.window.totalHeight}px`;
windowElement.style.transform = `translateY(${snapshot.window.offsetTop}px)`;
windowElement.style.height = `${descriptors.length * ROW_HEIGHT}px`;
const fragment = documentRef.createDocumentFragment();
for (const descriptor of descriptors) fragment.appendChild(createRow(descriptor));
windowElement.replaceChildren(fragment);
const active = descriptors.find((row) => snapshot.state.activeKey === row.key);
if (active) tree.setAttribute('aria-activedescendant', active.domId);
else tree.removeAttribute('aria-activedescendant');
tree.classList.toggle('has-hidden-selection', snapshot.rows.hiddenSelected === true);
if (typeof options.onCommit === 'function') options.onCommit(snapshot, descriptors);
return descriptors;
}
return Object.freeze({ commit });
}
return Object.freeze({ ROW_HEIGHT, stableDomId, describeVirtualRows, createSidebarView });
}));
+170 -2
View File
@@ -51,7 +51,7 @@ body {
/* Left Sidebar - Available Devices */
.devices-sidebar {
width: 220px;
width: 300px;
background: var(--card-bg);
border-right: 1px solid var(--border);
display: flex;
@@ -113,7 +113,175 @@ body {
.device-list {
flex: 1;
overflow-y: auto;
padding: 8px;
padding: 0;
}
/* Accessible, bounded virtual hierarchy */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.tree-results {
min-height: 16px;
margin-top: 6px;
color: var(--text-secondary);
font-size: 11px;
}
.virtual-tree {
position: relative;
outline: none;
}
.virtual-tree:focus-visible {
box-shadow: inset 0 0 0 2px var(--accent-primary);
}
.virtual-tree.has-hidden-selection::after {
content: 'Selected camera hidden';
position: sticky;
bottom: 4px;
left: 8px;
z-index: 3;
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
background: var(--bg-primary);
color: var(--warning);
font-size: 10px;
pointer-events: none;
}
.virtual-tree-spacer {
position: relative;
min-height: 100%;
}
.virtual-tree-window {
position: absolute;
top: 0;
right: 0;
left: 0;
will-change: transform;
}
.tree-row {
position: absolute;
right: 0;
left: 0;
height: 30px;
display: flex;
align-items: center;
gap: 5px;
padding-right: 8px;
border-left: 2px solid transparent;
color: var(--text-primary);
cursor: default;
user-select: none;
}
.tree-row:hover,
.tree-row.active {
background: var(--hover-bg);
}
.tree-row.active {
border-left-color: var(--accent-primary);
box-shadow: inset 0 0 0 1px rgba(14, 122, 254, 0.35);
}
.tree-row.selected {
background: rgba(14, 122, 254, 0.28);
}
.tree-site {
padding-left: 8px;
color: var(--accent-primary);
font-weight: 700;
}
.tree-group {
padding-left: 24px;
color: #b8cbea;
font-weight: 600;
}
.tree-camera {
padding-left: 42px;
}
.tree-camera::before {
content: '';
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: var(--error);
}
.tree-camera.online::before {
background: var(--success);
}
.tree-camera.proxy-active {
border-right: 3px solid var(--success);
}
.tree-disclosure {
width: 10px;
flex: 0 0 10px;
color: var(--text-secondary);
font-size: 9px;
text-align: center;
}
.tree-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-address {
margin-left: auto;
overflow: hidden;
color: var(--text-secondary);
font-family: Consolas, monospace;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-badge {
flex: 0 0 auto;
padding: 1px 4px;
border-radius: 3px;
font-size: 9px;
}
.tree-badge.pending,
.tree-row.pending-deletion {
color: var(--warning);
}
.tree-warning {
width: 14px;
height: 14px;
flex: 0 0 14px;
border: 1px solid var(--warning);
border-radius: 50%;
color: var(--warning);
font-size: 9px;
line-height: 12px;
text-align: center;
}
.device-item {
+3 -2
View File
@@ -13,11 +13,11 @@ function baseFixture() {
return {
sites: [
{ id: 's2', name: 'Zulu' },
{ id: 's1', name: 'Alpha', pendingDeletion: true },
{ id: 's1', name: 'Alpha', pendingDeletionStart: '2026-08-19T12:00:00Z' },
],
groups: [
{ id: 'g2', name: 'Doors', parentId: 's2' },
{ id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletion: true },
{ id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletionStart: '2026-08-19T12:00:00Z' },
],
devices: [
{ id: 'c4', name: 'No Group', siteId: 's1', type: 'camera' },
@@ -36,6 +36,7 @@ test('builds deterministic hierarchy with explicit exceptional buckets and diagn
assert.deepEqual(model.sites.map((site) => site.name), ['Alpha', 'Orphaned cameras', 'Unknown site: missing-s', 'Zulu']);
assert.equal(model.sites[0].pendingDeletion, true);
assert.deepEqual(model.sites[0].groups.map((group) => group.name), ['Doors', 'Lobby', 'Ungrouped', 'Unknown group: missing-g']);
assert.equal(model.sites[0].groups.find((group) => group.id === 'g1').pendingDeletion, true);
assert.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']);
const conflict = model.cameraByKey.get('camera:c3');
+76
View File
@@ -0,0 +1,76 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { describeVirtualRows } = require('../sidebar-view');
const ROOT = path.resolve(__dirname, '..');
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
function syntheticSnapshot(count) {
const rows = Array.from({ length: count }, (_, index) => ({
key: `camera:00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
kind: 'camera', name: `Camera ${index}`, ariaLevel: 3, ariaSelected: index === 4,
ariaPosInSet: index + 1, ariaSetSize: count,
}));
const startIndex = Math.max(0, Math.floor(count / 2) - 20);
return {
state: { activeKey: rows[startIndex].key, selectedKey: rows[4].key },
rows: Object.assign(rows, { hiddenSelected: true }),
window: { rows: rows.slice(startIndex, startIndex + 60), startIndex, offsetTop: startIndex * 30, totalHeight: count * 30 },
};
}
test('renderer loads hierarchy foundations in order and exposes an accessible virtual tree', () => {
const html = read('index.html');
const treeIndex = html.indexOf('device-tree.js');
const controllerIndex = html.indexOf('sidebar-controller.js');
const viewIndex = html.indexOf('sidebar-view.js');
const rendererIndex = html.indexOf('renderer.js');
assert.ok(treeIndex >= 0 && treeIndex < controllerIndex && controllerIndex < viewIndex && viewIndex < rendererIndex);
assert.match(html, /id="deviceList"[^>]*role="tree"[^>]*tabindex="0"[^>]*aria-activedescendant/);
assert.match(html, /id="deviceResults"[^>]*role="status"[^>]*aria-live="polite"/);
});
test('renderer uses one hierarchy request and no parallel flat discovery', () => {
const renderer = read('renderer.js');
assert.match(renderer, /electronAPI\.getDeviceHierarchy\(\)/);
assert.doesNotMatch(renderer, /electronAPI\.getDevices\(|electronAPI\.getDeviceSites\(|Promise\.all\(\s*\[\s*window\.electronAPI\.get/);
assert.doesNotMatch(renderer, /\ballDevices\b|\ballSites\b|\bcollapsedSites\b|groupDevicesBySite/);
assert.match(renderer, /loadGeneration/);
assert.match(renderer, /generation\s*!==\s*loadGeneration/);
});
test('renderer uses delegated tree events, throttled scroll, and debounced search', () => {
const renderer = read('renderer.js');
assert.equal((renderer.match(/deviceList\.addEventListener\('click'/g) || []).length, 1);
assert.equal((renderer.match(/deviceList\.addEventListener\('keydown'/g) || []).length, 1);
assert.equal((renderer.match(/deviceList\.addEventListener\('scroll'/g) || []).length, 1);
assert.match(renderer, /requestAnimationFrame/);
assert.match(renderer, /setTimeout\([^,]+,\s*125\)/s);
});
test('virtual row descriptors stay bounded for 1,500 and 5,000 visible rows', () => {
for (const count of [1_500, 5_000]) {
const descriptors = describeVirtualRows(syntheticSnapshot(count), { activeDeviceIds: new Set() });
assert.ok(descriptors.length <= 80, `${count} rows mounted ${descriptors.length} descriptors`);
assert.equal(descriptors[0].role, 'treeitem');
assert.equal(descriptors[0].aria.level, 3);
assert.match(descriptors[0].domId, /^apt-tree-row-/);
}
});
test('view descriptors preserve selection and refresh proxy/status classes without rebuilding rows', () => {
const snapshot = syntheticSnapshot(1_500);
const selectedId = snapshot.rows[4].id;
const before = describeVirtualRows(snapshot, { activeDeviceIds: new Set() });
const camera = before.find((row) => row.kind === 'camera');
const after = describeVirtualRows(snapshot, { activeDeviceIds: new Set([camera.deviceId]), isOnline: () => true });
assert.ok(after.find((row) => row.key === camera.key).classes.includes('proxy-active'));
assert.ok(after.find((row) => row.key === camera.key).classes.includes('online'));
assert.equal(snapshot.rows[4].id, selectedId);
assert.equal(snapshot.rows.hiddenSelected, true);
});
+5
View File
@@ -81,6 +81,11 @@ test('controller commits exactly once per dispatch through view adapter', () =>
assert.equal(commits.length, 2);
assert.equal(commits[1].state.expandedKeys.has('site:s'), true);
assert.ok(Array.isArray(commits[1].window.rows));
const modelReference = controller.getSnapshot().rows;
controller.refresh();
assert.equal(commits.length, 3);
assert.notEqual(controller.getSnapshot().rows, modelReference);
assert.equal(controller.getSnapshot().rows.length, modelReference.length);
});
test('active and selected state survive virtualization and selection is indicated when hidden', () => {