fix(tasks): name upgrade tasks, humanise dead instance slugs, keep icons

Three visible faults on the Tasks page, one shared root.

Upgrade tasks rendered as their raw command — "libreportal updater
upgrade rocketchat 8.7.1" beside properly named neighbours. The title
table had rows for updater check/apply/apply-all/rollback and none for
upgrade, because the upgrade command is assembled in task-actions.js
rather than task-commands.js — and lp-task-names, the guard built to
catch exactly this, only read task-commands.js. It certified 16 commands
and reported that as the whole surface; the surface was 29. The guard
now reads both dispatch sites (JS ${expr} interpolations become sample
placeholders; commented-out prose mentioning commands in backticks is
skipped, or it reports fictional commands), and all 29 pass.

A removed instance's tasks outlive it, and its slug rendered as a tech
identifier: "Bookstack_uitest - Remove Instance". getAppDisplayName
cannot help — it capitalises as its own fallback, so unknown is
indistinguishable from known-and-plain. The formatter now does the same
membership test the helper uses internally: slug absent from
window.apps, prefix before the underscore present -> render the way
live instances are shown, "Bookstack · uitest".

Same story for the icon: bookstack_uitest.svg is deleted with the
instance, and onerror="display:none" left a bare gap in the row. Now a
fallback chain — the TYPE's icon (which survives), then the LibrePortal
logo. Verified live: the dead instance's rows show bookstack.svg with
the fallback marker set, everything else keeps its own icon.

Verified in a real browser session — full render, zero console errors.
The 'add' verb also joins the app-action map so "Add Application" is
deliberate wording rather than the blind "<Verb> Application" compose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-20 23:57:30 +01:00
parent 035efa948b
commit 785df3dcd8
3 changed files with 95 additions and 2 deletions

View File

@ -11,7 +11,26 @@ Object.assign(TasksManager.prototype, {
// Adding a new task: just append one row here. Add the matching command // 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 // shape in the WebUI submission site and you're done — no new branch
// needed in this function. // 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 = [ const PATTERNS = [
// -- System / setup ---------------------------------------------------- // -- System / setup ----------------------------------------------------
@ -29,6 +48,10 @@ Object.assign(TasksManager.prototype, {
{ match: /^libreportal updater apply-all\b/, title: 'Apps - Update All' }, { match: /^libreportal updater apply-all\b/, title: 'Apps - Update All' },
{ match: /^libreportal updater apply (\S+)/, title: (m) => `${displayName(m[1])} - Update` }, { match: /^libreportal updater apply (\S+)/, title: (m) => `${displayName(m[1])} - Update` },
{ match: /^libreportal updater rollback (\S+)/, title: (m) => `${displayName(m[1])} - Roll Back` }, { 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 apply (\S+)/, title: (m) => `Hotfix ${m[1]} - Apply` },
{ match: /^libreportal artifact revert (\S+)/, title: (m) => `Hotfix ${m[1]} - Revert` }, { match: /^libreportal artifact revert (\S+)/, title: (m) => `Hotfix ${m[1]} - Revert` },
@ -125,6 +148,7 @@ Object.assign(TasksManager.prototype, {
const action = libreportalMatch[1]; const action = libreportalMatch[1];
const appName = libreportalMatch[2]; const appName = libreportalMatch[2];
const actionMap = { const actionMap = {
'add': 'Add Application',
'install': 'Install Application', 'install': 'Install Application',
'uninstall': 'Uninstall Application', 'uninstall': 'Uninstall Application',
'restart': 'Restart Application', 'restart': 'Restart Application',

View File

@ -280,7 +280,16 @@ Object.assign(TasksManager.prototype, {
const isSystemSentinel = task.app === 'system' || task.app === 'updater'; const isSystemSentinel = task.app === 'system' || task.app === 'updater';
if (task.app && !isSystemSentinel) { if (task.app && !isSystemSentinel) {
const appIconPath = this.getAppIconPath(task); const appIconPath = this.getAppIconPath(task);
return `${typeIcon}<img src="${appIconPath}" alt="${task.app}" class="task-app-icon" onerror="this.style.display='none'">`; // 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 <type>_<name>, 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}<img src="${appIconPath}" alt="${task.app}" class="task-app-icon" `
+ `onerror="if(!this.dataset.fb){this.dataset.fb=1;this.src='${fb1}';}`
+ `else{this.onerror=null;this.src='/core/icons/libreportal.svg';}">`;
} }
if (isSystemSentinel || this.isLibrePortalSystemTask(task)) { if (isSystemSentinel || this.isLibrePortalSystemTask(task)) {
return `${typeIcon}<img src="/core/icons/libreportal.svg" alt="LibrePortal" class="task-app-icon">`; return `${typeIcon}<img src="/core/icons/libreportal.svg" alt="LibrePortal" class="task-app-icon">`;

View File

@ -41,6 +41,7 @@ import sys
ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..")) ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", ".."))
FRONTEND = os.path.join(ROOT, "containers", "libreportal", "frontend") FRONTEND = os.path.join(ROOT, "containers", "libreportal", "frontend")
COMMANDS = os.path.join(FRONTEND, "core", "tasks", "js", "task-commands.js") 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") 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 # Stand-ins for the {placeholders} in a command template. Values only have to be
@ -54,6 +55,25 @@ SAMPLES = {
"backupId": "backup_20260101", "backupId": "backup_20260101",
"toolId": "list_users", "toolId": "list_users",
"args": "", "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 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
<app> <version>` 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 fill(template):
def sub(m): def sub(m):
key = m.group(1) key = m.group(1)
@ -122,6 +173,15 @@ def main():
cases = [(key, fill(tpl)) for key, tpl in command_templates()] cases = [(key, fill(tpl)) for key, tpl in command_templates()]
if not cases: if not cases:
die("no command templates found in task-commands.js — has its shape changed?") 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") harness = os.path.join("/tmp", f"lp-task-names-{os.getpid()}.js")
with open(harness, "w", encoding="utf-8") as fh: with open(harness, "w", encoding="utf-8") as fh: