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. 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 ## Development and verification
```bash ```bash
@@ -54,7 +58,8 @@ Core files:
- `src/proxy-launch.js` — fixed shell-free helper process management - `src/proxy-launch.js` — fixed shell-free helper process management
- `src/update-policy.js` — exact GitPeji check-only release policy - `src/update-policy.js` — exact GitPeji check-only release policy
- `preload.js` — narrow context bridge - `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 - `chrome-extension/` — stable-ID paired cookie sender
- `test/` — pure and end-to-end contract tests - `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 identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics);
const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, { const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
canonicalId: identity.canonicalId, canonicalId: identity.canonicalId,
pendingDeletion: safe.pendingDeletion === true, pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
source: safe, source: safe,
}); });
sites.push(site); sites.push(site);
@@ -105,7 +105,7 @@
const group = makeGroup(site, identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, { const group = makeGroup(site, identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
canonicalId: identity.canonicalId, canonicalId: identity.canonicalId,
rawParentId: parentId, rawParentId: parentId,
pendingDeletion: safe.pendingDeletion === true, pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
source: safe, source: safe,
}); });
site.groups.push(group); site.groups.push(group);
+12 -3
View File
@@ -12,15 +12,21 @@
<div class="main-layout"> <div class="main-layout">
<aside class="devices-sidebar"> <aside class="devices-sidebar">
<div class="sidebar-header"><h2>Available Devices</h2></div> <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"> <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>
<div class="device-list-container"> <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> <p class="placeholder-text">Connect through the paired Chrome extension to load devices</p>
</div> </div>
</div> </div>
</div>
</div>
</aside> </aside>
<main class="main-content"> <main class="main-content">
@@ -103,6 +109,9 @@
</div> </div>
<script src="renderer-controller.js"></script> <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> <script src="renderer.js"></script>
</body> </body>
</html> </html>
+3
View File
@@ -25,6 +25,9 @@
"preload.js", "preload.js",
"renderer.js", "renderer.js",
"renderer-controller.js", "renderer-controller.js",
"device-tree.js",
"sidebar-controller.js",
"sidebar-view.js",
"index.html", "index.html",
"styles.css", "styles.css",
"src/**/*", "src/**/*",
+178 -149
View File
@@ -3,6 +3,9 @@
const connectionStatus = document.getElementById('connectionStatus'); const connectionStatus = document.getElementById('connectionStatus');
const deviceStatus = document.getElementById('deviceStatus'); const deviceStatus = document.getElementById('deviceStatus');
const deviceList = document.getElementById('deviceList'); 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 statusIndicator = document.getElementById('statusIndicator');
const deviceSearch = document.getElementById('deviceSearch'); const deviceSearch = document.getElementById('deviceSearch');
const disconnectBtn = document.getElementById('disconnectBtn'); const disconnectBtn = document.getElementById('disconnectBtn');
@@ -21,12 +24,14 @@ const pairingSecretRow = document.getElementById('pairingSecretRow');
const rotatePairingBtn = document.getElementById('rotatePairingBtn'); const rotatePairingBtn = document.getElementById('rotatePairingBtn');
const revokePairingBtn = document.getElementById('revokePairingBtn'); const revokePairingBtn = document.getElementById('revokePairingBtn');
const ROW_HEIGHT = window.AptSidebarView.ROW_HEIGHT;
let connection = { connected: false, origin: null, activeProxies: [] }; let connection = { connected: false, origin: null, activeProxies: [] };
let selectedDevice = null; let selectedDevice = null;
let allDevices = []; let hierarchyModel = null;
let allSites = {}; let sidebarController = null;
let collapsedSites = new Set(); let loadGeneration = 0;
let loadingDevices = false; let searchTimer = null;
let scrollFrame = null;
function showStatus(element, message, type) { function showStatus(element, message, type) {
element.textContent = message; element.textContent = message;
@@ -35,7 +40,20 @@ function showStatus(element, message, type) {
} }
function activeDeviceIds() { 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) { function renderConnectionState(state) {
@@ -50,171 +68,141 @@ function renderConnectionState(state) {
text.textContent = connection.connected ? 'Connected' : 'Disconnected'; text.textContent = connection.connected ? 'Connected' : 'Disconnected';
disconnectBtn.disabled = !connection.connected; disconnectBtn.disabled = !connection.connected;
updateProxyButtons(); updateProxyButtons();
if (sidebarController) sidebarController.refresh();
} }
function updateProxyButtons() { function showTreePlaceholder(message) {
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); treeSpacer.style.height = '0px';
const active = id ? activeDeviceIds().has(id.toLowerCase()) : false; treeWindow.style.transform = '';
const hasUsername = altaUsername.value.trim().length > 0;
startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active;
stopProxyBtn.disabled = !id || !active;
}
function clearDeviceList() {
deviceList.textContent = '';
const placeholder = document.createElement('p'); const placeholder = document.createElement('p');
placeholder.className = 'placeholder-text'; placeholder.className = 'placeholder-text';
placeholder.textContent = connection.connected ? 'Loading devices...' : 'Connect through the paired Chrome extension to load devices'; placeholder.textContent = message;
deviceList.appendChild(placeholder); 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; selectedDevice = null;
selectedDeviceId.value = ''; 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(); updateProxyButtons();
} }
function deviceStatusFor(device) { function syncSelection(snapshot) {
const raw = device && device.live && device.live.display_status !== undefined const selectedKey = snapshot && snapshot.state.selectedKey;
? device.live.display_status const camera = selectedKey && hierarchyModel ? hierarchyModel.cameraByKey.get(selectedKey) : null;
: device && device.status !== undefined if (!camera) return;
? device.status if (selectedDevice && selectedDevice.id === camera.source.id) return;
: device && device.online; selectedDevice = camera.source;
const value = String(raw ?? '').toLowerCase(); selectedDeviceId.value = selectedDevice.id;
return ['green', 'online', 'live', 'connected', 'true'].includes(value); showStatus(connectionStatus, `Selected device: ${selectedDevice.name || 'Unnamed Device'}`, 'info');
}
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');
updateProxyButtons(); updateProxyButtons();
} }
function createDeviceItem(device) { function syncTreeScroll(snapshot) {
const item = document.createElement('div'); if (!snapshot) return;
const id = device.guid || device.id || ''; const target = snapshot.state.scrollTop;
item.className = 'device-item'; if (Math.abs(deviceList.scrollTop - target) >= ROW_HEIGHT) deviceList.scrollTop = target;
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 groupDevicesBySite(devices) { function describeHierarchyWarning(hierarchy, model) {
const groups = new Map(); const diagnostics = hierarchy && hierarchy.diagnostics && typeof hierarchy.diagnostics === 'object'
for (const device of devices) { ? hierarchy.diagnostics
const siteId = device.server_group_id; : {};
const siteName = siteId && allSites[siteId]; const metadataErrors = hierarchy && hierarchy.metadataErrors && typeof hierarchy.metadataErrors === 'object'
const key = siteName ? siteId : '__ungrouped'; ? hierarchy.metadataErrors
if (!groups.has(key)) groups.set(key, { name: siteName || 'Ungrouped', devices: [] }); : {};
groups.get(key).devices.push(device); const unavailable = ['sites', 'groups'].filter((kind) => metadataErrors[kind] || (diagnostics[kind] && diagnostics[kind].error));
} const issueCount = model.diagnostics.length;
return [...groups.entries()].sort((left, right) => left[1].name.localeCompare(right[1].name)); 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) { function installHierarchy(hierarchy) {
deviceList.textContent = ''; hierarchyModel = window.AptDeviceTree.buildDeviceTreeModel(hierarchy);
if (!devices.length) { const view = window.AptSidebarView.createSidebarView({
const empty = document.createElement('p'); document,
empty.className = 'no-devices'; tree: deviceList,
empty.textContent = deviceSearch.value.trim() ? 'No devices match your search' : 'No local cameras found'; spacer: treeSpacer,
deviceList.appendChild(empty); windowElement: treeWindow,
return; model: hierarchyModel,
} getActiveDeviceIds: activeDeviceIds,
isOnline: deviceStatusFor,
const grouped = Object.keys(allSites).length > 0; onCommit: (snapshot) => {
if (!grouped) { const count = snapshot.state.searchQuery && snapshot.state.searchCameraKeys
for (const device of devices) deviceList.appendChild(createDeviceItem(device)); ? snapshot.state.searchCameraKeys.size
return; : hierarchyModel.cameraCount;
} const hidden = snapshot.rows.hiddenSelected ? ' Selected camera is hidden by the current view.' : '';
deviceResults.textContent = snapshot.state.searchQuery
for (const [siteId, group] of groupDevicesBySite(devices)) { ? `${count} exact search result${count === 1 ? '' : 's'}.${hidden}`
const header = document.createElement('div'); : `${count} camera${count === 1 ? '' : 's'}.${hidden}`;
const collapsed = collapsedSites.has(siteId); },
header.className = `site-group-header${collapsed ? ' collapsed' : ''}`; });
const arrow = document.createElement('span'); sidebarController = window.AptSidebarController.createSidebarController({
arrow.className = 'site-group-arrow'; model: hierarchyModel,
arrow.textContent = collapsed ? '\u25B6' : '\u25BC'; view,
const name = document.createElement('span'); rowHeight: ROW_HEIGHT,
name.className = 'site-group-name'; overscan: 16,
name.textContent = group.name; initialState: { viewportHeight: deviceList.clientHeight },
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();
}); });
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() { async function loadDevices() {
if (!connection.connected || loadingDevices) return; if (!connection.connected) return;
loadingDevices = true; const generation = ++loadGeneration;
showStatus(deviceStatus, 'Fetching devices...', 'info'); const origin = connection.origin;
showStatus(deviceStatus, 'Fetching camera hierarchy...', 'info');
try { try {
const [devicesResult, sitesResult] = await Promise.all([ const result = await window.electronAPI.getDeviceHierarchy();
window.electronAPI.getDevices(), if (generation !== loadGeneration || !connection.connected || connection.origin !== origin) return;
window.electronAPI.getDeviceSites(), if (!result.success) {
]); showStatus(deviceStatus, result.message || 'Failed to load cameras', 'error');
allSites = {}; showTreePlaceholder('Could not load cameras');
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();
return; return;
} }
allDevices = devicesResult.devices.filter((device) => const hierarchy = result.hierarchy || { devices: [], sites: [], groups: [] };
!device.capabilities || device.capabilities.localStorage === undefined || device.capabilities.localStorage === false 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 { } catch {
showStatus(deviceStatus, 'Could not load devices.', 'error'); if (generation !== loadGeneration) return;
} finally { showStatus(deviceStatus, 'Could not load cameras.', 'error');
loadingDevices = false; 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({ const rendererController = window.AptRendererController.createRendererController({
disconnect: () => window.electronAPI.disconnect(), disconnect: () => window.electronAPI.disconnect(),
renderConnectionState, renderConnectionState,
clearDisconnectedState: () => { clearDisconnectedState: () => {
allDevices = [];
allSites = {};
collapsedSites.clear();
altaUsername.value = ''; altaUsername.value = '';
clearDeviceList(); clearHierarchy();
}, },
showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type), showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type),
}); });
@@ -224,15 +212,13 @@ async function handleDisconnect() {
} }
async function handleStartProxy() { async function handleStartProxy() {
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); const id = selectedDevice && selectedDevice.id;
if (!id) return; if (!id) return;
startProxyBtn.disabled = true; startProxyBtn.disabled = true;
showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info'); showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info');
const result = await window.electronAPI.launchProxy(id, altaUsername.value); const result = await window.electronAPI.launchProxy(id, altaUsername.value);
if (result.success) { if (result.success) {
connection = await window.electronAPI.getConnectionState(); renderConnectionState(await window.electronAPI.getConnectionState());
renderConnectionState(connection);
filterDevices();
showStatus(connectionStatus, 'Camera proxy started.', 'success'); showStatus(connectionStatus, 'Camera proxy started.', 'success');
} else { } else {
showStatus(connectionStatus, result.message || 'Failed to start camera proxy.', 'error'); showStatus(connectionStatus, result.message || 'Failed to start camera proxy.', 'error');
@@ -241,13 +227,11 @@ async function handleStartProxy() {
} }
async function handleStopProxy() { async function handleStopProxy() {
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); const id = selectedDevice && selectedDevice.id;
if (!id) return; if (!id) return;
stopProxyBtn.disabled = true; stopProxyBtn.disabled = true;
const result = await window.electronAPI.stopProxy(id); const result = await window.electronAPI.stopProxy(id);
connection = await window.electronAPI.getConnectionState(); renderConnectionState(await window.electronAPI.getConnectionState());
renderConnectionState(connection);
filterDevices();
showStatus(connectionStatus, result.success ? 'Camera proxy stopped.' : result.message || 'Failed to stop camera proxy.', result.success ? 'success' : 'error'); 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() { async function initialize() {
renderConnectionState(await window.electronAPI.getConnectionState()); renderConnectionState(await window.electronAPI.getConnectionState());
renderPairingStatus(await window.electronAPI.getPairingStatus()); renderPairingStatus(await window.electronAPI.getPairingStatus());
@@ -296,10 +291,12 @@ async function initialize() {
const wasConnected = connection.connected; const wasConnected = connection.connected;
const previousOrigin = connection.origin; const previousOrigin = connection.origin;
renderConnectionState(state); renderConnectionState(state);
if (!state.connected) {
clearHierarchy();
return;
}
if (state.connected && (!wasConnected || state.origin !== previousOrigin)) { if (state.connected && (!wasConnected || state.origin !== previousOrigin)) {
allDevices = []; clearHierarchy();
allSites = {};
clearDeviceList();
showStatus(connectionStatus, `Connected to ${state.origin}`, 'success'); showStatus(connectionStatus, `Connected to ${state.origin}`, 'success');
await loadDevices(); await loadDevices();
} }
@@ -307,10 +304,42 @@ async function initialize() {
setTimeout(() => checkForUpdates(false), 2000); 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); disconnectBtn.addEventListener('click', handleDisconnect);
startProxyBtn.addEventListener('click', handleStartProxy); startProxyBtn.addEventListener('click', handleStartProxy);
stopProxyBtn.addEventListener('click', handleStopProxy); stopProxyBtn.addEventListener('click', handleStopProxy);
deviceSearch.addEventListener('input', filterDevices);
altaUsername.addEventListener('input', updateProxyButtons); altaUsername.addEventListener('input', updateProxyButtons);
checkUpdateBtn.addEventListener('click', () => checkForUpdates(true)); checkUpdateBtn.addEventListener('click', () => checkForUpdates(true));
openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage()); 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]]) { 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}`); 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]) => const productionFiles = new Map([...files].filter(([name]) =>
productionRoots.some((root) => name === root || name.startsWith(root)) productionRoots.some((root) => name === root || name.startsWith(root))
)); ));
+11 -2
View File
@@ -133,7 +133,7 @@
} }
const model = options.model; const model = options.model;
const view = options.view; 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 overscan = Number.isInteger(options.overscan) ? options.overscan : 16;
const searchRunner = options.searchRunner || treeApi.createSearchRunner({ scheduler: options.scheduler, chunkSize: options.chunkSize }); const searchRunner = options.searchRunner || treeApi.createSearchRunner({ scheduler: options.scheduler, chunkSize: options.chunkSize });
let state = createInitialState(options.initialState); let state = createInitialState(options.initialState);
@@ -149,6 +149,15 @@
selectedKey: state.selectedKey, selectedKey: state.selectedKey,
matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null, 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, { const window = treeApi.calculateVirtualWindow(rows, {
scrollTop: state.scrollTop, viewportHeight: state.viewportHeight, rowHeight, overscan, scrollTop: state.scrollTop, viewportHeight: state.viewportHeight, rowHeight, overscan,
}); });
@@ -188,7 +197,7 @@
commit(); commit();
return Object.freeze({ return Object.freeze({
dispatch, search, getState: () => state, getSnapshot: () => lastSnapshot, dispatch, search, refresh: commit, getState: () => state, getSnapshot: () => lastSnapshot,
destroy() { searchRunner.cancel(); }, 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 */ /* Left Sidebar - Available Devices */
.devices-sidebar { .devices-sidebar {
width: 220px; width: 300px;
background: var(--card-bg); background: var(--card-bg);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
@@ -113,7 +113,175 @@ body {
.device-list { .device-list {
flex: 1; flex: 1;
overflow-y: auto; 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 { .device-item {
+3 -2
View File
@@ -13,11 +13,11 @@ function baseFixture() {
return { return {
sites: [ sites: [
{ id: 's2', name: 'Zulu' }, { id: 's2', name: 'Zulu' },
{ id: 's1', name: 'Alpha', pendingDeletion: true }, { id: 's1', name: 'Alpha', pendingDeletionStart: '2026-08-19T12:00:00Z' },
], ],
groups: [ groups: [
{ id: 'g2', name: 'Doors', parentId: 's2' }, { 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: [ devices: [
{ id: 'c4', name: 'No Group', siteId: 's1', type: 'camera' }, { 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.deepEqual(model.sites.map((site) => site.name), ['Alpha', 'Orphaned cameras', 'Unknown site: missing-s', 'Zulu']);
assert.equal(model.sites[0].pendingDeletion, true); 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.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']); assert.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']);
const conflict = model.cameraByKey.get('camera:c3'); 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.length, 2);
assert.equal(commits[1].state.expandedKeys.has('site:s'), true); assert.equal(commits[1].state.expandedKeys.has('site:s'), true);
assert.ok(Array.isArray(commits[1].window.rows)); 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', () => { test('active and selected state survive virtualization and selection is indicated when hidden', () => {