7 page-specific controllers were eager-loaded in index.html on every cold
visit, even when the user lands on /dashboard and never opens /backup,
/admin, etc. Moved them to lazy-load via spa.js's existing loadScript()
helper, fired from each route's handler on first navigation:
/js/components/backup/backup-page.js — handleBackup()
/js/components/backup/backup-app-card.js — handleBackup()
/js/components/ssh/ssh-page.js — config-manager ssh-access
/js/components/peers/peers-page.js — config-manager peers
/js/components/admin/admin-overview.js — config-manager overview
/js/components/admin/charts.js — config-manager overview
/js/components/admin/admin-system.js — config-manager system
config-manager.js gets a tiny `lazyLoad` helper that delegates to
window.spaClean.loadScript with a graceful fallback when the SPA hasn't
booted (legacy paths). loadScript is idempotent — subsequent visits to
the same route are no-ops, so we don't re-fetch after the first nav.
Cold-load impact on /dashboard (the most common landing):
Before: 25 sync <script> tags loading ~1.7 MB raw / ~430 KB gzipped
After: 18 sync <script> tags loading ~1.5 MB raw / ~380 KB gzipped
+ corresponding parse-cost reduction on the client (no longer parsing
backup-page.js + apps-related JS just to render the dashboard)
Page-specific JS still loads cleanly when the user navigates there — a
single extra network round-trip per route on first visit, then cached
for 1h (per Phase A's cache headers). Compression (Phase A) means the
deferred JS is ~75 % smaller on the wire than it would have been
pre-Phase-A.
Sister update to .../Scripts/update.sh: rsync now uses --delete so
file removals in the source tree (this commit deletes 7 script tags;
earlier commits deleted config-manager-old.js) propagate to the live
install. Excludes still protect frontend/data/.
Signed-off-by: librelad <librelad@digitalangels.vip>
394 lines
18 KiB
JavaScript
Executable File
394 lines
18 KiB
JavaScript
Executable File
// Config Manager - Main orchestrator for modular config system
|
|
if (typeof window.ConfigManager === 'undefined') {
|
|
//console.log('ConfigManager: Defining new ConfigManager class...');
|
|
|
|
class ConfigManager {
|
|
constructor() {
|
|
this.core = new ConfigCore();
|
|
this.domainManager = new DomainManager();
|
|
this.whitelistManager = new IPWhitelistManager();
|
|
this.renderer = new ConfigRenderer();
|
|
this.sidebar = new ConfigSidebar();
|
|
this.form = new ConfigForm();
|
|
this.utils = new ConfigUtils();
|
|
|
|
// Expose IPWhitelistManager globally for wrapper functions
|
|
window.IPWhitelistManager = this.whitelistManager;
|
|
}
|
|
|
|
async renderConfig(category) {
|
|
//console.log('ConfigManager: Rendering ' + category + ' config...');
|
|
|
|
const configSection = document.getElementById('config-section');
|
|
if (!configSection) {
|
|
console.error('ConfigManager: config-section element not found');
|
|
return;
|
|
}
|
|
|
|
// The sidebar and page headers read window.configData. Load it up front so
|
|
// populateSidebar() has categories on a cold admin visit (e.g. straight
|
|
// from the dashboard); otherwise the overview/tools branches below render
|
|
// an empty sidebar until something else populates configData. Cached after
|
|
// the first call, so the config-category path below is a cache hit.
|
|
try { await this.core.loadConfig(category); } catch (e) {}
|
|
|
|
// Tool controllers are loaded on demand — they're not in index.html's
|
|
// initial <script> block (Phase B of the WebUI lazy-load work). Falls
|
|
// back gracefully if window.spaClean isn't around for some reason
|
|
// (e.g. legacy bootstrap path).
|
|
const lazyLoad = (src) =>
|
|
window.spaClean?.loadScript ? window.spaClean.loadScript(src) : Promise.resolve();
|
|
|
|
// Overview is the Admin landing — an ops/health board, not a config form.
|
|
if (category === 'overview') {
|
|
try { this.sidebar.populateSidebar(); } catch (e) {}
|
|
// charts.js is the chart-rendering helper admin-overview pulls in.
|
|
await Promise.all([
|
|
lazyLoad('/js/components/admin/admin-overview.js'),
|
|
lazyLoad('/js/components/admin/charts.js')
|
|
]);
|
|
if (typeof AdminOverview !== 'undefined') {
|
|
window.adminOverview = new AdminOverview('config-section');
|
|
await window.adminOverview.init();
|
|
} else {
|
|
configSection.innerHTML = '<div class="error">Admin overview failed to load.</div>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// SSH Access is an admin tool page that lives in this sidebar rather than
|
|
// a config category — render its own controller into the main pane.
|
|
if (category === 'ssh-access') {
|
|
try { this.sidebar.populateSidebar(); } catch (e) {}
|
|
await lazyLoad('/js/components/ssh/ssh-page.js');
|
|
if (typeof SshPage !== 'undefined') {
|
|
window.sshPage = new SshPage('config-section');
|
|
await window.sshPage.init();
|
|
} else {
|
|
configSection.innerHTML = '<div class="error">SSH Access page failed to load.</div>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Peers is an admin tool page — named references to other LibrePortal
|
|
// instances (kind=backup-channel for shared-backup migrate, or
|
|
// kind=direct-ssh-direct for live SSH pulls). Same shape as SSH Access:
|
|
// we inject its content template, then init PeersPage.
|
|
if (category === 'peers') {
|
|
try { this.sidebar.populateSidebar(); } catch (e) {}
|
|
await lazyLoad('/js/components/peers/peers-page.js');
|
|
try {
|
|
const html = await fetch('/html/peers-content.html').then(r => r.text());
|
|
configSection.innerHTML = html;
|
|
} catch (e) {
|
|
configSection.innerHTML = '<div class="error">Peers page template failed to load.</div>';
|
|
return;
|
|
}
|
|
if (typeof PeersPage !== 'undefined') {
|
|
window.peersPage = new PeersPage('config-section');
|
|
await window.peersPage.init();
|
|
} else {
|
|
configSection.innerHTML = '<div class="error">PeersPage controller not loaded.</div>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// System is an admin tool page (live host + per-app statistics) with its
|
|
// own controller, like SSH Access above.
|
|
if (category === 'system') {
|
|
try { this.sidebar.populateSidebar(); } catch (e) {}
|
|
await lazyLoad('/js/components/admin/admin-system.js');
|
|
if (typeof AdminSystem !== 'undefined') {
|
|
window.adminSystem = new AdminSystem('config-section');
|
|
await window.adminSystem.init();
|
|
} else {
|
|
configSection.innerHTML = '<div class="error">System page failed to load.</div>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Show loading state with enhanced box styling
|
|
configSection.innerHTML = `
|
|
<div class="loading-content" style="
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
background: var(--input-bg);
|
|
border: 2px solid var(--border-color);
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
width: calc(100% - 40px);
|
|
height: calc(100% - 40px);
|
|
box-sizing: border-box;
|
|
margin: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 400px;
|
|
">
|
|
<div class="loading-spinner" style="
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 3px solid rgba(52, 152, 219, 0.3);
|
|
border-top: 3px solid #3498db;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 16px auto;
|
|
display: inline-block;
|
|
"></div>
|
|
<div class="loading-message" style="
|
|
font-size: 16px;
|
|
color: #ffffff;
|
|
font-weight: 500;
|
|
margin-bottom: 8px;
|
|
">
|
|
Loading configuration...
|
|
</div>
|
|
<div class="loading-subtitle" style="
|
|
font-size: 14px;
|
|
color: rgba(255, 255, 255, 0.8);
|
|
font-style: italic;
|
|
">
|
|
${this.core.getRandomLoadingMessage()}
|
|
</div>
|
|
</div>
|
|
<style>
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
</style>
|
|
`;
|
|
|
|
// Load configuration data
|
|
const configData = await this.core.loadConfig(category);
|
|
|
|
// Populate sidebar with categories
|
|
this.sidebar.populateSidebar();
|
|
|
|
if (Object.keys(configData).length === 0) {
|
|
configSection.innerHTML = '<div class="no-config"><h3>No Configuration Available</h3><p>No configuration items found for this category.</p></div>';
|
|
return;
|
|
}
|
|
|
|
// Render configuration sections
|
|
var formHTML = '';
|
|
var self = this; // Preserve 'this' context
|
|
|
|
// Features page is system-level — add a Danger Zone header at the
|
|
// top so it's visually obvious before the user touches anything.
|
|
// Reuses the same `.danger-zone-section` / `.danger-zone-header`
|
|
// styling used elsewhere, but without the advanced/unused toggle
|
|
// tickboxes that live inside the normal danger zone — this is just
|
|
// the heading.
|
|
if (category === 'features') {
|
|
formHTML += '<div class="danger-zone-section danger-zone-section--header-only"><div class="danger-zone-header"><h3>⚠️ Danger Zone</h3><p>These options are for advanced users and may affect system stability</p></div></div>';
|
|
// Divider below the features Danger Zone banner, separating it from
|
|
// the feature fields — same rule used elsewhere in the config form.
|
|
formHTML += '<div class="config-divider"></div>';
|
|
}
|
|
|
|
//console.log('ConfigManager: About to process configData entries:', Object.keys(configData));
|
|
|
|
// Filter subcategories by type
|
|
const subcategoryTypes = this.utils.filterSubcategoriesByType(configData, category);
|
|
|
|
// Render regular subcategories
|
|
for (const subcategoryName of subcategoryTypes.regular) {
|
|
const subcategoryData = configData[subcategoryName];
|
|
//console.log('ConfigManager: Processing regular subcategory:', subcategoryName, 'data:', subcategoryData);
|
|
|
|
if (typeof subcategoryData === 'object' && subcategoryData !== null) {
|
|
//console.log('ConfigManager: Calling renderSubcategory for:', subcategoryName);
|
|
formHTML += await self.renderSubcategory.call(self, category, subcategoryName, subcategoryData);
|
|
}
|
|
}
|
|
|
|
// Render advanced and unused sections
|
|
formHTML = await this.utils.renderSectionedContent(formHTML, subcategoryTypes.advanced, subcategoryTypes.unused, self, category, configData);
|
|
|
|
//console.log('ConfigManager: Final formHTML length:', formHTML.length);
|
|
|
|
if (formHTML) {
|
|
// Page-level header for the config section. Mirrors the
|
|
// .backup-page-header used on /backup so /config gets the same
|
|
// prominent H1 + description above the form fields. Looked up from
|
|
// window.configData.categories[category] so titles/descriptions
|
|
// come straight from the .category metadata file.
|
|
var catMeta = (window.configData && window.configData.categories && window.configData.categories[category]) || {};
|
|
var catTitle = catMeta.title || (typeof ConfigShared !== 'undefined' && ConfigShared.formatCategoryName ? ConfigShared.formatCategoryName(category) : category);
|
|
var catDesc = catMeta.description || '';
|
|
var catIcon = catMeta.icon || category;
|
|
var headerHTML =
|
|
'<div class="page-header config-page-header">' +
|
|
'<img class="page-header-icon" src="/icons/config/' + catIcon + '.svg" alt="" onerror="this.style.display=\'none\'">' +
|
|
'<div class="page-header-title">' +
|
|
'<div class="admin-breadcrumb">Config</div>' +
|
|
'<h1>' + catTitle + '</h1>' +
|
|
(catDesc ? '<p>' + catDesc + '</p>' : '') +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
configSection.innerHTML = headerHTML + '<form id="config-form" class="config-form">' + formHTML + '<div class="config-divider"></div><div class="config-actions">' +
|
|
'<button type="submit" class="btn btn-primary">' +
|
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
|
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>' +
|
|
'<polyline points="7 10 12 15 17 10"></polyline>' +
|
|
'<line x1="12" y1="15" x2="12" y2="3"></line>' +
|
|
'</svg>' +
|
|
'Save Configuration' +
|
|
'</button>' +
|
|
'<button type="button" class="btn btn-secondary" onclick="window.configManager.resetForm()">' +
|
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
|
'<polyline points="23 4 23 10 17 10"></polyline>' +
|
|
'<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>' +
|
|
'</svg>' +
|
|
'Reset' +
|
|
'</button>' +
|
|
'</div></form>';
|
|
// Wire the submit event so it dispatches the config-update task
|
|
// instead of letting the browser fall back to a GET that dumps every
|
|
// CFG value (including passwords) into the URL.
|
|
if (this.form && typeof this.form.attachSubmitHandler === 'function') {
|
|
this.form.attachSubmitHandler();
|
|
}
|
|
}
|
|
|
|
//console.log('ConfigManager: Successfully rendered ' + category + ' config');
|
|
|
|
// Force rediscover toggles to handle timing issues
|
|
if (window.toggleManager && window.toggleManager.forceRediscover) {
|
|
setTimeout(() => {
|
|
window.toggleManager.forceRediscover();
|
|
}, 200);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('ConfigManager: Error rendering ' + category + ' config: ', error);
|
|
configSection.innerHTML = '<div class="error-container"><h3>Error Loading Configuration</h3><p>Failed to load configuration: ' + error.message + '</p><button type="button" class="btn btn-primary" onclick="window.configManager.renderConfig(\'' + category + '\')">Retry</button></div>';
|
|
}
|
|
}
|
|
|
|
async renderSubcategory(category, subcategoryName, subcategoryData) {
|
|
//console.log('ConfigManager: renderSubcategory() called - category: ' + category + ', subcategory: ' + subcategoryName);
|
|
|
|
var displaySubcategory = this.utils.formatSubcategoryName(subcategoryName);
|
|
// Strip the parent-category prefix from the display title so the user
|
|
// sees "Basic" instead of "General Basic" while on the General page.
|
|
if (typeof ConfigShared !== 'undefined' && ConfigShared.stripCategoryPrefix) {
|
|
displaySubcategory = ConfigShared.stripCategoryPrefix(displaySubcategory, category);
|
|
}
|
|
var subcategoryDescription = this.utils.cleanDescription(subcategoryData.description || '');
|
|
|
|
// The subcategoryData IS the config items, not a container for them
|
|
var configItems = [];
|
|
|
|
// Look for actual config items in the main config object
|
|
if (window.configData && window.configData.config) {
|
|
Object.entries(window.configData.config).forEach(function([configKey, configValue]) {
|
|
if (configValue.subcategory === subcategoryName) {
|
|
configItems.push({
|
|
key: configKey,
|
|
title: configValue.title,
|
|
description: configValue.description,
|
|
value: configValue.value,
|
|
options: configValue.options,
|
|
master: configValue.master,
|
|
subcategory: configValue.subcategory
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
//console.log('ConfigManager: Processing subcategory:', subcategoryName, 'data:', subcategoryData);
|
|
//console.log('ConfigManager: configItems count: ' + configItems.length);
|
|
//console.log('ConfigManager: All config items keys:', configItems.map(item => item.key));
|
|
|
|
if (configItems.length === 0) {
|
|
//console.log('ConfigManager: No config items, returning empty string');
|
|
return '';
|
|
}
|
|
|
|
//console.log('ConfigManager: renderSubcategory called with:', {
|
|
//category,
|
|
//subcategoryName,
|
|
//displaySubcategory,
|
|
//hasData: !!subcategoryData
|
|
//});
|
|
|
|
// Check for master toggle in this subcategory
|
|
var masterKey = configItems.find(function(item) { return item.master === true; });
|
|
//console.log('ConfigManager: masterKey found: ' + !!masterKey, masterKey ? masterKey.key : null);
|
|
|
|
// Look for any ENABLED options and use universal toggle renderer
|
|
var enabledKey = configItems.find(function(item) {
|
|
//console.log('Checking item for ENABLED:', item.key, item.key.includes('ENABLED'));
|
|
return item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE';
|
|
});
|
|
//console.log('ConfigManager: enabledKey found: ' + !!enabledKey, enabledKey ? enabledKey.key : null);
|
|
|
|
// Special handling for domains section
|
|
var isDomains = subcategoryName.includes('domains') || subcategoryName.includes('network_domains');
|
|
//console.log('ConfigManager: isDomains:', isDomains);
|
|
|
|
// Special handling for IP whitelist section
|
|
var isWhitelist = subcategoryName === 'network_whitelist' || subcategoryName.includes('whitelist');
|
|
//console.log('ConfigManager: subcategoryName:', subcategoryName, 'isWhitelist:', isWhitelist);
|
|
|
|
var resultHTML = '';
|
|
|
|
if (isDomains) {
|
|
//console.log('ConfigManager: Using domains renderer');
|
|
// Render domains section with special handling
|
|
resultHTML = await this.domainManager.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription);
|
|
} else if (isWhitelist) {
|
|
//console.log('ConfigManager: Using whitelist renderer');
|
|
resultHTML = await this.whitelistManager.renderWhitelistSection(configItems, displaySubcategory, subcategoryDescription);
|
|
} else if (enabledKey) {
|
|
//console.log('ConfigManager: Using universal toggle renderer');
|
|
// Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE
|
|
resultHTML = window.toggleManager ? window.toggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription) : '';
|
|
} else if (masterKey) {
|
|
//console.log('ConfigManager: Using master toggle renderer');
|
|
// Render with master toggle
|
|
resultHTML = this.renderer.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription);
|
|
} else {
|
|
//console.log('ConfigManager: Using regular renderer');
|
|
// Render regular subcategory
|
|
resultHTML = this.renderer.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription);
|
|
}
|
|
|
|
//console.log('ConfigManager: resultHTML length:', resultHTML.length);
|
|
return resultHTML;
|
|
}
|
|
|
|
// Delegate form operations to ConfigForm
|
|
resetForm() {
|
|
return this.form.resetForm();
|
|
}
|
|
|
|
// Domain management methods
|
|
addDomain() {
|
|
return window.domainManager.addDomain();
|
|
}
|
|
|
|
deleteDomain(domainKey, buttonElement) {
|
|
return window.domainManager.deleteDomain(domainKey, buttonElement);
|
|
}
|
|
|
|
async saveConfig() {
|
|
return await this.form.saveConfig();
|
|
}
|
|
|
|
showNotification(message, type) {
|
|
return this.form.showNotification(message, type);
|
|
}
|
|
}
|
|
|
|
// Export to global scope
|
|
window.ConfigManager = ConfigManager;
|
|
} else {
|
|
//console.log('ConfigManager: Already exists, using existing instance');
|
|
}
|