librelad d5fe1bc56b feat(webui): out-of-date detection + one-click update
Surface when LibrePortal is behind upstream and let users update from the
WebUI, reusing the proven git-update path instead of reinventing it.

Detection (host): webuiSystemUpdateCheck writes
frontend/data/system/update_status.json from a throttled git fetch +
behind-count + VERSION compare, off the existing per-minute
`webui generate system` cron. A new /VERSION file is the canonical version.

Display (frontend): update-notifier.js/.css render a global topbar badge
(every page) and a dashboard banner (prominent when behind, subtle "up to
date" with a manual check otherwise), plus a details panel.

Actions go through the task pipeline:
- `libreportal update apply` -> webuiRunUpdate (non-interactive: guards,
  forced check, gitPerformUpdate, then dockerInstallApp libreportal)
- `libreportal update check` -> forced recheck

gitFolderResetAndBackup's body is extracted into gitPerformUpdate (no exit)
so the WebUI path can reuse it; the interactive CLI flow is unchanged.

Detection JSON verified against the repo (up-to-date and behind cases).
webuiRunUpdate's re-clone + redeploy still needs validation on a live host.

The latest-version source is git for now and is the single swap point for
get.libreportal.org later — the JSON contract and frontend stay unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 23:33:43 +01:00

1344 lines
46 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// System Loader - Centralized loading and health check system for LibrePortal
class SystemLoader {
constructor() {
this.systems = new Map();
this.checks = new Map();
this.components = new Map();
this.progress = 0;
this.totalChecks = 0;
this.completedChecks = 0;
this.errors = [];
this.isFirstTime = false;
this.callbacks = {
onProgress: [],
onSystemCheck: [],
onComplete: [],
onError: []
};
this.initializeSystems();
this.initializeHealthChecks();
// console.log('🚨 BREAKPOINT: About to call initializeComponents()');
this.initializeComponentRegistry();
// console.log('BREAKPOINT: initializeComponents() completed');
}
// Initialize component registry
initializeComponentRegistry() {
// Config Manager - Essential for config pages
this.components.set('config-manager', {
system: 'managers',
initializer: () => {
if (typeof ConfigManager !== 'undefined') {
window.configManager = new ConfigManager();
return window.configManager;
}
return null;
},
global: 'configManager',
dependencies: ['data'],
scripts: [
'/js/components/config/config-options.js',
'/js/components/config/config-shared.js',
'/js/components/config/config-validator.js',
'/js/components/config/toggle-manager.js',
'/js/components/config/config-core.js',
'/js/components/config/domain-manager.js',
'/js/components/config/ip-whitelist-manager.js',
'/js/components/config/config-renderer.js',
'/js/components/config/config-sidebar.js',
'/js/components/config/config-form.js',
'/js/components/config/config-utils.js',
'/js/components/config/config-manager.js'
]
});
// Mobile Menu
this.components.set('mobile-menu', {
system: 'components',
initializer: () => {
if (typeof setupMobileMenu === 'function') {
setupMobileMenu();
// console.log(' Mobile menu initialized');
return true;
}
console.warn(' setupMobileMenu not available');
return null;
},
global: null,
dependencies: [],
scripts: [
'/js/components/topbar.js',
'/js/components/update-notifier.js',
'/js/components/mobile-menu.js'
]
});
// Confirmation Dialog
this.components.set('confirmation-dialog', {
system: 'components',
initializer: () => {
if (typeof initConfirmationDialog === 'function') {
initConfirmationDialog();
// console.log(' Confirmation dialog initialized');
return true;
}
console.warn(' initConfirmationDialog not available');
return null;
},
global: null,
dependencies: [],
script: '/js/components/confirmation-dialog.js'
});
// Notifications
this.components.set('notifications', {
system: 'components',
initializer: () => {
if (typeof NotificationSystem !== 'undefined') {
window.notificationSystem = new NotificationSystem();
// console.log(' Notification system initialized');
return window.notificationSystem;
}
console.warn(' NotificationSystem not available');
return null;
},
global: 'notificationSystem',
dependencies: [],
script: '/js/components/notifications.js'
});
// Dashboard
this.components.set('dashboard', {
system: 'core',
initializer: () => {
// Initialize data loading system
if (typeof initializeData === 'function') {
initializeData();
// console.log(' Data loading system initialized');
}
// Initialize dashboard-specific functions
if (typeof loadSystemInfo === 'function') {
loadSystemInfo();
// console.log(' System info loaded');
}
if (typeof setupEventListeners === 'function') {
setupEventListeners();
// console.log(' Dashboard event listeners set up');
}
return true;
},
global: null,
dependencies: []
// data-loader.js is now loaded in core HTML
});
// Task System - Load all task-related scripts together
this.components.set('task-system', {
system: 'task',
initializer: () => {
// console.log('🔧 Initializing Task System...');
// Open the SSE feed before anything else so we don't miss the upserts
// that fire as the page boots.
if (window.taskEventBus && typeof window.taskEventBus.start === 'function') {
window.taskEventBus.start();
}
// Initialize task global functions
if (typeof setupTaskGlobalFunctions === 'function') {
setupTaskGlobalFunctions();
}
// Create TasksManager instance
if (typeof TasksManager !== 'undefined') {
window.tasksManager = new TasksManager();
// console.log('✅ TasksManager initialized and available globally');
return window.tasksManager;
}
console.warn('⚠️ TasksManager not available');
return null;
},
global: 'tasksManager',
dependencies: [],
scripts: [
'/js/components/task/task-event-bus.js',
'/js/components/task/task-commands.js',
'/js/components/task/task-actions.js',
'/js/components/task/task-router.js',
'/js/components/task/task-global-functions.js',
'/js/components/task/task-manager.js',
'/js/task-parameter-preserve.js',
'/js/components/tasks/tasks-manager.js'
]
});
// Apps Manager - Essential for app management
this.components.set('apps-manager', {
system: 'managers',
initializer: () => {
// console.log('🔧 DEBUG: AppsManager initializer called');
// console.log('🔧 DEBUG: AppsManager class available:', typeof AppsManager !== 'undefined');
// console.log('🔧 DEBUG: Available globals:', Object.keys(window).filter(key => key.includes('Manager')));
if (typeof AppsManager !== 'undefined') {
// console.log('🔧 DEBUG: Creating AppsManager instance...');
try {
window.appsManager = new AppsManager();
// console.log('✅ AppsManager instance created successfully');
return window.appsManager;
} catch (error) {
console.error('❌ Failed to create AppsManager instance:', error);
return null;
}
} else {
console.error('❌ AppsManager class not available after script loading');
return null;
}
},
global: 'appsManager',
dependencies: ['data'],
scripts: [
'/js/components/app/port-manager.js',
'/js/components/task/task-manager.js', // Add TaskManager for backup functionality
'/js/components/backup/backup-app-card.js',
'/js/components/app/services-manager.js',
'/js/components/app/tools-manager.js',
'/js/components/app/routing-manager.js',
'/js/components/app/apps-manager.js'
]
});
// App Tabbed Manager - Essential for app detail pages
this.components.set('app-tabbed-manager', {
system: 'managers',
initializer: () => {
// console.log('🔧 DEBUG: Attempting to initialize AppTabbedManager...');
// console.log('🔧 DEBUG: AppTabbedManager class available:', typeof AppTabbedManager !== 'undefined');
// console.log('🔧 DEBUG: Available globals:', Object.keys(window).filter(key => key.includes('Tabbed') || key.includes('Manager')));
if (typeof AppTabbedManager !== 'undefined') {
window.appTabbedManager = new AppTabbedManager();
// console.log('✅ App Tabbed Manager initialized');
return window.appTabbedManager;
}
console.warn('⚠️ AppTabbedManager not available');
return null;
},
global: 'appTabbedManager',
dependencies: [],
script: '/js/components/app/app-tabbed-manager.js'
});
// console.log('TEST: Components added. Total components:', this.components.size);
// console.log('TEST: Method completed');
}
// Initialize all system definitions
initializeSystems() {
this.systems.set('core', {
name: 'Core System',
description: 'Essential system components',
priority: 1,
dependencies: [],
checks: ['dom', 'spa', 'utils']
});
// console.log('🔍 Core System checks:', ['dom', 'spa', 'utils']);
this.systems.set('data', {
name: 'Data Functions',
description: 'Data loading functions availability',
priority: 2,
dependencies: ['core'],
checks: []
});
// console.log('🔍 Data Functions checks:', []);
this.systems.set('components', {
name: 'UI Components',
description: 'User interface components',
priority: 3,
dependencies: ['core'],
checks: ['topbar', 'confirmation-dialog', 'notifications', 'icon-preloading']
});
this.systems.set('task', {
name: 'Task System',
description: 'Task management and operations',
priority: 4,
dependencies: ['core'],
checks: ['task-manager']
});
this.systems.set('managers', {
name: 'System Managers',
description: 'Application managers and services',
priority: 5,
dependencies: ['core', 'task'],
checks: ['apps-manager', 'config-manager', 'app-tabbed-manager']
});
this.systems.set('backend', {
name: 'Backend Services',
description: 'External service connectivity',
priority: 6,
dependencies: ['core'],
checks: ['server-connectivity', 'api-endpoints']
});
this.systems.set('config-validation', {
name: 'Config Files',
description: 'Configuration files validation',
priority: 7,
dependencies: ['core', 'task'],
checks: ['config-files'],
critical: true
});
this.systems.set('update-lock', {
name: 'Update Lock Check',
description: 'Check for update lock file to prevent concurrent updates',
priority: 8,
dependencies: ['core'],
checks: ['update-lock'],
critical: false,
check: async () => this.checkUpdateLock()
});
this.systems.set('pause-lock', {
name: 'Pause Lock Check',
description: 'Check for pause lock file to prevent operations during pause',
priority: 8.1,
dependencies: ['core'],
checks: ['pause-lock'],
critical: false,
check: async () => this.checkPauseLock()
});
}
// Initialize health check functions
initializeHealthChecks() {
// Core checks
this.checks.set('dom', {
name: 'DOM Ready',
description: 'Document Object Model loaded',
weight: 1,
timeout: 5000,
check: () => document.readyState === 'complete' || document.readyState === 'interactive'
});
this.checks.set('spa', {
name: 'SPA Router',
description: 'Single Page Application router',
weight: 2,
timeout: 5000,
check: () => typeof LibrePortalSPAClean !== 'undefined'
});
this.checks.set('utils', {
name: 'Utility Functions',
description: 'Helper utilities and tools',
weight: 1,
timeout: 5000,
check: async () => {
// Wait a moment for scripts to load
await new Promise(resolve => setTimeout(resolve, 500));
// Check for essential utilities that should be loaded statically
const hasSafeGetElement = typeof safeGetElement === 'function';
const hasSafeQuerySelector = typeof safeQuerySelector === 'function';
const hasGetAppIcon = typeof getAppIcon === 'function';
// Check for utilities that will be loaded dynamically
const hasDataLoader = typeof DataLoader !== 'undefined';
const hasInitializeData = typeof initializeData === 'function';
// console.log('🔍 Utils Check:', {
//hasDataLoader,
//hasSafeGetElement,
//hasSafeQuerySelector,
//hasGetAppIcon,
//hasSetupThemeToggle,
//hasInitializeData,
// Check what's actually available globally
//availableGlobals: Object.keys(window).filter(key =>
//key.includes('safe') ||
//key.includes('get') ||
//key.includes('Data') ||
//key.includes('Theme') ||
//key.includes('initialize')
//).slice(0, 10)
//});
// More flexible check - require at least 2 of the essential utilities
// Data loader and theme toggle will be loaded dynamically, so don't require them here
const essentialUtilitiesCount = [hasSafeGetElement, hasSafeQuerySelector, hasGetAppIcon].filter(Boolean).length;
return essentialUtilitiesCount >= 2;
}
});
// Data Loader health check
this.checks.set('data-loader', {
name: 'Data Loader',
description: 'Data loading and management system',
weight: 2,
timeout: 5000,
check: async () => {
// Wait a moment for data-loader to load
await new Promise(resolve => setTimeout(resolve, 1000));
const hasDataLoader = typeof DataLoader !== 'undefined';
const hasInitializeData = typeof initializeData === 'function';
const hasLoadDashboardData = typeof loadDashboardData === 'function';
const hasLoadAppsPageData = typeof loadAppsPageData === 'function';
const hasLoadConfigDetailData = typeof loadConfigDetailData === 'function';
const hasLoadMinimalData = typeof loadMinimalData === 'function';
// console.log('🔍 Data Loader Check:', {
//hasDataLoader,
//hasInitializeData,
//hasLoadDashboardData,
//hasLoadAppsPageData,
//hasLoadConfigDetailData,
//hasLoadMinimalData,
//availableDataFunctions: Object.keys(window).filter(key =>
//key.includes('load') ||
//key.includes('Data') ||
//key.includes('initialize')
//).slice(0, 10)
//});
// Check if data loader functions are available
return hasInitializeData && (hasLoadDashboardData || hasLoadAppsPageData || hasLoadConfigDetailData);
}
});
// Note: Individual data checks (apps-data, categories-data, config-data) removed
// They are now handled by the comprehensive config-files check above
// Component checks - always pass since they're loaded dynamically
this.checks.set('topbar', {
name: 'Topbar Component',
description: 'Navigation topbar',
weight: 2,
timeout: 1000, // Reduced timeout since we're not actually checking
check: async () => {
// console.log('🔍 Topbar Check: Will be loaded dynamically during component initialization');
// Always pass - TopbarComponent will be loaded dynamically by mobile-menu component
return true;
}
});
this.checks.set('confirmation-dialog', {
name: 'Confirmation Dialog',
description: 'User confirmation dialog system',
weight: 1,
timeout: 1000,
check: async () => {
// console.log('🔍 Confirmation Dialog Check: Will be loaded dynamically during component initialization');
return true;
}
});
this.checks.set('notifications', {
name: 'Notification System',
description: 'User notification system',
weight: 1,
timeout: 1000, // Reduced timeout since we're not actually checking
check: async () => {
// console.log('🔍 Notification System Check: Will be loaded dynamically during component initialization');
// Always pass - NotificationSystem will be loaded dynamically by notifications component
return true;
}
});
this.checks.set('icon-preloading', {
name: 'Icon Preloading',
description: 'Preload app center icons for faster loading',
weight: 2,
timeout: 15000,
check: async () => this.checkIconPreloading()
});
// Manager checks - always pass since they're loaded dynamically
this.checks.set('apps-manager', {
name: 'Apps Manager',
description: 'Application management system',
weight: 3,
timeout: 1000, // Reduced timeout since we're not actually checking
check: async () => {
// console.log('🔍 Apps Manager Check: Will be loaded dynamically during component initialization');
// Always pass - AppsManager will be loaded dynamically by apps-manager component
return true;
}
});
this.checks.set('task-manager', {
name: 'Task Manager',
description: 'Background task management',
weight: 3,
timeout: 1000, // Reduced timeout since we're not actually checking
check: async () => {
// console.log('🔍 Task Manager Check: Will be loaded dynamically during component initialization');
// Always pass - TasksManager will be loaded dynamically by task-manager component
return true;
}
});
this.checks.set('config-manager', {
name: 'Configuration Manager',
description: 'System configuration management',
weight: 2,
timeout: 1000, // Reduced timeout since we're not actually checking
check: async () => {
// console.log('🔍 Config Manager Check: Will be loaded dynamically during component initialization');
// Always pass - ConfigManager will be loaded dynamically by config-manager component
return true;
}
});
this.checks.set('app-tabbed-manager', {
name: 'App Tabbed Manager',
description: 'Application tabbed interface management',
weight: 3,
timeout: 1000, // Reduced timeout since we're not actually checking
check: async () => {
// console.log('🔍 App Tabbed Manager Check: Will be loaded dynamically during component initialization');
// Always pass - AppTabbedManager will be loaded dynamically by app-tabbed-manager component
return true;
}
});
// Backend checks
this.checks.set('server-connectivity', {
name: 'Server Connectivity',
description: 'Basic server connectivity',
weight: 2,
timeout: 8000,
check: async () => {
try {
const response = await fetch('/', { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}
});
this.checks.set('api-endpoints', {
name: 'API Endpoints',
description: 'Backend API availability',
weight: 3,
timeout: 10000,
check: async () => {
const endpoints = [
'/data/apps/generated/apps.json',
'/data/apps/apps-categories.json',
'/data/config/generated/configs.json'
];
try {
const results = await Promise.allSettled(
endpoints.map(endpoint => fetch(endpoint, { method: 'HEAD' }))
);
return results.some(result =>
result.status === 'fulfilled' && result.value.ok
);
} catch {
return false;
}
}
});
// Update Lock Check
this.checks.set('update-lock', {
name: 'Update Lock',
description: 'Check for update lock file to prevent concurrent updates',
weight: 2,
timeout: 10000,
check: async () => this.checkUpdateLock()
});
// Pause Lock Check
this.checks.set('pause-lock', {
name: 'Pause Lock',
description: 'Check for pause lock file to prevent operations during pause',
weight: 2,
timeout: 10000,
check: async () => this.checkPauseLock()
});
// Config Files
this.checks.set('config-files', {
name: 'Config Files',
description: 'Required configuration files availability',
weight: 2,
timeout: 15000, // Increased timeout for content validation
check: async () => this.checkConfigFiles()
});
}
// Icon Preloading Check
async checkIconPreloading() {
// console.log('🔄 Starting icon preloading check...');
// Load data directly since window.apps isn't populated yet
let appsData = [];
let categoriesData = [];
try {
// console.log('🌐 Fetching data from /data/apps/generated/apps.json and /data/apps/apps-categories.json');
const [appsResponse, categoriesResponse] = await Promise.all([
fetch('/data/apps/generated/apps.json'),
fetch('/data/apps/apps-categories.json')
]);
// console.log('📊 Apps response status:', appsResponse.status, appsResponse.statusText);
// console.log('📊 Categories response status:', categoriesResponse.status, categoriesResponse.statusText);
if (appsResponse.ok) {
const appsText = await appsResponse.text();
// console.log('📄 Apps response text length:', appsText.length);
const appsParsed = JSON.parse(appsText);
appsData = appsParsed.apps || [];
// console.log('✅ Apps data loaded successfully');
} else {
console.warn('⚠️ Apps response not ok:', appsResponse.status);
}
if (categoriesResponse.ok) {
const categoriesText = await categoriesResponse.text();
// console.log('📄 Categories response text length:', categoriesText.length);
const categoriesParsed = JSON.parse(categoriesText);
categoriesData = categoriesParsed.categories || [];
// console.log('✅ Categories data loaded successfully');
} else {
console.warn('⚠️ Categories response not ok:', categoriesResponse.status);
}
} catch (error) {
console.warn('⚠️ Failed to load data for icon preloading:', error);
}
// console.log('📊 Apps data length:', appsData?.length || 0);
// console.log('📊 Categories data length:', categoriesData?.length || 0);
// Collect all icon URLs to preload
const iconUrls = new Set();
// Add app icons
if (appsData && Array.isArray(appsData)) {
appsData.forEach(app => {
if (app.icon) {
iconUrls.add(app.icon.startsWith('/') ? app.icon : '/' + app.icon);
} else {
const cleanAppName = app.command?.split(' ').pop() || app.name;
iconUrls.add(`/icons/apps/${cleanAppName}.svg`);
}
iconUrls.add('/icons/apps/default.svg');
});
}
// Add category icons
if (categoriesData && Array.isArray(categoriesData)) {
categoriesData.forEach(category => {
if (category.icon) {
iconUrls.add(category.icon.startsWith('/') ? category.icon : '/' + category.icon);
}
});
}
// Add 'all' and 'installed' category icons
iconUrls.add('/icons/categories/all.svg');
iconUrls.add('/icons/categories/installed.svg');
// console.log(`📦 Found ${iconUrls.size} unique icons to preload`);
// Preload all icons using Image objects
const preloadPromises = Array.from(iconUrls).map(iconUrl => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = () => resolve(); // Resolve even on error to not block
img.src = iconUrl;
});
});
await Promise.all(preloadPromises);
// console.log(`✅ Preloaded ${iconUrls.size} app center icons`);
// Pre-render app center in background
if (window.appsManager && typeof window.appsManager.showAppsList === 'function') {
// console.log('🔄 Pre-rendering app center content...');
window.appsManager.showAppsList('all');
await new Promise(resolve => setTimeout(resolve, 300)); // Wait for rendering
// console.log('✅ App center pre-rendered');
}
return true;
}
// Add event callbacks
on(event, callback) {
if (this.callbacks[event]) {
this.callbacks[event].push(callback);
}
}
// Trigger event callbacks
trigger(event, data) {
if (this.callbacks[event]) {
this.callbacks[event].forEach(callback => callback(data));
}
}
// Calculate total checks weight
calculateTotalWeight() {
let total = 0;
for (const [systemId, system] of this.systems) {
for (const checkId of system.checks) {
const check = this.checks.get(checkId);
if (check) {
total += check.weight;
}
}
}
return total;
}
// Initialize all components in dependency order
async initializeComponents() {
// console.log('🔧 BREAKPOINT: initializeComponents() called!');
// console.log('🔧 Initializing components...');
// Get systems in priority order
const sortedSystems = Array.from(this.systems.entries())
.sort(([,a], [,b]) => a.priority - b.priority);
for (const [systemId, system] of sortedSystems) {
// console.log(`🔄 Processing system: ${systemId}`);
// Check dependencies first
const dependenciesMet = system.dependencies.every(dep =>
this.systems.get(dep)?.initialized === true
);
if (!dependenciesMet) {
// console.log(`⏳ Skipping ${system.name} - dependencies not met:`, system.dependencies);
continue;
}
// Find components for this system
const systemComponents = Array.from(this.components.entries())
.filter(([, component]) => component.system === systemId);
// console.log(`📦 Found ${systemComponents.length} components for ${systemId}:`, systemComponents.map(([id]) => id));
// Initialize components for this system
for (const [componentId, component] of systemComponents) {
try {
// console.log(`🔧 Initializing ${componentId}...`);
// Load component script(s) if specified
if (component.script) {
// console.log(`📦 Loading script for ${componentId}: ${component.script}`);
await this.loadScript(component.script);
} else if (component.scripts) {
// console.log(`📦 Loading scripts for ${componentId}:`, component.scripts);
await this.loadScripts(component.scripts);
}
// Check component dependencies
const componentDepsMet = component.dependencies.every(dep =>
this.systems.get(dep)?.initialized === true
);
if (!componentDepsMet) {
// console.log(`⏳ Skipping ${componentId} - component dependencies not met:`, component.dependencies);
continue;
}
// Initialize component
const result = component.initializer();
// Store result if available
if (result && component.global) {
window[component.global] = result;
// console.log(`✅ ${componentId} stored as global: ${component.global}`);
}
// console.log(`✅ ${componentId} initialized successfully`);
} catch (error) {
console.error(`❌ Failed to initialize ${componentId}:`, error);
this.errors.push({
system: system.name,
component: componentId,
error: error.message
});
}
}
// Mark system as initialized
system.initialized = true;
// console.log(`✅ ${system.name} system initialized`);
}
// console.log('🎉 Component initialization complete');
}
// Load a script dynamically
async loadScript(src) {
return new Promise((resolve, reject) => {
// Check if script is already loaded
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Run all health checks
async runHealthChecks() {
this.totalChecks = this.calculateTotalWeight();
this.completedChecks = 0;
this.errors = [];
const results = new Map();
// Process systems in priority order
const sortedSystems = Array.from(this.systems.entries())
.sort(([,a], [,b]) => a.priority - b.priority);
for (const [systemId, system] of sortedSystems) {
// Check dependencies first
const dependenciesMet = system.dependencies.every(dep =>
results.get(dep)?.status === 'passed'
);
// console.log(`🔍 Processing system: ${system.name} (priority: ${system.priority}, critical: ${system.critical})`);
// console.log(`🔍 Dependencies met: ${dependenciesMet}`);
if (!dependenciesMet) {
results.set(systemId, {
status: 'skipped',
reason: 'Dependencies not met',
checks: []
});
continue;
}
// Run system checks
const systemResults = [];
// If system has no checks, it automatically passes
if (system.checks.length === 0) {
// console.log(`✅ System "${system.name}" (${systemId}) has no checks - automatically passing`);
// Trigger status update for systems with no checks
this.trigger('onSystemCheck', {
systemId,
systemName: system.name,
checkId: 'auto-pass',
checkName: 'No checks required',
status: 'passed'
});
} else {
// Run checks for systems that have them
for (const checkId of system.checks) {
const check = this.checks.get(checkId);
if (!check) continue;
this.trigger('onSystemCheck', {
systemId,
systemName: system.name,
checkId,
checkName: check.name,
status: 'checking'
});
// console.log(`🔍 System "${system.name}" (${systemId}) is running check "${check.name}" (${checkId})`);
try {
const result = await this.runSingleCheck(checkId, check);
systemResults.push(result);
if (result.status === 'failed') {
// console.log(`❌ Check failed: ${check.name} for system ${system.name}`);
// console.log(`🔍 System critical: ${system.critical}`);
this.errors.push({
system: system.name,
check: check.name,
error: result.error
});
// If this is a critical system, stop all further processing
if (system.critical) {
console.error(`🚨 CRITICAL SYSTEM FAILURE: ${system.name} - stopping all further checks`);
// Mark ALL remaining systems as skipped due to critical failure
const currentIndex = sortedSystems.findIndex(([id]) => id === systemId);
for (let i = currentIndex + 1; i < sortedSystems.length; i++) {
const [remainingSystemId, remainingSystem] = sortedSystems[i];
results.set(remainingSystemId, {
status: 'skipped',
reason: `Critical system ${system.name} failed`,
checks: []
});
}
// Return immediately with critical failure
const criticalResult = {
results: Object.fromEntries(results),
errors: this.errors,
success: false,
criticalFailure: true
};
this.trigger('onComplete', criticalResult);
return criticalResult;
}
}
} catch (error) {
// console.log(`❌ Exception in check: ${check.name} for system ${system.name}:`, error.message);
// console.log(`🔍 System critical: ${system.critical}`);
systemResults.push({
id: checkId,
name: check.name,
status: 'failed',
error: error.message
});
this.errors.push({
system: system.name,
check: check.name,
error: error.message
});
// If this is a critical system, stop all further processing
if (system.critical) {
console.error(`🚨 CRITICAL SYSTEM FAILURE: ${system.name} - stopping all further checks`);
// Mark ALL remaining systems as skipped due to critical failure
const currentIndex = sortedSystems.findIndex(([id]) => id === systemId);
for (let i = currentIndex + 1; i < sortedSystems.length; i++) {
const [remainingSystemId, remainingSystem] = sortedSystems[i];
results.set(remainingSystemId, {
status: 'skipped',
reason: `Critical system ${system.name} failed`,
checks: []
});
}
// Return immediately with critical failure
const criticalResult = {
results: Object.fromEntries(results),
errors: this.errors,
success: false,
criticalFailure: true
};
this.trigger('onComplete', criticalResult);
return criticalResult;
}
}
}
}
const systemPassed = systemResults.every(r => r.status === 'passed');
results.set(systemId, {
status: systemPassed ? 'passed' : 'failed',
checks: systemResults
});
}
this.trigger('onComplete', {
results: Object.fromEntries(results),
errors: this.errors,
success: this.errors.length === 0
});
return {
results: Object.fromEntries(results),
errors: this.errors,
success: this.errors.length === 0
};
}
// Run a single health check
async runSingleCheck(checkId, check) {
const startTime = Date.now();
try {
// console.log(`🔍 Running health check: ${check.name} (${checkId})`);
const result = await Promise.race([
Promise.resolve(check.check()),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), check.timeout)
)
]);
const duration = Date.now() - startTime;
const passed = Boolean(result);
// console.log(`✅ Health check ${check.name}: ${passed ? 'PASSED' : 'FAILED'} (${duration}ms)`);
this.completedChecks += check.weight;
this.progress = Math.min((this.completedChecks / this.totalChecks) * 100, 100);
this.trigger('onProgress', {
checkId,
checkName: check.name,
status: passed ? 'passed' : 'failed',
progress: this.progress,
duration
});
return {
id: checkId,
name: check.name,
status: passed ? 'passed' : 'failed',
duration,
error: passed ? null : 'Check failed'
};
} catch (error) {
const duration = Date.now() - startTime;
console.error(`❌ Health check ${check.name} FAILED:`, error);
this.completedChecks += check.weight;
this.progress = Math.min((this.completedChecks / this.totalChecks) * 100, 100);
this.trigger('onProgress', {
checkId,
checkName: check.name,
status: 'failed',
progress: this.progress,
duration,
error: error.message
});
return {
id: checkId,
name: check.name,
status: 'failed',
duration,
error: error.message
};
}
}
// Check if this is first-time setup
async checkFirstTimeSetup() {
try {
// Check for essential config files
const essentialFiles = [
'/data/apps/generated/apps.json',
'/data/apps/apps-categories.json'
];
for (const file of essentialFiles) {
const response = await fetch(file, { method: 'HEAD' });
if (!response.ok) {
this.isFirstTime = true;
return true;
}
}
// Check localStorage for setup flag
const setupComplete = localStorage.getItem('libreportal_setup_complete');
if (!setupComplete) {
this.isFirstTime = true;
return true;
}
this.isFirstTime = false;
return false;
} catch {
this.isFirstTime = true;
return true;
}
}
// Check required config files
async checkConfigFiles() {
// console.log('🔍 Config Files Check: Starting validation');
// Critical files that MUST exist and not be empty
const criticalFiles = [
'/data/tasks/current.json',
'/data/tasks/queue.json',
'/data/system/system_info.json',
'/data/system/memory_usage.json',
'/data/system/disk_usage.json',
'/data/system/system_info.json',
'/data/apps/generated/apps.json',
'/data/apps/apps-categories.json',
'/data/apps/apps-field-mappings.json',
'/data/apps/apps-config-categories.json',
'/data/config/generated/configs.json'
];
// console.log('🔍 Critical files to check:', criticalFiles);
// Optional files (can be missing but should exist if possible)
const optionalFiles = [
];
const allFiles = [...criticalFiles, ...optionalFiles];
const results = {
available: [],
missing: [],
empty: [],
total: allFiles.length,
criticalMissing: [],
criticalEmpty: []
};
for (const file of allFiles) {
try {
// console.log(`🔍 Checking file: ${file}`);
const response = await fetch(file, { method: 'HEAD' });
// console.log(`📊 Response status: ${response.status} ${response.statusText}`);
// console.log(`📊 Content-Length: ${response.headers.get('content-length')}`);
// console.log(`📊 Response ok: ${response.ok}`);
if (response.ok) {
// Get content length to check if file is empty
const contentLength = response.headers.get('content-length');
const isEmpty = contentLength === '0' || contentLength === null;
// console.log(`🔍 File ${file} - contentLength: ${contentLength}, isEmpty: ${isEmpty}`);
// For critical files, also check actual content
if (criticalFiles.includes(file) && !isEmpty) {
try {
const getResponse = await fetch(file);
const content = await getResponse.text();
// console.log(`📄 Content preview for ${file}:`, content.substring(0, 100) + '...');
// Check if content starts with '{' or '[' (basic JSON file check)
const startsWithBrace = content.trim().startsWith('{');
const startsWithBracket = content.trim().startsWith('[');
const isValidJSONStart = startsWithBrace || startsWithBracket;
// console.log(`🔍 JSON structure check for ${file}: starts with '{' or '['? ${isValidJSONStart} (content: ${content.trim().substring(0, 10)}...)`);
if (!isValidJSONStart) {
// console.log(`❌ Critical file does not start with '{' or '[' (not valid JSON): ${file}`);
results.criticalEmpty.push(file);
results.empty.push(file);
}
// Check if content is actual JSON data or fallback/error content
else if (content.includes('404 Not Found') || content.includes('404') && content.includes('Not Found') ||
(content.includes('Error') && (content.includes('<!DOCTYPE') || content.includes('<html>') || content.includes('status'))) ||
content.includes('<!DOCTYPE') || content.includes('<html>')) {
// console.log(`❌ Critical file contains error/fallback content: ${file}`);
results.criticalEmpty.push(file);
results.empty.push(file);
}
// Validate JSON syntax
else {
try {
JSON.parse(content);
// console.log(`✅ JSON syntax valid for: ${file}`);
results.available.push(file);
// console.log(`✅ File is available and valid: ${file}`);
} catch (jsonError) {
// console.log(`❌ Critical file has invalid JSON syntax: ${file} - ${jsonError.message}`);
results.criticalEmpty.push(file);
results.empty.push(file);
}
}
} catch (contentError) {
// console.log(`❌ Failed to read content for ${file}:`, contentError.message);
results.criticalEmpty.push(file);
results.empty.push(file);
}
} else if (isEmpty) {
results.empty.push(file);
if (criticalFiles.includes(file)) {
results.criticalEmpty.push(file);
// console.log(`❌ Critical file is EMPTY: ${file}`);
}
} else {
results.available.push(file);
// console.log(`✅ File is available: ${file}`);
}
} else {
results.missing.push(file);
if (criticalFiles.includes(file)) {
results.criticalMissing.push(file);
// console.log(`❌ Critical file is MISSING: ${file}`);
}
// console.log(`❌ File not accessible: ${file} - ${response.status} ${response.statusText}`);
}
} catch (error) {
// console.log(`❌ Exception checking file ${file}:`, error.message);
results.missing.push(file);
if (criticalFiles.includes(file)) {
results.criticalMissing.push(file);
// console.log(`❌ Critical file exception: ${file}`);
}
}
}
// console.log('Config Files Results:', {
//available: results.available.length,
//missing: results.missing.length,
//empty: results.empty.length,
//criticalMissing: results.criticalMissing.length,
//criticalEmpty: results.criticalEmpty.length,
//missingFiles: results.missing,
//emptyFiles: results.empty
//});
// Fail if ANY critical files are missing or empty
if (results.criticalMissing.length > 0 || results.criticalEmpty.length > 0) {
const errorMessages = [];
if (results.criticalMissing.length > 0) {
errorMessages.push(`Missing critical files: ${results.criticalMissing.join(', ')}`);
}
if (results.criticalEmpty.length > 0) {
errorMessages.push(`Empty critical files: ${results.criticalEmpty.join(', ')}`);
}
const errorMessage = `Critical configuration files are missing or empty. Please run: libreportal webui generate all \n\nDetails: ${errorMessages.join('\n')}`;
console.error('🚨 Config Files Check FAILED - throwing error:', errorMessage);
throw new Error(errorMessage);
}
// console.log('✅ Config Files Check PASSED');
return results;
}
// Check for update lock file to prevent concurrent updates
async checkUpdateLock() {
// console.log('🔍 Update Lock Check: Checking for update lock file');
try {
const response = await fetch('/data/updater-lock', { method: 'HEAD' });
const lockExists = response.ok;
// console.log(`🔒 Update lock file exists: ${lockExists}`);
if (lockExists) {
return {
status: 'locked',
message: 'Update in progress - please wait for current update to complete'
};
} else {
return {
status: 'unlocked',
message: 'No update in progress'
};
}
} catch (error) {
console.error('❌ Error checking update lock:', error);
return {
status: 'error',
message: `Error checking update lock: ${error.message}`
};
}
}
// Check for pause lock file to prevent operations during pause
async checkPauseLock() {
// console.log('🔍 Pause Lock Check: Checking for pause lock file');
try {
const response = await fetch('/data/pause.lock', { method: 'HEAD' });
const lockExists = response.ok;
// console.log(`🔒 Pause lock file exists: ${lockExists}`);
if (lockExists) {
return {
status: 'locked',
message: 'System is paused - operations are temporarily disabled'
};
} else {
return {
status: 'unlocked',
message: 'System is operational'
};
}
} catch (error) {
console.error('❌ Error checking pause lock:', error);
return {
status: 'error',
message: `Error checking pause lock: ${error.message}`
};
}
}
// Get system summary
getSystemSummary() {
return {
totalSystems: this.systems.size,
totalChecks: this.checks.size,
totalWeight: this.calculateTotalWeight(),
systems: Array.from(this.systems.entries()).map(([id, system]) => ({
id,
name: system.name,
description: system.description,
priority: system.priority,
dependencies: system.dependencies,
checks: system.checks,
initialized: system.initialized || false
})),
components: Array.from(this.components.entries()).map(([id, component]) => ({
id,
system: component.system,
global: component.global,
dependencies: component.dependencies,
script: component.script || null
}))
};
}
// Load a script dynamically
async loadScript(src) {
return new Promise((resolve, reject) => {
// Check if script is already loaded
if (document.querySelector(`script[src="${src}"]`)) {
// console.log(`📦 Script already loaded: ${src}`);
resolve();
return;
}
// console.log(`📦 Loading script: ${src}`);
const script = document.createElement('script');
script.src = src;
script.onload = () => {
// console.log(`✅ Script loaded: ${src}`);
resolve();
};
script.onerror = () => {
console.error(`❌ Failed to load script: ${src}`);
reject(new Error(`Failed to load script: ${src}`));
};
document.head.appendChild(script);
});
}
// Load multiple scripts for a component
async loadScripts(scripts) {
if (!scripts || scripts.length === 0) return;
// console.log(`📦 Loading ${scripts.length} scripts for component...`);
await Promise.all(scripts.map(script => this.loadScript(script)));
// console.log(`✅ All scripts loaded for component`);
}
}
// Global instance
window.SystemLoader = SystemLoader;