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:
@@ -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 }) => {
|
ipcMain.handle('api-get-auth-info', async (event, { deploymentUrl, cookies }) => {
|
||||||
try {
|
try {
|
||||||
const authUrl = `${deploymentUrl}/api/v1/auth`;
|
const authUrl = `${deploymentUrl}/api/v1/auth`;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron');
|
|||||||
// the ipcRenderer without exposing the entire object
|
// the ipcRenderer without exposing the entire object
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
getDevices: (params) => ipcRenderer.invoke('api-get-devices', params),
|
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),
|
getAuthInfo: (params) => ipcRenderer.invoke('api-get-auth-info', params),
|
||||||
|
|
||||||
// Camera proxy functionality
|
// Camera proxy functionality
|
||||||
|
|||||||
+178
-30
@@ -37,6 +37,8 @@ const updateLaterBtn = document.getElementById('updateLaterBtn');
|
|||||||
let selectedDevice = null;
|
let selectedDevice = null;
|
||||||
let activeCookieProxyConnections = new Map(); // Track cookie-based proxy connections
|
let activeCookieProxyConnections = new Map(); // Track cookie-based proxy connections
|
||||||
let allDevices = []; // Store all devices for search functionality
|
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
|
let pendingUpdateInfo = null; // Store update info for install action
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
@@ -66,6 +68,8 @@ function handleDisconnect() {
|
|||||||
selectedDevice = null;
|
selectedDevice = null;
|
||||||
activeCookieProxyConnections.clear();
|
activeCookieProxyConnections.clear();
|
||||||
allDevices = []; // Clear stored devices
|
allDevices = []; // Clear stored devices
|
||||||
|
allSites = {}; // Clear site data
|
||||||
|
collapsedSites.clear();
|
||||||
|
|
||||||
updateConnectionStatus(false);
|
updateConnectionStatus(false);
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
@@ -218,6 +222,28 @@ function updateButtonStates() {
|
|||||||
updateCookieProxyButtonStates();
|
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)
|
// Handle get devices (now called automatically)
|
||||||
async function handleGetDevices() {
|
async function handleGetDevices() {
|
||||||
if (!sessionData.isConnected) {
|
if (!sessionData.isConnected) {
|
||||||
@@ -229,14 +255,18 @@ async function handleGetDevices() {
|
|||||||
clearDeviceList();
|
clearDeviceList();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.getDevices({
|
// Fetch devices and sites in parallel
|
||||||
deploymentUrl: sessionData.deploymentUrl,
|
const [devicesResult] = await Promise.all([
|
||||||
cookies: sessionData.cookies
|
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)
|
// 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
|
// Check if device has capabilities and localStorage property
|
||||||
if (device.capabilities && device.capabilities.localStorage !== undefined) {
|
if (device.capabilities && device.capabilities.localStorage !== undefined) {
|
||||||
// Only show devices where localStorage is false (non-cloud cameras)
|
// Only show devices where localStorage is false (non-cloud cameras)
|
||||||
@@ -246,11 +276,15 @@ async function handleGetDevices() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalDevices = result.devices.length;
|
const totalDevices = devicesResult.devices.length;
|
||||||
const filteredCount = filteredDevices.length;
|
const filteredCount = filteredDevices.length;
|
||||||
const cloudDevicesHidden = totalDevices - filteredCount;
|
const cloudDevicesHidden = totalDevices - filteredCount;
|
||||||
|
|
||||||
|
const siteCount = Object.keys(allSites).length;
|
||||||
let statusMessage = `Found ${filteredCount} local camera${filteredCount !== 1 ? 's' : ''}`;
|
let statusMessage = `Found ${filteredCount} local camera${filteredCount !== 1 ? 's' : ''}`;
|
||||||
|
if (siteCount > 0) {
|
||||||
|
statusMessage += ` across ${siteCount} site${siteCount !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
if (cloudDevicesHidden > 0) {
|
if (cloudDevicesHidden > 0) {
|
||||||
statusMessage += ` (${cloudDevicesHidden} cloud camera${cloudDevicesHidden !== 1 ? 's' : ''} hidden)`;
|
statusMessage += ` (${cloudDevicesHidden} cloud camera${cloudDevicesHidden !== 1 ? 's' : ''} hidden)`;
|
||||||
}
|
}
|
||||||
@@ -261,7 +295,7 @@ async function handleGetDevices() {
|
|||||||
allDevices = filteredDevices;
|
allDevices = filteredDevices;
|
||||||
displayDevices(filteredDevices);
|
displayDevices(filteredDevices);
|
||||||
} else {
|
} else {
|
||||||
showStatus(deviceStatus, `Failed to get devices: ${result.message}`, 'error');
|
showStatus(deviceStatus, `Failed to get devices: ${devicesResult.message}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get devices error:', error);
|
console.error('Get devices error:', error);
|
||||||
@@ -347,25 +381,73 @@ function handleDeviceSearch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter devices based on search term
|
// Filter devices based on search term (includes site name)
|
||||||
const filteredDevices = allDevices.filter(device => {
|
const filteredDevices = allDevices.filter(device => {
|
||||||
const deviceName = (device.name || '').toLowerCase();
|
const deviceName = (device.name || '').toLowerCase();
|
||||||
const deviceId = (device.guid || device.id || '').toLowerCase();
|
const deviceId = (device.guid || device.id || '').toLowerCase();
|
||||||
const deviceType = (device.type || '').toLowerCase();
|
const deviceType = (device.type || '').toLowerCase();
|
||||||
const deviceModel = (device.model || '').toLowerCase();
|
const deviceModel = (device.model || '').toLowerCase();
|
||||||
const deviceIp = (device.ipAddress || '').toLowerCase();
|
const deviceIp = (device.ipAddress || '').toLowerCase();
|
||||||
|
const siteName = (device.server_group_id && allSites[device.server_group_id] || '').toLowerCase();
|
||||||
|
|
||||||
return deviceName.includes(searchTerm) ||
|
return deviceName.includes(searchTerm) ||
|
||||||
deviceId.includes(searchTerm) ||
|
deviceId.includes(searchTerm) ||
|
||||||
deviceType.includes(searchTerm) ||
|
deviceType.includes(searchTerm) ||
|
||||||
deviceModel.includes(searchTerm) ||
|
deviceModel.includes(searchTerm) ||
|
||||||
deviceIp.includes(searchTerm);
|
deviceIp.includes(searchTerm) ||
|
||||||
|
siteName.includes(searchTerm);
|
||||||
});
|
});
|
||||||
|
|
||||||
displayDevices(filteredDevices);
|
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) {
|
function displayDevices(devices) {
|
||||||
clearDeviceList();
|
clearDeviceList();
|
||||||
|
|
||||||
@@ -376,33 +458,99 @@ function displayDevices(devices) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
devices.forEach((device, index) => {
|
const hasSites = Object.keys(allSites).length > 0;
|
||||||
const deviceItem = document.createElement('div');
|
|
||||||
deviceItem.className = 'device-item';
|
|
||||||
deviceItem.dataset.deviceIndex = index;
|
|
||||||
deviceItem.dataset.deviceId = device.guid || device.id;
|
|
||||||
|
|
||||||
const deviceStatus = getDeviceStatus(device);
|
// If no sites were fetched, fall back to flat list
|
||||||
const deviceId = device.guid || device.id;
|
if (!hasSites) {
|
||||||
const isCookieProxyActive = activeCookieProxyConnections.has(deviceId);
|
devices.forEach((device, index) => {
|
||||||
|
deviceList.appendChild(createDeviceItem(device, index));
|
||||||
|
});
|
||||||
|
updateCookieProxyButtonStates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add cookie-proxy-active class if this device has an active cookie connection
|
const { groups, ungrouped } = groupDevicesBySite(devices);
|
||||||
if (isCookieProxyActive) {
|
|
||||||
deviceItem.classList.add('cookie-proxy-active');
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceItem.innerHTML = `
|
// Sort site groups alphabetically by name
|
||||||
<div class="device-name">${escapeHtml(device.name || 'Unnamed Device')}</div>
|
const sortedSiteIds = Object.keys(groups).sort((a, b) =>
|
||||||
<div class="device-status-dot ${deviceStatus.isOnline ? 'online' : 'offline'}"></div>
|
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
|
siteHeader.addEventListener('click', () => {
|
||||||
deviceItem.addEventListener('click', () => selectDevice(device, deviceItem));
|
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();
|
updateCookieProxyButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
@@ -167,6 +167,53 @@ body {
|
|||||||
box-shadow: 0 0 6px rgba(244, 67, 54, 0.6);
|
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 {
|
.placeholder-text {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
Reference in New Issue
Block a user