feat: render scalable Alta site and group hierarchy
This commit is contained in:
+179
-150
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user