librelad 9f7ad8f177 feat(system): live 1 Hz SSE stream behind admin gauges + dashboard tile
Adds /api/system/stream — a Server-Sent Events feed driven by a single
per-process ticker that reads /proc directly and splices in the latest
host-side metrics.json each second. Subscribers share the connection so
N open tabs cost one ticker, and the ticker pauses entirely when nobody
is listening.

Frontend gets a singleton LiveSystem EventSource manager with auto-
reconnect, Page-Visibility integration (closes on tab hide), and last-
sample replay for late subscribers. Admin -> System gauges and the
dashboard memory + disk tile now tick at 1 Hz; trend charts and the
per-app table keep their 30 s poll because the underlying files only
regenerate once a minute.

Also adds /api/system/history as a thin range-query wrapper over the
existing 24 h JSON ring buffer — the binary ring backend will slot in
behind it in the next phase without changing the response shape.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 20:17:58 +01:00

599 lines
20 KiB
JavaScript
Executable File

// 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
updateDiskChart(diskChartData);
// 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-circle-fill');
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 circleFill = document.getElementById('disk-circle-fill');
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 (circleFill && percentText && systemInfoEl && uptimeEl && memoryEl && installedCountEl) {
resolve();
} else {
// Check again after 100ms
setTimeout(checkElements, 100);
}
};
checkElements();
});
}
// Update disk usage circle chart
function updateDiskChart(data) {
//console.log('updateDiskChart called with:', data);
const circleFill = document.getElementById('disk-circle-fill');
const percentText = document.getElementById('disk-percent');
if (!circleFill || !percentText) {
// Silently fail if elements don't exist yet - they may load later
return;
}
// Extract used and total from data structure
let used, total;
if (data.root) {
used = data.root.used;
total = data.root.total;
} else {
used = data.used;
total = data.total;
}
// Calculate percentage
const percentage = Math.round((used / total) * 100);
//console.log(`Disk usage: ${used}/${total} = ${percentage}%`);
// Update percentage text (only inside circle)
if (percentText) percentText.textContent = `${percentage}%`;
// Set circle fill height based on percentage (fills from bottom)
circleFill.style.height = `${Math.max(percentage, 5)}%`; // Minimum 5% for visibility
// Color based on usage
let fillColor;
if (percentage > 90) {
fillColor = '#dc3545'; // Red
} else if (percentage > 75) {
fillColor = '#ffc107'; // Yellow
} else {
fillColor = '#28a745'; // Green
}
circleFill.style.background = `linear-gradient(to top, ${fillColor} 0%, ${fillColor} 100%)`;
}
// 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