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)}?
`; }