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
+179 -150
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();
});
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));
}));
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 },
});
}
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());