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

// 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;