librelad cfdd39386c feat(admin): move Peers into Admin/Tools; lift System next to Overview
Two related UI tidies — both removing surface area from the topbar / Tools
group rather than adding new pages.

Peers → /admin/tools/peers
  Was a top-level /peers route with its own topbar nav item, which doubled
  the navigation surface for what's really an admin tool (same shape as
  SSH Access). Now lives under the Admin sidebar's Tools group alongside
  SSH Access. /peers is kept as a legacy redirect → /admin/tools/peers.

  Plumbing:
  - config-sidebar.js gains a Peers entry under the Tools label.
  - config-manager.js gains a 'peers' branch that fetches
    peers-content.html into config-section, then inits PeersPage.
  - window.adminPath() learns 'peers' → /admin/tools/peers.
  - spa.js handlePeers() is now a redirect (mirrors handleSsh).
  - topbar.html drops the Peers nav item.
  - peers-content.html slimmed to a config-section template (no
    standalone page wrapper) so it embeds cleanly under the admin shell.
  - PeersPage gains a rootId constructor arg for symmetry with SshPage
    (queries still work globally — IDs are unique).

System lifted out of the Tools group
  User feedback: 'overview/system are kinda like, the same thing'. Moved
  System to sit right under Overview at the top of the sidebar, before
  the 'Config' label. Both surfaces are admin-landing pages (Overview =
  ops/health summary, System = live host + per-app stats) — distinct from
  config form pages or the Tools utilities.

  config-sidebar.js: System block moved to the top section (right after
  Overview's click handler). Original Tools-group instance removed.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 20:16:45 +01:00

379 lines
17 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) {}
// Overview is the Admin landing — an ops/health board, not a config form.
if (category === 'overview') {
try { this.sidebar.populateSidebar(); } catch (e) {}
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) {}
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) {}
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) {}
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');
}