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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 22:27:05 +00:00
parent 14ce5c728d
commit c3c3fc83e3
4 changed files with 253 additions and 30 deletions
+27
View File
@@ -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`;
+1
View File
@@ -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
+178 -30
View File
@@ -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 = `
<div class="device-name">${escapeHtml(device.name || 'Unnamed Device')}</div>
<div class="device-status-dot ${status.isOnline ? 'online' : 'offline'}"></div>
`;
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 = `
<div class="device-name">${escapeHtml(device.name || 'Unnamed Device')}</div>
<div class="device-status-dot ${deviceStatus.isOnline ? 'online' : 'offline'}"></div>
// 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 = `
<span class="site-group-arrow">${isCollapsed ? '\u25B6' : '\u25BC'}</span>
<span class="site-group-name">${escapeHtml(group.name)}</span>
<span class="site-group-count">${group.devices.length}</span>
`;
// 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 = `
<span class="site-group-arrow">${collapsedSites.has('__ungrouped') ? '\u25B6' : '\u25BC'}</span>
<span class="site-group-name">Ungrouped</span>
<span class="site-group-count">${ungrouped.length}</span>
`;
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();
}
+47
View File
@@ -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;