librelad 35c06a90a5 fix(apps): balance column count so 4-on-3-col wraps to 2x2 instead of leaving an orphan card
Screenshot showed a 4-card category laying out as 3+1 (three cards on
row 1, Wireguard Easy alone on row 2 with two card-shaped empty cells
on its right). Fixed-width tracks + auto-fill kept the cards aligned
across categories but couldn't avoid the orphan — pure CSS grid has
no way to collapse partial-row trailing cells when the column above
them is filled.

apps-manager.js now picks --app-cols deliberately: the natural
column count for the viewport, reduced by one when the last row
would otherwise be exactly one orphan card. 4 cards on a 3-col
viewport becomes 2x2; 5 cards stays at 3+2; 6 stays at 3+3+0; 7
drops to 2-col so the last row gets a partner (still has one orphan
at the very end since 7 is prime, but never below 2 cols — a single
column stack reads worse than an orphan).

CSS swap: grid-template-columns now consumes the new --app-cols
custom property and uses minmax(--app-min, 1fr) so cards stretch
within their tracks (the orphan-prevention dance means widths can
vary across categories now — tradeoff for never having internal
gaps). 1-card view still shrinks the box via the existing formula
so a lone card isn't stretched across the full row.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 01:06:50 +01:00

4152 lines
172 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Single source of truth for "given an apps-services.json entry, what
// clickable links should we render for it?" Returns an array of
// { url, label } pairs.
//
// New schema: each service entry carries a `links: [{label, externalURL,
// internalURL}, ...]` array (built from the comma-separated label/path
// pairs in CFG_<APP>_PORT_N cols 9 and 10).
//
// Legacy fallback: if `links` is missing, build a single entry from
// externalURL (or serverIP+externalPort) + buttonText, matching the
// original single-button behaviour for snapshots that pre-date the
// multi-button generator.
//
// Used by every UI surface that renders Open buttons:
// - dashboard.js (dashboard app-card hover overlay)
// - apps-manager.js (apps list popup + app-header buttons)
// - services-manager.js (Services tab Open buttons)
window.expandServiceLinks = function(s) {
const proto = ['http', 'https'].includes((s.protocol || '').toLowerCase())
? s.protocol.toLowerCase()
: 'http';
if (Array.isArray(s.links) && s.links.length > 0) {
return s.links
.map(l => ({
url: l.externalURL || l.internalURL,
label: l.label || s.buttonText || s.name
}))
.filter(l => !!l.url);
}
const fallbackUrl = s.externalURL ||
(s.serverIP
? `${proto}://${s.serverIP}:${s.externalPort}`
: `${proto}://localhost:${s.externalPort}`);
return fallbackUrl ? [{ url: fallbackUrl, label: s.buttonText || s.name }] : [];
};
// Apps Manager - Manages the apps list page and app details
class AppsManager {
constructor() {
this.cache = new Map();
this.setupTaskCompletionListener();
// The dynamic-width box decides cap-vs-full based on the parent's
// current size, so re-run it when the window resizes (drags, snaps,
// devtools open). updateAppsCount is a fast no-op when no
// #apps-section is on screen.
window.addEventListener('resize', () => this.updateAppsCount());
}
setupTaskCompletionListener() {
// Listen for task completion events to reload apps data
window.addEventListener('taskCompleted', async (event) => {
const { action, appName, status } = event.detail;
// Tool tasks mutate per-app config — refresh cache silently for next read.
if (action === 'tool' && status === 'completed') {
this.clearCache();
await this.reloadAppsData();
// If the user is viewing this app's detail page, re-render the
// config section in place so updated CFG_* values (e.g. a freshly
// reset password) show without needing a page refresh. Don't
// switch tabs — they may be reading the tool's task log.
const url = new URL(window.location.href);
const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || url.searchParams.get('app') || url.searchParams.get('');
const onAppDetail = window.location.pathname === '/app' || window.location.pathname.startsWith('/app/');
if (onAppDetail && appName && currentAppFromUrl === appName) {
this.displayConfigForm?.((window.apps || []).find(a =>
(a.command || '').endsWith(' ' + appName)
));
}
return;
}
// First-install welcome modal — only on the very first successful install per app per browser.
if (action === 'install' && status === 'completed' && appName) {
const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`;
try {
if (!localStorage.getItem(key)) {
setTimeout(() => this.showInstallWelcome(appName), 600);
}
} catch (_) {}
}
// Only reload on successful install or uninstall
if ((action === 'install' || action === 'uninstall') && status === 'completed') {
// Skip duplicate events for the same task id — the reconcile
// loop can synthesise a 404-fallback completed event after we've
// already handled the real one, which would re-trigger the
// heavy re-render + tab switch and visually flash the page.
const _taskId = event.detail.taskId || event.detail.id;
this._handledTaskIds = this._handledTaskIds || new Set();
if (_taskId && this._handledTaskIds.has(_taskId)) return;
if (_taskId) this._handledTaskIds.add(_taskId);
try {
// If this uninstall asked to delete its own task, do it now —
// the bash side skipped the in-flight task on purpose to
// avoid racing with the processor's final status write.
const taskId = event.detail.taskId || event.detail.id;
if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) {
this._pendingTaskCleanup.delete(taskId);
try {
if (window.tasksManager?.taskManager?.deleteTask) {
await window.tasksManager.taskManager.deleteTask(taskId, { force: true });
}
// Re-render Tasks tab so the empty state ("No tasks found for X") shows.
if (window.tasksManager?.loadTasks) await window.tasksManager.loadTasks();
if (window.tasksManager?.renderTasks) window.tasksManager.renderTasks();
// If the user is parked on the Tasks tab and now there's
// nothing to look at, bounce them to Config.
if (window.appTabbedManager?.currentTab === 'tasks') {
window.appTabbedManager.switchTab('config');
}
} catch (e) { console.error('post-uninstall task cleanup failed:', e); }
}
this.clearCache();
await this.reloadAppsData();
if (window.serviceButtons) {
try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); }
}
const currentUrl = new URL(window.location.href);
const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || currentUrl.searchParams.get('app') || currentUrl.searchParams.get('');
const pathname = window.location.pathname;
const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/');
const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/');
if (isAppsPage && !isAppDetailPage) {
const category = window.appsCategory || 'all';
this.renderApps(category);
} else if (isAppDetailPage && currentAppFromUrl === appName) {
// Defer + isolate the heavy re-render so a throw inside
// displayConfigForm / port-manager init can't lock up the
// post-task UI cleanup. Fires on the next tick — gives the
// task spinners + button enables a chance to repaint first.
setTimeout(() => {
// _skipReload flag tells renderAppDetail not to re-fetch
// apps.json again (we already just did, line above).
this.renderAppDetail(appName, null, true, { skipReload: true })
.catch(err => console.error('renderAppDetail failed:', err));
}, 0);
}
// After uninstall, bounce off the Tasks tab — there's nothing
// to watch any more. Mark the app as "recently uninstalled"
// so the 5s watchForTaskCreation poll doesn't bounce back.
if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) {
window.appTabbedManager = window.appTabbedManager || null;
if (window.appTabbedManager) {
window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map();
window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000);
setTimeout(() => {
if (window.appTabbedManager?.currentTab === 'tasks') {
window.appTabbedManager.switchTab('config');
}
}, 50);
}
}
if (typeof window.renderInstalledApps === 'function') {
window.renderInstalledApps();
}
} catch (err) {
console.error('Post-task handler failed for', action, appName, ':', err);
}
}
});
}
clearCache() {
this.cache.clear();
console.log('🗑️ Apps cache cleared');
}
async reloadAppsData() {
try {
// Reload global apps data
const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' });
if (response.ok) {
const appsData = await response.json();
window.apps = appsData.apps || [];
// console.log(`✅ Reloaded ${window.apps.length} apps`);
}
} catch (error) {
console.error('❌ Failed to reload apps data:', error);
}
}
async loadApps(category = 'all') {
// Check cache first
if (this.cache.has(category)) {
return this.cache.get(category);
}
try {
// Load apps data directly
const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Failed to load apps.json: ${response.status}`);
}
const appsData = await response.json();
// Filter apps by category
let filteredApps = appsData.apps || [];
if (category === 'installed') {
filteredApps = filteredApps.filter(app => app.installed);
} else if (category !== 'all') {
// Apps may live in multiple categories (e.g. "Security,Recommended"
// in their .config). apps.json emits BOTH `categories[]` and a
// singular `category` for back-compat; prefer the array.
filteredApps = filteredApps.filter(app => {
if (Array.isArray(app.categories)) return app.categories.includes(category);
return app.category === category;
});
}
// Sort installed apps first
filteredApps.sort((a, b) => {
if (a.installed && !b.installed) return -1;
if (!a.installed && b.installed) return 1;
return 0;
});
// Cache the result
this.cache.set(category, filteredApps);
return filteredApps;
} catch (error) {
console.error(`AppsManager: Error loading ${category} apps:`, error);
return [];
}
}
async loadCategories() {
try {
const response = await fetch('/data/apps/apps-categories.json');
if (!response.ok) {
throw new Error(`Failed to load apps-categories.json: ${response.status}`);
}
const categoriesData = await response.json();
return categoriesData.categories || [];
} catch (error) {
console.error('AppsManager: Error loading categories:', error);
return [];
}
}
async initialize() {
// Don't load data here - SPA handles it
// Just setup page based on URL
const path = window.location.pathname;
const searchParams = new URLSearchParams(window.location.search);
if (path === '/app' || path.startsWith('/app/') || searchParams.has('app')) {
const appName = decodeURIComponent((path.match(/^\/app\/([^/?]+)/) || [])[1] || '') || searchParams.get('app') || window.appName || '';
this.showAppDetail(appName);
} else {
// Use the category parsed by SPA
let category = window.appsCategory || 'all';
this.showAppsList(category);
}
}
showAppsList(category) {
this.currentView = 'apps';
this.currentApp = null;
// Update URL only for specific categories, not for 'all'
if (category && category !== 'all') {
history.pushState({}, '', `/apps/${category}`);
}
// For 'all' category, keep URL as /apps to avoid redirect loops
// Switch to apps view
this.showView('apps');
// Render apps
this.renderApps(category);
// Setup sidebar
this.setupSidebar(category);
}
showAppDetail(appName, forceConfigTab = false) {
// console.log('🔍 showAppDetail called with:', { appName, forceConfigTab });
//// // console.log(`AppsManager: Showing app detail: ${appName}`);
// Don't proceed if appName is empty - redirect to apps list instead
if (!appName || appName.trim() === '') {
//// // console.log('AppsManager: Empty app name, redirecting to apps list');
this.showAppsList('all');
return;
}
// Check if app has changed - only re-render header if app changed
const appChanged = this.currentApp !== appName;
// Set current view first
this.currentView = 'app-detail';
this.currentApp = appName;
// Update URL to reflect current state
let targetTab;
if (forceConfigTab) {
// Force config tab for install/manage buttons
targetTab = 'config';
// console.log('🔍 Forcing config tab due to forceConfigTab=true');
} else {
// Preserve existing tab or default to config for direct navigation
const currentUrl = new URL(window.location.href);
targetTab = currentUrl.searchParams.get('tab') || 'config';
// console.log('🔍 Preserving existing tab:', targetTab);
}
const newUrl = window.appPath(appName, targetTab);
history.pushState({}, '', newUrl);
// Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or
// any code it triggers calls switchTab → loadTabContent → restoreButtonState,
// we need this.currentApp to already be updated so restoreButtonState checks
// the right app for running tasks.
if (window.appTabbedManager) {
if (typeof window.appTabbedManager.setCurrentApp === 'function') {
window.appTabbedManager.setCurrentApp(appName);
} else {
window.appTabbedManager.currentApp = appName;
}
}
// Switch to app detail view
this.showView('app-detail');
// Render app detail (async) - only re-render header if app changed
this.renderAppDetail(appName, null, appChanged);
// Find app category and setup sidebar
const app = window.apps?.find(a =>
a.name === appName ||
a.id === appName ||
a.slug === appName
);
if (app && app.category) {
this.setupSidebar(app.category);
} else {
this.setupSidebar('all');
}
const appCategory = app ? app.category : 'all';
//// // console.log(`🎯 App category: "${appCategory}"`);
this.setupSidebar(appCategory);
}
// Show app detail with config tab (for install/manage buttons)
showAppDetailWithConfig(appName) {
// console.log('🔍 showAppDetailWithConfig called with:', appName);
// console.log('🔍 Forcing config tab for button click');
// Check if there's a running task for this app — switch straight to the tasks
// tab if so, instead of landing on config (whose buttons would be disabled).
let targetTab = 'config';
let runningTaskId = null;
if (window.appTabbedManager && typeof window.appTabbedManager.getRunningTaskForApp === 'function') {
const running = window.appTabbedManager.getRunningTaskForApp(appName);
if (running) {
targetTab = 'tasks';
runningTaskId = running.taskId;
}
}
// Set URL to target tab (config or tasks)
const newUrl = window.appPath(appName, targetTab);
history.pushState({}, '', newUrl);
// Update app-tabbed-manager. setCurrentApp clears stale disable state from
// whichever app the user came from before showing the new app's content.
if (window.appTabbedManager) {
if (typeof window.appTabbedManager.setCurrentApp === 'function') {
window.appTabbedManager.setCurrentApp(appName);
} else {
window.appTabbedManager.currentApp = appName;
}
// Simulate clicking target tab functionally
// console.log('🔄 Simulating target tab click:', targetTab);
setTimeout(() => {
window.appTabbedManager.switchTab(targetTab);
// Highlight the running task if switching to tasks tab
if (targetTab === 'tasks' && runningTaskId && window.appTabbedManager.tasksManager) {
window.appTabbedManager.tasksManager.highlightedTaskId = runningTaskId;
window.appTabbedManager.tasksManager.renderTasks();
}
}, 100); // Small delay to ensure app is loaded
}
// Continue with normal app detail loading
this.showAppDetail(appName, true);
}
showView(viewType) {
// Get both view containers
const appsView = document.getElementById('apps-view');
const appDetailView = document.getElementById('app-detail-view');
if (viewType === 'apps') {
// Show apps view, hide app detail view
if (appsView) appsView.style.display = 'block';
if (appDetailView) appDetailView.style.display = 'none';
} else if (viewType === 'app-detail') {
// Show app detail view, hide apps view
if (appsView) appsView.style.display = 'none';
if (appDetailView) appDetailView.style.display = 'block';
}
}
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 || `/icons/categories/${id}.svg`;
if (!iconPath.startsWith('/')) iconPath = '/' + iconPath;
iconHtml = `<img src="${iconPath}" alt="${name}" onerror="this.src='/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();
this.updateAppsCount();
// 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);
});
}
// Sync --app-cols + --app-count on .apps-section so the grid lays out
// without orphans on the last row.
//
// --app-cols picks a balanced column count: as many as the row fits
// naturally, but reduced by 1 when the natural choice would leave a
// single card alone on the last row (e.g. 4 cards on a 3-col viewport
// becomes 2x2 instead of 3+1). 5+2, 6+3, etc are accepted — only the
// worst case (last row has exactly 1 orphan) is rebalanced.
//
// --app-count drives the CSS max-width cap: 1 visible card shrinks
// the box to one card's worth (stretching one card to 1000px+ looks
// bad); 2+ cards pass a sentinel (99) so the cap defers to the
// 100%-44px parent and the box runs edge-to-edge.
//
// Driven from render, sidebar search filter, and window resize.
updateAppsCount() {
const container = document.getElementById('apps-section');
if (!container) return;
let visible = 0;
container.querySelectorAll('.app-card').forEach(card => {
if (card.style.display !== 'none') visible++;
});
visible = Math.max(visible, 1);
const style = getComputedStyle(container);
const minCol = parseFloat(style.getPropertyValue('--app-min')) || 300;
const gap = parseFloat(style.getPropertyValue('--app-gap')) || 20;
// Section eats 90px of parent's inner width before any card lands:
// 22px margin + 22px padding + 1px border, doubled.
const parent = container.parentElement;
const inside = parent ? Math.max(0, parent.clientWidth - 90) : 0;
const maxCols = inside > 0
? Math.max(1, Math.floor((inside + gap) / (minCol + gap)))
: visible;
let cols = Math.min(visible, maxCols);
// Avoid orphaning a single card on the last row — reduce columns
// by one so the layout becomes (cols-1)+(cols-1)+… with a fuller
// tail. Don't drop below 2; a 1-col stack looks worse than an
// orphan. Only handles the immediate "last row is 1" case;
// last-row-of-2 etc are accepted as good enough.
if (cols > 2 && visible > cols && visible % cols === 1) {
cols--;
}
container.style.setProperty('--app-cols', cols);
container.style.setProperty('--app-count', visible === 1 ? 1 : 99);
}
// 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';
});
this.updateAppsCount();
}
clearAppsSearch() {
const input = document.getElementById('apps-search-input');
if (input) input.value = '';
this.filterAppsByQuery('');
if (input) input.focus();
}
async renderAppDetail(appName, preferredCategory = null, appChanged = true, opts = {}) {
//// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`);
//// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command })));
// Use global preferred category if not provided
if (!preferredCategory && window.preferredConfigCategory) {
preferredCategory = window.preferredConfigCategory;
}
// …or pick it out of the path (/app/<name>/config/<sub>) so a refresh /
// deep-link lands on the sub-tab encoded in the URL.
if (!preferredCategory && window.appPartsFromPath) {
const parts = window.appPartsFromPath(window.location.pathname);
if (parts.tab === 'config' && parts.sub) preferredCategory = parts.sub;
}
const container = document.getElementById('app-detail-view');
if (!container) return;
// Don't clear the entire container - update specific sections
// This preserves the original console-section styling
// Get current installed status from DOM before reloading
const serviceButtonsContainer = document.getElementById('service-buttons-container');
const wasInstalled = serviceButtonsContainer !== null;
// Reload fresh app data if app has changed (ensures installed status is current after uninstall).
// Caller can pass skipReload when they've just refreshed (post-task listener).
if (appChanged && !opts.skipReload) {
await this.reloadAppsData();
}
// Find app data
const app = window.apps?.find(a =>
a.name === appName ||
a.command === `libreportal app install ${appName}` ||
a.command.endsWith(` ${appName}`)
);
//// // console.log(`🎯 Found app in renderAppDetail:`, app);
if (!app) {
container.innerHTML = '<div class="error">App not found</div>';
return;
}
// Check if installed status changed (e.g., after uninstall) and force header re-render
const isNowInstalled = app.installed;
const installedStatusChanged = wasInstalled !== isNowInstalled;
const shouldRenderHeader = appChanged || installedStatusChanged;
//// // console.log(`🔍 Installed status: was=${wasInstalled}, now=${isNowInstalled}, changed=${installedStatusChanged}`);
// Get app details - extract only the app name (before any dash)
const shortName = app.name.split(' - ')[0].trim(); // Only take text before the first dash
const cleanAppName = app.command.split(' ').pop();
document.title = `${shortName} - LibrePortal`;
// Hide the Backups + Services tabs for not-yet-installed apps — there's
// nothing to back up and no docker compose services running yet.
const backupTab = document.querySelector('.main-tab-button[data-tab="backups"], .tab-button[data-tab="backups"]');
const backupPane = document.getElementById('backups-tab');
if (backupTab) backupTab.style.display = isNowInstalled ? '' : 'none';
if (backupPane && !isNowInstalled) backupPane.classList.remove('active');
const servicesTab = document.querySelector('.main-tab-button[data-tab="services"], .tab-button[data-tab="services"]');
const servicesPane = document.getElementById('services-tab');
if (servicesTab) servicesTab.style.display = isNowInstalled ? '' : 'none';
if (servicesPane && !isNowInstalled) servicesPane.classList.remove('active');
// Tools tab: hide for not-installed AND for installed-but-has-no-tools
// apps. Without the second check, clicking the visible tab triggers
// app-tabbed-manager's "no tools → bounce to config" path which
// looks like a broken redirect.
const toolsTab = document.querySelector('.main-tab-button[data-tab="tools"], .tab-button[data-tab="tools"]');
const toolsPane = document.getElementById('tools-tab');
const catalogEntry = window.toolsCatalog?.apps?.[cleanAppName];
const hasTools = Array.isArray(catalogEntry?.tools) && catalogEntry.tools.length > 0;
const showToolsTab = isNowInstalled && hasTools;
if (toolsTab) toolsTab.style.display = showToolsTab ? '' : 'none';
if (toolsPane && !showToolsTab) toolsPane.classList.remove('active');
const onHiddenTab =
(!isNowInstalled && (window.appTabbedManager?.currentTab === 'backups'
|| window.appTabbedManager?.currentTab === 'services'
|| window.appTabbedManager?.currentTab === 'tools'))
|| (window.appTabbedManager?.currentTab === 'tools' && !showToolsTab);
if (onHiddenTab) {
window.appTabbedManager.switchTab('config');
}
let icon = app.icon || '/icons/apps/default.svg';
// Ensure absolute path from root
if (!icon.startsWith('/')) {
icon = '/' + icon;
}
const status = app.installed ? 'Installed' : 'Not Installed';
const categoryName = this.getCategoryName(app.category);
const categoryIcon = this.getCategoryIcon(app.category);
// Create tags matching app center style
const installedTag = app.installed
? `<span class="app-tag installed-tag">${status}</span>`
: `<span class="app-tag not-installed-tag">${status}</span>`;
const categoryTag = `<span class="app-tag category-tag"><img src="${categoryIcon}"/> ${categoryName}</span>`;
// Render app header section (always define, but only update DOM if app changed)
const headerHTML = `
<div class="app-info">
<div class="app-card-icon" style="width: 80px; height: 80px; min-width: 80px;">
<img src="${icon}" alt="${shortName}" onerror="this.onerror=null; this.src='/icons/apps/default.svg'" style="width: 100%; height: 100%; object-fit: contain;"/>
</div>
<div class="app-details">
<h2>${shortName}</h2>
<p class="app-description">${app.description || 'No description available'}</p>
${app.longDescription ? `<p class="app-long-description">${app.longDescription}</p>` : ''}
<div class="app-meta" style="margin-top: 10px;">
${categoryTag}
${installedTag}
</div>
</div>
</div>
${app.installed ? `
<div class="service-buttons-container" id="service-buttons-container">
<!-- Service buttons will be dynamically inserted -->
</div>
` : ''}
`;
// Render config section with working app-config-original.js approach
// Use the working displayConfigForm from app-config-original.js
await this.displayConfigForm(app, preferredCategory);
const configHTML = document.getElementById('config-section')?.innerHTML || '';
// Initialize port managers after config form is rendered
setTimeout(async () => {
await this.initializePortManagers();
if (typeof ConfigOptions !== 'undefined') {
ConfigOptions.refreshGluetunCredentialVisibility?.();
}
this.wireShowWhenListeners();
this.wireConfigDirtyTracking(cleanAppName);
// Only update service buttons if app has changed or installed status changed
if (shouldRenderHeader) {
this.updateServiceButtonsSidebar(app, cleanAppName);
}
}, 100);
// Render console section with original styling
const consoleHTML = `
<div class="console-section">
<div class="console-title">
<h3>Installation Console</h3>
<p>Monitor the installation and configuration process</p>
</div>
<div class="console-output" id="message-log">
<div class="log-entry info">
<span class="log-timestamp">[${new Date().toLocaleTimeString()}]</span>
Ready to install ${app.name}
</div>
</div>
<div class="console-actions">
<button class="btn btn-primary" onclick="appsManager.installApp('${cleanAppName}')">
Install Application
</button>
<button class="btn btn-secondary" onclick="appsManager.showAppsList('all')">
Back to Apps
</button>
</div>
</div>
`;
// Update specific sections instead of overwriting entire container
// This preserves the original console-section styling
// Only update app header if app has changed or installed status changed
if (shouldRenderHeader) {
const appHeader = document.getElementById('app-header');
//// // console.log('app-header element found:', !!appHeader);
if (appHeader) {
appHeader.innerHTML = headerHTML;
// Explicitly remove service-buttons-container if app is not installed (prevents space)
if (!app.installed) {
const serviceButtonsContainer = document.getElementById('service-buttons-container');
if (serviceButtonsContainer) {
serviceButtonsContainer.remove();
}
}
//// // console.log('App header updated successfully');
}
}
// Update config section
const configSection = document.getElementById('config-section');
//// // console.log('config-section element found:', !!configSection);
if (configSection) {
configSection.innerHTML = configHTML;
//// // console.log('Config section updated successfully, innerHTML length:', configHTML.length);
} else {
console.error('config-section element not found in DOM');
}
// Update console section (preserve original structure)
const consoleSection = container.querySelector('.console-section');
if (consoleSection) {
// Update console output within the existing console-section
const messageLog = consoleSection.querySelector('#message-log');
if (messageLog) {
messageLog.innerHTML = `
<div class="log-entry info">
<span class="log-timestamp">[${new Date().toLocaleTimeString()}]</span>
Ready to install ${app.name}
</div>
`;
}
}
// Initialize console to start at top
this.initializeConsole();
// Load actual app configuration if available
this.loadAppConfig(cleanAppName);
}
async loadAppConfig(appName) {
//// // console.log(`AppsManager: Loading config for ${appName}...`);
try {
// Get app data from global apps array (like original app-config-original.js)
const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName));
if (appData && appData.config) {
//// // console.log(`AppsManager: Loaded config for ${appName}:`, appData.config);
// Update form with actual configuration values from app.config
this.updateConfigForm(appName, appData.config);
} else {
//// // console.log(`AppsManager: No config found for ${appName}, showing default configuration`);
// Show default configuration when no config exists
this.updateConfigForm(appName, {
CFG_APP_NAME: appData?.name || appName,
CFG_VERSION: appData?.version || '1.0.0',
CFG_PORT: '8080',
CFG_DOMAIN: '',
CFG_USERNAME: 'admin',
CFG_PASSWORD: '',
CFG_DEBUG: 'false',
CFG_LOG_LEVEL: 'INFO'
});
}
} catch (error) {
//// // console.log(`AppsManager: Error loading config for ${appName}:`, error);
// Get app data for defaults
const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName));
// Show default configuration on error
this.updateConfigForm(appName, {
CFG_APP_NAME: appData?.name || appName,
CFG_VERSION: appData?.version || '1.0.0',
CFG_PORT: '8080',
CFG_DOMAIN: '',
CFG_USERNAME: 'admin',
CFG_PASSWORD: '',
CFG_DEBUG: 'false',
CFG_LOG_LEVEL: 'INFO'
});
}
}
updateConfigForm(appName, appConfig) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) return;
const appData = window.apps?.find(a => a.name === appName || a.command?.includes(appName));
Object.entries(appConfig).forEach(([key, value]) => {
const field = form.querySelector(`[name="${key}"]`);
if (!field) return;
let nextValue = value;
if (key.endsWith('_NETWORK')) {
nextValue = this.applyContextualDefault('NETWORK', value, appData);
}
if (field.type === 'checkbox') {
field.checked = nextValue === 'true' || nextValue === 'yes';
} else {
field.value = nextValue;
}
});
}
// Display configuration form (working method from app-config-original.js)
async displayConfigForm(appData, preferredCategory = null) {
//// // console.log('displayConfigForm called with:', appData, 'preferredCategory:', preferredCategory);
const configSection = document.getElementById('config-section');
if (!configSection) {
return;
}
const cleanAppName = appData.command.split(' ').pop();
const requiresKey = Object.keys(appData.config || {}).find(k => k.endsWith('_REQUIRES_SERVICE'));
const requiredService = requiresKey ? (appData.config[requiresKey] || '').trim() : '';
if (requiredService && !this.checkServiceInstalled(requiredService) && !appData.installed) {
const slug = requiredService.toLowerCase();
const serviceLabel = slug.charAt(0).toUpperCase() + slug.slice(1);
const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`;
configSection.innerHTML = `
<div class="config-title">
<h3>🛠️ Configuration Settings</h3>
<p>Configure ${this.escHtml(appData.name)} to match your requirements</p>
</div>
<div class="dep-required-card" data-service="${this.escAttr(slug)}">
<img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
<div class="dep-required-body">
<div class="dep-required-title">${this.escHtml(serviceLabel)} required</div>
<div class="dep-required-reason">${this.escHtml(serviceLabel)} needs to be installed before you can configure ${this.escHtml(appData.name)}.</div>
</div>
<button type="button" class="btn btn-primary dep-required-action" onclick="appsManager.navigateToServiceApp('${this.escAttr(slug)}')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17,21 17,13 7,13 7,21"></polyline>
<polyline points="7,3 7,8 15,8"></polyline>
</svg>
Install ${this.escHtml(serviceLabel)}
</button>
</div>
`;
return;
}
//// // console.log('Setting config form HTML for:', appData.name);
// Generate simple tabbed interface with preferred category
//// // console.log('🎨 Generating config HTML with working approach...');
const tabsContent = await this.generateSimpleTabsAndContent(appData, preferredCategory);
const configHTML = `
<div class="config-title">
<h3>🛠️ Configuration Settings</h3>
<p>Configure ${appData.name} to match your requirements</p>
</div>
<form id="app-form-${cleanAppName}" class="config-form" onsubmit="return false;">
<div class="config-container">
<div class="tabs-wrapper">
<div class="tabs-list" id="tabs-list-${Date.now()}">
${tabsContent.tabsHTML}
</div>
<div class="tabs-content">
${tabsContent.contentHTML}
</div>
</div>
</div>
<div class="config-actions">
<button type="button" class="btn btn-secondary" onclick="appsManager.showAppsList('all')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to Apps
</button>
<button type="button" class="btn ${appData.installed ? 'btn-manage' : 'btn-install'}" onclick="appsManager.installApp('${cleanAppName}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
${appData.installed ? 'Update Configuration' : 'Install App'}
</button>
${appData.installed && cleanAppName !== 'libreportal' ? `
<button type="button" class="btn btn-uninstall" onclick="appsManager.uninstallApp('${cleanAppName}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="m19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
Uninstall App
</button>
` : ''}
${appData.installed && cleanAppName === 'gluetun' ? `
<button type="button" class="btn btn-secondary" onclick="appsManager.openGluetunRouteAppsModal()">
<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="M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z"/>
</svg>
Route Apps
</button>
` : ''}
</div>
</form>
`;
configSection.innerHTML = configHTML;
// Initialize tab functionality
this.initializeSimpleTabs();
// Enhance scrollbar dynamically
this.enhanceTabsScrollbar();
//// // console.log('Config form HTML set successfully');
}
// Generate simple tabs and content together (clean, reliable approach)
async generateSimpleTabsAndContent(appData, preferredCategory = null) {
//// // console.log('🏷️📄 generateSimpleTabsAndContent called');
const categories = await this.getConfigCategories();
const fieldMappings = await this.getFieldMappings();
const appConfig = appData.config || {};
//// // console.log(`🏷️ Config categories loaded:`, categories);
//// // console.log(`🏷️ Field mappings loaded:`, fieldMappings);
//// // console.log(`🏷️ App config:`, appConfig);
//// // console.log(`🏷️ Preferred category:`, preferredCategory);
//// // console.log('📂 Available categories:', Object.keys(categories));
//// // console.log('🗂️ Available field mappings:', Object.keys(fieldMappings));
//// // console.log('🔍 Looking for PORT_MANAGER in field mappings:', 'PORT_MANAGER' in fieldMappings);
if ('PORT_MANAGER' in fieldMappings) {
//// // console.log('🔍 PORT_MANAGER field config:', fieldMappings['PORT_MANAGER']);
}
let tabsHTML = '';
let contentHTML = '';
// Sort categories by order
const sortedCategories = Object.entries(categories)
.sort(([,a], [,b]) => a.order - b.order);
// Render each category's fields up front and keep only the ones that
// actually produced fields. A "hasFields" heuristic used to gate the tabs
// separately, but it drifted from what generateConfigFields really emits —
// so a category like Network could pass the check yet render empty, leaving
// a blank tab whose body just reads "No configuration options available".
// Trusting the rendered output keeps tabs and content in lockstep.
const renderedCategories = [];
for (const [key, category] of sortedCategories) {
const content = await this.generateConfigFields(key, appData);
if (!content || content.includes('class="no-fields"')) continue;
renderedCategories.push({ key, category, content });
}
// Use the preferred category if it's one that has fields, else the first.
const activeTab = (preferredCategory && renderedCategories.some(c => c.key === preferredCategory))
? preferredCategory
: (renderedCategories[0] ? renderedCategories[0].key : null);
for (const { key, category, content } of renderedCategories) {
const isActive = key === activeTab ? 'active' : '';
tabsHTML += `
<button class="tab-button ${isActive}" data-tab="${key}" onclick="appsManager.showTab('${key}')">
<span class="tab-emoji">${category.icon}</span>
<span class="tab-name">${category.name}</span>
</button>
`;
contentHTML += `
<div class="tab-panel ${isActive}" id="panel-${key}">
<div class="panel-header">
<h4>${category.icon} ${category.name}</h4>
<p>${category.description}</p>
</div>
<div class="panel-fields app-config">
${content}
</div>
</div>
`;
}
return { tabsHTML, contentHTML };
}
// Initialize tab functionality
initializeSimpleTabs() {
//// // console.log('Simple tabs initialized');
}
// Generate simple fields (working method from app-config-original.js)
async generateSimpleFields(categoryKey, appData) {
//// // console.log(`🔧 Generating fields for category: ${categoryKey}`);
const fieldMappings = await this.getFieldMappings();
const appConfig = appData.config || {};
let fieldsHTML = '';
let hiddenFieldsHTML = '';
// Find fields that belong to this category
for (const [fieldKey, fieldConfig] of Object.entries(fieldMappings)) {
if (fieldConfig.category === categoryKey) {
//// // console.log(`🔧 Processing field: ${fieldKey} with config:`, fieldConfig);
const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig);
// Skip generic mappings when a longer/more-specific one binds to the same cfgKey.
if (cfgKey) {
const moreSpecific = Object.keys(fieldMappings).some(otherKey =>
otherKey !== fieldKey
&& otherKey.length > fieldKey.length
&& this.findMatchingCFGKey(otherKey, appConfig) === cfgKey
);
if (moreSpecific) continue;
}
//// // console.log(`🔧 Found CFG key: ${cfgKey} with value:`, appConfig[cfgKey]);
// Special debug for PORT_1
if (fieldKey === 'PORT_1') {
//// // console.log(`🔧 PORT_1 DEBUG: fieldKey=${fieldKey}, cfgKey=${cfgKey}, hasValue=${!!appConfig[cfgKey]}, value="${appConfig[cfgKey]}"`);
//// // console.log(`🔧 PORT_1 DEBUG: All app config keys:`, Object.keys(appConfig));
}
// For advanced tab, only show advanced fields
if (categoryKey === 'advanced' && !fieldConfig.advanced) {
continue; // Skip non-advanced fields in advanced tab
}
// For regular tabs, skip advanced fields
if (categoryKey !== 'advanced' && fieldConfig.advanced) {
continue; // Skip advanced fields in regular tabs
}
// Skip fields gated by category allowlist when this app's category
// isn't in the list AND the override requirement isn't enabled.
if (Array.isArray(fieldConfig.categoryAllowlist)) {
const appCategory = String(appData?.category || '').toLowerCase();
const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory);
const override = fieldConfig.requirementOverride
? this.checkRequirementEnabled(fieldConfig.requirementOverride)
: false;
if (!inList && !override) continue;
}
// Generic requiresService gating from field-mapping JSON.
if (fieldConfig.requiresService) {
if (!this.checkServiceInstalled(fieldConfig.requiresService)) {
const value = this.unmetDependencyValue(fieldConfig);
fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`);
continue;
}
}
// Gate fields that depend on the global mail config being on.
// Used for per-app email-notification toggles so a user can't
// enable Email here without configuring SMTP under General first.
if (fieldConfig.requiresGlobalMail) {
const mailEnabled = await this.isGlobalMailEnabled();
if (!mailEnabled) {
const value = this.unmetDependencyValue(fieldConfig);
fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
fieldConfig.disabledReason || 'Configure mail in General settings first (CFG_MAIL_ENABLED=true).');
continue;
}
}
// Check conditional requirements for certain fields
if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') {
let serviceName;
let isServiceInstalled;
let disabledReason;
if (fieldKey === 'AUTHELIA') {
serviceName = 'authelia';
isServiceInstalled = this.checkServiceInstalled(serviceName);
disabledReason = 'Authelia needs to be installed';
} else if (fieldKey === 'HEADSCALE') {
serviceName = 'headscale';
isServiceInstalled = this.checkServiceInstalled(serviceName);
disabledReason = 'Headscale needs to be installed';
} else if (fieldKey === 'WHITELIST') {
serviceName = 'traefik';
isServiceInstalled = this.checkServiceInstalled(serviceName);
disabledReason = 'Traefik needs to be installed.';
}
if (!isServiceInstalled) {
// Force off-state so a stored "true" can't render checked when the dep is missing.
const value = this.unmetDependencyValue(fieldConfig);
fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason);
continue;
}
}
// Get current value or use default
let fieldValue = cfgKey && appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData);
const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig);
if (fieldConfig.hideByDefault) {
hiddenFieldsHTML += fieldHTML;
} else {
fieldsHTML += fieldHTML;
}
}
}
if (hiddenFieldsHTML) {
fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML);
}
if (!fieldsHTML) {
fieldsHTML = '<div class="no-fields">No configuration options available for this category.</div>';
}
return fieldsHTML;
}
// Wrap each hidden field as a direct grid sibling tagged .advanced-field so
// they participate in the parent .panel-fields grid (continuing on the right
// of the toggle) rather than reflowing into a nested grid.
applyContextualDefault(fieldKey, value, appData) {
if (fieldKey === 'NETWORK'
&& !appData?.installed
&& (value === 'default' || value === '')
&& this.checkServiceInstalled('gluetun')) {
return 'gluetun';
}
return value;
}
renderAdvancedToggleAndFields(hiddenFieldsHTML) {
const tagged = hiddenFieldsHTML.replace(/<div class="form-field/g, '<div class="form-field advanced-field is-hidden');
return `
<div class="form-field advanced-toggle-field">
<label class="form-label">Advanced Settings</label>
<label class="checkbox-label">
<input type="checkbox" class="advanced-fields-checkbox">
<div class="checkbox-custom"></div>
<span class="checkbox-text">Show advanced settings</span>
</label>
<small class="form-help">Reveal less-common configuration options for power users.</small>
</div>
${tagged}
`;
}
// Generate field (working method from app-config-original.js)
async generateField(fieldKey, cfgKey, value, fieldConfig) {
const fieldId = fieldKey; // Use fieldKey to ensure unique IDs
const required = fieldConfig.required ? '<span class="required">*</span>' : '';
const helpIcon = fieldConfig.tooltip ? `<span class="help-icon" title="${this.escAttr(fieldConfig.tooltip)}">?</span>` : '';
let inputHTML = '';
// Special handling for DOMAIN fields - show domain dropdown
if (fieldKey === 'DOMAIN') {
//// // console.log('🎯 DOMAIN field detected, generating dropdown...');
try {
const domainOptions = await this.getDomainOptions();
//// // console.log('📊 Domain options received:', domainOptions);
let optionsHTML = '';
domainOptions.forEach(option => {
const isSelected = option.value === value.toString() ? 'selected' : '';
optionsHTML += `<option value="${option.value}" ${isSelected}>${option.label}</option>`;
});
inputHTML = `<select id="${fieldId}" name="${cfgKey}" class="form-select">${optionsHTML}</select>`;
//// // console.log('✅ Domain dropdown generated successfully');
} catch (error) {
console.error('❌ Error loading domain options, falling back to number input:', error);
// Fallback to regular number input if domain loading fails
inputHTML = `<input type="number" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input" min="${fieldConfig.min || '1'}" max="${fieldConfig.max || '9'}" step="${fieldConfig.step || '1'}">`;
}
} else if (fieldKey === 'GLUETUN_VPN_COUNTRIES') {
const selected = (typeof value === 'string' ? value : '').split(',').map(s => s.trim()).filter(Boolean);
const chips = selected.length
? selected.map(c => `<span class="gluetun-country-chip"><span class="gluetun-flag">${this.countryFlagEmoji(c)}</span>${c}</span>`).join('')
: `<span class="gluetun-country-empty">Any</span>`;
inputHTML = `
<div class="gluetun-countries-field" id="${fieldId}-wrap">
<div class="gluetun-countries-display" id="${fieldId}-chips">${chips}</div>
<button type="button" class="btn btn-secondary gluetun-countries-edit" onclick="appsManager.openGluetunCountriesModal('${fieldId}')">Edit</button>
<input type="hidden" id="${fieldId}" name="${cfgKey}" value="${value || ''}">
</div>`;
} else {
// Regular field handling for all other types
// Auto-detect PORT fields and use port manager
if (fieldKey.startsWith('PORT_') || fieldConfig.type === 'port-manager') {
// Special handling for port manager - will be initialized after DOM is ready
//// // console.log(`🔌 Creating port-manager field: ${fieldKey} for app: ${this.getCurrentAppName()}`);
inputHTML = `<div id="${fieldId}" class="port-manager-container" data-field-key="${fieldKey}" data-app-name="${this.getCurrentAppName()}">Loading port manager...</div>`;
} else {
switch (fieldConfig.type) {
case 'text':
inputHTML = `<input type="text" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input" placeholder="${fieldConfig.placeholder || ''}">`;
break;
case 'password': {
const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value);
if (randomMatch) {
const placeholderToken = value;
inputHTML = `
<div class="password-mode-wrapper" data-field-id="${fieldId}" data-field-key="${cfgKey}" data-placeholder-token="${placeholderToken}" style="display: flex; gap: 6px; align-items: stretch;">
<select class="form-input password-mode-select" style="max-width: 130px;" onchange="appsManager.setPasswordMode('${fieldId}', this.value)">
<option value="random" selected>Random</option>
<option value="custom">Custom</option>
</select>
<div class="password-field-wrapper" style="flex: 1;">
<input type="password" id="${fieldId}" value="" class="form-input password-field" placeholder="Will generate on save" readonly>
<input type="hidden" id="${fieldId}-token" name="${cfgKey}" value="${placeholderToken}">
<button type="button" class="password-toggle" tabindex="-1" aria-label="Show password" onclick="appsManager.togglePasswordVisibility('${fieldId}')">
<span id="${fieldId}-icon" class="password-toggle-icon">👁</span>
</button>
</div>
</div>`;
} else {
inputHTML = `
<div class="password-field-wrapper">
<input type="password" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input password-field" placeholder="${fieldConfig.placeholder || ''}">
<button type="button" class="password-toggle" tabindex="-1" aria-label="Show password" onclick="appsManager.togglePasswordVisibility('${fieldId}')">
<span id="${fieldId}-icon" class="password-toggle-icon">👁</span>
</button>
</div>`;
}
break;
}
case 'number':
inputHTML = `<input type="number" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input" min="${fieldConfig.min || ''}" max="${fieldConfig.max || ''}" step="${fieldConfig.step || ''}">`;
break;
case 'select':
let optionsHTML = '';
let selectOptions = fieldConfig.options;
if (!selectOptions && typeof ConfigOptions !== 'undefined' && ConfigOptions.isDropdownKey?.(cfgKey)) {
selectOptions = ConfigOptions.getSelectOptions(cfgKey);
}
// Backup strategy: "live" is only valid for apps we can snapshot
// consistently. Hide it elsewhere so we never offer a choice that
// would just fall back to stopping the app.
if (selectOptions && cfgKey.endsWith('_BACKUP_STRATEGY') && !this.isCurrentAppLiveCapable()) {
selectOptions = selectOptions.filter(o => String(o.value) !== 'live');
}
// Fall back to default if stored value isn't in the option list.
let effectiveValue = value;
if (selectOptions && selectOptions.length > 0) {
const hasMatch = selectOptions.some(o => String(o.value) === String(value));
if (!hasMatch) {
effectiveValue = (fieldConfig.default !== undefined && fieldConfig.default !== null)
? fieldConfig.default
: selectOptions[0].value;
}
}
if (selectOptions) {
selectOptions.forEach(option => {
const isSelected = String(option.value) === String(effectiveValue) ? 'selected' : '';
optionsHTML += `<option value="${option.value}" ${isSelected}>${option.label}</option>`;
});
}
inputHTML = `<select id="${fieldId}" name="${cfgKey}" class="form-select">${optionsHTML}</select>`;
break;
case 'checkbox':
const isChecked = value === 'true' || value === true ? 'checked' : '';
inputHTML = `
<label class="checkbox-label">
<input type="checkbox" id="${fieldId}" name="${cfgKey}" ${isChecked}>
<div class="checkbox-custom"></div>
<span class="checkbox-text">${fieldConfig.label}</span>
</label>
`;
break;
case 'textarea':
inputHTML = `<textarea id="${fieldId}" name="${cfgKey}" class="form-textarea" rows="${fieldConfig.rows || 3}" placeholder="${fieldConfig.placeholder || ''}">${value}</textarea>`;
break;
default:
inputHTML = `<input type="text" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input">`;
}
}
}
// Generic conditional field: only render-visible when another field's
// current value matches. The post-render `wireShowWhenListeners` keeps
// visibility in sync as the watched field changes. Schema:
// showWhen: { "<KEY>": "<expectedValue>" }
// <KEY> can be either a full CFG_ name or a bare suffix like
// "NOTIFY_EMAIL"; bare keys auto-resolve against the current field's
// app prefix so the same field-mapping is reusable across apps.
// For checkboxes the expected value is "true" or "false".
let showWhenAttrs = '';
let showWhenStyle = '';
if (fieldConfig.showWhen && typeof fieldConfig.showWhen === 'object') {
const entries = Object.entries(fieldConfig.showWhen);
if (entries.length > 0) {
let [watchKey, expected] = entries[0];
// Auto-prefix bare keys with the current field's CFG_<APP>_ prefix.
if (cfgKey && !String(watchKey).startsWith('CFG_')) {
const m = String(cfgKey).match(/^(CFG_[A-Z0-9]+_)/);
if (m) watchKey = `${m[1]}${watchKey}`;
}
const currentValue = this._readWatchedValue(watchKey);
const visible = String(currentValue) === String(expected);
showWhenAttrs = ` data-show-when-key="${watchKey}" data-show-when-equals="${String(expected)}"`;
if (!visible) showWhenStyle = ' style="display: none;"';
}
}
return `
<div class="form-field"${showWhenAttrs}${showWhenStyle}>
<label class="form-label" for="${fieldId}">
${fieldConfig.label}${required}${helpIcon}
</label>
${inputHTML}
${fieldConfig.tooltip ? `<small class="form-help">${this.escHtml(fieldConfig.tooltip)}</small>` : ''}
</div>
`;
}
// Best-effort lookup of a watched field's current value during render.
// Reads from the in-flight form (already-rendered fields above this one)
// OR from the cached app config so the initial visibility is right even
// for forward references.
_readWatchedValue(cfgKey) {
const live = document.querySelector(`[name="${cfgKey}"]`);
if (live) {
if (live.type === 'checkbox') return live.checked ? 'true' : 'false';
return live.value;
}
const cached = this.currentAppConfig || {};
if (Object.prototype.hasOwnProperty.call(cached, cfgKey)) {
const v = cached[cfgKey];
if (typeof v === 'boolean') return v ? 'true' : 'false';
return String(v);
}
return '';
}
// Hook change events on every watched CFG_KEY and toggle dependent
// .form-field[data-show-when-key=...] elements when the watched value
// changes. Called after the config form is rendered.
wireShowWhenListeners() {
const dependents = document.querySelectorAll('.form-field[data-show-when-key]');
if (dependents.length === 0) return;
// Build a map: watchedKey -> [{element, expected}]
const watch = new Map();
dependents.forEach((el) => {
const key = el.getAttribute('data-show-when-key');
const expected = el.getAttribute('data-show-when-equals');
if (!key) return;
if (!watch.has(key)) watch.set(key, []);
watch.get(key).push({ element: el, expected });
});
const evalKey = (key) => {
const entry = watch.get(key);
if (!entry) return;
const input = document.querySelector(`[name="${key}"]`);
let val = '';
if (input) {
val = input.type === 'checkbox' ? (input.checked ? 'true' : 'false') : input.value;
}
entry.forEach(({ element, expected }) => {
element.style.display = String(val) === String(expected) ? '' : 'none';
});
};
watch.forEach((_v, key) => {
const input = document.querySelector(`[name="${key}"]`);
if (!input || input.dataset.showWhenWired === '1') return;
input.dataset.showWhenWired = '1';
input.addEventListener('change', () => evalKey(key));
input.addEventListener('input', () => evalKey(key));
// Run once on init so any forward-reference defaults reconcile.
evalKey(key);
});
// showWhen dependents render as the grid cell immediately after their
// controller (generateConfigFields reorders them there), so revealing one
// drops the input in the slot right next to its toggle.
}
// Generate configuration field HTML (from old file - needed for tab content)
// Are any CFG_DOMAIN_N configured? The per-app DOMAIN field is just an
// index into that list, so showing it when the list is empty is noise.
// Refetched on every form render so changes on the config page are
// reflected the next time an app's config tab opens.
async hasConfiguredDomains() {
try {
const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' });
if (!res.ok) return false;
const json = await res.json();
const flat = JSON.stringify(json);
for (let i = 1; i <= 9; i++) {
const m = flat.match(new RegExp(`"CFG_DOMAIN_${i}"\\s*:\\s*\\{[^}]*"value"\\s*:\\s*"([^"]*)"`));
if (m && m[1].trim()) return true;
}
return false;
} catch { return false; }
}
// Returns true if the user has switched on the global mail config.
// Used by `requiresGlobalMail` field gating so per-app email-notification
// toggles can refuse to enable until SMTP is configured once globally.
async isGlobalMailEnabled() {
try {
const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' });
if (!res.ok) return false;
const json = await res.json();
const v = json?.config?.CFG_MAIL_ENABLED?.value;
return String(v).toLowerCase() === 'true';
} catch { return false; }
}
async generateConfigFields(categoryKey, appData) {
const fieldMappings = await this.getFieldMappings();
const appConfig = appData.config || {};
const domainsAvailable = await this.hasConfiguredDomains();
let fieldsHTML = '';
let hiddenFieldsHTML = '';
// Collect every field that belongs to this category.
const categoryFields = [];
Object.entries(fieldMappings).forEach(([fieldKey, fieldConfig]) => {
if (fieldConfig.category !== categoryKey) return;
const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig);
// Advanced fields only on the advanced tab, and vice versa.
if (categoryKey === 'advanced' && !fieldConfig.advanced) return;
if (categoryKey !== 'advanced' && fieldConfig.advanced) return;
// Only show a field if this app actually has the CFG_ variable.
if (!cfgKey || !appConfig.hasOwnProperty(cfgKey)) return;
// The DOMAIN selector is just an index into the domain list — hide it
// entirely when no CFG_DOMAIN_N is configured.
if (fieldKey === 'DOMAIN' && !domainsAvailable) return;
// BACKUP gets priority -1 so "Enable Backups?" is always first; other
// inputs are 0, remaining checkboxes 1.
const isBackup = fieldKey === 'BACKUP';
categoryFields.push({
fieldKey,
fieldConfig,
cfgKey,
priority: isBackup ? -1 : (fieldConfig.type === 'checkbox' ? 1 : 0)
});
});
categoryFields.sort((a, b) => a.priority - b.priority);
// The sort above orders by type (inputs before checkboxes), which can
// separate a showWhen field from its controlling toggle. Reorder so each
// dependent sits immediately after its controller — then its conditional
// input reveals in the grid cell right next to the toggle.
const byCfgKey = new Map(categoryFields.map(f => [f.cfgKey, f]));
const resolveWatchKey = (entry) => {
const sw = entry.fieldConfig.showWhen;
if (!sw || typeof sw !== 'object') return null;
const swEntries = Object.entries(sw);
if (!swEntries.length) return null;
let [watchKey] = swEntries[0];
if (!String(watchKey).startsWith('CFG_')) {
const m = String(entry.cfgKey).match(/^(CFG_[A-Z0-9]+_)/);
if (m) watchKey = `${m[1]}${watchKey}`;
}
return watchKey;
};
const ordered = [];
const placed = new Set();
for (const entry of categoryFields) {
if (placed.has(entry.cfgKey)) continue;
// Dependents whose controller is in this category are placed alongside
// their controller below — skip them in this outer pass.
const watchKey = resolveWatchKey(entry);
if (watchKey && byCfgKey.has(watchKey)) continue;
ordered.push(entry);
placed.add(entry.cfgKey);
for (const dep of categoryFields) {
if (placed.has(dep.cfgKey)) continue;
if (resolveWatchKey(dep) === entry.cfgKey) {
ordered.push(dep);
placed.add(dep.cfgKey);
}
}
}
// Safety net: anything still unplaced (e.g. a dependent whose controller
// lives in another category) keeps its original sorted position.
for (const entry of categoryFields) {
if (!placed.has(entry.cfgKey)) { ordered.push(entry); placed.add(entry.cfgKey); }
}
for (const entry of ordered) {
const rendered = await this._renderCategoryField(entry, appData, appConfig);
if (!rendered) continue;
if (rendered.hidden) hiddenFieldsHTML += rendered.html;
else fieldsHTML += rendered.html;
}
if (hiddenFieldsHTML) {
fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML);
}
if (!fieldsHTML) {
fieldsHTML = '<div class="no-fields">No configuration options available for this category.</div>';
}
return fieldsHTML;
}
// Render a single collected category field: runs the dependency/service
// gating, then produces the .form-field HTML. Returns { html, hidden } or
// null when the field should be skipped entirely.
async _renderCategoryField(entry, appData, appConfig) {
const { fieldKey, fieldConfig, cfgKey } = entry;
// Skip categoryAllowlist fields when this app's category isn't listed
// AND the override requirement isn't enabled.
if (Array.isArray(fieldConfig.categoryAllowlist)) {
const appCategory = String(appData?.category || '').toLowerCase();
const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory);
const override = fieldConfig.requirementOverride
? this.checkRequirementEnabled(fieldConfig.requirementOverride)
: false;
if (!inList && !override) return null;
}
// Generic requiresService gating from the field-mapping JSON.
if (fieldConfig.requiresService && !this.checkServiceInstalled(fieldConfig.requiresService)) {
const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
return {
html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`),
hidden: false
};
}
// requiresServices: ALL listed services must be installed (e.g. the
// MONITORING toggle needs both prometheus and grafana).
if (Array.isArray(fieldConfig.requiresServices)) {
const missing = fieldConfig.requiresServices.filter(s => !this.checkServiceInstalled(s));
if (missing.length) {
const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
return {
html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
fieldConfig.disabledReason || `${missing.join(' + ')} needs to be installed.`),
hidden: false
};
}
}
// Legacy hardcoded service checks for fields not yet migrated.
if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') {
let serviceName, disabledReason;
if (fieldKey === 'AUTHELIA') { serviceName = 'authelia'; disabledReason = 'Authelia needs to be installed'; }
else if (fieldKey === 'HEADSCALE') { serviceName = 'headscale'; disabledReason = 'Headscale needs to be installed'; }
else { serviceName = 'traefik'; disabledReason = 'Traefik needs to be installed.'; }
if (!this.checkServiceInstalled(serviceName)) {
const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
return { html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason), hidden: false };
}
}
let fieldValue = appConfig[cfgKey] || (fieldConfig.default || '');
fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData);
const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig);
return { html: fieldHTML, hidden: !!fieldConfig.hideByDefault };
}
// Generate configuration field HTML
generateConfigField(cfgKey, value, fieldConfig) {
const description = fieldConfig.description || '';
let fieldHTML = `
<div class="config-field">
<label class="config-label">${cfgKey}</label>
`;
const type = fieldConfig.type || 'text';
const options = fieldConfig.options;
switch (type) {
case 'text':
fieldHTML += `<input type="text" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input">`;
break;
case 'number':
fieldHTML += `<input type="number" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input">`;
break;
case 'password':
fieldHTML += `
<div class="password-field-wrapper">
<input type="password" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input password-field">
<button type="button" class="password-toggle" tabindex="-1" aria-label="Show password" onclick="appsManager.togglePasswordVisibility('${cfgKey}')">
<span id="${cfgKey}-icon" class="password-toggle-icon">👁</span>
</button>
</div>`;
break;
case 'checkbox':
const checked = value === 'true' || value === 'yes' ? 'checked' : '';
fieldHTML += `<input type="checkbox" id="${cfgKey}" name="${cfgKey}" ${checked} class="config-checkbox">`;
break;
case 'select':
fieldHTML += `<select id="${cfgKey}" name="${cfgKey}" class="config-input">`;
if (options) {
options.forEach(option => {
const selected = value === option ? 'selected' : '';
fieldHTML += `<option value="${option}" ${selected}>${option}</option>`;
});
}
fieldHTML += `</select>`;
break;
default:
fieldHTML += `<input type="text" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input">`;
}
fieldHTML += `
</div>
${description ? `<p class="config-description">${description}</p>` : ''}
</div>
`;
return fieldHTML;
}
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 || '/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='/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 = '';
}
}
}
getCategoryIcon(categoryId) {
if (!categoryId || categoryId === 'all') return null;
// Convert sidebar categories object to array with id field
const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories :
Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value }));
// Find category in categories array (case-insensitive)
const category = categoriesArray.find(cat =>
cat.id === categoryId.toLowerCase() ||
cat.name.toLowerCase() === categoryId.toLowerCase()
);
let iconPath = category ? category.icon : `/icons/categories/${categoryId}.svg`;
// Ensure absolute path from root
if (iconPath && !iconPath.startsWith('/')) {
iconPath = '/' + iconPath;
}
return iconPath;
}
getCategoryName(categoryId) {
//// // console.log(`🏷️ Getting category name for: ${categoryId}`);
//// // console.log(`🏷️ window.sidebarCategories type: ${typeof window.sidebarCategories}`, window.sidebarCategories);
if (!categoryId || categoryId === 'all') return 'All';
// Check if sidebar categories is available
if (!window.sidebarCategories) {
console.warn(`🏷️ window.sidebarCategories is not available, returning categoryId: ${categoryId}`);
return categoryId;
}
// Convert sidebar categories object to array with id field
const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories :
Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value }));
//// // console.log(`🏷️ Categories array:`, categoriesArray);
// Find category in categories array (case-insensitive)
const category = categoriesArray.find(cat =>
cat.id === categoryId.toLowerCase() ||
cat.name.toLowerCase() === categoryId.toLowerCase()
);
//// // console.log(`🏷️ Found category:`, category);
return category ? category.name : categoryId;
}
// Show tab (working method from app-config-original.js)
showTab(tabKey) {
// Hide all panels
const allPanels = document.querySelectorAll('.tab-panel');
allPanels.forEach(panel => panel.classList.remove('active'));
// Remove active from all config category tabs (not main navigation tabs)
const allButtons = document.querySelectorAll('.tab-panel:has(.config-section) .tab-button, .config-section .tab-button');
allButtons.forEach(button => button.classList.remove('active'));
// Show selected panel
const targetPanel = document.getElementById(`panel-${tabKey}`);
if (targetPanel) {
targetPanel.classList.add('active');
}
// Add active to clicked config category button
const targetButton = document.querySelector(`.config-section [data-tab="${tabKey}"], .tab-panel:has(.config-section) [data-tab="${tabKey}"]`);
if (targetButton) {
targetButton.classList.add('active');
}
// Push the path-based URL so this sub-tab is shareable + back-buttonable —
// /app/<name>/config/<sub>. Skipped when there's no current app (e.g. when
// the form is rendered outside of the per-app context).
const currentApp = window.appTabbedManager?.currentApp;
if (currentApp && window.appPath) {
const newUrl = window.appPath(currentApp, 'config', tabKey);
if (window.location.pathname + window.location.search !== newUrl) {
history.pushState({}, '', newUrl);
}
}
}
// Initialize simple tabs (working method from app-config-original.js)
initializeSimpleTabs() {
//// // console.log('Simple tabs initialized');
}
// Check if a service is installed
checkServiceInstalled(serviceName) {
if (!window.apps || window.apps.length === 0) {
return false;
}
const serviceApp = window.apps.find(app =>
app.command && app.command.endsWith(`libreportal app install ${serviceName}`)
);
return serviceApp && serviceApp.installed === true;
}
checkRequirementEnabled(suffix) {
if (!suffix) return false;
const sysCfg = window.systemConfig || window.configs || {};
const v = sysCfg[`CFG_REQUIREMENT_${suffix}`];
return v === true || v === 'true';
}
// Get navigation button for installing required services
getNavigationButton(fieldKey) {
const servicePages = {
'AUTHELIA': '/app/authelia',
'HEADSCALE': '/app/headscale',
'WHITELIST': '/app/traefik',
'TRAEFIK': '/app/traefik'
};
let serviceName;
if (fieldKey === 'WHITELIST') {
serviceName = 'Traefik';
} else if (fieldKey === 'AUTHELIA') {
serviceName = 'Authelia';
} else if (fieldKey === 'HEADSCALE') {
serviceName = 'Headscale';
} else {
serviceName = fieldKey.charAt(0) + fieldKey.slice(1).toLowerCase();
}
const pageUrl = servicePages[fieldKey] || '#';
return `
<button class="nav-button install-button" onclick="appsManager.handleNavigation('${pageUrl}', '${serviceName}')" data-service="${serviceName.toLowerCase()}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17,21 17,13 7,13 7,21"></polyline>
<polyline points="7,3 7,8 15,8"></polyline>
</svg>
Install ${serviceName}
</button>
`;
}
// Handle navigation with unsaved changes check
handleNavigation(url, serviceName) {
// SPA in-app nav (path-based routes), with an absolute-path full-load
// fallback. A relative window.location.href here resolved wrong from the
// /admin/config/* pages these buttons render on.
if (typeof window.navigateToRoute === 'function' && window.spaClean) {
window.navigateToRoute(url);
} else {
window.location.href = url;
}
}
// Generate disabled field with navigation button
serviceForField(fieldKey, fieldConfig) {
const map = { AUTHELIA: 'authelia', HEADSCALE: 'headscale', WHITELIST: 'traefik' };
return (map[fieldKey] || fieldConfig.requiresService || '').toLowerCase();
}
generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason) {
const fieldId = fieldKey;
const slug = this.serviceForField(fieldKey, fieldConfig);
const serviceName = slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : '';
const iconUrl = slug ? `/icons/apps/${encodeURIComponent(slug)}.svg` : '/icons/apps/default.svg';
const isCheckbox = fieldConfig.type === 'checkbox';
const hiddenInput = isCheckbox
? `<input type="hidden" id="${fieldId}" name="${cfgKey}" value="false">`
: `<input type="hidden" id="${fieldId}" name="${cfgKey}" value="${this.escAttr(value)}">`;
return `
<div class="dep-required-card" data-service="${this.escAttr(slug)}">
${hiddenInput}
<img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
<div class="dep-required-body">
<div class="dep-required-title">${this.escHtml(fieldConfig.label)}</div>
<div class="dep-required-reason">${this.escHtml(disabledReason)}</div>
</div>
${slug ? `<button type="button" class="btn btn-primary dep-required-action" onclick="appsManager.navigateToServiceApp('${this.escAttr(slug)}')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17,21 17,13 7,13 7,21"></polyline>
<polyline points="7,3 7,8 15,8"></polyline>
</svg>
Install ${this.escHtml(serviceName)}
</button>` : ''}
</div>
`;
}
// First-install welcome modal — also openable from the app-header button.
async showInstallWelcome(appName, opts = {}) {
const slug = String(appName || '').toLowerCase();
if (!slug) return;
const app = (window.apps || []).find(a => ((a.command || '').split(' ').pop() || '').toLowerCase() === slug);
if (!app) return;
let services = [];
try {
const r = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
if (r.ok) {
const d = await r.json();
services = Array.isArray(d?.services) ? d.services.filter(s => s.app === slug) : [];
}
} catch (_) {}
const traefikInstalled = this.checkServiceInstalled('traefik');
const cfg = app.config || {};
const upper = slug.toUpperCase();
const isPublic = services.some(s => s.traefikManaged);
const isAuth = String(cfg[`CFG_${upper}_AUTHELIA`] || '').toLowerCase() === 'true' && this.checkServiceInstalled('authelia');
const isVpn = String(cfg[`CFG_${upper}_NETWORK`] || '').toLowerCase() === 'gluetun';
const anyTraefikLogin = services.some(s => s.traefikManaged && s.loginRequired);
const badges = [];
if (isPublic) badges.push({ icon: '🌍', label: 'Public', cls: 'public' });
if (services.some(s => s.traefikManaged) && traefikInstalled) badges.push({ icon: '🛡️', label: 'Traefik', cls: 'traefik' });
if (isAuth) badges.push({ icon: '🔒', label: 'Authelia', cls: 'authelia' });
if (isVpn) badges.push({ icon: '🌐', label: 'Gluetun VPN', cls: 'gluetun' });
if (anyTraefikLogin && traefikInstalled) badges.push({ icon: '🔑', label: 'Login required', cls: 'login' });
const expand = typeof window.expandServiceLinks === 'function' ? window.expandServiceLinks : null;
const urls = [];
services.forEach(s => {
if (s.buttonEnabled === false) return;
const links = expand ? expand(s) : [{ url: s.externalURL, label: s.buttonText || s.name }];
links.forEach(l => { if (l?.url) urls.push({ url: l.url, label: l.label || s.buttonText || s.name }); });
});
const creds = [];
if (anyTraefikLogin && traefikInstalled) {
creds.push({
title: 'Website Authentication (Traefik basic-auth)',
username: window.globalConfig?.CFG_TRAEFIK_USER || '(see CFG_TRAEFIK_USER)',
password: window.globalConfig?.CFG_TRAEFIK_PASS || '(see CFG_TRAEFIK_PASS)'
});
}
// App-login keys are the app's OWN admin credentials, anchored to the
// app's CFG prefix. Without the anchor, loose suffix matches pull in
// notification-channel / upstream creds that aren't logins at all
// (e.g. CFG_GLUETUN_OPENVPN_PASSWORD).
const loginKey = (kind) => new RegExp(`^CFG_${upper}_(ADMIN_)?(${kind})$`);
const emailKeys = Object.keys(cfg).filter(k => loginKey('EMAIL').test(k));
const userKeys = Object.keys(cfg).filter(k => loginKey('USER(NAME)?').test(k));
const passKeys = Object.keys(cfg).filter(k => loginKey('PASSWORD').test(k));
const emailVal = emailKeys[0] ? cfg[emailKeys[0]] : '';
const userVal = userKeys[0] ? cfg[userKeys[0]] : '';
const identifier = emailVal || userVal || 'admin';
const userLabel = (emailVal || (typeof identifier === 'string' && identifier.includes('@'))) ? 'Email' : 'User';
if (userKeys[0] || emailKeys[0] || passKeys[0]) {
creds.push({
title: `${app.name.split(' - ')[0]} Login`,
username: identifier,
userLabel,
password: cfg[passKeys[0]] || '(not generated)'
});
}
const iconUrl = app.icon ? (app.icon.startsWith('/') ? app.icon : '/' + app.icon) : `/icons/apps/${slug}.svg`;
const shortName = app.name.split(' - ')[0];
const eoBadges = badges.map(b => ({
icon: b.icon, label: b.label,
variant: ({public:'success', traefik:'info', authelia:'purple', gluetun:'warning', login:'danger'})[b.cls]
}));
const bodyParts = [];
bodyParts.push(window.eoBadgeRow(eoBadges));
if (urls.length) bodyParts.push(window.eoSection(`Open ${shortName}`, window.eoUrlList(urls)));
if (creds.length) bodyParts.push(window.eoSection('Login Details', window.eoCredList(creds)));
if (!urls.length && !creds.length) bodyParts.push(window.eoEmpty('All set up — head to the Services tab for details.'));
window.openEoModal({
id: 'install-welcome-modal',
size: 'sm',
icon: iconUrl,
iconAlt: shortName,
eyebrow: `🎉 ${opts.replay ? 'Welcome back to' : 'Installed'}`,
title: shortName,
desc: app.description || '',
body: bodyParts.join(''),
actions: [{ label: 'Done', variant: 'primary' }]
});
try { localStorage.setItem(`libreportal.welcomeShown.${slug}`, '1'); } catch (_) {}
}
navigateToServiceApp(slug) {
if (typeof window.navigateToApp === 'function') return window.navigateToApp(slug);
if (window.librePortalSPA?.navigate) return window.librePortalSPA.navigate(`/app/${slug}`);
if (typeof window.navigateToRoute === 'function') return window.navigateToRoute(`app/${slug}`);
window.location.href = `/app/${slug}`;
}
// Find matching CFG_ key for a field (working method from app-config-original.js)
togglePasswordVisibility(fieldId) {
const input = document.getElementById(fieldId);
const icon = document.getElementById(`${fieldId}-icon`);
if (!input) return;
const showing = input.type === 'text';
input.type = showing ? 'password' : 'text';
if (icon) icon.textContent = showing ? '👁' : '🙈';
}
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) : '/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='/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) || '/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();
}
setPasswordMode(fieldId, mode) {
const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`);
const input = document.getElementById(fieldId);
const tokenInput = document.getElementById(`${fieldId}-token`);
if (!wrapper || !input || !tokenInput) return;
const key = wrapper.dataset.fieldKey;
if (mode === 'random') {
input.dataset.previousCustom = input.value || '';
input.value = '';
input.readOnly = true;
input.type = 'password';
input.setAttribute('placeholder', 'Will generate on save');
input.removeAttribute('name');
tokenInput.setAttribute('name', key);
const icon = document.getElementById(`${fieldId}-icon`);
if (icon) icon.textContent = '👁';
} else {
input.readOnly = false;
input.removeAttribute('placeholder');
input.value = input.dataset.previousCustom || '';
input.setAttribute('name', key);
tokenInput.removeAttribute('name');
input.focus();
}
}
// Off-value for a checkbox whose dependency isn't installed.
unmetDependencyValue(fieldConfig) {
return fieldConfig.type === 'checkbox' ? 'false' : '';
}
escHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
escAttr(s) {
return this.escHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
findMatchingCFGKey(fieldKey, appConfig) {
// Try exact match first
const exactMatch = `CFG_${fieldKey}`;
if (appConfig.hasOwnProperty(exactMatch)) {
return exactMatch;
}
// Try partial matches (more precise)
const keys = Object.keys(appConfig);
for (const cfgKey of keys) {
const cfgKeyWithoutPrefix = cfgKey.replace('CFG_', '');
// Only match if the field key is a complete word within the cfg key
if (cfgKeyWithoutPrefix === fieldKey ||
cfgKeyWithoutPrefix.endsWith('_' + fieldKey) ||
cfgKeyWithoutPrefix.startsWith(fieldKey + '_')) {
return cfgKey;
}
}
return null;
}
// Helper methods to load config data (working methods from app-config-original.js)
async getConfigCategories() {
try {
// Load config categories (for app config tabs)
const response = await fetch('/data/apps/apps-config-categories.json');
const data = await response.json();
//// // console.log('✅ Loaded config categories from apps folder');
return data.categories || data; // Return the actual data object
} catch (error) {
console.error('Error loading config categories:', error);
throw new Error('Failed to load config categories. Please check your configuration files.');
}
}
async getFieldMappings() {
try {
// Load from apps folder (static file)
const response = await fetch('/data/apps/apps-field-mappings.json');
const data = await response.json();
//// // console.log('✅ Loaded field mappings from apps folder');
return data.fields || data;
} catch (error) {
console.error('Error loading field mappings:', error);
throw new Error('Failed to load field mappings. Please check your configuration files.');
}
}
// Get domain options for DOMAIN field
async getDomainOptions() {
//// // console.log('🎯 Getting domain options...');
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 [
{ value: '1', label: 'No domains configured - Configure domains in Network settings first' }
];
}
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 domainConfig = config[domainKey];
//// // console.log(`🌐 Checking ${domainKey}:`, domainConfig, 'type:', typeof domainConfig);
// Check if domainConfig has a value property and it's a non-empty string
let domainValue = '';
if (domainConfig && typeof domainConfig === 'object' && domainConfig.value) {
domainValue = domainConfig.value;
} else if (typeof domainConfig === 'string') {
domainValue = domainConfig;
}
//// // console.log(`🔤 Extracted domain value: "${domainValue}" type: ${typeof domainValue}`);
// Only add domains that have actual content (non-empty string)
if (typeof domainValue === 'string' && domainValue.trim() !== '') {
//// // console.log(`✅ Adding domain: ${domainValue.trim()}`);
domains.push({
number: i,
domain: domainValue.trim(),
key: domainKey
});
} else {
//// // console.log(`⏭️ Skipping empty domain ${domainKey}`);
}
}
//// // console.log('✅ Found configured domains:', domains);
if (domains.length === 0) {
//// // console.log('⚠️ No domains found, returning fallback 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;
} catch (error) {
console.error('❌ Error fetching domains:', error);
return [
{ value: '1', label: 'Error loading domains - Check console for details' }
];
}
}
// Get current app name
getCurrentAppName() {
// Try to get from current app data
if (this.currentApp) {
return this.currentApp;
}
// Try to get from URL
const urlParams = new URLSearchParams(window.location.search);
const appName = urlParams.get('app');
if (appName) {
return appName;
}
// Try to get from current path
const pathParts = window.location.pathname.split('/');
const appIndex = pathParts.indexOf('app');
if (appIndex !== -1 && pathParts[appIndex + 1]) {
return pathParts[appIndex + 1];
}
return 'unknown';
}
// 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;
}
// Get current app configuration
getCurrentAppConfig() {
//// // console.log(`🔧 getCurrentAppConfig DEBUG: this.currentApp = "${this.currentApp}"`);
//// // console.log(`🔧 getCurrentAppConfig DEBUG: window.apps =`, window.apps ? `${window.apps.length} apps` : 'undefined');
if (window.apps && this.currentApp) {
const target = String(this.currentApp).toLowerCase();
const app = window.apps.find(a => {
const slug = (a.command || '').split(' ').pop();
return slug.toLowerCase() === target;
});
return app?.config || {};
}
return {};
}
// True when the current app can be backed up live (set by the apps.json
// generator from its compose backup labels). Drives whether the per-app
// "live" backup-strategy option is offered.
isCurrentAppLiveCapable() {
if (window.apps && this.currentApp) {
const target = String(this.currentApp).toLowerCase();
const app = window.apps.find(a => {
const slug = (a.command || '').split(' ').pop();
return slug.toLowerCase() === target;
});
return app?.backup_live_capable === true;
}
return false;
}
/**
* Collect configuration from form and format as pipe-separated string
*/
collectConfigFromForm(appName) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) {
console.warn(` No config form found for ${appName}, proceeding with no config override`);
return '';
}
const configPairs = [];
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const name = input.name;
if (!name || !name.startsWith('CFG_')) return;
// Skip the port-manager's aggregate hidden input — it's UI-only state
// (combined value of all ports for the live editor) and isn't a real key
// in any .config file. The actual per-port keys (CFG_<APP>_PORT_1 …) are
// separate hidden inputs that we DO want to collect.
if (name.endsWith('_PORT_MANAGER')) return;
let value;
if (input.type === 'checkbox') {
value = input.checked ? 'true' : 'false';
} else {
value = input.value.trim();
if (!value) return;
}
// Encode `|` characters inside values so the bash splitter (IFS='|') doesn't
// fragment values that legitimately contain a pipe — port configs are the
// main case, format: `service|name|ext:int|access|...`. The bash side decodes
// `%7C` back to `|` after splitting.
const encodedValue = value.replace(/\|/g, '%7C');
configPairs.push(`${name}=${encodedValue}`);
});
// `|` between pairs — `,` shows up in real config values (domain lists etc).
const collectedConfig = configPairs.join('|');
console.log(`📋 Collected config for ${appName}:`, collectedConfig || '(empty - using defaults from apps.json)');
return collectedConfig;
}
// ----- Unsaved config-change tracking -------------------------------------
// The app config panel is pure DOM until the user hits Apply/Update. These
// helpers track when a field differs from its rendered (saved) value, show a
// sticky bar offering Apply/Discard, and register an SPA nav guard so leaving
// the page with unsaved edits prompts first.
// 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'); } }
});
});
}
// 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';
});
}
async installApp(appName) {
const installedApp = (window.apps || []).find(a =>
(a.command || '').endsWith(` ${appName}`) || a.name === appName
);
const isInstalled = !!(installedApp && installedApp.installed);
if (isInstalled) {
this.showUpdateConfirmModal(appName);
return;
}
if (await this.shouldRecommendGluetun(appName, installedApp)) {
this.showGluetunRecommendModal(appName, installedApp);
return;
}
return this.executeInstall(appName, false);
}
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 || `/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);
}}
]
});
}
// Per-app required-field map keyed by lowercased app slug. Add entries
// here as new apps grow required inputs. Each value is { keys, message }
// where `keys` is the CFG_* names that must be non-empty.
getRequiredConfigKeys(appName) {
const slug = (appName || '').toLowerCase();
const map = {
traefik: ['CFG_TRAEFIK_EMAIL']
};
return map[slug] || [];
}
validateRequiredConfig(appName) {
const required = this.getRequiredConfigKeys(appName);
if (required.length === 0) return { ok: true, missing: [] };
const missing = [];
for (const key of required) {
const input = document.getElementById(`config-${key}`);
if (!input) continue;
const v = (input.value || '').trim();
if (!v || v === 'changeme' || v === 'changeme.com') missing.push({ key, input });
}
return { ok: missing.length === 0, missing };
}
flashRequiredFields(missing) {
if (!missing.length) return;
if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') {
window.appTabbedManager.switchTab('config');
}
missing.forEach(({ input }, idx) => {
input.classList.add('field-required-error');
input.setAttribute('title', 'Required');
const clear = () => {
input.classList.remove('field-required-error');
input.removeAttribute('title');
input.removeEventListener('input', clear);
input.removeEventListener('change', clear);
};
input.addEventListener('input', clear);
input.addEventListener('change', clear);
if (idx === 0) {
setTimeout(() => {
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
try { input.focus({ preventScroll: true }); } catch { input.focus(); }
}, 200);
}
});
}
async executeInstall(appName, resetNetwork) {
const validation = this.validateRequiredConfig(appName);
if (!validation.ok) {
this.flashRequiredFields(validation.missing);
const labels = validation.missing.map(({ key }) =>
key.replace(/^CFG_[A-Z0-9]+_/, '').replace(/_/g, ' ').toLowerCase()
).join(', ');
const sys = (typeof window.ensureNotificationSystem === 'function')
? window.ensureNotificationSystem()
: window.notificationSystem;
if (sys && typeof sys.show === 'function') {
sys.show(`Missing required field${validation.missing.length > 1 ? 's' : ''}: ${labels}`, 'error');
}
return;
}
// Immediately disable buttons using appTabbedManager for consistency
if (window.appTabbedManager) {
window.appTabbedManager.disableAppButtons(appName, 'install');
} else {
this.disableInstallButton(appName, 'install');
}
// Initialize task system if not available
if (!window.tasksManager || !window.tasksManager.router) {
try {
await this.loadTaskSystem();
} catch (error) {
console.error(`❌ Failed to initialize task system:`, error);
if (window.appTabbedManager) {
window.appTabbedManager.enableAppButtons(appName);
} else {
this.enableInstallButton(appName);
}
}
}
if (window.tasksManager && window.tasksManager.router) {
try {
// Collect configuration from form
const config = this.collectConfigFromForm(appName);
// Create installation task
const task = await window.tasksManager.router.routeAction('install', {
appName: appName,
config: config,
resetNetwork: resetNetwork
});
// Changes are being applied — clear the unsaved-changes state so the
// switch to the tasks view isn't caught by the leave-confirm guard.
this._clearConfigDirty();
// Show success message and switch to tasks
this.addSuccessLog(`Installation task created for ${appName}. Switching to tasks view...`);
// Switch to tasks view to show the installation progress with auto-loaded task
setTimeout(() => {
if (window.appTabbedManager) {
// Switch to tasks tab within current app page
window.appTabbedManager.switchTab('tasks');
// Auto-expand the created task
setTimeout(() => {
if (task && window.appTabbedManager.tasksManager) {
window.appTabbedManager.tasksManager.highlightedTaskId = task.id;
window.appTabbedManager.tasksManager.renderTasks();
}
}, 500);
} else if (window.librePortalSPA) {
// Fallback: navigate to app with tasks tab
const taskUrl = window.appPath(appName, 'tasks', null, task ? task.id : null);
window.librePortalSPA.navigateTo(taskUrl);
} else if (window.navigateToRoute) {
window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, ''));
}
}, 1000);
} catch (error) {
this.addErrorLog(`Failed to create installation task: ${error.message}`);
// Re-enable buttons on error
if (window.appTabbedManager) {
window.appTabbedManager.enableAppButtons(appName);
} else {
this.enableInstallButton(appName);
}
}
} else {
// Fallback to original simulation if task system not available
//// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`);
//// // console.log(`🔍 Debug info:`, {
//tasksManager: !!window.tasksManager,
//router: !!(window.tasksManager && window.tasksManager.router),
//windowTasksManager: window.tasksManager
//});
this.addInfoLog(`Starting installation of ${appName}...`);
// Simulate installation process
setTimeout(() => {
this.addSuccessLog(`Installation completed successfully!`);
// Re-enable buttons after simulation completes
if (window.appTabbedManager) {
window.appTabbedManager.enableAppButtons(appName);
} else {
this.enableInstallButton(appName);
}
}, 2000);
}
}
/**
* Initialize task system on demand
*/
async loadTaskSystem() {
try {
//// // console.log(`🔧 Loading task system components...`);
// Only load scripts if they're not already loaded
const scripts = [
{ name: 'TaskManager', src: '/js/components/task/task-manager.js' },
{ name: 'TaskCommands', src: '/js/components/task/task-commands.js' },
{ name: 'TaskActions', src: '/js/components/task/task-actions.js' },
{ name: 'TaskRouter', src: '/js/components/task/task-router.js' },
{ name: 'TasksManager', src: '/js/components/tasks/tasks-manager.js' }
];
for (const script of scripts) {
if (!window[script.name]) {
//// // console.log(`📦 Loading ${script.name}...`);
await this.loadScript(script.src);
} else {
//// // console.log(`✅ ${script.name} already loaded`);
}
}
// Initialize tasks manager if not already initialized
if (window.TasksManager && !window.tasksManager) {
//// // console.log(`🔧 Initializing TasksManager instance...`);
try {
window.tasksManager = new TasksManager();
//// // console.log(`✅ TasksManager constructor completed`);
//// // console.log(`🔧 TasksManager instance:`, window.tasksManager);
if (typeof window.tasksManager.init === 'function') {
//// // console.log(`🔧 Calling TasksManager.init()...`);
await window.tasksManager.init();
//// // console.log(`✅ TasksManager.init() completed`);
} else {
//// // console.log(`⚠️ TasksManager.init() is not a function`);
}
} catch (error) {
console.error(`❌ Failed to initialize TasksManager:`, error);
throw error;
}
} else if (window.tasksManager) {
//// // console.log(`✅ TasksManager instance already exists`);
} else {
//// // console.log(`❌ TasksManager class not available`);
}
//// // console.log(`✅ Task system components loaded and initialized`);
} catch (error) {
console.error(`❌ Failed to load task system:`, error);
throw error;
}
}
/**
* Load script helper
*/
loadScript(src) {
return new Promise((resolve, reject) => {
// Check if script is already loaded
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 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);
}
}
// Public entry point bound to the "Uninstall App" button. Doesn't kick
// anything off itself — it only opens the confirmation modal so the user
// has to explicitly approve a destructive action (matches the create
// backup / update modals' UX). Actual work happens in executeUninstall.
uninstallApp(appName) {
this.showUninstallConfirmModal(appName);
}
async executeUninstall(appName, deleteImage = false, deleteTasks = false) {
// Track the task id this call spawns so the post-completion handler
// can delete it (the bash side skips the in-flight task to avoid a
// race with the processor still writing its status).
this._pendingTaskCleanup = this._pendingTaskCleanup || new Map();
// Immediately disable buttons using appTabbedManager for consistency
if (window.appTabbedManager) {
window.appTabbedManager.disableAppButtons(appName, 'uninstall');
} else {
// Fallback to our own button disabling if appTabbedManager not available
this.disableUninstallButton(appName, 'uninstall');
}
// Check if tasks system is available, initialize if needed
//// // console.log(`🔍 Checking task system availability for uninstall...`);
//// // console.log(`🔍 window.tasksManager:`, !!window.tasksManager);
//// // console.log(`🔍 window.tasksManager.router:`, !!(window.tasksManager && window.tasksManager.router));
// Initialize task system if not available
if (!window.tasksManager || !window.tasksManager.router) {
//// // console.log(`🔧 Initializing task system...`);
try {
// Load task system components
await this.loadTaskSystem();
//// // console.log(`✅ Task system initialized successfully`);
} catch (error) {
console.error(`❌ Failed to initialize task system:`, error);
// Re-enable buttons on error
if (window.appTabbedManager) {
window.appTabbedManager.enableAppButtons(appName);
} else {
this.enableUninstallButton(appName);
}
// Continue with fallback if initialization fails
}
}
if (window.tasksManager && window.tasksManager.router) {
//// // console.log(`🚀 Uninstalling ${appName} via task system...`);
try {
// Create uninstallation task
const task = await window.tasksManager.router.routeAction('uninstall', {
appName: appName,
deleteImage: deleteImage,
deleteTasks: deleteTasks
});
if (deleteTasks && task && task.id) {
this._pendingTaskCleanup.set(task.id, appName);
}
// Show success message and switch to tasks
this.addSuccessLog(`Uninstallation task created for ${appName}. Switching to tasks view...`);
// Switch to tasks view to show the uninstallation progress with auto-loaded task
setTimeout(() => {
if (window.appTabbedManager) {
// Switch to tasks tab within current app page
window.appTabbedManager.switchTab('tasks');
// Auto-expand the created task
setTimeout(() => {
if (task && window.appTabbedManager.tasksManager) {
window.appTabbedManager.tasksManager.highlightedTaskId = task.id;
window.appTabbedManager.tasksManager.renderTasks();
}
}, 500);
} else if (window.librePortalSPA) {
// Fallback: navigate to app with tasks tab
const taskUrl = window.appPath(appName, 'tasks', null, task ? task.id : null);
// console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`);
window.librePortalSPA.navigateTo(taskUrl);
} else if (window.navigateToRoute) {
window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, ''));
}
}, 1000);
} catch (error) {
this.addErrorLog(`Failed to create uninstallation task: ${error.message}`);
// Re-enable buttons on error
if (window.appTabbedManager) {
window.appTabbedManager.enableAppButtons(appName);
} else {
this.enableUninstallButton(appName);
}
}
} else {
// Fallback to original simulation if task system not available
//// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`);
this.addInfoLog(`Starting uninstallation of ${appName}...`);
// Simulate uninstallation process
setTimeout(() => {
this.addSuccessLog(`Uninstallation completed successfully!`);
// Re-enable buttons after simulation completes
if (window.appTabbedManager) {
window.appTabbedManager.enableAppButtons(appName);
} else {
this.enableUninstallButton(appName);
}
}, 1500);
}
}
// Enhance scrollbar dynamically for tabs-list
enhanceTabsScrollbar() {
const tabsList = document.querySelector('.tabs-list');
if (tabsList) {
// Check if scrolling is needed
const isScrollable = tabsList.scrollWidth > tabsList.clientWidth;
if (isScrollable) {
// Add data attribute for enhanced styling
tabsList.setAttribute('data-scrollable', 'true');
//// // console.log('✅ Enhanced tabs scrollbar for scrollable content');
} else {
// Remove attribute if not scrollable
tabsList.removeAttribute('data-scrollable');
//// // console.log('📝 Tabs list not scrollable, using default styling');
}
// Monitor for content changes
const observer = new MutationObserver(() => {
setTimeout(() => this.enhanceTabsScrollbar(), 100);
});
observer.observe(tabsList, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style']
});
}
}
// Helper methods for button state management
disableInstallButton(appName, action) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) return;
// Find the install/update button
const button = form.querySelector('.btn-install, .btn-manage');
if (!button) return;
// Disable button and add spinner
button.disabled = true;
button.classList.add('disabled', 'task-running');
// Add loading spinner if not already present
if (!button.querySelector('.spinner')) {
const originalContent = button.innerHTML;
button.dataset.originalContent = originalContent;
// Extract text content from original HTML (remove icons/SVGs)
const tempDiv = document.createElement('div');
tempDiv.innerHTML = originalContent;
const textContent = tempDiv.textContent || tempDiv.innerText || originalContent;
button.innerHTML = `
<span class="spinner" style="
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 6px;
vertical-align: middle;
"></span>
<span style="vertical-align: middle;">${textContent.trim()}</span>
`;
}
// console.log(`🔍 Install button disabled for ${appName} during ${action}`);
}
enableInstallButton(appName) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) return;
// Find the install/update button
const button = form.querySelector('.btn-install, .btn-manage');
if (!button) return;
// Re-enable button and restore original content
button.disabled = false;
button.classList.remove('disabled', 'task-running');
// Restore original content if it was saved
if (button.dataset.originalContent) {
button.innerHTML = button.dataset.originalContent;
delete button.dataset.originalContent;
}
// Remove any spinners
const spinners = button.querySelectorAll('.spinner');
spinners.forEach(spinner => spinner.remove());
// console.log(`🔍 Install button enabled for ${appName}`);
}
disableUninstallButton(appName, action) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) return;
// Find the uninstall button
const button = form.querySelector('.btn-uninstall');
if (!button) return;
// Disable button and add spinner
button.disabled = true;
button.classList.add('disabled', 'task-running');
// Add loading spinner if not already present
if (!button.querySelector('.spinner')) {
const originalContent = button.innerHTML;
button.dataset.originalContent = originalContent;
// Extract text content from original HTML (remove icons/SVGs)
const tempDiv = document.createElement('div');
tempDiv.innerHTML = originalContent;
const textContent = tempDiv.textContent || tempDiv.innerText || originalContent;
button.innerHTML = `
<span class="spinner" style="
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 6px;
vertical-align: middle;
"></span>
<span style="vertical-align: middle;">${textContent.trim()}</span>
`;
}
// console.log(`🔍 Uninstall button disabled for ${appName} during ${action}`);
}
enableUninstallButton(appName) {
const form = document.getElementById(`app-form-${appName}`);
if (!form) return;
// Find the uninstall button
const button = form.querySelector('.btn-uninstall');
if (!button) return;
// Re-enable button and restore original content
button.disabled = false;
button.classList.remove('disabled', 'task-running');
// Restore original content if it was saved
if (button.dataset.originalContent) {
button.innerHTML = button.dataset.originalContent;
delete button.dataset.originalContent;
}
// Remove any spinners
const spinners = button.querySelectorAll('.spinner');
spinners.forEach(spinner => spinner.remove());
// console.log(`🔍 Uninstall button enabled for ${appName}`);
}
// Static methods for global access
static showAppsList(category = null) {
if (window.appsManager) {
window.appsManager.showAppsList(category);
}
}
static showAppDetail(appName) {
if (window.appsManager) {
window.appsManager.showAppDetail(appName);
}
}
showUpdateConfirmModal(appName) {
let displayName = appName;
let icon = `/icons/apps/${appName}.svg`;
if (window.apps) {
const app = window.apps.find(a =>
(a.command || '').endsWith(` ${appName}`) ||
(a.name && a.name.toLowerCase() === appName.toLowerCase())
);
if (app) {
displayName = app.name;
if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.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">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">Container will restart</p>
<p class="eo-empty-state-text">The configuration will be reapplied and the container restarted to pick up changes.</p>
</div>
</div>
<label class="eo-toggle eo-toggle-card" for="update-reset-network" style="margin-top: 14px;">
<input type="checkbox" id="update-reset-network">
<span class="eo-toggle-track"></span>
<span class="eo-toggle-text">
<span class="eo-toggle-text-title">Reset network</span>
<span class="eo-toggle-text-help">Re-randomize IPs and ports. Off by default — keeps your existing network setup.</span>
</span>
</label>`;
window.openEoModal({
id: 'update-confirm-modal',
size: 'sm',
icon,
iconAlt: displayName,
eyebrow: 'Apply Configuration',
title: displayName,
desc: 'Reapply config and restart the container.',
body: bodyHtml,
actions: [
{ label: 'Update', variant: 'primary', onClick: (modal) => {
const cb = modal.contentEl.querySelector('#update-reset-network');
const resetNetwork = !!(cb && cb.checked);
modal.close();
this.executeInstall(appName, resetNetwork);
}},
{ label: 'Cancel', variant: 'secondary' }
]
});
}
// Confirmation modal for the destructive "Uninstall App" action.
showUninstallConfirmModal(appName) {
let displayName = appName;
let icon = `/icons/apps/${appName}.svg`;
let app = null;
if (window.apps) {
app = window.apps.find(a =>
(a.command || '').endsWith(` ${appName}`) ||
(a.name && a.name.toLowerCase() === appName.toLowerCase())
);
if (app) {
displayName = app.name;
if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon;
}
}
// Cascade warning: if the app declares related apps (via CFG_<APP>_RELATED_APPS)
// and any of them are currently installed, the uninstall will take them down
// too — surface that up-front so the user isn't surprised.
const upper = appName.toUpperCase().replace(/-/g, '_');
const relatedRaw = app?.config?.[`CFG_${upper}_RELATED_APPS`]?.value
|| app?.config?.[`CFG_${upper}_RELATED_APPS`]
|| '';
const relatedNames = String(relatedRaw)
.split(',').map(s => s.trim()).filter(Boolean)
.map(slug => {
const rel = (window.apps || []).find(a =>
(a.command || '').endsWith(` ${slug}`) ||
(a.name && a.name.toLowerCase() === slug.toLowerCase())
);
return rel && rel.installed ? (rel.name || slug) : null;
})
.filter(Boolean);
const cascadeBlock = relatedNames.length
? `<div class="eo-empty-state warning" role="status" style="margin-bottom: 10px;">
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">Cascade removal</p>
<p class="eo-empty-state-text">Will also uninstall: ${relatedNames.map(n => `<strong>${n}</strong>`).join(', ')}.</p>
</div>
</div>`
: '';
const bodyHtml = `
${cascadeBlock}
<div class="eo-empty-state danger" 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="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>
</div>
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">This cannot be undone</p>
<p class="eo-empty-state-text">The container will be stopped and its data removed.</p>
</div>
</div>
<label class="eo-toggle eo-toggle-card uninstall-extra" for="uninstall-delete-image" style="margin-top: 14px;">
<input type="checkbox" id="uninstall-delete-image">
<span class="eo-toggle-track"></span>
<span class="eo-toggle-text">
<span class="eo-toggle-text-title">Delete docker image</span>
<span class="eo-toggle-text-help">Frees disk space. Reinstall will re-pull.</span>
</span>
<span class="uninstall-extra-icon image">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</span>
</label>
<label class="eo-toggle eo-toggle-card uninstall-extra" for="uninstall-delete-tasks" style="margin-top: 8px;">
<input type="checkbox" id="uninstall-delete-tasks">
<span class="eo-toggle-track"></span>
<span class="eo-toggle-text">
<span class="eo-toggle-text-title">Delete related tasks</span>
<span class="eo-toggle-text-help">Wipes this app's Tasks tab history.</span>
</span>
<span class="uninstall-extra-icon tasks">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
</span>
</label>`;
window.openEoModal({
id: 'uninstall-confirm-modal',
size: 'sm',
icon,
iconAlt: displayName,
eyebrow: 'Uninstall',
title: displayName,
desc: 'Confirm to remove this application.',
body: bodyHtml,
actions: [
{ label: 'Uninstall', variant: 'danger', onClick: (modal) => {
const cb1 = modal.contentEl.querySelector('#uninstall-delete-image');
const cb2 = modal.contentEl.querySelector('#uninstall-delete-tasks');
const deleteImage = !!(cb1 && cb1.checked);
const deleteTasks = !!(cb2 && cb2.checked);
modal.close();
this.executeUninstall(appName, deleteImage, deleteTasks);
}},
{ label: 'Cancel', variant: 'secondary' }
]
});
}
}
// Service Buttons Manager - handles service URL buttons from port config + apps-services.json
class ServiceButtons {
constructor() {
this.services = [];
}
// Load services from apps-services.json
async loadServices() {
try {
const response = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
const data = await response.json();
this.services = data.services || [];
return this.services;
} catch (error) {
console.error('Error loading services:', error);
return [];
}
}
// Parse port configuration from app config
parsePortConfig(appName) {
const ports = [];
// Get app config from window.apps if available
const app = window.apps?.find(a => a.name === appName || a.command.includes(appName));
if (!app || !app.config) return ports;
const appConfig = app.config;
const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`;
Object.keys(appConfig).forEach(key => {
if (key.startsWith(portPrefix)) {
const configValue = appConfig[key];
if (configValue && configValue.trim() !== '') {
const parts = configValue.split('|');
if (parts.length >= 8) {
const isNineCol = parts.length >= 9;
ports.push({
service: parts[0],
name: parts[1],
external: parts[2].split(':')[0],
internal: parts[2].split(':')[1],
access: parts[3],
protocol: parts[4],
loginRequired: isNineCol ? parts[5] === 'true' : false,
traefikManaged: isNineCol ? parts[6] === 'true' : parts[5] === 'true',
buttonEnabled: isNineCol ? parts[7] === 'true' : parts[6] === 'true',
buttonText: isNineCol ? parts[8] : parts[7]
});
}
}
}
});
return ports;
}
// Get services for a specific app from config, then fill in real IPs/ports from apps-services.json
async getServicesForApp(appName) {
const portConfig = this.parsePortConfig(appName);
// console.log(`📦 Port config for ${appName}:`, portConfig);
const portServices = portConfig.filter(p => p.buttonEnabled);
// console.log(`✅ Enabled services for ${appName}:`, portServices);
if (portServices.length === 0) return [];
// Load services from apps-services.json if not already loaded
if (this.services.length === 0) {
await this.loadServices();
}
// console.log(`🌐 Loaded ${this.services.length} services from apps-services.json`);
// Merge port config with real data from apps-services.json
return portServices.map(portService => {
// Find matching service in apps-services.json
const serviceData = this.services.find(s =>
s.app === appName &&
s.name === portService.name
);
// console.log(`🔗 Matching service for ${portService.name}:`, serviceData);
const merged = {
...portService,
serviceIP: serviceData?.serviceIP || '',
externalPort: serviceData?.externalPort || portService.external,
internalPort: serviceData?.internalPort || portService.internal,
serverIP: serviceData?.serverIP || '',
externalURL: serviceData?.externalURL || '',
internalURL: serviceData?.internalURL || ''
};
// console.log(`🎯 Merged service data:`, merged);
return merged;
});
}
getServiceIcon(serviceName) {
const icons = {
'webui': '🌐',
'ssh': '🔒',
'dns': '🌍',
'api': '🔌',
'admin': '⚙️',
'dashboard': '📊',
'default': '🔗'
};
return icons[serviceName.toLowerCase()] || icons['default'];
}
// Generate HTML for service buttons
async generateButtonsHTML(appName) {
const appServices = await this.getServicesForApp(appName);
if (appServices.length === 0) {
return '';
}
return appServices.map(service => {
// Use externalURL if available, otherwise construct from serverIP and port
let url;
const proto = ['http', 'https'].includes((service.protocol || '').toLowerCase()) ? service.protocol.toLowerCase() : 'http';
if (service.externalURL) {
url = service.externalURL;
} else if (service.serverIP && service.externalPort) {
url = `${proto}://${service.serverIP}:${service.externalPort}`;
} else if (service.externalPort && service.externalPort !== 'random') {
url = `${proto}://localhost:${service.externalPort}`;
} else {
return '';
}
const icon = this.getServiceIcon(service.name);
const protectedClass = service.loginRequired ? ' protected' : '';
const lockIcon = service.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : '';
return `
<a href="${url}" target="_blank" class="service-button${protectedClass}" data-service="${service.name}">
<span class="service-icon">${icon}</span>
<span class="service-text">${service.buttonText}</span>
${lockIcon}
<span class="service-external-icon">↗</span>
</a>
`;
}).join('');
}
lockIconSVG(title) {
return `<span class="service-lock-icon" title="${title}" aria-label="${title}">
<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>`;
}
// Update service buttons in app-header
async updateSidebar(appName) {
const buttonsContainer = document.getElementById('service-buttons-container');
if (!buttonsContainer) return;
if (this.services.length === 0) {
await this.loadServices();
}
// Use apps-services.json as primary source — filter by app and buttonEnabled flag
const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http';
const appServices = this.services.filter(s => s.app === appName && s.buttonEnabled === true);
// Welcome chip — first slot, before the URL buttons.
const welcomeBtn = `
<button type="button" class="service-button service-button-welcome" onclick="appsManager.showInstallWelcome('${appName}', { replay: true })" title="Show the install welcome again">
<span class="service-icon">🎉</span>
<span>Welcome</span>
</button>`;
if (appServices.length === 0) {
buttonsContainer.innerHTML = welcomeBtn;
buttonsContainer.style.display = 'flex';
return;
}
// Multi-button render via the shared expandServiceLinks() helper.
buttonsContainer.innerHTML = welcomeBtn + appServices.flatMap(s => {
const protectedClass = s.loginRequired ? ' protected' : '';
const lockIcon = s.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : '';
return window.expandServiceLinks(s).map(({ url, label }) => `
<a href="${url}" target="_blank" rel="noopener noreferrer" class="service-button${protectedClass}" data-service="${s.name}">
<svg width="14" height="14" 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>
<span>${label}</span>
${lockIcon}
<span class="service-external-icon">↗</span>
</a>
`);
}).filter(Boolean).join('');
buttonsContainer.style.display = 'flex';
}
}
// Global instance
window.appsManager = new AppsManager();
window.serviceButtons = new ServiceButtons();
// Toggle service trigger popup — one open at a time, click-outside to close
window.toggleServiceTrigger = (appName) => {
const clicked = document.getElementById(`service-trigger-${appName}`);
if (!clicked) return;
const isOpen = clicked.classList.contains('open');
// Close all open triggers
document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open'));
if (!isOpen) clicked.classList.add('open');
};
document.addEventListener('click', () => {
document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open'));
});
document.addEventListener('change', (e) => {
if (!e.target?.classList?.contains('advanced-fields-checkbox')) return;
const panel = e.target.closest('.panel-fields') || e.target.closest('.config-section') || document;
panel.querySelectorAll('.advanced-field').forEach(el => {
el.classList.toggle('is-hidden', !e.target.checked);
});
});