fix(webui): refresh the Instances bar when an instance task completes

The post-task handler only repainted the app detail page when the finished
task's app WAS the app on screen, and only ever re-rendered that same slug.
An instance removal is neither: it is dispatched for the instance, while the
list that changed — the Instances bar — is drawn on the parent and on every
sibling. Removing an instance from a family page left its pill sitting there.

Now the family (INSTANCE_OF) is read before the apps.json reload — a removed
instance is gone from it afterwards — and the page ON SCREEN is repainted
whenever it belongs to the same family as the completed task, so a pill
appears on create and disappears on remove without a manual refresh.

Also handles the page whose app no longer exists: an instance removal deletes
the app outright (a plain uninstall only flips it to not-installed), and the
removal flow parks the user on the instance's own Tasks tab, so re-rendering
printed "App not found". Land on the type it was an instance of instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-24 02:48:18 +01:00
parent 66cdb5be0d
commit 0e6eb841bd

View File

@ -155,6 +155,22 @@ class AppsManager {
} catch (e) { console.error('post-uninstall task cleanup failed:', e); }
}
// Instance family membership, read BEFORE the reload. `libreportal
// instance remove` deletes the instance's definition outright, so once
// apps.json is refreshed there is nothing left to say which type it
// belonged to — and the page on screen may be a SIBLING whose Instances
// bar lists the app that just came or went.
const _currentAppPreReload = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '')
|| new URL(window.location.href).searchParams.get('app') || '';
const familyOf = (slug) => {
if (!slug) return '';
const a = (window.apps || []).find(x => (x.command || '').split(' ').pop() === slug);
const cfg = (a && a.config) || {};
return cfg[`CFG_${String(slug).toUpperCase()}_INSTANCE_OF`] || slug;
};
const completedFamilyPre = familyOf(appName);
const currentFamily = familyOf(_currentAppPreReload);
this.clearCache();
await this.reloadAppsData();
if (window.serviceButtons) {
@ -166,11 +182,36 @@ class AppsManager {
const pathname = window.location.pathname;
const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/');
const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/');
const stillListed = (slug) => !!slug && (window.apps || []).some(a => (a.command || '').split(' ').pop() === slug);
// A CREATED instance only gains its INSTANCE_OF after the reload, a
// REMOVED one only had it before — take whichever read still has data.
const completedFamily = stillListed(appName) ? familyOf(appName) : completedFamilyPre;
// The app this page is showing can be GONE: an instance removal deletes
// it, where a plain uninstall only flips it to not-installed. Rendering
// it would print "App not found", so land on the type it was an instance
// of — its Instances bar is where the removal reads as a result.
if (isAppDetailPage && currentAppFromUrl === appName && !stillListed(appName)) {
const dest = (completedFamily !== appName && stillListed(completedFamily))
? (typeof window.appPath === 'function' ? window.appPath(completedFamily, 'config') : `/app/${completedFamily}/config`)
: '/apps';
if (window.librePortalSPA?.navigateTo) window.librePortalSPA.navigateTo(dest);
else if (window.navigateToRoute) window.navigateToRoute(dest.replace(/^\//, ''));
else window.location.href = dest;
if (typeof window.renderInstalledApps === 'function') window.renderInstalledApps();
return;
}
if (isAppsPage && !isAppDetailPage) {
const category = window.appsCategory || 'all';
this.renderApps(category);
} else if (isAppDetailPage && currentAppFromUrl === appName) {
} else if (isAppDetailPage && currentAppFromUrl
&& (currentAppFromUrl === appName
|| (currentFamily && currentFamily === completedFamily))) {
// Repaint the app ON SCREEN, which is not always the one the task ran
// for: a sibling instance created or removed changes this page's
// Instances bar and nothing else would redraw it.
//
// 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
@ -178,7 +219,7 @@ class AppsManager {
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 })
this.renderAppDetail(currentAppFromUrl, null, true, { skipReload: true })
.catch(err => console.error('renderAppDetail failed:', err));
}, 0);
}