diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index 9881ee7..b6dc5d1 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -437,33 +437,90 @@ } /* ============================================================================ - Multi-instance — "instance of" badge + the "New instance" card action and - creation modal. An instance is just another app; this is only the create UX. + Multi-instance — grid count chip, the app-detail "family switcher" bar + (swap between sibling instances via real navigation), and the create/remove + modals. An instance is just another app; this is only the swap/manage UX. ========================================================================= */ -.app-tag.instance-tag { + +/* Subtle "N instances" chip on a type's grid card (discovery, no clutter). */ +.app-tag.instance-count-tag { background: rgba(var(--accent-rgb), 0.12); color: var(--accent); border-color: rgba(var(--accent-rgb), 0.30); font-weight: 600; } -.app-card-actions .new-instance-btn { - flex: 0 0 auto; - min-height: 44px; - padding: 12px 14px; - border-radius: 8px; +/* Family switcher bar — sits under the app title, above the tab strip. */ +.instance-family-bar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin: 14px 0 2px; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid var(--border-subtle, var(--border-color)); + border-radius: 12px; +} +.instance-family-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} +.instance-pills { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + flex: 1; +} +.instance-pill { + padding: 6px 14px; + border-radius: 999px; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; background: transparent; - color: var(--accent); - border: 1px dashed rgba(var(--accent-rgb), 0.55) !important; - transition: background 0.2s, border-color 0.2s, transform 0.2s; + color: var(--text-secondary); + border: 1px solid var(--border-color); + transition: background 0.15s, color 0.15s, border-color 0.15s; } -.app-card-actions .new-instance-btn:hover { +.instance-pill:hover { + color: var(--text-primary); + border-color: rgba(var(--accent-rgb), 0.5); +} +.instance-pill.active { + background: var(--accent); + color: var(--text-primary); + border-color: var(--accent); +} +.instance-pill-add { + color: var(--accent); + border-style: dashed; + border-color: rgba(var(--accent-rgb), 0.55); +} +.instance-pill-add:hover { background: rgba(var(--accent-rgb), 0.12); - border-color: var(--accent) !important; + border-color: var(--accent); +} +.instance-remove { + margin-left: auto; + padding: 6px 14px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + background: transparent; + color: var(--status-danger, #e5484d); + border: 1px solid rgba(var(--status-danger-rgb, 229, 72, 77), 0.45); + transition: background 0.15s, border-color 0.15s; +} +.instance-remove:hover { + background: rgba(var(--status-danger-rgb, 229, 72, 77), 0.12); + border-color: var(--status-danger, #e5484d); } /* Modal */ @@ -595,3 +652,11 @@ .lp-instance-create:not(:disabled):hover { filter: brightness(1.08); } +.lp-instance-danger { + background: var(--status-danger, #e5484d); + border-color: var(--status-danger, #e5484d); + color: #fff; +} +.lp-instance-danger:hover { + filter: brightness(1.08); +} diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js index 4c2f8ff..3d9bc54 100644 --- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js @@ -209,19 +209,23 @@ Object.assign(AppsManager.prototype, { ` : ''; - // Multi-instance: capability + instance-of are read straight off the app's - // own config (apps.json emits every CFG__* var). An instance is just - // another app, so this only adds a sibling badge + a "New instance" action. + // Multi-instance: instances are hidden from the grid and managed inside the + // type's detail page. The only grid hint is a subtle count chip on a + // multi-instance-capable type that already has instances — discovery without + // clutter. (Capability is read straight off the app's own config.) const _cfg = app.config || {}; const _U = appName.toUpperCase(); - const instanceOf = _cfg[`CFG_${_U}_INSTANCE_OF`] || ''; const isMultiCapable = String(_cfg[`CFG_${_U}_MULTI_INSTANCE`]).toLowerCase() === 'true'; - const instanceBadge = instanceOf - ? `⧉ instance of ${instanceOf}` - : ''; - const newInstanceBtn = isMultiCapable - ? `` - : ''; + let instanceCountChip = ''; + if (isMultiCapable && Array.isArray(window.apps)) { + const n = window.apps.filter(a => { + const s = (a.command || '').split(' ').pop(); + return (a.config || {})[`CFG_${s.toUpperCase()}_INSTANCE_OF`] === appName; + }).length; + if (n > 0) { + instanceCountChip = `⧉ ${n} instance${n > 1 ? 's' : ''}`; + } + } card.innerHTML = `
@@ -233,7 +237,7 @@ Object.assign(AppsManager.prototype, {
${descriptionTag} ${categoryTag} - ${instanceBadge} + ${instanceCountChip} ${status}
@@ -243,7 +247,6 @@ Object.assign(AppsManager.prototype, { - ${newInstanceBtn} ${serviceTrigger} `; @@ -293,4 +296,69 @@ Object.assign(AppsManager.prototype, { } } }, + + // ---- Multi-instance family switcher (app-detail header) ------------------- + // An instance is just another app with its own route, so "swapping" instances + // is real navigation between sibling slugs. This bar renders under the app + // title for any multi-instance-capable type and its instances: a pill per + // family member (base + each instance) + an "+ Add" pill, and — when the + // current app IS an instance — a "Remove instance" action. Reads the family + // entirely from window.apps via the INSTANCE_OF / MULTI_INSTANCE config. + renderInstanceFamilyBar(cleanAppName) { + if (!cleanAppName) return ''; + const apps = window.apps || []; + const esc = (s) => (typeof escapeHtml === 'function' ? escapeHtml(s) : String(s == null ? '' : s)); + const slugOf = (a) => (a.command || '').split(' ').pop(); + const cfgOf = (slug) => { const a = apps.find(x => slugOf(x) === slug); return (a && a.config) || {}; }; + const U = (s) => String(s).toUpperCase(); + + const myCfg = cfgOf(cleanAppName); + const isInstance = !!myCfg[`CFG_${U(cleanAppName)}_INSTANCE_OF`]; + const type = isInstance ? myCfg[`CFG_${U(cleanAppName)}_INSTANCE_OF`] : cleanAppName; + const typeCfg = cfgOf(type); + const isCapable = String(typeCfg[`CFG_${U(type)}_MULTI_INSTANCE`]).toLowerCase() === 'true'; + if (!isCapable) return ''; + + const instances = apps + .map(slugOf) + .filter(slug => cfgOf(slug)[`CFG_${U(slug)}_INSTANCE_OF`] === type) + .sort(); + const members = [type, ...instances]; + + const typeApp = apps.find(x => slugOf(x) === type); + const typeTitle = typeApp ? (typeApp.name || type).split(' - ')[0].trim() : type; + const labelFor = (slug) => slug === type ? typeTitle : slug.slice(type.length + 1); + + const pills = members.map(slug => { + const active = slug === cleanAppName ? ' active' : ''; + return ``; + }).join(''); + + const addPill = ``; + + const removeBtn = isInstance + ? `` + : ''; + + return ` +
+ Instances +
${pills}${addPill}
+ ${removeBtn} +
`; + }, + + // Swap to a sibling instance: real path-based navigation, current tab kept. + gotoInstance(slug) { + if (!slug) return; + const tab = (window.appTabbedManager && window.appTabbedManager.currentTab) || 'config'; + const path = (typeof window.appPath === 'function') ? window.appPath(slug, tab) : `/app/${slug}/${tab}`; + if (window.librePortalSPA && window.librePortalSPA.navigateTo) { + window.librePortalSPA.navigateTo(path); + } else if (window.navigateToRoute) { + window.navigateToRoute(path.replace(/^\//, '')); + } else { + window.location.href = path; + } + }, }); diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js index 4551a43..25349d2 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -256,7 +256,15 @@ class AppsManager { // Filter apps by category let filteredApps = appsData.apps || []; - + + // Instances are managed inside their type's detail page (the family + // switcher), never as standalone grid cards — hide any app that declares + // INSTANCE_OF so the grid shows one card per app type. + filteredApps = filteredApps.filter(app => { + const slug = (app.command || '').split(' ').pop(); + return !(app.config || {})[`CFG_${slug.toUpperCase()}_INSTANCE_OF`]; + }); + if (category === 'installed') { filteredApps = filteredApps.filter(app => app.installed); } else if (category !== 'all') { @@ -621,8 +629,9 @@ class AppsManager { ` : ''} + ${this.renderInstanceFamilyBar(cleanAppName)} `; - + // Render config section with working app-config-original.js approach // Use the working displayConfigForm from app-config-original.js await this.displayConfigForm(app, preferredCategory); diff --git a/containers/libreportal/frontend/components/apps/core/js/instance-manager.js b/containers/libreportal/frontend/components/apps/core/js/instance-manager.js index 533705f..5471084 100644 --- a/containers/libreportal/frontend/components/apps/core/js/instance-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/instance-manager.js @@ -181,6 +181,64 @@ class InstanceManager { notify(`Failed to create instance: ${error.message}`, 'error'); } } + + // Confirm + remove a single instance. typeSlug is where we land afterwards + // (the instance's own page is going away). + confirmRemove(slug, typeSlug) { + if (document.getElementById('lp-instance-modal')) return; + const overlay = document.createElement('div'); + overlay.id = 'lp-instance-modal'; + overlay.className = 'lp-instance-overlay'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + this._onKey = (e) => { if (e.key === 'Escape') this.close(); }; + document.addEventListener('keydown', this._onKey); + overlay.querySelector('.lp-instance-x').addEventListener('click', () => this.close()); + overlay.querySelector('.lp-instance-cancel').addEventListener('click', () => this.close()); + overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); }); + overlay.querySelector('.lp-instance-danger').addEventListener('click', () => this._remove(slug, typeSlug)); + } + + async _remove(slug, typeSlug) { + if ((!window.tasksManager || !window.tasksManager.router) && window.appsManager && window.appsManager.loadTaskSystem) { + try { await window.appsManager.loadTaskSystem(); } catch (_) {} + } + const notify = (msg, kind) => { + const sys = (typeof window.ensureNotificationSystem === 'function') + ? window.ensureNotificationSystem() : window.notificationSystem; + if (sys && typeof sys.show === 'function') sys.show(msg, kind || 'info'); + }; + if (!window.tasksManager || !window.tasksManager.router) { + notify('Task system unavailable — could not remove instance.', 'error'); + return; + } + try { + await window.tasksManager.router.routeAction('instance_remove', { appName: slug }); + this.close(); + notify(`Removing instance ${slug} — track progress in Tasks.`, 'success'); + const path = (typeof window.appPath === 'function') ? window.appPath(typeSlug, 'config') : `/app/${typeSlug}/config`; + if (window.librePortalSPA && window.librePortalSPA.navigateTo) { + window.librePortalSPA.navigateTo(path); + } else if (window.navigateToRoute) { + window.navigateToRoute(path.replace(/^\//, '')); + } else { + window.location.href = path; + } + } catch (error) { + notify(`Failed to remove instance: ${error.message}`, 'error'); + } + } } window.instanceManager = window.instanceManager || new InstanceManager(); diff --git a/containers/libreportal/frontend/core/tasks/js/task-actions.js b/containers/libreportal/frontend/core/tasks/js/task-actions.js index 43d15d0..8742670 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-actions.js +++ b/containers/libreportal/frontend/core/tasks/js/task-actions.js @@ -50,6 +50,20 @@ class TaskActions { } } + /** + * Remove a single instance (uninstall + drop its template clone). Dispatched + * as an 'uninstall' of the instance slug so the UI treats it like a normal + * removal; the verbatim command runs the instance-specific teardown. + */ + async instanceRemove(slug) { + try { + this.commands.validateCommand('instance_remove', { appName: slug }); + return await this.executeTask('uninstall', slug, `libreportal instance remove ${slug}`); + } catch (error) { + throw new Error(`Failed to remove instance ${slug}: ${error.message}`); + } + } + /** * Uninstall an application */ diff --git a/containers/libreportal/frontend/core/tasks/js/task-commands.js b/containers/libreportal/frontend/core/tasks/js/task-commands.js index f7e0638..c6a076e 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-commands.js +++ b/containers/libreportal/frontend/core/tasks/js/task-commands.js @@ -19,6 +19,8 @@ class TaskCommands { // multi-instance-capable app. The verbatim command (with optional // domain#/subdomain) is built in TaskActions.instanceCreate. instance_create: 'libreportal instance create {type} {name}', + // Remove a single instance (uninstall + drop its template clone). + instance_remove: 'libreportal instance remove {appName}', // Docker Compose Management (✅ IMPLEMENTED) up: 'libreportal app up {appName}', @@ -54,6 +56,7 @@ class TaskCommands { system_update: 'implemented', system_reset: 'implemented', instance_create: 'implemented', + instance_remove: 'implemented', // ❌ Not yet implemented in CLI restore: 'not_implemented', @@ -143,7 +146,8 @@ class TaskCommands { up: ['appName'], down: ['appName'], reload: ['appName'], - instance_create: ['type', 'name'] + instance_create: ['type', 'name'], + instance_remove: ['appName'] }; const required = requiredParams[type] || []; diff --git a/containers/libreportal/frontend/core/tasks/js/task-router.js b/containers/libreportal/frontend/core/tasks/js/task-router.js index ea40e33..a3bd900 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-router.js +++ b/containers/libreportal/frontend/core/tasks/js/task-router.js @@ -24,6 +24,9 @@ class TaskRouter { case 'instance_create': return await this.actions.instanceCreate(params.type, params.name, params.domainIndex, params.subdomain); + case 'instance_remove': + return await this.actions.instanceRemove(params.appName); + case 'uninstall': return await this.actions.uninstallApp(params.appName, params.deleteImage, params.deleteTasks);