// Data Loader - Handles loading JSON data files class DataLoader { static async loadApps() { try { const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); if (!response.ok) { throw new Error(`Failed to load apps: ${response.status}`); } const data = await response.json(); return data.apps; } catch (error) { console.error('Error loading apps data:', error); return []; } } static async loadCategories() { try { const response = await fetch('/data/apps/apps-categories.json'); if (!response.ok) { throw new Error(`Failed to load categories: ${response.status}`); } const data = await response.json(); return data.categories || []; } catch (error) { console.error('Error loading categories data:', error); return []; } } static async loadConfigCategories() { try { const response = await fetch('/data/apps/apps-config-categories.json'); if (!response.ok) { throw new Error(`Failed to load config categories: ${response.status}`); } const data = await response.json(); return data.categories || data; } catch (error) { console.error('Error loading config categories data:', error); return {}; } } static async isAppInstalled(appName) { try { const apps = await this.loadApps(); const app = apps.find(a => a.name === appName || a.id === appName); return app ? app.installed : false; } catch (error) { console.error(`Error checking if app ${appName} is installed:`, error); return false; } } static async getContainers() { try { // Local implementation - return empty array for now //console.log('Loading containers locally...'); // TODO: Implement local container detection // This would require backend integration to query Docker // Return empty array for now return []; } catch (error) { console.error('Error loading containers:', error); return []; } } static async loadSystemConfigs() { try { //console.log('Loading system configs'); // Load unified config try { const response = await fetch('/data/config/generated/configs.json'); if (response.ok) { const data = await response.json(); //console.log('✅ Loaded unified system config'); return data; } else { console.warn('Failed to load unified config:', response.status); throw new Error(`Failed to load unified config: ${response.status}`); } } catch (error) { console.error('Error loading unified config:', error); throw error; } } catch (error) { console.error('Error loading system configs:', error); return []; } } static async loadConfig(configType) { try { //console.log(`Loading ${configType} config`); const response = await fetch(`/data/config/generated/config_${configType}.json`); if (!response.ok) { throw new Error(`Failed to load ${configType} config: ${response.status}`); } const data = await response.json(); return data; } catch (error) { console.error(`Error loading ${configType} data:`, error); return null; } } } // Category helper functions let categories = []; function getCategoryIcon(categoryId) { const category = categories.find(cat => cat.id === categoryId); return category ? category.icon : '/icons/categories/misc.svg'; } function getCategoryName(categoryId) { const category = categories.find(cat => cat.id === categoryId); return category ? category.name : categoryId.charAt(0).toUpperCase() + categoryId.slice(1); } function getCategoryDescription(categoryId) { const category = categories.find(cat => cat.id === categoryId); return category ? category.description : ''; } // Global apps variable for backward compatibility let apps = []; // Global system configs variable let systemConfigs = []; // Initialize apps and categories data async function initializeData() { try { // Determine current page/route and load appropriate data const currentPath = window.location.pathname; const searchParams = new URLSearchParams(window.location.search); if (currentPath === '/dashboard' || currentPath === '/') { // Dashboard: Only installed apps + system info (no categories needed) await loadDashboardData(); } else if (currentPath.startsWith('/apps') || currentPath.startsWith('/app') || searchParams.has('apps') || searchParams.has('app')) { // Apps page: All apps + categories (no system configs needed) await loadAppsPageData(); } else if (currentPath.startsWith('/admin') || currentPath.startsWith('/config') || currentPath.startsWith('/ssh') || searchParams.has('config')) { // Admin area (config + SSH): system configs + apps + categories await loadConfigDetailData(); } else { // Default: Load all data for SPA await loadAppsPageData(); } } catch (error) { console.error('Error initializing data:', error); } } // Dashboard update tracking let lastDashboardUpdate = null; let lastInstalledCount = 0; let lastSystemDataHash = null; // Dashboard-specific data loading async function loadDashboardData() { // console.log('🔄 Loading dashboard data...'); // Load all apps with installation status const allApps = await DataLoader.loadApps(); apps = allApps; // All apps, not just installed categories = []; // Dashboard doesn't need categories systemConfigs = []; // Dashboard doesn't need system configs // Calculate installed apps count const installedCount = allApps.filter(app => app.installed === true || app.installed === 'true').length; // Check for updates const hasNewApps = installedCount !== lastInstalledCount; const currentTime = new Date(); // Load system info for dashboard display (this waits for DOM elements) await loadSystemInfo(); // Update installed count and show update indicator const installedCountEl = document.getElementById('installed-count'); if (installedCountEl) { const oldValue = installedCountEl.textContent; installedCountEl.textContent = installedCount; // Don't show notification for automatic updates - only manual reloads // if (hasNewApps) { // showUpdateIndicator(`New apps detected: ${installedCount - lastInstalledCount > 0 ? '+' + (installedCount - lastInstalledCount) : 'Updated'}`); // } } else { console.warn('⚠️ installed-count element not found'); } // Update last update timestamp updateLastUpdateTime(currentTime); // Store current values for next comparison lastInstalledCount = installedCount; lastDashboardUpdate = currentTime; // Start countdown to next automatic update startUpdateCountdown(); // Show/refresh the "out of date" banner on the dashboard. window.updateNotifier?.renderDashboardBanner(); // console.log('✅ Dashboard data loaded successfully'); } // Countdown timer for next automatic update let updateCountdownInterval = null; function startUpdateCountdown() { // Clear any existing countdown if (updateCountdownInterval) { clearInterval(updateCountdownInterval); } // Use existing countdown element from HTML const countdownEl = document.getElementById('update-countdown'); if (!countdownEl) { console.warn('Update countdown element not found in HTML'); return; } // Update countdown every second updateCountdownInterval = setInterval(() => { const now = new Date(); const nextUpdate = new Date(lastDashboardUpdate.getTime() + 60000); // 60 seconds from last update const timeLeft = Math.max(0, Math.floor((nextUpdate - now) / 1000)); if (timeLeft > 0) { countdownEl.textContent = `Next update: ${timeLeft}s`; } else { countdownEl.textContent = 'Updating...'; // Actually update the dashboard data updateDashboardData().then(() => { // After update completes, reset the last update time and restart countdown lastDashboardUpdate = new Date(); startUpdateCountdown(); // Restart the countdown }); } }, 1000); } // Actually update dashboard data elements async function updateDashboardData() { try { console.log('🔄 Auto-updating dashboard data...'); // Directly update system info (simpler than full dashboard reload) await loadSystemInfo(); // Reload apps to get updated installed count const allApps = await DataLoader.loadApps(); apps = allApps; // Update installed count const installedCount = allApps.filter(app => app.installed === true || app.installed === 'true').length; const installedCountEl = document.getElementById('installed-count'); if (installedCountEl) { installedCountEl.textContent = installedCount; } console.log('✅ Dashboard data updated successfully'); } catch (error) { console.error('❌ Auto-update failed:', error); } } // Update last update timestamp display (simplified - just for tracking) function updateLastUpdateTime(time) { // No timestamp display - just store the time for countdown } // Calculate time ago string function getTimeAgo(date) { const now = new Date(); const diff = Math.floor((now - date) / 1000); // seconds if (diff < 60) return 'just now'; if (diff < 3600) return `${Math.floor(diff / 60)} min ago`; if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; return `${Math.floor(diff / 86400)} days ago`; } // Apps page data loading async function loadAppsPageData() { //console.log('Loading apps page data (all apps + categories)...'); // Load apps and categories in parallel try { const [appsData, sidebarCategoriesData, configCategoriesData] = await Promise.all([ DataLoader.loadApps(), DataLoader.loadCategories(), DataLoader.loadConfigCategories() ]); //console.log('🔍 DataLoader Results:'); //console.log(' - appsData length:', appsData?.length || 0); //console.log(' - sidebarCategoriesData type:', typeof sidebarCategoriesData); //console.log(' - sidebarCategoriesData keys:', Object.keys(sidebarCategoriesData || {})); //console.log(' - configCategoriesData type:', typeof configCategoriesData); //console.log(' - configCategoriesData keys:', Object.keys(configCategoriesData || {})); apps = appsData; categories = sidebarCategoriesData; // For sidebar systemConfigs = []; // Apps page doesn't need system configs //console.log(`Apps page loaded: ${apps.length} apps, ${categories.length} categories`); ////console.log('Apps array:', apps); ////console.log('Categories array:', categories); // Trigger custom event for data loaded window.dispatchEvent(new CustomEvent('dataLoaded', { detail: { apps, categories: categories, // For sidebar sidebarCategories: categories, // Explicit sidebar categories configCategories: configCategoriesData, // For config tabs systemConfigs } })); } catch (error) { console.error('❌ Promise.all failed:', error); // Fallback: try loading individually try { const appsData = await DataLoader.loadApps(); const sidebarCategoriesData = await DataLoader.loadCategories(); apps = appsData; categories = sidebarCategoriesData; systemConfigs = []; // Trigger custom event for data loaded (fallback) window.dispatchEvent(new CustomEvent('dataLoaded', { detail: { apps, categories: categories, // For sidebar sidebarCategories: categories, // Explicit sidebar categories configCategories: {}, // Empty for fallback systemConfigs } })); } catch (individualError) { console.error('❌ Individual loading failed:', individualError); apps = []; categories = {}; systemConfigs = []; } } } // Config detail page data loading async function loadConfigDetailData() { // Load everything needed for config management const [appsData, categoriesData, systemConfigsData] = await Promise.all([ DataLoader.loadApps(), DataLoader.loadCategories(), DataLoader.loadSystemConfigs() ]); apps = appsData; categories = categoriesData; systemConfigs = systemConfigsData; ////console.log(`Config detail loaded: ${apps.length} apps, ${categories.length} categories, ${systemConfigs.length} system configs`); } // Load system information for dashboard async function loadSystemInfo() { try { //console.log('Loading system data'); // Add timestamp to prevent browser caching const timestamp = Date.now(); // Load system info from files generated by LibrePortal commands const systemResponse = await fetch(`/data/system/system_info.json?t=${timestamp}`); const diskResponse = await fetch(`/data/system/disk_usage.json?t=${timestamp}`); const memoryResponse = await fetch(`/data/system/memory_usage.json?t=${timestamp}`); if (!systemResponse.ok || !diskResponse.ok || !memoryResponse.ok) { throw new Error(`Failed to load system files: system=${systemResponse.status}, disk=${diskResponse.status}, memory=${memoryResponse.status}`); } const systemData = await systemResponse.json(); const diskData = await diskResponse.json(); const memoryData = await memoryResponse.json(); //console.log('System data:', systemData); //console.log('Disk data:', diskData); //console.log('Memory data:', memoryData); // Prepare system data const osInfo = systemData.os || 'Unknown'; const cleanOs = osInfo.includes('Ubuntu') ? 'Linux Ubuntu' : osInfo.includes('Debian') ? 'Linux Debian' : osInfo.includes('CentOS') ? 'Linux CentOS' : osInfo.split(' ')[0]; // Take first part like "Linux" const diskChartData = diskData.root || diskData; //console.log('Disk chart data:', diskChartData); // Wait for all dashboard elements to be available await waitForDashboardElements(); // Now update all elements const systemInfoEl = document.getElementById('system-info'); const uptimeEl = document.getElementById('uptime-info'); const memoryEl = document.getElementById('memory-info'); if (systemInfoEl) { systemInfoEl.textContent = cleanOs; // console.log('✅ Updated OS:', cleanOs); } else { console.warn('⚠️ system-info element not found'); } if (uptimeEl) { uptimeEl.textContent = systemData.uptime || 'Unknown'; // console.log('✅ Updated uptime:', systemData.uptime); } else { console.warn('⚠️ uptime-info element not found'); } // Update memory info - use the text field which should be properly formatted if (memoryEl) { memoryEl.textContent = memoryData.text || 'Unknown'; // console.log('✅ Updated memory:', memoryData.text); } else { console.warn('⚠️ memory-info element not found'); } // Update disk usage chart. disk_usage.json reports df's 1K blocks, so // normalise to bytes — the donut compares against byte-valued app/docker // totals. (LiveSystem already emits bytes.) updateDiskChart({ used: (diskChartData.used || 0) * 1024, total: (diskChartData.total || 0) * 1024 }); loadStorageBreakdown(); // Make the disk card open the full Storage breakdown. onclick (not // addEventListener) so a dashboard re-mount can't stack duplicate handlers. const diskCardEl = document.getElementById('disk-stat-card'); if (diskCardEl) { const goStorage = () => window.navigateToRoute && window.navigateToRoute('/admin/system/storage'); diskCardEl.onclick = goStorage; diskCardEl.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goStorage(); } }; } // Attach the 1 Hz live stream so the headline values tick like an // instrument. The static fetch above gave us a complete first paint; the // stream takes over for tactile updates without a page refresh. attachDashboardLive(); } catch (error) { console.error('Error loading system info:', error); // Fallback values const systemInfoEl = document.getElementById('system-info'); const uptimeEl = document.getElementById('uptime-info'); const memoryEl = document.getElementById('memory-info'); if (systemInfoEl) systemInfoEl.textContent = 'Linux'; if (uptimeEl) uptimeEl.textContent = 'Unknown'; if (memoryEl) memoryEl.textContent = 'Unknown'; // Mock disk usage for demo - wait for DOM elements waitForDashboardElements().then(() => { updateDiskChart({ used: 50, total: 100 }); }); } } // Wire the dashboard's headline values to the 1 Hz live SSE stream. Idempotent // — repeated calls swap the previous subscription for a fresh one so a SPA // re-mount of the dashboard doesn't double up. We listen via LiveSystem (a // singleton EventSource manager), so adding this subscription is essentially // free even with the Admin → System page open in another tab — same backend // ticker feeds both. Cleanup hangs off a route-change check: if the dashboard // DOM goes away we drop the sub on the next sample. let _dashboardLiveUnsub = null; function attachDashboardLive() { if (!window.LiveSystem) return; if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; } _dashboardLiveUnsub = window.LiveSystem.subscribe((s) => { const memoryEl = document.getElementById('memory-info'); const diskCard = document.getElementById('disk-donut'); if (!memoryEl && !diskCard) { // Dashboard isn't on screen anymore — release the sub. if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; } return; } if (memoryEl && s && s.memory && Number.isFinite(s.memory.total) && s.memory.total > 0) { const usedGb = (s.memory.used / 1073741824).toFixed(2); const totalGb = (s.memory.total / 1073741824).toFixed(2); const pct = (s.memory.percent ?? 0).toFixed(1); const next = `${usedGb} GB / ${totalGb} GB - ${pct}%`; if (memoryEl.textContent !== next) memoryEl.textContent = next; } if (diskCard && Array.isArray(s?.disks) && s.disks.length) { const root = s.disks.find((d) => d.mount === '/') || s.disks[0]; if (root && Number.isFinite(root.total) && root.total > 0) { updateDiskChart({ used: root.used, total: root.total }); } } }); } // Wait for dashboard elements to be available function waitForDashboardElements() { return new Promise((resolve) => { const checkElements = () => { const donutEl = document.getElementById('disk-donut'); const percentText = document.getElementById('disk-percent'); const systemInfoEl = document.getElementById('system-info'); const uptimeEl = document.getElementById('uptime-info'); const memoryEl = document.getElementById('memory-info'); const installedCountEl = document.getElementById('installed-count'); if (donutEl && percentText && systemInfoEl && uptimeEl && memoryEl && installedCountEl) { resolve(); } else { // Check again after 100ms setTimeout(checkElements, 100); } }; checkElements(); }); } // Per-app + Docker storage totals (bytes) for the disk donut breakdown. Fetched // separately from the live disk used/total because they come from different // sources (the du-based generator + docker df) and change slowly. let _diskBreakdown = { apps: 0, docker: 0 }; let _lastDisk = null; // { used, total } in bytes, last value we drew function _fmtBytes(n) { n = Number(n) || 0; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; } // Hand-rolled SVG donut. Segments: [{ color, value }]; the ring fills // proportionally and any remainder shows as the track. function _diskDonutSvg(segments) { const total = segments.reduce((t, s) => t + (s.value || 0), 0) || 1; const r = 42, circ = 2 * Math.PI * r, sw = 13; let acc = 0; const arcs = segments.map((s) => { const frac = (s.value || 0) / total; const len = circ * frac; const off = circ * (1 - acc); acc += frac; return len > 0 ? `` : ''; }).join(''); return ``; } // Fetch the storage breakdown once and redraw the donut with it. async function loadStorageBreakdown() { try { const [app, dock] = await Promise.all([ fetch(`/data/system/app_storage.json?t=${Date.now()}`).then((r) => (r.ok ? r.json() : null)).catch(() => null), fetch('/api/system/storage').then((r) => (r.ok ? r.json() : null)).catch(() => null), ]); _diskBreakdown = { apps: (app && Number(app.total_local)) || 0, // on-disk app data (root device) docker: (dock && Number(dock.total)) || 0, // images + build cache }; if (_lastDisk) updateDiskChart(_lastDisk); } catch (_) { /* leave the donut as a plain used/free split */ } } // Draw the disk donut: a slice for Apps, Docker, Other (the rest of used) and // Free, with disk % used in the centre. used/total are bytes. function updateDiskChart(data) { const donutEl = document.getElementById('disk-donut'); const percentText = document.getElementById('disk-percent'); if (!donutEl || !percentText) return; // dashboard not on screen yet let used, total; if (data && data.root) { used = data.root.used; total = data.root.total; } else if (data) { used = data.used; total = data.total; } used = Number(used) || 0; total = Number(total) || 0; if (total <= 0) return; _lastDisk = { used, total }; const percentage = Math.round((used / total) * 100); percentText.textContent = `${percentage}%`; percentText.style.color = percentage > 90 ? 'var(--status-danger)' : percentage > 75 ? 'var(--status-warning)' : ''; // Apps and Docker are subsets of "used"; clamp so rounding/measurement skew // can never make the slices exceed what's actually used. const apps = Math.max(0, Math.min(_diskBreakdown.apps, used)); const docker = Math.max(0, Math.min(_diskBreakdown.docker, used - apps)); const other = Math.max(0, used - apps - docker); const free = Math.max(0, total - used); donutEl.innerHTML = _diskDonutSvg([ { color: 'var(--accent)', value: apps }, { color: 'var(--status-info)', value: docker }, { color: 'rgba(var(--text-rgb), 0.35)', value: other }, { color: 'rgba(var(--text-rgb), 0.12)', value: free }, ]); const legendEl = document.getElementById('disk-legend'); if (legendEl) { const row = (cls, label, val) => `
${label}${_fmtBytes(val)}
`; legendEl.innerHTML = row('apps', 'Apps', apps) + row('docker', 'Docker', docker) + row('other', 'Other', other) + row('free', 'Free', free); } } // Minimal data loading (fallback) async function loadMinimalData() { //console.log('Loading minimal data...'); apps = []; categories = []; systemConfigs = []; } // Preload essential data for smooth navigation async function preloadOtherPagesData(currentPage) { //console.log('Preloading essential data for navigation...'); try { // No preloading needed - data loads fresh each page //console.log('Preloading complete (no caching)'); } catch (error) { console.error('Error preloading navigation data:', error); } } // Data initialization is now handled by SystemLoader // initializeData() will be called centrally