Merge claude/2
This commit is contained in:
commit
16dd146710
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">×</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();
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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] || [];
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user