refactor(apps): decompose apps-manager god-file into 7 responsibility files

Brace-aware split of apps-manager.js (4154->2015 base) into apps-grid,
config-form, port-manager-integration, gluetun-vpn, config-dirty-guard,
install-console, service-buttons-sidebar — augmenting AppsManager.prototype.
Removed the dead duplicate initializeSimpleTabs (kept the live later def).
Used a self-checking extractor (node --check per cluster, auto-revert on
failure): install-dispatch contains a regex literal (/^\d{16}$/) that trips
brace-bounding, so its methods were safely LEFT in the base rather than risk a
bad split. ServiceButtons class + expandServiceLinks + bootstrap also remain
inline. Verified: all 117 methods preserved (none lost), all files
node --check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-30 15:06:42 +01:00
parent 3d058b3469
commit df75059330
9 changed files with 2165 additions and 2140 deletions

View File

@ -0,0 +1,280 @@
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
Object.assign(AppsManager.prototype, {
setupSidebar(activeCategory = 'all') {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
// Clear sidebar
const container = document.getElementById('dynamic-categories');
if (!container) return;
container.innerHTML = '';
// Hide loading
const loading = document.querySelector('.loading-categories');
if (loading) loading.style.display = 'none';
// Add categories
this.addCategory('All', 'all');
this.addCategory('Installed', 'installed');
// Add dynamic categories
if (window.sidebarCategories) {
const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories :
Object.entries(window.sidebarCategories).map(([key, value]) => ({ id: key, ...value }));
categoriesArray.forEach(cat => {
this.addCategory(cat.name, cat.id, cat.icon);
});
}
// Add back button for app detail
if (this.currentView === 'app-detail') {
this.addBackButton();
}
// Set active category
this.setActiveCategory(activeCategory);
},
addCategory(name, id, icon) {
const container = document.getElementById('dynamic-categories');
if (!container) return;
const div = document.createElement('div');
div.className = 'category';
div.setAttribute('data-category', id);
let iconHtml;
if (!icon && id === 'all') {
iconHtml = '<svg class="category-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
} else if (!icon && id === 'installed') {
iconHtml = '<svg class="category-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
} else {
let iconPath = icon || `/core/icons/categories/${id}.svg`;
if (!iconPath.startsWith('/')) iconPath = '/' + iconPath;
iconHtml = `<img src="${iconPath}" alt="${name}" onerror="this.src='/core/icons/categories/default.svg'"/>`;
}
div.innerHTML = `${iconHtml} ${name}`;
div.addEventListener('click', (e) => {
e.preventDefault();
this.switchCategory(id);
});
container.appendChild(div);
},
addBackButton() {
const container = document.getElementById('dynamic-categories');
if (!container) return;
const div = document.createElement('div');
div.className = 'category';
div.innerHTML = '← Back to Apps';
div.addEventListener('click', () => {
this.showAppsList('all');
});
container.appendChild(div);
},
setActiveCategory(categoryId) {
document.querySelectorAll('.category').forEach(cat => {
cat.classList.remove('active');
});
const active = document.querySelector(`[data-category="${categoryId}"]`);
if (active) active.classList.add('active');
},
switchCategory(categoryId) {
//// // console.log(`AppsManager: Switching to category: ${categoryId}`);
// Update URL to reflect current state
if (categoryId === 'all') {
history.pushState({}, '', '/apps');
} else {
history.pushState({}, '', `/apps/${categoryId}`);
}
// Direct view update without URL change to avoid conflicts
this.currentView = 'apps';
this.currentApp = null;
// Switch to apps view
this.showView('apps');
// Render apps for new category
this.renderApps(categoryId);
// Update sidebar for new category
this.setupSidebar(categoryId);
},
renderApps(category) {
const container = document.getElementById('apps-section');
if (!container) return;
container.innerHTML = '';
// Load and render apps
this.loadApps(category).then(apps => {
apps.forEach(app => {
const card = this.createAppCard(app);
container.appendChild(card);
});
this.populateInlineServiceButtons();
// Re-apply any active sidebar search so changing category
// doesn't reveal apps that should be filtered out.
if (this.appsSearchQuery) this.filterAppsByQuery(this.appsSearchQuery);
}).catch(error => {
console.error('Error rendering apps:', error);
});
},
// Client-side substring filter wired to the sidebar search box.
// Cards carry data-search (built in createAppCard) so this stays
// a single querySelectorAll + display toggle.
filterAppsByQuery(query) {
const q = (query || '').trim().toLowerCase();
this.appsSearchQuery = q;
const wrap = document.querySelector('.apps-search');
if (wrap) wrap.classList.toggle('has-value', !!q);
const cards = document.querySelectorAll('#apps-section .app-card');
cards.forEach(card => {
const hay = card.dataset.search || '';
card.style.display = (!q || hay.includes(q)) ? '' : 'none';
});
},
clearAppsSearch() {
const input = document.getElementById('apps-search-input');
if (input) input.value = '';
this.filterAppsByQuery('');
if (input) input.focus();
},
createAppCard(app) {
const card = document.createElement('div');
card.className = 'app-card';
if (app.installed) card.classList.add('installed');
// Searchable text for the sidebar search box. Combined name +
// description + long description + category, lowercased once here
// so filterAppsByQuery is a cheap substring match.
const searchHaystack = [
app.name,
app.description,
app.longDescription,
app.category,
this.getCategoryName ? this.getCategoryName(app.category) : ''
].filter(Boolean).join(' ').toLowerCase();
card.dataset.search = searchHaystack;
const appName = app.command.split(' ').pop();
let icon = app.icon || '/core/icons/apps/default.svg';
// Ensure absolute path from root
if (!icon.startsWith('/')) {
icon = '/' + icon;
}
const status = app.installed ? 'Installed' : 'Not Installed';
// Get category icon and name
const categoryIcon = this.getCategoryIcon(app.category);
const categoryName = this.getCategoryName(app.category);
// Create rich tags like original
const descriptionTag = app.description ? `<span class="app-tag description-tag"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg> ${app.description}</span>` : '';
const categoryTag = `<span class="app-tag category-tag clickable" onclick="event.stopPropagation(); appsManager.switchCategory('${app.category}')"><img src="${categoryIcon}"/> ${categoryName}</span>`;
// Format long description with period if missing
let formattedLongDescription = '';
if (app.longDescription) {
formattedLongDescription = app.longDescription;
if (!formattedLongDescription.endsWith('.') && !formattedLongDescription.endsWith('?') && !formattedLongDescription.endsWith('!')) {
formattedLongDescription += '.';
}
}
// Service trigger icon (only for installed apps - visibility controlled after services load)
const serviceTrigger = app.installed ? `
<div class="service-trigger" id="service-trigger-${appName}" style="display:none;">
<div class="service-trigger-icon" onclick="event.stopPropagation(); window.toggleServiceTrigger('${appName}')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
Open
</div>
<div class="service-trigger-popup">
<div id="service-popup-content-${appName}"></div>
</div>
</div>` : '';
card.innerHTML = `
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
<div class="app-card-icon">
<img src="${icon}" alt="${app.name}" onerror="this.src='/core/icons/apps/default.svg'"/>
</div>
<div class="app-card-content">
<div class="app-card-title" style="cursor: pointer;">${app.name.split(' - ')[0].trim()}</div>
<div class="app-card-tags">
${descriptionTag}
${categoryTag}
<span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span>
</div>
</div>
</div>
${formattedLongDescription ? `<div class="app-card-long-description">${formattedLongDescription}</div>` : ''}
<div class="app-card-actions">
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
${app.installed ? 'Manage' : 'Install'}
</button>
${serviceTrigger}
</div>
`;
return card;
},
// Populate inline service trigger popups for installed apps
async populateInlineServiceButtons() {
if (!window.serviceButtons) return;
if (window.serviceButtons.services.length === 0) {
await window.serviceButtons.loadServices();
}
const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http';
const popupContents = document.querySelectorAll('[id^="service-popup-content-"]');
for (const content of popupContents) {
const appName = content.id.replace('service-popup-content-', '');
const trigger = document.getElementById(`service-trigger-${appName}`);
const appServices = window.serviceButtons.services.filter(s => s.app === appName && s.buttonEnabled === true);
if (appServices.length === 0) continue;
// Multi-button render via the shared expandServiceLinks() helper.
const buttons = appServices.flatMap(s => {
const protectedClass = s.loginRequired ? ' protected' : '';
const lockIcon = s.loginRequired
? `<span class="service-lock-icon" title="Login required for this URL — credentials in Config → General → Logins."><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>`
: '';
return window.expandServiceLinks(s).map(({ url, label }) => `
<a href="${url}" target="_blank" rel="noopener noreferrer" class="service-button${protectedClass}" onclick="event.stopPropagation()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
${label}
${lockIcon}
</a>
`);
}).filter(Boolean).join('');
if (buttons) {
content.innerHTML = buttons;
if (trigger) trigger.style.display = '';
}
}
},
});

View File

@ -0,0 +1,198 @@
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
Object.assign(AppsManager.prototype, {
// Snapshot of CFG_ field values, keyed by input name. Mirrors the filter in
// collectConfigFromForm so the two agree on what counts as a config field.
_readConfigFieldState(form) {
const state = {};
form.querySelectorAll('input, select, textarea').forEach((input) => {
const name = input.name;
if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return;
state[name] = (input.type === 'checkbox') ? (input.checked ? 'true' : 'false') : input.value;
});
return state;
},
_getDirtyConfigFields() {
if (!this._dirtyAppName || !this._configSnapshot) return [];
const form = document.getElementById(`app-form-${this._dirtyAppName}`);
if (!form) return [];
const current = this._readConfigFieldState(form);
return Object.keys(current).filter((name) => current[name] !== (this._configSnapshot[name] ?? ''));
},
_isConfigDirty() {
return this._getDirtyConfigFields().length > 0;
},
// Called once per config-panel render: snapshots the saved state, wires the
// change listener + sticky bar, and (re)registers the nav guard. Only tracks
// installed apps — for a fresh install the Install button is already the
// "apply" action, so a dirty bar would just be noise.
wireConfigDirtyTracking(appName) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) return;
const app = (window.apps || []).find((a) =>
(a.command || '').endsWith(` ${appName}`) ||
(a.name && a.name.toLowerCase() === appName.toLowerCase())
);
if (!app || !app.installed) {
this._clearConfigDirty();
return;
}
this._dirtyAppName = appName;
this._configSnapshot = this._readConfigFieldState(form);
if (form.dataset.dirtyWired !== '1') {
form.dataset.dirtyWired = '1';
const onEdit = () => this._refreshDirtyBar();
form.addEventListener('input', onEdit);
form.addEventListener('change', onEdit);
}
this._ensureDirtyBar(appName, form);
// beforeunload covers tab close / refresh / external nav — the browser
// shows its own generic prompt. Registered once for the page lifetime.
if (!this._beforeUnloadWired) {
this._beforeUnloadWired = true;
window.addEventListener('beforeunload', (e) => {
if (this._isConfigDirty()) { e.preventDefault(); e.returnValue = ''; }
});
}
// SPA route changes route through this guard (see spa.js navigate()).
window.__appConfigNavGuard = (targetPath) => this._appConfigNavGuard(targetPath);
this._refreshDirtyBar();
},
// Build (or rebuild) the sticky bar at the bottom of the config form.
_ensureDirtyBar(appName, form) {
const stale = document.getElementById('config-dirty-bar');
if (stale) stale.remove();
const bar = document.createElement('div');
bar.id = 'config-dirty-bar';
bar.className = 'config-dirty-bar';
bar.style.display = 'none';
bar.innerHTML = `
<span class="config-dirty-msg">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span class="config-dirty-count"></span>
</span>
<span class="config-dirty-actions">
<button type="button" class="btn btn-secondary config-dirty-discard">Discard</button>
<button type="button" class="btn btn-primary config-dirty-apply">Apply</button>
</span>`;
// Sits in normal flow between the config content and the action buttons.
const actions = form.querySelector('.config-actions');
if (actions) {
form.insertBefore(bar, actions);
} else {
form.appendChild(bar);
}
bar.querySelector('.config-dirty-discard').addEventListener('click', () => this._discardConfigChanges());
bar.querySelector('.config-dirty-apply').addEventListener('click', () => this.installApp(appName));
},
_refreshDirtyBar() {
const bar = document.getElementById('config-dirty-bar');
if (!bar) return;
const count = this._getDirtyConfigFields().length;
if (count === 0) {
bar.style.display = 'none';
return;
}
const label = bar.querySelector('.config-dirty-count');
if (label) label.textContent = `${count} unsaved change${count === 1 ? '' : 's'}`;
bar.style.display = 'flex';
},
// Revert every field to its snapshot value, then re-fire change/input so
// dependent UI (showWhen visibility, etc.) reconciles.
_discardConfigChanges() {
if (!this._dirtyAppName || !this._configSnapshot) return;
const form = document.getElementById(`app-form-${this._dirtyAppName}`);
if (!form) return;
form.querySelectorAll('input, select, textarea').forEach((input) => {
const name = input.name;
if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return;
if (!(name in this._configSnapshot)) return;
const orig = this._configSnapshot[name];
if (input.type === 'checkbox') {
input.checked = (orig === 'true');
} else {
input.value = orig;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
});
this._refreshDirtyBar();
},
// Drop the dirty state without touching the form — used when the changes are
// being applied (the form is about to be replaced) or discarded on leave.
_clearConfigDirty() {
this._configSnapshot = null;
this._dirtyAppName = null;
window.__appConfigNavGuard = null;
const bar = document.getElementById('config-dirty-bar');
if (bar) bar.style.display = 'none';
},
// SPA nav guard body — returns 'proceed' | 'stay'. 'apply' kicks off the
// normal apply flow and stays put (apply navigates to the tasks view itself).
async _appConfigNavGuard() {
if (!this._isConfigDirty()) return 'proceed';
const appName = this._dirtyAppName;
const decision = await this._confirmLeaveUnsaved(appName);
if (decision === 'apply') {
this.installApp(appName);
return 'stay';
}
if (decision === 'discard') {
this._clearConfigDirty();
return 'proceed';
}
return 'stay';
},
// Apply / Discard / Stay prompt. Resolves with the chosen action; closing
// via the X or backdrop resolves 'stay' (the safe default).
_confirmLeaveUnsaved(appName) {
let displayName = appName;
const app = (window.apps || []).find((a) =>
(a.command || '').endsWith(` ${appName}`) ||
(a.name && a.name.toLowerCase() === appName.toLowerCase())
);
if (app && app.name) displayName = app.name.split(' - ')[0].trim();
return new Promise((resolve) => {
let decided = false;
const finish = (val, modal) => {
if (decided) return;
decided = true;
if (modal) modal.close();
resolve(val);
};
window.openEoModal({
id: 'config-unsaved-modal',
size: 'sm',
eyebrow: '⚠ Unsaved changes',
title: displayName,
desc: 'You have configuration changes that havent been applied.',
body: `
<div class="eo-empty-state warning" role="status">
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">Apply before you go?</p>
<p class="eo-empty-state-text">Apply runs the update now. Discard throws the edits away. Stay keeps you on this page.</p>
</div>
</div>`,
actions: [
{ label: 'Apply', variant: 'primary', onClick: (m) => finish('apply', m) },
{ label: 'Discard', variant: 'secondary', onClick: (m) => finish('discard', m) },
{ label: 'Stay', variant: 'secondary', onClick: (m) => finish('stay', m) }
],
onClose: () => { if (!decided) { decided = true; resolve('stay'); } }
});
});
},
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,350 @@
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
Object.assign(AppsManager.prototype, {
countryFlagEmoji(name) {
const map = {
'Albania':'AL','Algeria':'DZ','Andorra':'AD','Angola':'AO','Argentina':'AR','Armenia':'AM','Australia':'AU','Austria':'AT','Azerbaijan':'AZ',
'Bahamas':'BS','Bahrain':'BH','Bangladesh':'BD','Belarus':'BY','Belgium':'BE','Belize':'BZ','Bermuda':'BM','Bhutan':'BT','Bolivia':'BO',
'Bosnia and Herzegovina':'BA','Brazil':'BR','Brunei':'BN','Brunei Darussalam':'BN','Bulgaria':'BG','Cambodia':'KH','Canada':'CA','Chile':'CL',
'China':'CN','Colombia':'CO','Costa Rica':'CR','Croatia':'HR','Cyprus':'CY','Czech Republic':'CZ','Czechia':'CZ',
'Denmark':'DK','Dominican Republic':'DO','Ecuador':'EC','Egypt':'EG','El Salvador':'SV','Estonia':'EE','Ethiopia':'ET',
'Finland':'FI','France':'FR','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR','Greenland':'GL','Guatemala':'GT',
'Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS','India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ',
'Ireland':'IE','Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP','Jordan':'JO','Kazakhstan':'KZ',
'Kenya':'KE','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV','Lebanon':'LB','Liechtenstein':'LI','Lithuania':'LT',
'Luxembourg':'LU','Macao':'MO','Macau':'MO','North Macedonia':'MK','Macedonia':'MK','Madagascar':'MG','Malaysia':'MY','Malta':'MT',
'Mexico':'MX','Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME','Morocco':'MA','Myanmar':'MM','Nepal':'NP',
'Netherlands':'NL','New Zealand':'NZ','Nicaragua':'NI','Nigeria':'NG','Norway':'NO','Oman':'OM','Pakistan':'PK','Panama':'PA',
'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH','Poland':'PL','Portugal':'PT','Puerto Rico':'PR','Qatar':'QA',
'Romania':'RO','Russia':'RU','Russian Federation':'RU','Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Singapore':'SG',
'Slovakia':'SK','Slovenia':'SI','South Africa':'ZA','South Korea':'KR','Korea, Republic of':'KR','Spain':'ES','Sri Lanka':'LK',
'Sweden':'SE','Switzerland':'CH','Taiwan':'TW','Tajikistan':'TJ','Thailand':'TH','Trinidad and Tobago':'TT','Tunisia':'TN',
'Turkey':'TR','Türkiye':'TR','Turkmenistan':'TM','Ukraine':'UA','United Arab Emirates':'AE','UAE':'AE','United Kingdom':'GB','UK':'GB',
'United States':'US','USA':'US','United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ','Venezuela':'VE','Vietnam':'VN','Viet Nam':'VN'
};
const code = map[name];
if (!code) return '🏳️';
return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + (c.charCodeAt(0) - 65)));
},
openGluetunCountriesModal(fieldId) {
const hidden = document.getElementById(fieldId);
const chips = document.getElementById(`${fieldId}-chips`);
if (!hidden) return;
const providerEl = (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunProviderEl)
? ConfigOptions.findGluetunProviderEl()
: null;
const provider = providerEl ? providerEl.value : '';
const providers = window.gluetunProviders || {};
const countries = (provider && providers[provider] && Array.isArray(providers[provider].countries))
? [...providers[provider].countries].sort((a, b) => a.localeCompare(b))
: [];
const current = new Set((hidden.value || '').split(',').map(s => s.trim()).filter(Boolean));
const existing = document.getElementById('gluetun-countries-modal');
if (existing) existing.remove();
const flag = (n) => this.countryFlagEmoji(n);
const renderChips = (list) => list.length
? list.map(c => `<span class="gluetun-country-chip"><span class="gluetun-flag">${flag(c)}</span>${c}</span>`).join('')
: `<span class="gluetun-country-empty">Any</span>`;
const fallbackProviderIcon = `data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2338bdf8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z'/><path d='M9 12l2 2 4-4'/></svg>`;
const providerLabel = provider ? provider.replace(/\b\w/g, (c) => c.toUpperCase()) : '— none selected —';
const iconManifest = window.gluetunProviderIcons || {};
const providerIconUrl = (provider && iconManifest[provider]) || fallbackProviderIcon;
const bodyHtml = `
<div class="gluetun-provider-card">
<div class="gluetun-provider-icon-wrap">
<img class="gluetun-provider-icon" src="${providerIconUrl}" alt="${provider}" onerror="this.onerror=null; this.src='${fallbackProviderIcon}';">
</div>
<div class="gluetun-provider-text">
<p class="gluetun-provider-label">Provider</p>
<p class="gluetun-provider-name">${providerLabel}</p>
</div>
</div>
<div class="gluetun-search-card">
<div class="gluetun-search-row">
<svg class="gluetun-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" class="gluetun-country-search" placeholder="Filter countries...">
</div>
<div class="gluetun-search-actions">
<button type="button" class="btn btn-secondary gluetun-country-all">Select all</button>
<button type="button" class="btn btn-secondary gluetun-country-none">Clear</button>
</div>
</div>
<div class="gluetun-country-list">
${countries.length === 0
? `<p class="gluetun-country-empty-msg">No country list available for this provider. Pick a provider first or wait for the snapshot to load.</p>`
: countries.map(c => `
<label class="gluetun-country-item">
<input type="checkbox" value="${c}" ${current.has(c) ? 'checked' : ''}>
<span class="gluetun-country-name"><span class="gluetun-flag">${flag(c)}</span>${c}</span>
</label>`).join('')}
</div>`;
const m = window.openEoModal({
id: 'gluetun-countries-modal',
title: '🌍 Select VPN Countries',
body: bodyHtml,
actions: [
{ label: 'Save', variant: 'primary', onClick: (modal) => {
const picked = Array.from(modal.contentEl.querySelectorAll('.gluetun-country-item input:checked')).map(cb => cb.value);
hidden.value = picked.join(',');
hidden.dispatchEvent(new Event('change', { bubbles: true }));
if (chips) chips.innerHTML = renderChips(picked);
modal.close();
}},
{ label: 'Cancel', variant: 'secondary' }
]
});
const root = m.contentEl;
root.querySelector('.gluetun-country-search').addEventListener('input', (e) => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('.gluetun-country-item').forEach(item => {
const label = item.querySelector('.gluetun-country-name').textContent.toLowerCase();
item.style.display = label.includes(q) ? '' : 'none';
});
});
root.querySelector('.gluetun-country-all').addEventListener('click', () => {
root.querySelectorAll('.gluetun-country-item').forEach(item => {
if (item.style.display !== 'none') item.querySelector('input').checked = true;
});
});
root.querySelector('.gluetun-country-none').addEventListener('click', () => {
root.querySelectorAll('.gluetun-country-item input').forEach(cb => cb.checked = false);
});
},
async openGluetunRouteAppsModal() {
const existing = document.getElementById('gluetun-route-apps-modal');
if (existing) existing.remove();
let allow = [];
try {
const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' });
if (r.ok) allow = (await r.json()).categories || [];
} catch {}
const allowSet = new Set(allow.map((c) => String(c).toLowerCase()));
const overrideOn = (typeof ConfigOptions !== 'undefined') && this.checkRequirementEnabled('GLUETUN_FOR_ALL');
const skip = new Set(['gluetun', 'libreportal', 'traefik', 'fail2ban']);
const apps = (window.apps || [])
.filter((a) => a.installed)
.map((a) => {
const slug = (a.command || '').split(' ').pop();
return { ...a, slug };
})
.filter((a) => a.slug && !skip.has(a.slug))
.filter((a) => overrideOn || allowSet.has(String(a.category || '').toLowerCase()))
.sort((a, b) => (a.name || a.slug).localeCompare(b.name || b.slug));
const bodyHtml = `
<p class="eo-modal-section-text">
Tick an app to send its outbound traffic through the Gluetun VPN. Untick to restore the default network.
Each change re-runs that app's install task to apply the new compose.
</p>
${apps.length === 0 ? `
<div class="eo-empty-state info" role="status">
<div class="eo-empty-state-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</div>
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">No eligible installed apps</p>
<p class="eo-empty-state-text">
Install an app from the curated categories first, or enable the
<strong>Gluetun For All Apps</strong> requirement to expose every app.
</p>
</div>
</div>
` : `
<div class="gluetun-country-list">
${apps.map((a) => {
const cfgKey = `CFG_${a.slug.toUpperCase()}_NETWORK`;
const current = (a.config && a.config[cfgKey]) || 'default';
const checked = current === 'gluetun' ? 'checked' : '';
const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/core/icons/apps/default.svg';
return `
<label class="gluetun-country-item">
<input type="checkbox" data-slug="${a.slug}" data-current="${current}" ${checked}>
<span class="gluetun-country-name" style="display:flex; align-items:center; gap:10px;">
<img src="${icon}" alt="" style="width:20px; height:20px; object-fit:contain;" onerror="this.onerror=null; this.src='/core/icons/apps/default.svg';">
${a.name || a.slug}
</span>
</label>`;
}).join('')}
</div>
`}`;
const m = window.openEoModal({
id: 'gluetun-route-apps-modal',
title: '🛡️ Route apps through Gluetun',
body: bodyHtml,
actions: [
{ label: 'Apply', variant: 'primary', onClick: async (modal) => {
if (apps.length === 0) { modal.close(); return; }
const root = modal.contentEl;
const applyBtn = root.querySelectorAll('.eo-modal-footer .btn')[0];
const changes = [];
root.querySelectorAll('.gluetun-country-item input[type=checkbox]').forEach((cb) => {
const desired = cb.checked ? 'gluetun' : 'default';
if (desired !== cb.dataset.current) changes.push({ slug: cb.dataset.slug, value: desired });
});
if (changes.length === 0) { modal.close(); return; }
applyBtn.disabled = true; applyBtn.textContent = 'Applying…';
try {
if (!window.tasksManager?.router) await this.loadTaskSystem?.();
for (const { slug, value } of changes) {
const cfgKey = `CFG_${slug.toUpperCase()}_NETWORK`;
await window.tasksManager.router.routeAction('install', { appName: slug, config: { [cfgKey]: value } });
}
this.addSuccessLog?.(`Queued ${changes.length} gluetun routing task(s).`);
modal.close();
if (window.appTabbedManager) window.appTabbedManager.switchTab('tasks');
} catch (err) {
applyBtn.disabled = false; applyBtn.textContent = 'Apply';
console.error('Failed to queue gluetun routing tasks', err);
}
}},
{ label: 'Cancel', variant: 'secondary' }
]
});
if (apps.length === 0) {
const applyBtn = m.contentEl.querySelectorAll('.eo-modal-footer .btn')[0];
if (applyBtn) applyBtn.disabled = true;
}
},
openMullvadGenerateModal() {
const existing = document.getElementById('mullvad-generate-modal');
if (existing) existing.remove();
const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/components/apps/core/icons/vpn/mullvad.svg';
const bodyHtml = `
<p class="eo-modal-section-text">
Enter your 16-digit Mullvad account number. A new WireGuard key will be generated locally
and registered with Mullvad this consumes one of your 5 device slots.
</p>
<div class="form-group">
<label for="mullvad-acct" style="display:block; margin-bottom:6px; font-size:13px;">Account number</label>
<input type="text" id="mullvad-acct" class="form-input" placeholder="1234567890123456" autocomplete="off" inputmode="numeric">
</div>
<p class="mullvad-error" style="color: #f87171; font-size: 13px; margin: 8px 0 0 0; display:none;"></p>`;
const m = window.openEoModal({
id: 'mullvad-generate-modal',
size: 'sm',
icon: mullvadIcon,
iconAlt: 'Mullvad',
eyebrow: 'Provider',
title: 'Generate Mullvad Config',
body: bodyHtml,
actions: [
{ label: 'Generate', variant: 'primary', onClick: async (modal) => {
const root = modal.contentEl;
const errEl = root.querySelector('.mullvad-error');
const confirmBtn = root.querySelectorAll('.eo-modal-footer .btn')[0];
const acctEl = root.querySelector('#mullvad-acct');
const setError = (msg) => { errEl.textContent = msg || ''; errEl.style.display = msg ? '' : 'none'; };
const account = (acctEl.value || '').replace(/\s+/g, '');
if (!/^\d{16}$/.test(account)) { setError('Account number must be 16 digits.'); return; }
setError('');
confirmBtn.disabled = true; confirmBtn.textContent = 'Generating…';
try {
const res = await fetch('/api/gluetun/mullvad-wireguard', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountNumber: account })
});
const data = await res.json();
if (!res.ok || !data.success) {
setError(data.error || `Request failed (${res.status}).`);
confirmBtn.disabled = false; confirmBtn.textContent = 'Generate';
return;
}
const findField = (suffix) => (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunFieldEl) ? ConfigOptions.findGluetunFieldEl(suffix) : null;
const setField = (suffix, value) => {
const el = findField(suffix); if (!el) return;
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
};
setField('WIREGUARD_PRIVATE_KEY', data.privateKey);
setField('WIREGUARD_ADDRESSES', data.addresses);
modal.close();
} catch (err) {
setError(err.message || 'Network error.');
confirmBtn.disabled = false; confirmBtn.textContent = 'Generate';
}
}},
{ label: 'Cancel', variant: 'secondary' }
]
});
m.contentEl.querySelector('#mullvad-acct').focus();
},
async shouldRecommendGluetun(appName, appData) {
if (appName === 'gluetun') return false;
if (this.checkServiceInstalled('gluetun')) return false;
const overrideOn = this.checkRequirementEnabled?.('GLUETUN_FOR_ALL');
if (overrideOn) return true;
if (!this._gluetunEligiblePromise) {
this._gluetunEligiblePromise = (async () => {
try {
const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' });
if (!r.ok) return new Set();
const j = await r.json();
return new Set((j.categories || []).map((c) => String(c).toLowerCase()));
} catch { return new Set(); }
})();
}
const allow = await this._gluetunEligiblePromise;
const cat = String(appData?.category || '').toLowerCase();
return allow.has(cat);
},
showGluetunRecommendModal(appName, appData) {
const existing = document.getElementById('gluetun-recommend-modal');
if (existing) existing.remove();
const displayName = appData?.name?.split(' - ')[0]?.trim() || appName;
let icon = appData?.icon || `/core/icons/apps/${appName}.svg`;
if (icon && !icon.startsWith('/')) icon = '/' + icon;
const bodyHtml = `
<div class="eo-empty-state warning" role="status">
<div class="eo-empty-state-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z"/>
</svg>
</div>
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">Apps in this category usually benefit from VPN routing</p>
<p class="eo-empty-state-text">Without Gluetun, this app's outbound traffic uses your real public IP exposing activity to ISPs, copyright trackers, and the destination services.</p>
</div>
</div>`;
window.openEoModal({
id: 'gluetun-recommend-modal',
size: 'sm',
icon,
iconAlt: displayName,
eyebrow: 'About to install',
title: displayName,
desc: 'VPN routing is recommended for this app.',
body: bodyHtml,
actions: [
{ label: 'Install Gluetun first', variant: 'primary', onClick: (modal) => {
modal.close();
this.showAppDetailWithConfig('gluetun');
}},
{ label: 'Continue without VPN', variant: 'secondary', onClick: (modal) => {
modal.close();
this.executeInstall(appName, false);
}}
]
});
},
});

View File

@ -0,0 +1,49 @@
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
Object.assign(AppsManager.prototype, {
// Terminal logging functions with old styling
addLogMessage(message, type = 'info') {
const messageLog = document.getElementById('message-log');
if (!messageLog) return;
const timestamp = new Date().toLocaleTimeString();
const messageLine = document.createElement('div');
messageLine.className = `log-entry ${type}`;
messageLine.innerHTML = `<span class="log-timestamp">[${timestamp}]</span> ${message}`;
messageLog.appendChild(messageLine);
// Only scroll to bottom if user hasn't scrolled up
if (messageLog.scrollTop >= messageLog.scrollHeight - messageLog.clientHeight - 50) {
messageLog.scrollTop = messageLog.scrollHeight;
}
},
addSuccessLog(message) {
this.addLogMessage(message, 'success');
},
addErrorLog(message) {
this.addLogMessage(message, 'error');
},
addWarningLog(message) {
this.addLogMessage(message, 'warning');
},
addInfoLog(message) {
this.addLogMessage(message, 'info');
},
clearConsole() {
const messageLog = document.getElementById('message-log');
if (!messageLog) return;
messageLog.innerHTML = '<div class="log-entry info"><span class="log-timestamp">[' + new Date().toLocaleTimeString() + ']</span> Console cleared...</div>';
messageLog.scrollTop = 0; // Force to top
},
initializeConsole() {
const messageLog = document.getElementById('message-log');
if (messageLog) {
messageLog.scrollTop = 0; // Force scroll to top
messageLog.innerHTML = ''; // Clear any existing content
// Add initial message after a tiny delay to ensure it renders at top
setTimeout(() => {
this.addInfoLog('Application configuration loaded successfully');
}, 100);
}
},
});

View File

@ -0,0 +1,99 @@
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
Object.assign(AppsManager.prototype, {
// Initialize port managers after DOM is ready
async initializePortManagers() {
//// // console.log('🔌 Looking for port manager containers...');
const portContainers = document.querySelectorAll('.port-manager-container');
//// // console.log(`🔌 Found ${portContainers.length} port manager containers`);
// Group port containers by app
const appPortContainers = {};
for (const container of portContainers) {
const appName = container.dataset.appName;
if (!appPortContainers[appName]) {
appPortContainers[appName] = [];
}
appPortContainers[appName].push(container);
}
// Create one consolidated port manager per app
for (const [appName, containers] of Object.entries(appPortContainers)) {
//// // console.log(`🔌 Creating consolidated port manager for app: ${appName} with ${containers.length} port fields`);
try {
// Get all port configurations for this app
const appConfig = this.getCurrentAppConfig();
const allPortConfigs = this.getAllPortConfigs(appConfig, appName);
// Create consolidated port manager
const portManager = new PortManager();
const html = portManager.generateHTML(appName, allPortConfigs);
// Replace the first container with the consolidated port manager
const firstContainer = containers[0];
firstContainer.innerHTML = html;
// Hide other port containers (PORT_2, PORT_3, etc.) and their labels
for (let i = 1; i < containers.length; i++) {
const container = containers[i];
const formField = container.closest('.form-field');
if (formField) {
formField.style.display = 'none';
} else {
container.style.display = 'none';
}
}
// Hide labels and help text for the first port container
this.hidePortFieldLabels(containers[0]);
// Initialize port manager with services
await portManager.initialize(appName);
//// // console.log(`🔌 Consolidated port manager initialized successfully for ${appName}`);
} catch (error) {
console.error(`Error initializing consolidated port manager for ${appName}:`, error);
containers[0].innerHTML = `<div class="error">Failed to initialize port manager: ${error.message}</div>`;
}
}
},
// Hide labels and help text for port field containers
hidePortFieldLabels(container) {
const formField = container.closest('.form-field');
if (formField) {
// Hide the label
const label = formField.querySelector('label.form-label');
if (label) {
label.style.display = 'none';
}
// Hide the help text
const helpText = formField.querySelector('small.form-help');
if (helpText) {
helpText.style.display = 'none';
}
// Hide any help icons
const helpIcons = formField.querySelectorAll('.help-icon');
helpIcons.forEach(icon => {
icon.style.display = 'none';
});
}
},
// Get all port configurations for an app
getAllPortConfigs(appConfig, appName) {
const portConfigs = [];
const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`;
Object.keys(appConfig).forEach(key => {
if (key.startsWith(portPrefix)) {
const configValue = appConfig[key];
if (configValue && configValue.trim() !== '') {
portConfigs.push(configValue);
}
}
});
// Return as array — one CFG_<APP>_PORT_N value per element. The
// port manager iterates this directly so commas inside fields
// (multi-button labels / paths) stay meaningful and hand-editable.
return portConfigs;
},
});

View File

@ -0,0 +1,47 @@
// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base.
Object.assign(AppsManager.prototype, {
// Update service buttons sidebar with service data from apps-services.json
async updateServiceButtonsSidebar(app, appName) {
if (!window.serviceButtons) return;
try {
// Update sidebar using the new ServiceButtons API
await window.serviceButtons.updateSidebar(appName);
} catch (error) {
console.error('Error updating service buttons sidebar:', error);
}
},
// Show service popup on hover
async showServicePopup(event, appName) {
if (!window.serviceButtons) return;
const popup = document.getElementById(`service-popup-${appName}`);
if (!popup) return;
try {
// Load services if not already loaded
if (window.serviceButtons.services.length === 0) {
await window.serviceButtons.loadServices();
}
// Generate buttons HTML
const buttonsHTML = await window.serviceButtons.generateButtonsHTML(appName);
const content = popup.querySelector('.service-popup-content');
if (content) {
content.innerHTML = buttonsHTML;
}
// Show popup
popup.style.display = 'block';
} catch (error) {
console.error('Error showing service popup:', error);
}
},
// Hide service popup
hideServicePopup() {
const popups = document.querySelectorAll('.service-popup');
popups.forEach(popup => {
popup.style.display = 'none';
});
},
});

View File

@ -219,7 +219,15 @@ class SystemLoader {
'/components/apps/services/js/services-manager.js', '/components/apps/services/js/services-manager.js',
'/components/apps/tools/js/tools-manager.js', '/components/apps/tools/js/tools-manager.js',
'/components/apps/routing/js/routing-manager.js', '/components/apps/routing/js/routing-manager.js',
'/components/apps/core/js/apps-manager.js' '/components/apps/core/js/apps-manager.js', // base: class + constructor + orchestration + ServiceButtons + bootstrap (+ install-dispatch, kept inline)
// prototype-augment clusters (load after base; ordered via async=false):
'/components/apps/core/js/apps-grid.js',
'/components/apps/core/js/config-form.js',
'/components/apps/core/js/port-manager-integration.js',
'/components/apps/core/js/gluetun-vpn.js',
'/components/apps/core/js/config-dirty-guard.js',
'/components/apps/core/js/install-console.js',
'/components/apps/core/js/service-buttons-sidebar.js'
] ]
}); });