librelad eaafd1bb38 refactor(webui): relocate admin area into features/admin/ + shared extractions
- features/admin/: the 10 admin-owned config controllers, the 5 admin pages
  (overview/system/charts/metric/storage), ssh-page.js, peers-page.js, plus
  admin.css/ip-whitelist.css/ssh.css (eager). config-manager.js kept last in
  the load order (it news the sub-managers).
- shared/js/: config-shared.js + config-options.js (ConfigShared/ConfigOptions
  globals consumed cross-feature by backup/apps/tasks).
- shared/css/: forms.css + config.css (generic form + config-form primitives
  borrowed by apps/backup/admin).
- Updated all path strings in system-loader.js (config component) and
  config-manager.js (lazyLoad of admin/ssh/peers controllers); index.html CSS
  hrefs. No /js/components/{config,admin,ssh,peers}/ refs remain.

js/components/ now holds only shared UI (topbar, notifications, eo-modal,
update-notifier, mobile-menu, confirmation-dialog).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 02:10:09 +01:00

470 lines
20 KiB
JavaScript
Executable File

// Config Options - Centralized configuration options for dropdown fields
class ConfigOptions {
// Per-app form renders id="GLUETUN_VPN_*"; global form renders id="config-CFG_GLUETUN_VPN_*".
static findGluetunProviderEl() {
return document.getElementById('config-CFG_GLUETUN_VPN_SERVICE_PROVIDER')
|| document.getElementById('GLUETUN_VPN_SERVICE_PROVIDER');
}
static findGluetunVpnTypeEl() {
return document.getElementById('config-CFG_GLUETUN_VPN_TYPE')
|| document.getElementById('GLUETUN_VPN_TYPE');
}
static findGluetunFieldEl(suffix) {
return document.getElementById(`config-CFG_GLUETUN_${suffix}`)
|| document.getElementById(`GLUETUN_${suffix}`);
}
// Show only the credential fields that match the selected VPN type.
static refreshGluetunCredentialVisibility() {
const typeEl = this.findGluetunVpnTypeEl();
if (!typeEl) return;
const type = (typeEl.value || '').toLowerCase();
const wg = ['WIREGUARD_PRIVATE_KEY', 'WIREGUARD_ADDRESSES'];
const ov = ['OPENVPN_USER', 'OPENVPN_PASSWORD'];
const setVisible = (suffix, visible) => {
const el = this.findGluetunFieldEl(suffix);
if (!el) return;
const wrapper = el.closest('.form-field') || el.parentElement;
if (wrapper) wrapper.style.display = visible ? '' : 'none';
};
wg.forEach((s) => setVisible(s, type === 'wireguard'));
ov.forEach((s) => setVisible(s, type === 'openvpn'));
this.refreshMullvadGenerateButton(type);
}
static refreshMullvadGenerateButton(typeValue) {
const providerEl = this.findGluetunProviderEl();
const provider = (providerEl?.value || '').toLowerCase();
const type = (typeValue || this.findGluetunVpnTypeEl()?.value || '').toLowerCase();
const shouldShow = provider === 'mullvad' && type === 'wireguard';
document.querySelectorAll('.mullvad-generate-field').forEach(b => b.remove());
if (!shouldShow) return;
const typeEl = this.findGluetunVpnTypeEl();
const anchor = typeEl && typeEl.closest('.form-field');
if (!anchor) return;
const keyEl = this.findGluetunFieldEl('WIREGUARD_PRIVATE_KEY');
const addrEl = this.findGluetunFieldEl('WIREGUARD_ADDRESSES');
const configured = !!(keyEl?.value && addrEl?.value);
const block = document.createElement('div');
block.className = 'form-field mullvad-generate-field';
block.innerHTML = `
<label class="form-label">Mullvad WireGuard</label>
<div class="mullvad-generate-actions">
<button type="button" class="btn btn-secondary mullvad-generate-btn">Generate from Mullvad account</button>
<span class="mullvad-generate-status${configured ? ' is-configured' : ''}">
<span class="mullvad-generate-tick">${configured ? '✓' : ''}</span>
<span class="mullvad-generate-status-text">${configured ? 'Configured' : 'Not configured'}</span>
</span>
</div>
<small class="form-help">Generates a WireGuard key against your Mullvad account and fills the credentials below.</small>
`;
block.querySelector('.mullvad-generate-btn')
.addEventListener('click', () => window.appsManager?.openMullvadGenerateModal?.());
anchor.insertAdjacentElement('afterend', block);
}
static refreshMullvadGenerateStatus() {
const status = document.querySelector('.mullvad-generate-field .mullvad-generate-status');
if (!status) return;
const keyEl = this.findGluetunFieldEl('WIREGUARD_PRIVATE_KEY');
const addrEl = this.findGluetunFieldEl('WIREGUARD_ADDRESSES');
const configured = !!(keyEl?.value && addrEl?.value);
status.classList.toggle('is-configured', configured);
status.querySelector('.mullvad-generate-tick').textContent = configured ? '✓' : '';
status.querySelector('.mullvad-generate-status-text').textContent = configured ? 'Configured' : 'Not configured';
}
static loadGluetunProviderIcons() {
if (this._gluetunIconsPromise) return this._gluetunIconsPromise;
this._gluetunIconsPromise = (async () => {
try {
const res = await fetch('/data/apps/gluetun-provider-icons.json', { cache: 'no-store' });
if (!res.ok) return {};
return await res.json();
} catch { return {}; }
})();
this._gluetunIconsPromise.then((m) => { window.gluetunProviderIcons = m || {}; });
return this._gluetunIconsPromise;
}
static _gluetunIconsPromise = null;
// Pulled from gluetun's upstream servers.json by appWebuiRefresh_gluetun.
// Generator writes to /data/apps/generated/; static fallback ships at /data/apps/.
// We try generated first, fall back to bundled, fall back to a tiny static list.
static _gluetunProvidersPromise = null;
static loadGluetunProviders() {
if (this._gluetunProvidersPromise) return this._gluetunProvidersPromise;
this._gluetunProvidersPromise = (async () => {
const tryFetch = async (url) => {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return null;
const json = await res.json();
return json && json.providers ? json.providers : null;
} catch { return null; }
};
const live = await tryFetch('/data/apps/generated/gluetun-providers.json');
if (live) return live;
const fallback = await tryFetch('/data/apps/gluetun-providers.json');
if (fallback) return fallback;
return null;
})();
this._gluetunProvidersPromise.then((p) => {
window.gluetunProviders = p;
// If the provider field rendered before this finished, repopulate it
// (and the VPN-type field, which depends on the selected provider).
const sel = ConfigOptions.findGluetunProviderEl();
if (sel && p) {
const previous = sel.value;
const opts = ConfigOptions.getGluetunProviderOptions();
sel.innerHTML = opts.map((o) =>
`<option value="${o.value}" ${o.value === previous ? 'selected' : ''}>${o.label}</option>`
).join('');
ConfigOptions.refreshGluetunVpnTypeOptions();
}
ConfigOptions.refreshGluetunCredentialVisibility();
});
return this._gluetunProvidersPromise;
}
static getGluetunProviderOptions() {
const providers = window.gluetunProviders;
if (!providers) {
this.loadGluetunProviders();
return [
{ value: 'mullvad', label: 'Mullvad' },
{ value: 'nordvpn', label: 'NordVPN' },
{ value: 'protonvpn', label: 'ProtonVPN' },
{ value: 'surfshark', label: 'Surfshark' },
{ value: 'custom', label: 'Custom (manual config)' }
];
}
const titleCase = (s) => s.replace(/\b\w/g, (c) => c.toUpperCase());
return Object.keys(providers).sort().map((slug) => ({
value: slug,
label: slug === 'custom' ? 'Custom (manual config)' : titleCase(slug)
}));
}
// Repaint the VPN-type <select> based on the currently chosen provider.
// Called from a delegated change listener so we don't touch the renderer.
static refreshGluetunVpnTypeOptions() {
const sel = ConfigOptions.findGluetunVpnTypeEl();
if (!sel) return;
const previous = sel.value;
const opts = this.getGluetunVpnTypeOptions();
const stillValid = opts.some((o) => o.value === previous);
sel.innerHTML = opts.map((o) =>
`<option value="${o.value}" ${(stillValid ? o.value === previous : false) || (!stillValid && o.value === opts[0].value) ? 'selected' : ''}>${o.label}</option>`
).join('');
this.refreshGluetunCredentialVisibility();
}
static getGluetunVpnTypeOptions() {
const providers = window.gluetunProviders;
const selected = (typeof document !== 'undefined' && ConfigOptions.findGluetunProviderEl())
? ConfigOptions.findGluetunProviderEl().value
: null;
if (providers && selected && providers[selected] && Array.isArray(providers[selected].vpnTypes) && providers[selected].vpnTypes.length) {
return providers[selected].vpnTypes.map((t) => ({
value: t,
label: t === 'openvpn' ? 'OpenVPN' : (t === 'wireguard' ? 'WireGuard' : t)
}));
}
return [
{ value: 'wireguard', label: 'WireGuard' },
{ value: 'openvpn', label: 'OpenVPN' }
];
}
// Get select options for specific config keys
static getSelectOptions(key) {
//console.log('=== getSelectOptions ENTRY === called with:', key);
const optionMaps = {
'CFG_DOCKER_INSTALL_TYPE': [
{ value: 'rooted', label: 'Rooted' },
{ value: 'rootless', label: 'Rootless' }
],
'CFG_ROOTLESS_NET': [
{ value: 'pasta', label: 'Pasta (recommended)' },
{ value: 'slirp4netns', label: 'slirp4netns (fallback)' }
],
'CFG_UFW_LOGGING': [
{ value: 'off', label: 'Off' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'full', label: 'Full' }
],
'CFG_TEXT_EDITOR': [
{ value: 'nano', label: 'Nano' },
{ value: 'vim', label: 'Vim' }
],
'CFG_INSTALL_MODE': [
{ value: 'git', label: 'Git Repository' },
{ value: 'local', label: 'Local Folder' }
],
'CFG_MAIL_SECURE': [
{ value: 'tls', label: 'TLS' },
{ value: 'ssl', label: 'SSL' },
{ value: 'none', label: 'None' }
],
'CFG_TRAEFIK_DASHBOARD_ACCESS': [
{ value: 'local-only', label: 'Local + Domain (recommended)' },
{ value: 'domain-only', label: 'Domain only (most secure)' },
{ value: 'public', label: 'Public + Domain (legacy, unauthenticated on :8080)' }
],
};
if (key === 'CFG_GLUETUN_VPN_SERVICE_PROVIDER') return this.getGluetunProviderOptions();
if (key === 'CFG_GLUETUN_VPN_TYPE') return this.getGluetunVpnTypeOptions();
// Generator-emitted options take precedence — they come from the
// [value:Label|...] block in the config file's inline comment, so a
// CFG file change auto-flows through to the dropdown without
// touching this file. The hardcoded cases below stay as a safety
// net for keys whose options are computed or theme-specific.
const generated = window.configData && window.configData.config &&
window.configData.config[key] &&
window.configData.config[key].options;
if (Array.isArray(generated) && generated.length > 0) {
return generated;
}
// Per-location backup dropdowns (CFG_BACKUP_LOC_<n>_*). Their options are
// static and identical for every location, but the config generator only
// scans flat per-category files — it never descends into the nested
// configs/backup/locations/<n>/ dir — so configData carries no options for
// these keys. Resolve them here by suffix so every location index works,
// including ones added after install. (The global CFG_BACKUP_ENGINE/
// STRATEGY still come from the generator via the check above.)
const locDropdown = key.match(/^CFG_BACKUP_LOC_[0-9]+_(TYPE|PATH_MODE|ENGINE|SSH_AUTH)$/);
if (locDropdown) {
const BACKUP_LOC_OPTIONS = {
TYPE: [
{ value: 'local', label: 'Local / mounted path' },
{ value: 'sftp', label: 'SFTP' },
{ value: 'rest', label: 'REST' },
{ value: 's3', label: 'S3' },
{ value: 'b2', label: 'Backblaze B2' },
{ value: 'gs', label: 'Google Cloud Storage' },
{ value: 'azure', label: 'Azure' },
{ value: 'rclone', label: 'rclone' }
],
PATH_MODE: [
{ value: 'auto', label: 'Automatic (backups root, one subfolder per location)' },
{ value: 'custom', label: 'Custom path' }
],
ENGINE: [
{ value: 'restic', label: 'Restic' },
{ value: 'borg', label: 'BorgBackup' },
{ value: 'kopia', label: 'Kopia' }
],
SSH_AUTH: [
{ value: 'key', label: 'SSH key (~/.ssh/id_rsa)' },
{ value: 'password', label: 'Password (via sshpass)' }
]
};
return BACKUP_LOC_OPTIONS[locDropdown[1]];
}
const result = optionMaps[key] || [];
//console.log('Final result for', key, ':', result);
return result;
}
// Get comprehensive timezone options
static getTimezoneOptions() {
return [
{ value: 'Etc/UTC', label: 'Etc/UTC' },
{ value: 'America/New_York', label: 'America/New_York' },
{ value: 'America/Chicago', label: 'America/Chicago' },
{ value: 'America/Denver', label: 'America/Denver' },
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles' },
{ value: 'America/Anchorage', label: 'America/Anchorage' },
{ value: 'America/Honolulu', label: 'America/Honolulu' },
{ value: 'America/Toronto', label: 'America/Toronto' },
{ value: 'America/Vancouver', label: 'America/Vancouver' },
{ value: 'America/Mexico_City', label: 'America/Mexico_City' },
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo' },
{ value: 'Europe/London', label: 'Europe/London' },
{ value: 'Europe/Paris', label: 'Europe/Paris' },
{ value: 'Europe/Berlin', label: 'Europe/Berlin' },
{ value: 'Europe/Rome', label: 'Europe/Rome' },
{ value: 'Europe/Madrid', label: 'Europe/Madrid' },
{ value: 'Europe/Amsterdam', label: 'Europe/Amsterdam' },
{ value: 'Europe/Brussels', label: 'Europe/Brussels' },
{ value: 'Europe/Zurich', label: 'Europe/Zurich' },
{ value: 'Europe/Vienna', label: 'Europe/Vienna' },
{ value: 'Europe/Stockholm', label: 'Europe/Stockholm' },
{ value: 'Europe/Oslo', label: 'Europe/Oslo' },
{ value: 'Europe/Copenhagen', label: 'Europe/Copenhagen' },
{ value: 'Europe/Helsinki', label: 'Europe/Helsinki' },
{ value: 'Europe/Warsaw', label: 'Europe/Warsaw' },
{ value: 'Europe/Prague', label: 'Europe/Prague' },
{ value: 'Europe/Budapest', label: 'Europe/Budapest' },
{ value: 'Europe/Moscow', label: 'Europe/Moscow' },
{ value: 'Asia/Dubai', label: 'Asia/Dubai' },
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata' },
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
{ value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
{ value: 'Asia/Seoul', label: 'Asia/Seoul' },
{ value: 'Asia/Singapore', label: 'Asia/Singapore' },
{ value: 'Asia/Bangkok', label: 'Asia/Bangkok' },
{ value: 'Asia/Jakarta', label: 'Asia/Jakarta' },
{ value: 'Australia/Sydney', label: 'Australia/Sydney' },
{ value: 'Australia/Melbourne', label: 'Australia/Melbourne' },
{ value: 'Australia/Perth', label: 'Australia/Perth' },
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland' },
{ value: 'Africa/Cairo', label: 'Africa/Cairo' },
{ value: 'Africa/Lagos', label: 'Africa/Lagos' },
{ value: 'Africa/Johannesburg', label: 'Africa/Johannesburg' }
];
}
// Check if a config key should use dropdown
static isDropdownKey(key) {
//console.log('ConfigOptions.isDropdownKey called with:', key);
const result = key === 'CFG_DOCKER_INSTALL_TYPE' ||
key === 'CFG_UFW_LOGGING' ||
key === 'CFG_TEXT_EDITOR' ||
/^CFG_BACKUP_LOC_[0-9]+_TYPE$/.test(key) ||
/^CFG_BACKUP_LOC_[0-9]+_ENGINE$/.test(key) ||
key === 'CFG_BACKUP_ENGINE' ||
/^CFG_BACKUP_LOC_[0-9]+_SSH_AUTH$/.test(key) ||
/^CFG_BACKUP_LOC_[0-9]+_PATH_MODE$/.test(key) ||
key === 'CFG_BACKUP_STRATEGY' ||
key === 'CFG_INSTALL_MODE' ||
key === 'CFG_MAIL_SECURE' ||
key === 'CFG_GLUETUN_VPN_SERVICE_PROVIDER' ||
key === 'CFG_GLUETUN_VPN_TYPE' ||
key === 'CFG_TRAEFIK_DASHBOARD_ACCESS';
//console.log('ConfigOptions.isDropdownKey result:', result);
return result;
}
// Check if a config key should use timezone dropdown
static isTimezoneKey(key) {
return key.includes('TIMEZONE');
}
// Fetch available domains from system configuration
static async getAvailableDomains() {
try {
//console.log('🔍 Starting domain fetch...');
// Try to load system config to get domain information
const response = await fetch('/data/config/generated/configs.json');
//console.log('📡 Config response status:', response.status);
if (!response.ok) {
console.warn('Could not load system config for domains, returning empty list');
return [];
}
const configData = await response.json();
//console.log('📄 Full config data:', configData);
//console.log('🔧 Config keys available:', Object.keys(configData));
const config = configData.config || {};
//console.log('⚙️ Config object:', config);
//console.log('🔑 Config keys:', Object.keys(config));
const domains = [];
// Check CFG_DOMAIN_1 through CFG_DOMAIN_9
for (let i = 1; i <= 9; i++) {
const domainKey = `CFG_DOMAIN_${i}`;
const domainValue = config[domainKey]?.value || config[domainKey] || '';
//console.log(`🌐 Checking ${domainKey}:`, domainValue);
if (domainValue.trim() !== '') {
domains.push({
number: i,
domain: domainValue.trim(),
key: domainKey
});
}
}
//console.log('✅ Found configured domains:', domains);
return domains;
} catch (error) {
console.error('❌ Error fetching domains:', error);
return [];
}
}
// Get domain options for DOMAIN field
static async getDomainOptions() {
//console.log('🎯 Getting domain options...');
const domains = await this.getAvailableDomains();
//console.log('📊 Domains returned:', domains);
if (domains.length === 0) {
//console.log('⚠️ No domains found, returning fallback option');
// No domains configured - return empty option
return [
{ value: '1', label: 'No domains configured - Configure domains in Network settings first' }
];
}
// Create options with just domain names
const options = domains.map(domain => ({
value: domain.number.toString(),
label: domain.domain
}));
//console.log('✅ Generated domain options:', options);
return options;
}
// Check if a config key should use domain dropdown
static isDomainKey(key) {
return key === 'DOMAIN';
}
}
// Export to global scope
window.ConfigOptions = ConfigOptions;
// Kick off provider snapshot fetch immediately so the dropdown is hot on first paint.
ConfigOptions.loadGluetunProviders();
ConfigOptions.loadGluetunProviderIcons();
// Delegated listener: when the gluetun provider <select> changes, rebuild the
// VPN-type <select> from that provider's supported types. Avoids reaching into
// the renderer to wire onchange per-field.
document.addEventListener('change', (e) => {
if (!e.target) return;
if (e.target.id === 'config-CFG_GLUETUN_VPN_SERVICE_PROVIDER'
|| e.target.id === 'GLUETUN_VPN_SERVICE_PROVIDER') {
ConfigOptions.refreshGluetunVpnTypeOptions();
} else if (e.target.id === 'config-CFG_GLUETUN_VPN_TYPE'
|| e.target.id === 'GLUETUN_VPN_TYPE') {
ConfigOptions.refreshGluetunCredentialVisibility();
} else if (e.target.id === 'config-CFG_GLUETUN_WIREGUARD_PRIVATE_KEY'
|| e.target.id === 'GLUETUN_WIREGUARD_PRIVATE_KEY'
|| e.target.id === 'config-CFG_GLUETUN_WIREGUARD_ADDRESSES'
|| e.target.id === 'GLUETUN_WIREGUARD_ADDRESSES') {
ConfigOptions.refreshMullvadGenerateStatus();
}
});
document.addEventListener('input', (e) => {
if (!e.target) return;
if (e.target.id === 'config-CFG_GLUETUN_WIREGUARD_PRIVATE_KEY'
|| e.target.id === 'GLUETUN_WIREGUARD_PRIVATE_KEY'
|| e.target.id === 'config-CFG_GLUETUN_WIREGUARD_ADDRESSES'
|| e.target.id === 'GLUETUN_WIREGUARD_ADDRESSES') {
ConfigOptions.refreshMullvadGenerateStatus();
}
});