Compare commits

..

No commits in common. "16dd146710f69799b2a1d7e24c0d3d5727998935" and "5ef969871e01435771a1e1b0ef465d567ddf091f" have entirely different histories.

7 changed files with 27 additions and 248 deletions

View File

@ -437,90 +437,33 @@
}
/* ============================================================================
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.
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.
========================================================================= */
/* Subtle "N instances" chip on a type's grid card (discovery, no clutter). */
.app-tag.instance-count-tag {
.app-tag.instance-tag {
background: rgba(var(--accent-rgb), 0.12);
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.30);
font-weight: 600;
}
/* 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;
.app-card-actions .new-instance-btn {
flex: 0 0 auto;
min-height: 44px;
padding: 12px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.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);
border: 1px dashed rgba(var(--accent-rgb), 0.55) !important;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
}
.instance-pill-add:hover {
.app-card-actions .new-instance-btn:hover {
background: rgba(var(--accent-rgb), 0.12);
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);
border-color: var(--accent) !important;
}
/* Modal */
@ -652,11 +595,3 @@
.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);
}

View File

@ -209,23 +209,19 @@ Object.assign(AppsManager.prototype, {
</div>
</div>` : '';
// 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.)
// Multi-instance: capability + instance-of are read straight off the app's
// own config (apps.json emits every CFG_<SLUG>_* var). An instance is just
// another app, so this only adds a sibling badge + a "New instance" action.
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';
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 = `<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>`;
}
}
const instanceBadge = instanceOf
? `<span class="app-tag instance-tag" title="An isolated instance of ${instanceOf}">⧉ instance of ${instanceOf}</span>`
: '';
const newInstanceBtn = isMultiCapable
? `<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>`
: '';
card.innerHTML = `
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
@ -237,7 +233,7 @@ Object.assign(AppsManager.prototype, {
<div class="app-card-tags">
${descriptionTag}
${categoryTag}
${instanceCountChip}
${instanceBadge}
<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>
@ -247,6 +243,7 @@ Object.assign(AppsManager.prototype, {
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
${app.installed ? 'Manage' : 'Install'}
</button>
${newInstanceBtn}
${serviceTrigger}
</div>
`;
@ -296,69 +293,4 @@ 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

@ -256,15 +256,7 @@ 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') {
@ -629,9 +621,8 @@ class AppsManager {
<!-- Service buttons will be dynamically inserted -->
</div>
` : ''}
${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);

View File

@ -181,64 +181,6 @@ 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 = `
<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();

View File

@ -50,20 +50,6 @@ 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
*/

View File

@ -19,8 +19,6 @@ 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}',
@ -56,7 +54,6 @@ class TaskCommands {
system_update: 'implemented',
system_reset: 'implemented',
instance_create: 'implemented',
instance_remove: 'implemented',
// ❌ Not yet implemented in CLI
restore: 'not_implemented',
@ -146,8 +143,7 @@ class TaskCommands {
up: ['appName'],
down: ['appName'],
reload: ['appName'],
instance_create: ['type', 'name'],
instance_remove: ['appName']
instance_create: ['type', 'name']
};
const required = requiredParams[type] || [];

View File

@ -24,9 +24,6 @@ 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);