Files
Alta-Proxy-Tool/renderer.js
T
peji c3c3fc83e3 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>
2026-03-26 22:27:05 +00:00

765 lines
27 KiB
JavaScript

// Global variables to store session data
let sessionData = {
deploymentUrl: '',
cookies: null,
isConnected: false
};
// DOM elements
const connectionStatus = document.getElementById('connectionStatus');
const deviceStatus = document.getElementById('deviceStatus');
const deviceList = document.getElementById('deviceList');
const statusIndicator = document.getElementById('statusIndicator');
const deviceSearch = document.getElementById('deviceSearch');
// Connection buttons
const disconnectBtn = document.getElementById('disconnectBtn');
// Cookie proxy elements
const cookieDeviceUUID = document.getElementById('cookieDeviceUUID');
const cookieKey = document.getElementById('cookieKey');
const startCookieProxyBtn = document.getElementById('startCookieProxyBtn');
const stopCookieProxyBtn = document.getElementById('stopCookieProxyBtn');
// Update elements
const checkUpdateBtn = document.getElementById('checkUpdateBtn');
const updateModalOverlay = document.getElementById('updateModalOverlay');
const updateModalCloseBtn = document.getElementById('updateModalCloseBtn');
const updateModalMessage = document.getElementById('updateModalMessage');
const updateModalNotes = document.getElementById('updateModalNotes');
const updateProgressContainer = document.getElementById('updateProgressContainer');
const updateProgressFill = document.getElementById('updateProgressFill');
const updateProgressText = document.getElementById('updateProgressText');
const updateInstallBtn = document.getElementById('updateInstallBtn');
const updateLaterBtn = document.getElementById('updateLaterBtn');
// Track selected device
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
disconnectBtn.addEventListener('click', handleDisconnect);
// Cookie proxy event listeners
startCookieProxyBtn.addEventListener('click', handleStartCookieProxy);
stopCookieProxyBtn.addEventListener('click', handleStopCookieProxy);
// Cookie key input listener to update button states
cookieKey.addEventListener('input', updateCookieProxyButtonStates);
// Device search event listener
deviceSearch.addEventListener('input', handleDeviceSearch);
// Update event listeners
checkUpdateBtn.addEventListener('click', handleCheckForUpdates);
updateInstallBtn.addEventListener('click', handleInstallUpdate);
updateLaterBtn.addEventListener('click', closeUpdateModal);
updateModalCloseBtn.addEventListener('click', closeUpdateModal);
// Handle disconnect
function handleDisconnect() {
sessionData.isConnected = false;
sessionData.cookies = null;
sessionData.deploymentUrl = '';
selectedDevice = null;
activeCookieProxyConnections.clear();
allDevices = []; // Clear stored devices
allSites = {}; // Clear site data
collapsedSites.clear();
updateConnectionStatus(false);
updateButtonStates();
clearDeviceList();
cookieDeviceUUID.value = '';
cookieKey.value = '';
deviceSearch.value = ''; // Clear search input
// Clear device status message
deviceStatus.style.display = 'none';
deviceStatus.textContent = '';
showStatus(connectionStatus, 'Disconnected from API', 'info');
}
// Handle start cookie proxy
async function handleStartCookieProxy() {
if (!selectedDevice) {
showStatus(connectionStatus, 'Please select a device first', 'error');
return;
}
const cookieKeyValue = cookieKey.value.trim();
if (!cookieKeyValue) {
showStatus(connectionStatus, 'Please enter a cookie key', 'error');
return;
}
if (!sessionData.deploymentUrl) {
showStatus(connectionStatus, 'Please connect to API first to get deployment URL', 'error');
return;
}
// Check if this device already has an active cookie connection
const deviceId = selectedDevice.guid || selectedDevice.id;
if (activeCookieProxyConnections.has(deviceId)) {
showStatus(connectionStatus, `Cookie proxy already running for device ${selectedDevice.name}`, 'warning');
return;
}
// Disable button during launch
startCookieProxyBtn.disabled = true;
showStatus(connectionStatus, `Starting cookie proxy for device ${selectedDevice.name}...`, 'info');
try {
const result = await window.electronAPI.launchCookieCameraProxy({
deploymentUrl: sessionData.deploymentUrl,
cookieKey: cookieKeyValue,
deviceUuid: deviceId
});
if (result.success) {
// Track this connection
activeCookieProxyConnections.set(deviceId, {
processId: result.processId,
deviceName: selectedDevice.name,
deviceId: deviceId,
startTime: Date.now(),
type: 'cookie'
});
updateCookieProxyButtonStates();
showStatus(connectionStatus, `${result.message} (Cookie proxy active for ${selectedDevice.name})`, 'success');
} else {
showStatus(connectionStatus, `Failed to start cookie proxy: ${result.message}`, 'error');
}
} catch (error) {
console.error('Cookie proxy launch error:', error);
showStatus(connectionStatus, `Error launching cookie proxy: ${error.message}`, 'error');
} finally {
startCookieProxyBtn.disabled = false;
}
}
// Handle stop cookie proxy
async function handleStopCookieProxy() {
if (activeCookieProxyConnections.size === 0) {
showStatus(connectionStatus, 'No active cookie proxy connections found', 'warning');
return;
}
showStatus(connectionStatus, 'Stopping cookie proxy connections...', 'info');
try {
const result = await window.electronAPI.stopCameraProxy(null); // Stop all processes
if (result.success) {
activeCookieProxyConnections.clear();
showStatus(connectionStatus, 'Cookie proxy connections stopped successfully', 'success');
// Update visual indicators for all devices
const deviceItems = deviceList.querySelectorAll('.device-item');
deviceItems.forEach(item => {
item.classList.remove('cookie-proxy-active');
});
} else {
showStatus(connectionStatus, `Failed to stop cookie proxy: ${result.message}`, 'warning');
}
} catch (error) {
console.error('Stop cookie proxy error:', error);
showStatus(connectionStatus, 'Error stopping cookie proxy', 'error');
}
updateCookieProxyButtonStates();
}
// Update cookie proxy button states
function updateCookieProxyButtonStates() {
const hasSelectedDevice = selectedDevice !== null;
const hasCookieKey = cookieKey.value.trim().length > 0;
const hasActiveConnection = activeCookieProxyConnections.size > 0;
if (sessionData.isConnected && hasSelectedDevice && hasCookieKey) {
const deviceId = selectedDevice.guid || selectedDevice.id;
const isThisDeviceActive = activeCookieProxyConnections.has(deviceId);
startCookieProxyBtn.disabled = isThisDeviceActive;
stopCookieProxyBtn.disabled = !hasActiveConnection;
} else {
startCookieProxyBtn.disabled = true;
stopCookieProxyBtn.disabled = !hasActiveConnection;
}
}
// Update connection status indicator
function updateConnectionStatus(connected) {
const statusDot = statusIndicator.querySelector('.status-dot');
const statusText = statusIndicator.querySelector('.status-text');
if (connected) {
statusDot.className = 'status-dot online';
statusText.textContent = 'Connected';
} else {
statusDot.className = 'status-dot offline';
statusText.textContent = 'Disconnected';
}
}
// Update button states based on connection status
function updateButtonStates() {
if (sessionData.isConnected) {
disconnectBtn.disabled = false;
} else {
disconnectBtn.disabled = true;
startCookieProxyBtn.disabled = true;
stopCookieProxyBtn.disabled = true;
}
// Always update cookie proxy button states
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) {
showStatus(deviceStatus, 'Please connect to the API first', 'error');
return;
}
showStatus(deviceStatus, 'Fetching devices...', 'info');
clearDeviceList();
try {
// Fetch devices and sites in parallel
const [devicesResult] = await Promise.all([
window.electronAPI.getDevices({
deploymentUrl: sessionData.deploymentUrl,
cookies: sessionData.cookies
}),
fetchDeviceSites()
]);
if (devicesResult.success) {
// Filter devices to only show non-cloud cameras (localStorage = false)
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)
return device.capabilities.localStorage === false;
}
// If no capabilities or localStorage property, include the device (fallback)
return true;
});
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)`;
}
showStatus(deviceStatus, statusMessage, 'success');
// Store all devices for search functionality
allDevices = filteredDevices;
displayDevices(filteredDevices);
} else {
showStatus(deviceStatus, `Failed to get devices: ${devicesResult.message}`, 'error');
}
} catch (error) {
console.error('Get devices error:', error);
showStatus(deviceStatus, `Error getting devices: ${error.message}`, 'error');
}
}
// Helper function to determine device status from API data
function getDeviceStatus(device) {
// Check for live.display_status first (Alta API standard)
if (device.live && device.live.display_status) {
const status = device.live.display_status.toLowerCase();
// Handle color-based status responses from Alta API
if (status === 'green') {
return {
isOnline: true,
statusText: 'Online'
};
} else if (status === 'red') {
return {
isOnline: false,
statusText: 'Offline'
};
} else if (status === 'yellow' || status === 'orange') {
return {
isOnline: false,
statusText: 'Warning'
};
}
// Handle text-based status responses
return {
isOnline: status === 'online' || status === 'live' || status === 'connected',
statusText: status === 'online' || status === 'live' || status === 'connected' ? 'Online' : 'Offline'
};
}
// Fallback to other possible status fields
if (device.online !== undefined) {
return {
isOnline: device.online,
statusText: device.online ? 'Online' : 'Offline'
};
}
if (device.status) {
const status = device.status.toLowerCase();
// Handle color-based status in other fields
if (status === 'green') {
return {
isOnline: true,
statusText: 'Online'
};
} else if (status === 'red') {
return {
isOnline: false,
statusText: 'Offline'
};
}
return {
isOnline: status === 'online' || status === 'live' || status === 'connected',
statusText: status === 'online' || status === 'live' || status === 'connected' ? 'Online' : 'Offline'
};
}
// Default to offline if no status information available
return {
isOnline: false,
statusText: 'Offline'
};
}
// Handle device search
function handleDeviceSearch() {
const searchTerm = deviceSearch.value.toLowerCase().trim();
if (!searchTerm) {
// Show all devices if search is empty
displayDevices(allDevices);
return;
}
// 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) ||
siteName.includes(searchTerm);
});
displayDevices(filteredDevices);
}
// 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();
if (!devices || devices.length === 0) {
const searchTerm = deviceSearch.value.toLowerCase().trim();
const message = searchTerm ? 'No devices match your search' : 'No devices found';
deviceList.innerHTML = `<p class="no-devices">${message}</p>`;
return;
}
const hasSites = Object.keys(allSites).length > 0;
// If no sites were fetched, fall back to flat list
if (!hasSites) {
devices.forEach((device, index) => {
deviceList.appendChild(createDeviceItem(device, index));
});
updateCookieProxyButtonStates();
return;
}
const { groups, ungrouped } = groupDevicesBySite(devices);
// 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>
`;
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(siteHeader);
// Device items (hidden if collapsed)
if (!isCollapsed) {
group.devices.forEach(device => {
deviceList.appendChild(createDeviceItem(device, globalIndex++));
});
}
});
// 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();
}
// Handle device selection
function selectDevice(device, deviceElement) {
// Remove previous selection
const previousSelected = deviceList.querySelector('.device-item.selected');
if (previousSelected) {
previousSelected.classList.remove('selected');
}
// Select current device
deviceElement.classList.add('selected');
selectedDevice = device;
// Auto-populate cookie device UUID field
const uuid = device.guid || device.id || '';
cookieDeviceUUID.value = uuid;
// Show device selection feedback with connection status
if (uuid) {
const isCookieActive = activeCookieProxyConnections.has(uuid);
const cookieStatusText = isCookieActive ? ' (COOKIE PROXY ACTIVE)' : '';
showStatus(connectionStatus, `Selected device: ${device.name || 'Unnamed Device'} (UUID: ${uuid})${cookieStatusText}`, 'info');
}
updateCookieProxyButtonStates();
}
// Utility functions
function showStatus(element, message, type) {
element.textContent = message;
element.className = `status-message ${type}`;
element.style.display = 'block';
}
function clearDeviceList() {
deviceList.innerHTML = '<p class="placeholder-text">Connect to API to load devices</p>';
selectedDevice = null;
}
function escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Self-Update Functions ---
async function handleCheckForUpdates() {
checkUpdateBtn.disabled = true;
try {
const result = await window.electronAPI.checkForUpdates();
if (!result.success) {
showStatus(connectionStatus, result.message || 'Failed to check for updates', 'error');
return;
}
if (result.message === 'No releases available yet') {
showStatus(connectionStatus, 'No releases available yet', 'info');
return;
}
if (result.updateAvailable) {
showUpdateModal(result);
} else {
showStatus(connectionStatus, `You're on the latest version (v${result.currentVersion})`, 'success');
}
} catch (error) {
console.error('Check for updates error:', error);
showStatus(connectionStatus, 'Could not check for updates. Check your internet connection.', 'error');
} finally {
checkUpdateBtn.disabled = false;
}
}
function showUpdateModal(updateInfo) {
pendingUpdateInfo = updateInfo;
updateModalMessage.textContent = `A new version is available: v${updateInfo.latestVersion} (current: v${updateInfo.currentVersion})`;
updateModalNotes.textContent = updateInfo.releaseNotes || '';
// Reset progress state
updateProgressContainer.style.display = 'none';
updateProgressFill.style.width = '0%';
updateProgressText.textContent = '0%';
// Reset button states
updateInstallBtn.disabled = !updateInfo.downloadUrl;
updateLaterBtn.disabled = false;
updateInstallBtn.textContent = 'Install Update';
if (!updateInfo.downloadUrl) {
updateModalMessage.textContent += '\n(No downloadable asset found for this release)';
}
updateModalOverlay.style.display = 'flex';
}
async function handleInstallUpdate() {
if (!pendingUpdateInfo || !pendingUpdateInfo.downloadUrl) return;
// Disable controls during download
updateInstallBtn.disabled = true;
updateInstallBtn.textContent = 'Downloading...';
updateLaterBtn.disabled = true;
updateModalCloseBtn.style.display = 'none';
updateProgressContainer.style.display = 'flex';
try {
const result = await window.electronAPI.downloadAndInstallUpdate({
downloadUrl: pendingUpdateInfo.downloadUrl
});
if (result.success) {
updateInstallBtn.textContent = 'Restarting...';
updateProgressFill.style.width = '100%';
updateProgressText.textContent = '100%';
} else {
showStatus(connectionStatus, `Update failed: ${result.message}`, 'error');
closeUpdateModal();
}
} catch (error) {
console.error('Install update error:', error);
showStatus(connectionStatus, 'Update failed. Please try again.', 'error');
closeUpdateModal();
}
}
function closeUpdateModal() {
updateModalOverlay.style.display = 'none';
pendingUpdateInfo = null;
updateProgressContainer.style.display = 'none';
updateProgressFill.style.width = '0%';
updateProgressText.textContent = '0%';
updateInstallBtn.textContent = 'Install Update';
updateInstallBtn.disabled = false;
updateLaterBtn.disabled = false;
updateModalCloseBtn.style.display = '';
}
async function checkForUpdatesOnStartup() {
try {
const result = await window.electronAPI.checkForUpdates();
if (result.success && result.updateAvailable) {
checkUpdateBtn.classList.add('update-available');
checkUpdateBtn.title = `Update available: v${result.latestVersion}`;
showStatus(connectionStatus, `Update available: v${result.latestVersion}`, 'info');
// Store for quick modal open
pendingUpdateInfo = result;
}
} catch (error) {
// Silent fail on startup check
console.log('Startup update check failed:', error.message);
}
}
// Handle cookie received from Chrome extension via local HTTP bridge
async function handleExtensionCookie(data) {
const { deploymentUrl, cookies, cookieValue } = data;
// If already connected, disconnect first
if (sessionData.isConnected) {
handleDisconnect();
}
// Set session state from extension cookie
sessionData.deploymentUrl = deploymentUrl;
sessionData.cookies = cookies;
sessionData.isConnected = true;
showStatus(connectionStatus, `Connected via Chrome extension to ${deploymentUrl}`, 'success');
updateConnectionStatus(true);
updateButtonStates();
// Auto-populate cookie key
cookieKey.value = cookieValue;
updateCookieProxyButtonStates();
// Fetch devices
try {
await handleGetDevices();
} catch (err) {
console.error('Failed to fetch devices after extension cookie:', err);
showStatus(deviceStatus, 'Connected, but failed to load devices.', 'warning');
}
}
// Initialize the app
document.addEventListener('DOMContentLoaded', async () => {
console.log('Alta Video Camera Proxy loaded');
// Initialize connection status
updateConnectionStatus(false);
updateButtonStates();
// Listen for cookies pushed from Chrome extension
window.electronAPI.onExtensionCookie(handleExtensionCookie);
// Listen for update download progress
window.electronAPI.onUpdateDownloadProgress((data) => {
updateProgressFill.style.width = `${data.percent}%`;
updateProgressText.textContent = `${data.percent}%`;
});
// Auto-check for updates after 2 seconds
setTimeout(checkForUpdatesOnStartup, 2000);
});