diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js
index 40a866e..643289c 100644
--- a/containers/libreportal/frontend/components/tasks/js/tasks-format.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js
@@ -11,7 +11,26 @@ Object.assign(TasksManager.prototype, {
// Adding a new task: just append one row here. Add the matching command
// shape in the WebUI submission site and you're done — no new branch
// needed in this function.
- const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1));
+ const displayName = (slug) => {
+ const cap = (x) => x.charAt(0).toUpperCase() + x.slice(1);
+ if (!window.getAppDisplayName) return cap(slug);
+ // getAppDisplayName never says "unknown" — it capitalises as its own
+ // fallback — so telling a real app from a leftover slug needs the same
+ // membership test it uses internally. The case this exists for: a
+ // REMOVED instance's tasks outlive it, and its slug (bookstack_uitest)
+ // is no longer in window.apps, so it rendered as a tech identifier.
+ // When the prefix before the underscore is still a real app, render the
+ // way live instances are shown: "Bookstack · uitest".
+ const apps = window.apps || [];
+ const known = (x) => apps.some(a => (a.command || '').endsWith(` ${x}`) || a.name?.toLowerCase() === x.toLowerCase());
+ if (known(slug)) return window.getAppDisplayName(slug);
+ const us = slug.indexOf('_');
+ if (us > 0 && known(slug.slice(0, us))) {
+ return `${window.getAppDisplayName(slug.slice(0, us))} · ${slug.slice(us + 1)}`;
+ }
+ return window.getAppDisplayName(slug);
+ };
+
const PATTERNS = [
// -- System / setup ----------------------------------------------------
@@ -29,6 +48,10 @@ Object.assign(TasksManager.prototype, {
{ match: /^libreportal updater apply-all\b/, title: 'Apps - Update All' },
{ match: /^libreportal updater apply (\S+)/, title: (m) => `${displayName(m[1])} - Update` },
{ match: /^libreportal updater rollback (\S+)/, title: (m) => `${displayName(m[1])} - Roll Back` },
+ // Stepped version upgrade — built in task-actions.js rather than
+ // task-commands.js, which is how it escaped lp-task-names' guard and
+ // shipped title-less: the row showed the raw command.
+ { match: /^libreportal updater upgrade (\S+)(?:\s+(\S+))?/, title: (m) => `${displayName(m[1])} - Upgrade${m[2] ? ` to ${m[2]}` : ''}` },
{ match: /^libreportal artifact apply (\S+)/, title: (m) => `Hotfix ${m[1]} - Apply` },
{ match: /^libreportal artifact revert (\S+)/, title: (m) => `Hotfix ${m[1]} - Revert` },
@@ -125,6 +148,7 @@ Object.assign(TasksManager.prototype, {
const action = libreportalMatch[1];
const appName = libreportalMatch[2];
const actionMap = {
+ 'add': 'Add Application',
'install': 'Install Application',
'uninstall': 'Uninstall Application',
'restart': 'Restart Application',
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
index f3a53d0..e8cabb1 100644
--- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
@@ -280,7 +280,16 @@ Object.assign(TasksManager.prototype, {
const isSystemSentinel = task.app === 'system' || task.app === 'updater';
if (task.app && !isSystemSentinel) {
const appIconPath = this.getAppIconPath(task);
- return `${typeIcon}
`;
+ // Fallback chain instead of vanishing. A removed instance's tasks outlive
+ // its icon file (bookstack_uitest.svg is deleted with the instance), and
+ // display:none left a bare gap in the row. Try the TYPE's icon first —
+ // an instance slug is _, and the type's icon survives — then
+ // the LibrePortal logo, which every install ships.
+ const baseSlug = String(task.app).includes('_') ? String(task.app).split('_')[0] : '';
+ const fb1 = baseSlug ? `/core/icons/apps/${baseSlug}.svg` : '/core/icons/libreportal.svg';
+ return `${typeIcon}
`;
}
if (isSystemSentinel || this.isLibrePortalSystemTask(task)) {
return `${typeIcon}
`;
diff --git a/scripts/dev/lp-task-names b/scripts/dev/lp-task-names
index d445a5a..63c3e3d 100755
--- a/scripts/dev/lp-task-names
+++ b/scripts/dev/lp-task-names
@@ -41,6 +41,7 @@ import sys
ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", ".."))
FRONTEND = os.path.join(ROOT, "containers", "libreportal", "frontend")
COMMANDS = os.path.join(FRONTEND, "core", "tasks", "js", "task-commands.js")
+ACTIONS = os.path.join(FRONTEND, "core", "tasks", "js", "task-actions.js")
FORMAT = os.path.join(FRONTEND, "components", "tasks", "js", "tasks-format.js")
# Stand-ins for the {placeholders} in a command template. Values only have to be
@@ -54,6 +55,25 @@ SAMPLES = {
"backupId": "backup_20260101",
"toolId": "list_users",
"args": "",
+ # task-actions.js interpolations (see actions_templates below)
+ "app": "bookstack",
+ "slug": "bookstack",
+ "serviceName": "bookstack-db",
+ "v": "1.2.3",
+ "cmd": "",
+ "id": "hotfix-1",
+ "flags.join(' ')": "",
+ "backupFile": "backup_20260101.tar.gz",
+ "changes": "CFG_X=1",
+ "flag": "",
+ "list": "bookstack,navidrome",
+ "list.join(',')": "bookstack,navidrome",
+ "deleteRemote1": "false",
+ "deleteRemote2": "false",
+ "action": "install",
+ "toolName": "list_users",
+ "version": "1.2.3",
+ "config.replace(/'/g, \"'\\\\''\")": "",
}
@@ -104,6 +124,37 @@ def command_templates():
return out
+def actions_templates():
+ """Every backtick `libreportal …` template built in task-actions.js.
+
+ This is the file the original guard did not read, and precisely where the
+ gap it exists to catch actually happened: `libreportal updater upgrade
+ ` is assembled here, never appears in task-commands.js, and
+ shipped with no title row — every upgrade task rendered as its raw command.
+ A guard that only reads one of the two dispatch sites certifies half the
+ surface and reports it as all of it.
+
+ JS ${expr} interpolations become {expr} placeholders and go through the
+ same SAMPLES fill as the {name} ones in task-commands.js. A template using
+ an expression SAMPLES does not know is a die(), not a skip — an unknown
+ placeholder is a command the guard cannot vouch for.
+ """
+ src = open(ACTIONS, encoding="utf-8").read()
+ # Strip comment lines first: prose mentions commands in backticks too
+ # ("// index via `libreportal app add` as a task"), and reading those as
+ # dispatch sites reports fictional commands as unnamed.
+ src = "\n".join(l for l in src.splitlines() if not l.strip().startswith("//"))
+ out = []
+ for m in re.finditer(r"`(libreportal [^`]*)`", src):
+ tmpl = re.sub(r"\$\{([^}]+)\}", r"{\1}", m.group(1))
+ # conditional-suffix templates (`… ${v ? … : …}`) collapse to their bare
+ # form: the guard cares that the COMMAND SHAPE has a title row, and the
+ # regex rows match on prefix.
+ tmpl = re.sub(r"\{[^}]*\?[^}]*\}", "", tmpl)
+ out.append(("task-actions.js", tmpl.strip()))
+ return out
+
+
def fill(template):
def sub(m):
key = m.group(1)
@@ -122,6 +173,15 @@ def main():
cases = [(key, fill(tpl)) for key, tpl in command_templates()]
if not cases:
die("no command templates found in task-commands.js — has its shape changed?")
+ # Second dispatch site — the one the guard originally missed (see
+ # actions_templates). Dedupe by filled command so shapes both files build
+ # are not reported twice.
+ seen = {c for _, c in cases}
+ for key, tpl in actions_templates():
+ filled = fill(tpl)
+ if filled not in seen:
+ seen.add(filled)
+ cases.append((key, filled))
harness = os.path.join("/tmp", f"lp-task-names-{os.getpid()}.js")
with open(harness, "w", encoding="utf-8") as fh: