fix(webui): move the "no instances" reason into a tooltip, and say more
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 <app> 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 <noreply@anthropic.com>
This commit is contained in:
parent
1b3d68d9f7
commit
964021ba5e
@ -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;
|
||||
}
|
||||
|
||||
@ -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 <app> 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' : ''} `
|
||||
+ `<code>${esc(pinned.join(', '))}</code>, 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 `
|
||||
<div class="instance-family-bar instance-family-bar-off">
|
||||
<span class="instance-family-label">Instances</span>
|
||||
<span class="instance-family-note">Not available — ${why}</span>
|
||||
<span class="instance-family-note">${esc(short)}<span class="instance-why" tabindex="0" role="note" aria-label="${esc(tip)}" title="${esc(tip)}">?</span></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user