refactor(apps): manage instances on the type's page, not the grid

Replaces the per-instance grid cards + per-card "New instance" action with a
single card per app type and an in-page family switcher — the UX you asked
for (swap between instances via LibrePortal navigation; manage on the page).

- Grid: hide any app declaring INSTANCE_OF (loadApps filter), so there's one
  card per type. A subtle "N instances" chip replaces the old card button.
- App detail: a "family switcher" bar under the title for multi-instance
  types and their instances — a pill per member (base + each instance) that
  path-navigates to that slug's detail (current tab kept), plus "+ Add"
  (existing create modal). When viewing an instance, a "Remove instance"
  action sits in the bar.
- Remove flow: instance_remove task verb (-> existing `libreportal instance
  remove`) with an on-brand confirm modal; lands back on the base type page.

Frontend-only; the instance create/remove backend and CLI are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-06-05 00:17:30 +01:00
parent 5ef969871e
commit ab1d335d35
7 changed files with 249 additions and 28 deletions

View File

@ -437,33 +437,90 @@
} }
/* ============================================================================ /* ============================================================================
Multi-instance "instance of" badge + the "New instance" card action and Multi-instance grid count chip, the app-detail "family switcher" bar
creation modal. An instance is just another app; this is only the create UX. (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); background: rgba(var(--accent-rgb), 0.12);
color: var(--accent); color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.30); border-color: rgba(var(--accent-rgb), 0.30);
font-weight: 600; font-weight: 600;
} }
.app-card-actions .new-instance-btn { /* Family switcher bar — sits under the app title, above the tab strip. */
flex: 0 0 auto; .instance-family-bar {
min-height: 44px; display: flex;
padding: 12px 14px; align-items: center;
border-radius: 8px; 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-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
background: transparent; background: transparent;
color: var(--accent); color: var(--text-secondary);
border: 1px dashed rgba(var(--accent-rgb), 0.55) !important; border: 1px solid var(--border-color);
transition: background 0.2s, border-color 0.2s, transform 0.2s; 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); 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 */ /* Modal */
@ -595,3 +652,11 @@
.lp-instance-create:not(:disabled):hover { .lp-instance-create:not(:disabled):hover {
filter: brightness(1.08); 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);
}

View File

@ -209,19 +209,23 @@ Object.assign(AppsManager.prototype, {
</div> </div>
</div>` : ''; </div>` : '';
// Multi-instance: capability + instance-of are read straight off the app's // Multi-instance: instances are hidden from the grid and managed inside the
// own config (apps.json emits every CFG_<SLUG>_* var). An instance is just // type's detail page. The only grid hint is a subtle count chip on a
// another app, so this only adds a sibling badge + a "New instance" action. // 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 _cfg = app.config || {};
const _U = appName.toUpperCase(); const _U = appName.toUpperCase();
const instanceOf = _cfg[`CFG_${_U}_INSTANCE_OF`] || '';
const isMultiCapable = String(_cfg[`CFG_${_U}_MULTI_INSTANCE`]).toLowerCase() === 'true'; const isMultiCapable = String(_cfg[`CFG_${_U}_MULTI_INSTANCE`]).toLowerCase() === 'true';
const instanceBadge = instanceOf let instanceCountChip = '';
? `<span class="app-tag instance-tag" title="An isolated instance of ${instanceOf}">⧉ instance of ${instanceOf}</span>` if (isMultiCapable && Array.isArray(window.apps)) {
: ''; const n = window.apps.filter(a => {
const newInstanceBtn = isMultiCapable const s = (a.command || '').split(' ').pop();
? `<button class="new-instance-btn" title="Run another isolated copy of this app" onclick="event.stopPropagation(); window.instanceManager && window.instanceManager.openCreateModal('${appName}')">+ New instance</button>` return (a.config || {})[`CFG_${s.toUpperCase()}_INSTANCE_OF`] === appName;
: ''; }).length;
if (n > 0) {
instanceCountChip = `<span class="app-tag instance-count-tag" title="${n} instance${n > 1 ? 's' : ''} of this app — manage them via Manage">⧉ ${n} instance${n > 1 ? 's' : ''}</span>`;
}
}
card.innerHTML = ` card.innerHTML = `
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')"> <div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
@ -233,7 +237,7 @@ Object.assign(AppsManager.prototype, {
<div class="app-card-tags"> <div class="app-card-tags">
${descriptionTag} ${descriptionTag}
${categoryTag} ${categoryTag}
${instanceBadge} ${instanceCountChip}
<span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span> <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> </div>
@ -243,7 +247,6 @@ Object.assign(AppsManager.prototype, {
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')"> <button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
${app.installed ? 'Manage' : 'Install'} ${app.installed ? 'Manage' : 'Install'}
</button> </button>
${newInstanceBtn}
${serviceTrigger} ${serviceTrigger}
</div> </div>
`; `;
@ -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 `<button class="instance-pill${active}" onclick="appsManager.gotoInstance('${esc(slug)}')">${esc(labelFor(slug))}</button>`;
}).join('');
const addPill = `<button class="instance-pill instance-pill-add" title="Create another isolated instance" onclick="window.instanceManager && window.instanceManager.openCreateModal('${esc(type)}')">+ Add</button>`;
const removeBtn = isInstance
? `<button class="instance-remove" title="Uninstall and delete this instance" onclick="window.instanceManager && window.instanceManager.confirmRemove('${esc(cleanAppName)}','${esc(type)}')">Remove instance</button>`
: '';
return `
<div class="instance-family-bar">
<span class="instance-family-label">Instances</span>
<div class="instance-pills">${pills}${addPill}</div>
${removeBtn}
</div>`;
},
// 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;
}
},
}); });

View File

@ -257,6 +257,14 @@ class AppsManager {
// Filter apps by category // Filter apps by category
let filteredApps = appsData.apps || []; 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') { if (category === 'installed') {
filteredApps = filteredApps.filter(app => app.installed); filteredApps = filteredApps.filter(app => app.installed);
} else if (category !== 'all') { } else if (category !== 'all') {
@ -621,6 +629,7 @@ class AppsManager {
<!-- Service buttons will be dynamically inserted --> <!-- Service buttons will be dynamically inserted -->
</div> </div>
` : ''} ` : ''}
${this.renderInstanceFamilyBar(cleanAppName)}
`; `;
// Render config section with working app-config-original.js approach // Render config section with working app-config-original.js approach

View File

@ -181,6 +181,64 @@ class InstanceManager {
notify(`Failed to create instance: ${error.message}`, 'error'); 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 = `
<div class="lp-instance-modal" role="dialog" aria-modal="true" aria-label="Remove instance">
<div class="lp-instance-head">
<h3>Remove instance</h3>
<button type="button" class="lp-instance-x" aria-label="Close">&times;</button>
</div>
<p class="lp-instance-sub">This uninstalls <code>${this._esc(slug)}</code> and permanently deletes its data, database and backup config. This can't be undone.</p>
<div class="lp-instance-actions">
<button type="button" class="lp-instance-btn lp-instance-cancel">Cancel</button>
<button type="button" class="lp-instance-btn lp-instance-danger">Remove instance</button>
</div>
</div>`;
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(); window.instanceManager = window.instanceManager || new InstanceManager();

View File

@ -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 * Uninstall an application
*/ */

View File

@ -19,6 +19,8 @@ class TaskCommands {
// multi-instance-capable app. The verbatim command (with optional // multi-instance-capable app. The verbatim command (with optional
// domain#/subdomain) is built in TaskActions.instanceCreate. // domain#/subdomain) is built in TaskActions.instanceCreate.
instance_create: 'libreportal instance create {type} {name}', 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) // Docker Compose Management (✅ IMPLEMENTED)
up: 'libreportal app up {appName}', up: 'libreportal app up {appName}',
@ -54,6 +56,7 @@ class TaskCommands {
system_update: 'implemented', system_update: 'implemented',
system_reset: 'implemented', system_reset: 'implemented',
instance_create: 'implemented', instance_create: 'implemented',
instance_remove: 'implemented',
// ❌ Not yet implemented in CLI // ❌ Not yet implemented in CLI
restore: 'not_implemented', restore: 'not_implemented',
@ -143,7 +146,8 @@ class TaskCommands {
up: ['appName'], up: ['appName'],
down: ['appName'], down: ['appName'],
reload: ['appName'], reload: ['appName'],
instance_create: ['type', 'name'] instance_create: ['type', 'name'],
instance_remove: ['appName']
}; };
const required = requiredParams[type] || []; const required = requiredParams[type] || [];

View File

@ -24,6 +24,9 @@ class TaskRouter {
case 'instance_create': case 'instance_create':
return await this.actions.instanceCreate(params.type, params.name, params.domainIndex, params.subdomain); 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': case 'uninstall':
return await this.actions.uninstallApp(params.appName, params.deleteImage, params.deleteTasks); return await this.actions.uninstallApp(params.appName, params.deleteImage, params.deleteTasks);