Every backup-scope app now carries CFG_<APP>_BACKUP_STRATEGY=auto, so the Backup Strategy dropdown appears in each app's Advanced tab — not just the DB apps. To keep it honest, the 'live' option is hidden where it isn't safe: - apps.json generator emits backup_live_capable per app (from compose backup labels: a dumpable DB, or a live-safe marker). - apps-manager filters the live option out of the strategy select when the current app isn't live-capable, so apps like gitea/focalboard (a DB we don't yet dump) never offer it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
4075 lines
169 KiB
JavaScript
Executable File
4075 lines
169 KiB
JavaScript
Executable File
// 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();
|
||
}
|
||
|
||
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 = 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 = 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 = 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 = `/app?=${appName}&tab=${targetTab}`;
|
||
// console.log('🔍 Setting URL to:', newUrl);
|
||
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 = `/app?=${appName}&tab=${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();
|
||
// Re-apply any active sidebar search so changing category
|
||
// doesn't reveal apps that should be filtered out.
|
||
if (this.appsSearchQuery) this.filterAppsByQuery(this.appsSearchQuery);
|
||
}).catch(error => {
|
||
console.error('Error rendering apps:', error);
|
||
});
|
||
}
|
||
|
||
// Client-side substring filter wired to the sidebar search box.
|
||
// Cards carry data-search (built in createAppCard) so this stays
|
||
// a single querySelectorAll + display toggle.
|
||
filterAppsByQuery(query) {
|
||
const q = (query || '').trim().toLowerCase();
|
||
this.appsSearchQuery = q;
|
||
const wrap = document.querySelector('.apps-search');
|
||
if (wrap) wrap.classList.toggle('has-value', !!q);
|
||
|
||
const cards = document.querySelectorAll('#apps-section .app-card');
|
||
cards.forEach(card => {
|
||
const hay = card.dataset.search || '';
|
||
card.style.display = (!q || hay.includes(q)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
clearAppsSearch() {
|
||
const input = document.getElementById('apps-search-input');
|
||
if (input) input.value = '';
|
||
this.filterAppsByQuery('');
|
||
if (input) input.focus();
|
||
}
|
||
|
||
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;
|
||
// console.log('🎯 Using global preferred category:', preferredCategory);
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
// 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.html?app=authelia',
|
||
'HEADSCALE': 'app.html?app=headscale',
|
||
'WHITELIST': 'app.html?app=traefik',
|
||
'TRAEFIK': 'app.html?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) {
|
||
// For now, just navigate - could add unsaved changes detection later
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
escAttr(s) {
|
||
return this.escHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
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 haven’t been applied.',
|
||
body: `
|
||
<div class="eo-empty-state warning" role="status">
|
||
<div class="eo-empty-state-body">
|
||
<p class="eo-empty-state-title">Apply before you go?</p>
|
||
<p class="eo-empty-state-text">Apply runs the update now. Discard throws the edits away. Stay keeps you on this page.</p>
|
||
</div>
|
||
</div>`,
|
||
actions: [
|
||
{ label: 'Apply', variant: 'primary', onClick: (m) => finish('apply', m) },
|
||
{ label: 'Discard', variant: 'secondary', onClick: (m) => finish('discard', m) },
|
||
{ label: 'Stay', variant: 'secondary', onClick: (m) => finish('stay', m) }
|
||
],
|
||
onClose: () => { if (!decided) { decided = true; resolve('stay'); } }
|
||
});
|
||
});
|
||
}
|
||
|
||
// 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 = task ? `/app?=${appName}&tab=tasks&task=${task.id}` : `/app?=${appName}&tab=tasks`;
|
||
window.librePortalSPA.navigateTo(taskUrl);
|
||
} else if (window.navigateToRoute) {
|
||
window.navigateToRoute(`app?=${appName}&tab=tasks${task ? `&task=${task.id}` : ''}`);
|
||
}
|
||
}, 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 = task ? `/app?=${appName}&tab=tasks&task=${task.id}` : `/app?=${appName}&tab=tasks`;
|
||
// console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`);
|
||
window.librePortalSPA.navigateTo(taskUrl);
|
||
} else if (window.navigateToRoute) {
|
||
window.navigateToRoute(`app?=${appName}&tab=tasks${task ? `&task=${task.id}` : ''}`);
|
||
}
|
||
}, 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);
|
||
});
|
||
});
|