From 964021ba5e2bd6aee7df7de787a76bea3d1e647c Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 19 Aug 2026 23:29:29 +0100 Subject: [PATCH] fix(webui): move the "no instances" reason into a tooltip, and say more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason spent a full line of every non-instanceable app's page on a sentence only someone asking "why not" wants. It now shows a short "Not available" / "Not enabled" with a hover-and-focus target carrying the technical detail. It also says more than it could before. The old text knew about one blocker — a pinned host port — and its own comment conceded the rest was "left unexplained rather than guessed at, since the remaining blocker lives in the compose, which the frontend never sees". It does see it: apps.json carries each app's service list, so the second rule instance_create.sh enforces (a service or container name with no prefix, which cannot be made unique per instance) is reproducible here. Prometheus now says node-exporter and cadvisor rather than nothing. And it separates two states that read identically before. An app with no blocker at all is not "unavailable", it is simply not opted in — a different sentence with a different fix, and the one Matrix was hitting. Keyboard reachable via tabindex, with aria-label duplicating the tooltip since screen readers do not reliably surface a title attribute. Co-Authored-By: Claude Opus 5 --- .../components/apps/core/css/apps.css | 27 ++++++++++ .../components/apps/core/js/apps-grid.js | 49 ++++++++++++++----- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index 01fea2c..a63fb57 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -805,3 +805,30 @@ .lp-instance-danger:hover { filter: brightness(1.08); } + +/* The "why not" affordance on the not-available instances line. The reason is + technical and only wanted by someone asking the question, so it hides behind + a hover/focus target rather than spending a line of the page on it. + tabindex makes it reachable by keyboard; aria-label carries the same text as + the tooltip for screen readers, which never see a title attribute reliably. */ +.instance-why { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + margin-left: 7px; + border-radius: 50%; + font-size: 10px; + font-weight: 700; + cursor: help; + vertical-align: middle; + background: rgba(var(--text-rgb), 0.12); + color: var(--text-secondary); +} +.instance-why:hover, +.instance-why:focus-visible { + background: rgba(var(--text-rgb), 0.22); + color: var(--text-primary); + outline: none; +} diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js index fd78624..88c3cd0 100644 --- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js @@ -336,16 +336,22 @@ Object.assign(AppsManager.prototype, { const typeTitleEarly = typeAppEarly ? (typeAppEarly.name || type).split(' - ')[0].trim() : type; // Not instanceable: say so where the pills would have been, rather than - // rendering nothing. An empty space reads as "this page has no instance + // rendering nothing — an empty space reads as "this page has no instance // feature", which sends people looking for a setting that isn't missing. // - // Where the reason is knowable from config, give it. A pinned host port is - // the common blocker and the only one visible client-side: the port rows are - // right here, so this mirrors _instanceCheckPortsInstanceable exactly — - // skip `disabled` rows (they publish nothing) and `random` ones (already - // per-instance). Anything else is left unexplained rather than guessed at, - // since the remaining blocker lives in the compose, which the frontend - // never sees. + // But the REASON is technical and only matters to someone asking "why not", + // so it lives in a tooltip rather than a paragraph across the page. What is + // always visible is one short line; hovering explains it. + // + // Both blockers the backend enforces are reproduced here, and they must stay + // in step with instance_create.sh: + // 1. a pinned host port (_instanceCheckPortsInstanceable) — `8201:80` is + // the same 8201 for every copy, so the second container cannot bind. + // 2. a service or container name with no prefix — there is no + // mechanical way to make `database` or `node-exporter` unique per + // instance, and rewriting a bare word would corrupt `image: minio/minio`. + // When neither applies the app is mechanically fine and simply has not been + // opted in, which is a different sentence and a different fix. if (!isCapable) { const pinned = []; for (let i = 1; i <= 20; i++) { @@ -356,14 +362,31 @@ Object.assign(AppsManager.prototype, { if (!ext || ext === 'random' || f[3] === 'disabled') continue; if (!pinned.includes(ext)) pinned.push(ext); } - const why = pinned.length - ? `${esc(typeTitleEarly)} publishes on fixed host port${pinned.length > 1 ? 's' : ''} ` - + `${esc(pinned.join(', '))}, which a second copy could not bind.` - : `${esc(typeTitleEarly)} isn't enabled for multiple instances.`; + const norm = (x) => String(x || '').replace(/-/g, '_'); + const tn = norm(type); + const unprefixed = ((typeAppEarly && typeAppEarly.services) || []) + .filter((sv) => { const n = norm(sv); return n !== tn && !n.startsWith(`${tn}_`); }); + + const bits = []; + if (pinned.length) { + bits.push(`publishes on fixed host port${pinned.length > 1 ? 's' : ''} ` + + `${pinned.join(', ')} — a second copy could not bind ${pinned.length > 1 ? 'them' : 'it'}`); + } + if (unprefixed.length) { + const shown = unprefixed.slice(0, 4).join(', '); + bits.push(`declares service${unprefixed.length > 1 ? 's' : ''} without a "${type}-" prefix ` + + `(${shown}${unprefixed.length > 4 ? `, +${unprefixed.length - 4} more` : ''}) ` + + `— those names cannot be made unique per instance`); + } + const short = bits.length ? 'Not available' : 'Not enabled'; + const tip = bits.length + ? `${typeTitleEarly} ${bits.join('; and ')}.` + : `${typeTitleEarly} has no technical blocker — it simply has not been opted in. ` + + `Set CFG_${U(type)}_MULTI_INSTANCE=true on a reviewed app to allow it.`; return `
Instances - Not available — ${why} + ${esc(short)}?
`; }