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>
559 lines
18 KiB
JavaScript
Executable File
559 lines
18 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 === '/apps' || currentPath === '/app' || searchParams.has('apps') || searchParams.has('app')) {
|
|
// Apps page: All apps + categories (no system configs needed)
|
|
await loadAppsPageData();
|
|
} else if (currentPath.startsWith('/config') || searchParams.has('config')) {
|
|
// Config pages: 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);
|
|
|
|
} 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 });
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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
|