From c3c3fc83e3f773d705565494abcc6519217019ea Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Thu, 26 Mar 2026 22:27:05 +0000 Subject: [PATCH] Add site-grouped device list in sidebar Fetch device sites from /api/v1/deviceSites in parallel with devices, then group cameras by site with collapsible headers. Search now also matches site names. Falls back to flat list if sites API is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) --- main.js | 27 +++++++ preload.js | 1 + renderer.js | 208 ++++++++++++++++++++++++++++++++++++++++++++-------- styles.css | 47 ++++++++++++ 4 files changed, 253 insertions(+), 30 deletions(-) diff --git a/main.js b/main.js index 883936c..4bb5acd 100644 --- a/main.js +++ b/main.js @@ -218,6 +218,33 @@ ipcMain.handle('api-get-devices', async (event, { deploymentUrl, cookies }) => { } }); +ipcMain.handle('api-get-device-sites', async (event, { deploymentUrl, cookies }) => { + try { + const sitesUrl = `${deploymentUrl}/api/v1/deviceSites`; + + const axiosInstance = axios.create({ + timeout: 10000, + headers: { + 'Cookie': cookies ? cookies.join('; ') : '' + } + }); + + const response = await axiosInstance.get(sitesUrl); + + return { + success: true, + sites: response.data + }; + } catch (error) { + console.error('Get device sites error:', error); + return { + success: false, + sites: [], + message: error.response?.data?.message || error.message || 'Failed to get device sites' + }; + } +}); + ipcMain.handle('api-get-auth-info', async (event, { deploymentUrl, cookies }) => { try { const authUrl = `${deploymentUrl}/api/v1/auth`; diff --git a/preload.js b/preload.js index 0dad6df..bc3131e 100644 --- a/preload.js +++ b/preload.js @@ -4,6 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron'); // the ipcRenderer without exposing the entire object contextBridge.exposeInMainWorld('electronAPI', { getDevices: (params) => ipcRenderer.invoke('api-get-devices', params), + getDeviceSites: (params) => ipcRenderer.invoke('api-get-device-sites', params), getAuthInfo: (params) => ipcRenderer.invoke('api-get-auth-info', params), // Camera proxy functionality diff --git a/renderer.js b/renderer.js index 07df1d0..2ee09b7 100644 --- a/renderer.js +++ b/renderer.js @@ -37,6 +37,8 @@ const updateLaterBtn = document.getElementById('updateLaterBtn'); let selectedDevice = null; let activeCookieProxyConnections = new Map(); // Track cookie-based proxy connections let allDevices = []; // Store all devices for search functionality +let allSites = {}; // Store site id → site name mapping +let collapsedSites = new Set(); // Track which site groups are collapsed let pendingUpdateInfo = null; // Store update info for install action // Event listeners @@ -66,6 +68,8 @@ function handleDisconnect() { selectedDevice = null; activeCookieProxyConnections.clear(); allDevices = []; // Clear stored devices + allSites = {}; // Clear site data + collapsedSites.clear(); updateConnectionStatus(false); updateButtonStates(); @@ -218,6 +222,28 @@ function updateButtonStates() { updateCookieProxyButtonStates(); } +// Fetch device sites and build id → name map +async function fetchDeviceSites() { + try { + const result = await window.electronAPI.getDeviceSites({ + deploymentUrl: sessionData.deploymentUrl, + cookies: sessionData.cookies + }); + + if (result.success && Array.isArray(result.sites)) { + allSites = {}; + result.sites.forEach(site => { + if (site.id) { + allSites[site.id] = site.name || 'Unnamed Site'; + } + }); + } + } catch (error) { + console.log('Could not fetch device sites:', error.message); + allSites = {}; + } +} + // Handle get devices (now called automatically) async function handleGetDevices() { if (!sessionData.isConnected) { @@ -229,14 +255,18 @@ async function handleGetDevices() { clearDeviceList(); try { - const result = await window.electronAPI.getDevices({ - deploymentUrl: sessionData.deploymentUrl, - cookies: sessionData.cookies - }); + // Fetch devices and sites in parallel + const [devicesResult] = await Promise.all([ + window.electronAPI.getDevices({ + deploymentUrl: sessionData.deploymentUrl, + cookies: sessionData.cookies + }), + fetchDeviceSites() + ]); - if (result.success) { + if (devicesResult.success) { // Filter devices to only show non-cloud cameras (localStorage = false) - const filteredDevices = result.devices.filter(device => { + const filteredDevices = devicesResult.devices.filter(device => { // Check if device has capabilities and localStorage property if (device.capabilities && device.capabilities.localStorage !== undefined) { // Only show devices where localStorage is false (non-cloud cameras) @@ -246,11 +276,15 @@ async function handleGetDevices() { return true; }); - const totalDevices = result.devices.length; + const totalDevices = devicesResult.devices.length; const filteredCount = filteredDevices.length; const cloudDevicesHidden = totalDevices - filteredCount; + const siteCount = Object.keys(allSites).length; let statusMessage = `Found ${filteredCount} local camera${filteredCount !== 1 ? 's' : ''}`; + if (siteCount > 0) { + statusMessage += ` across ${siteCount} site${siteCount !== 1 ? 's' : ''}`; + } if (cloudDevicesHidden > 0) { statusMessage += ` (${cloudDevicesHidden} cloud camera${cloudDevicesHidden !== 1 ? 's' : ''} hidden)`; } @@ -261,7 +295,7 @@ async function handleGetDevices() { allDevices = filteredDevices; displayDevices(filteredDevices); } else { - showStatus(deviceStatus, `Failed to get devices: ${result.message}`, 'error'); + showStatus(deviceStatus, `Failed to get devices: ${devicesResult.message}`, 'error'); } } catch (error) { console.error('Get devices error:', error); @@ -347,25 +381,73 @@ function handleDeviceSearch() { return; } - // Filter devices based on search term + // Filter devices based on search term (includes site name) const filteredDevices = allDevices.filter(device => { const deviceName = (device.name || '').toLowerCase(); const deviceId = (device.guid || device.id || '').toLowerCase(); const deviceType = (device.type || '').toLowerCase(); const deviceModel = (device.model || '').toLowerCase(); const deviceIp = (device.ipAddress || '').toLowerCase(); + const siteName = (device.server_group_id && allSites[device.server_group_id] || '').toLowerCase(); return deviceName.includes(searchTerm) || deviceId.includes(searchTerm) || deviceType.includes(searchTerm) || deviceModel.includes(searchTerm) || - deviceIp.includes(searchTerm); + deviceIp.includes(searchTerm) || + siteName.includes(searchTerm); }); displayDevices(filteredDevices); } -// Display devices in the UI +// Group devices by their site using server_group_id → allSites mapping +function groupDevicesBySite(devices) { + const groups = {}; + const ungrouped = []; + + devices.forEach(device => { + const siteId = device.server_group_id; + const siteName = siteId && allSites[siteId] ? allSites[siteId] : null; + + if (siteName) { + if (!groups[siteId]) { + groups[siteId] = { name: siteName, devices: [] }; + } + groups[siteId].devices.push(device); + } else { + ungrouped.push(device); + } + }); + + return { groups, ungrouped }; +} + +// Create a device item DOM element +function createDeviceItem(device, index) { + const deviceItem = document.createElement('div'); + deviceItem.className = 'device-item'; + deviceItem.dataset.deviceIndex = index; + deviceItem.dataset.deviceId = device.guid || device.id; + + const status = getDeviceStatus(device); + const deviceId = device.guid || device.id; + const isCookieProxyActive = activeCookieProxyConnections.has(deviceId); + + if (isCookieProxyActive) { + deviceItem.classList.add('cookie-proxy-active'); + } + + deviceItem.innerHTML = ` +
${escapeHtml(device.name || 'Unnamed Device')}
+
+ `; + + deviceItem.addEventListener('click', () => selectDevice(device, deviceItem)); + return deviceItem; +} + +// Display devices in the UI, grouped by site function displayDevices(devices) { clearDeviceList(); @@ -376,33 +458,99 @@ function displayDevices(devices) { return; } - devices.forEach((device, index) => { - const deviceItem = document.createElement('div'); - deviceItem.className = 'device-item'; - deviceItem.dataset.deviceIndex = index; - deviceItem.dataset.deviceId = device.guid || device.id; + const hasSites = Object.keys(allSites).length > 0; - const deviceStatus = getDeviceStatus(device); - const deviceId = device.guid || device.id; - const isCookieProxyActive = activeCookieProxyConnections.has(deviceId); + // If no sites were fetched, fall back to flat list + if (!hasSites) { + devices.forEach((device, index) => { + deviceList.appendChild(createDeviceItem(device, index)); + }); + updateCookieProxyButtonStates(); + return; + } - // Add cookie-proxy-active class if this device has an active cookie connection - if (isCookieProxyActive) { - deviceItem.classList.add('cookie-proxy-active'); - } + const { groups, ungrouped } = groupDevicesBySite(devices); - deviceItem.innerHTML = ` -
${escapeHtml(device.name || 'Unnamed Device')}
-
+ // Sort site groups alphabetically by name + const sortedSiteIds = Object.keys(groups).sort((a, b) => + groups[a].name.localeCompare(groups[b].name) + ); + + let globalIndex = 0; + + sortedSiteIds.forEach(siteId => { + const group = groups[siteId]; + const isCollapsed = collapsedSites.has(siteId); + + // Site header + const siteHeader = document.createElement('div'); + siteHeader.className = 'site-group-header' + (isCollapsed ? ' collapsed' : ''); + siteHeader.innerHTML = ` + ${isCollapsed ? '\u25B6' : '\u25BC'} + ${escapeHtml(group.name)} + ${group.devices.length} `; - // Add click handler for device selection - deviceItem.addEventListener('click', () => selectDevice(device, deviceItem)); + siteHeader.addEventListener('click', () => { + if (collapsedSites.has(siteId)) { + collapsedSites.delete(siteId); + } else { + collapsedSites.add(siteId); + } + // Re-render with current search filter + const searchTerm = deviceSearch.value.toLowerCase().trim(); + if (searchTerm) { + handleDeviceSearch(); + } else { + displayDevices(allDevices); + } + }); - deviceList.appendChild(deviceItem); + deviceList.appendChild(siteHeader); + + // Device items (hidden if collapsed) + if (!isCollapsed) { + group.devices.forEach(device => { + deviceList.appendChild(createDeviceItem(device, globalIndex++)); + }); + } }); - // Update cookie proxy button states after displaying devices + // Ungrouped devices at the bottom + if (ungrouped.length > 0) { + if (sortedSiteIds.length > 0) { + const ungroupedHeader = document.createElement('div'); + ungroupedHeader.className = 'site-group-header' + (collapsedSites.has('__ungrouped') ? ' collapsed' : ''); + ungroupedHeader.innerHTML = ` + ${collapsedSites.has('__ungrouped') ? '\u25B6' : '\u25BC'} + Ungrouped + ${ungrouped.length} + `; + + ungroupedHeader.addEventListener('click', () => { + if (collapsedSites.has('__ungrouped')) { + collapsedSites.delete('__ungrouped'); + } else { + collapsedSites.add('__ungrouped'); + } + const searchTerm = deviceSearch.value.toLowerCase().trim(); + if (searchTerm) { + handleDeviceSearch(); + } else { + displayDevices(allDevices); + } + }); + + deviceList.appendChild(ungroupedHeader); + } + + if (!collapsedSites.has('__ungrouped') || sortedSiteIds.length === 0) { + ungrouped.forEach(device => { + deviceList.appendChild(createDeviceItem(device, globalIndex++)); + }); + } + } + updateCookieProxyButtonStates(); } diff --git a/styles.css b/styles.css index 46372cc..4012ae0 100644 --- a/styles.css +++ b/styles.css @@ -167,6 +167,53 @@ body { box-shadow: 0 0 6px rgba(244, 67, 54, 0.6); } +/* Site Group Headers */ +.site-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + margin-bottom: 4px; + cursor: pointer; + border-radius: 4px; + user-select: none; + transition: background 0.15s ease; +} + +.site-group-header:hover { + background: var(--hover-bg); +} + +.site-group-arrow { + font-size: 10px; + color: var(--text-secondary); + width: 12px; + flex-shrink: 0; + text-align: center; +} + +.site-group-name { + font-size: 11px; + font-weight: bold; + color: var(--accent-primary); + text-transform: uppercase; + letter-spacing: 0.5px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.site-group-count { + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1px 6px; + flex-shrink: 0; +} + .placeholder-text { color: var(--text-secondary); font-style: italic;