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:
parent
3d058b3469
commit
df75059330
@ -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 = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 haven’t 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
@ -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);
|
||||
}}
|
||||
]
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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';
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -219,7 +219,15 @@ class SystemLoader {
|
||||
'/components/apps/services/js/services-manager.js',
|
||||
'/components/apps/tools/js/tools-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'
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user