diff --git a/ui/admin/index.html b/ui/admin/index.html index 3c88917a..67fc16f0 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -301,6 +301,6 @@

General

- + diff --git a/ui/index.html b/ui/index.html index ad42ddbf..37c32d39 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index a595fc45..9ed4b5fc 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -26,7 +26,7 @@ import { swapContent, } from '/js/admin-shared.js?v=38ed2b51'; import { createListbox } from '/js/listbox.js?v=ae7b9a38'; -import { MAX_LABELS } from '/js/badge-logic.js?v=e3c32915'; +import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=44fdebde'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 9aad2c8f..d3f351a0 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,4 +1,4 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=f7f58a4d'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=a896a105'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=8aee98e6'; import { recoveryShown } from '/js/config-recovery.js?v=e22b5e5a'; import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=547999d2'; diff --git a/ui/js/badge-logic.js b/ui/js/badge-logic.js index d7c84129..db76a348 100644 --- a/ui/js/badge-logic.js +++ b/ui/js/badge-logic.js @@ -106,11 +106,11 @@ export function healthReason(detail, translate) { if (detail.state === 'unknown') { parts.push(tr('status.containerNotFound')); } else if (detail.state && detail.state !== 'running') { - /* Untranslated. The text comes from the daemon. */ const known = CONTAINER_STATES[detail.state]; - parts.push( - _clip(detail.status || (known ? tr(known) : tr('status.containerState', { state: detail.state })), REASON_MAX), - ); + const state = known ? tr(known) : tr('status.containerState', { state: detail.state }); + const daemon = String(detail.status ?? '').trim(); + const room = Math.max(8, REASON_MAX - state.length - 3); + parts.push(_clip(daemon ? `${state} · ${_clip(daemon, room)}` : state, REASON_MAX)); } else if (detail.status && /unhealthy/i.test(detail.status)) { parts.push(_clip(detail.status, REASON_MAX)); } diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index cebc010f..36cf1f30 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -47,7 +47,7 @@ import { resetMobileChrome, mkFolderGlyph, } from '/js/ui.js?v=fd480686'; -import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=e3c32915'; +import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=5c72bd4c'; import { formatNumber } from '/js/format-number.js?v=52226e9c'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=9f510a4c'; import { observeGlass } from '/js/glass-rim.js?v=3faec233'; diff --git a/ui/test/badge-logic.test.mjs b/ui/test/badge-logic.test.mjs index db2a7a5a..c1091ef3 100644 --- a/ui/test/badge-logic.test.mjs +++ b/ui/test/badge-logic.test.mjs @@ -201,8 +201,11 @@ test('a user colour is always given its own ink', () => { The reason is the tile's hover text, and is appended to the accessible label so it is not sight-only. */ -test('a stopped container explains itself using Docker wording', () => { - assert.equal(healthReason({ state: 'exited', status: 'Exited (1) 2 hours ago' }), 'Exited (1) 2 hours ago'); +test('a stopped container names its state, then Docker wording', () => { + assert.equal( + healthReason({ state: 'exited', status: 'Exited (1) 2 hours ago' }), + 'Container stopped · Exited (1) 2 hours ago', + ); }); test('a container with no status falls back to its state', () => { @@ -261,8 +264,8 @@ test('an unhealthy badge carries the reason as hover text', () => { hasHC: true, healthDetail: { state: 'exited', status: 'Exited (1)' }, }); - assert.equal(v.title, 'Exited (1)'); - assert.match(v.aria, /needs attention: Exited \(1\)/, 'and is not sight-only'); + assert.equal(v.title, 'Container stopped · Exited (1)'); + assert.match(v.aria, /needs attention: Container stopped · Exited \(1\)/, 'and is not sight-only'); }); test('a healthy badge carries no hover text', () => { diff --git a/ui/test/dashboard-localisation.test.mjs b/ui/test/dashboard-localisation.test.mjs index cdcfafd5..9726aea8 100644 --- a/ui/test/dashboard-localisation.test.mjs +++ b/ui/test/dashboard-localisation.test.mjs @@ -136,9 +136,6 @@ test('the reason a tile is red is translated', () => { assert.match(healthReason({ pingStatus: 503 }, zh), /503/, 'the status code must survive'); }); -/* Docker's own text comes from the daemon, so inventing a translation for - "Exited (1) 2 hours ago" would be guessing at a string this code never - produces. */ test('a pending count takes the plural form of its language', () => { const es = translatorFor('es.json'); assert.equal(computeBadgeVisual({ health: 0, activity: 1, hasHC: true, translate: es }).aria, '1 pendiente'); @@ -153,9 +150,20 @@ test('a container state without daemon text is named in the reader language', () assert.equal(healthReason({ state: 'mystery' }), 'Container mystery'); }); -test("Docker's own status text is passed through untranslated", () => { +test("the translated state leads and Docker's own text follows it", () => { const fa = translatorFor('fa.json'); - assert.equal(healthReason({ state: 'exited', status: 'Exited (1) 2 hours ago' }, fa), 'Exited (1) 2 hours ago'); + assert.equal( + healthReason({ state: 'exited', status: 'Exited (1) 2 hours ago' }, fa), + `${fa('status.containerExited')} · Exited (1) 2 hours ago`, + ); +}); + +test('a long Docker status is trimmed, never the translated state', () => { + const de = translatorFor('de.json'); + const r = healthReason({ state: 'restarting', status: 'Restarting '.repeat(20) }, de); + assert.ok(r.startsWith(`${de('status.containerRestarting')} · Restarting`), r); + assert.ok(r.length <= 90, `too long: ${r.length}`); + assert.match(r, /…$/); }); /* ── the fallback ─────────────────────────────────────────────────────────── */ diff --git a/ui/test/system-summary-link-press.test.mjs b/ui/test/system-summary-link-press.test.mjs new file mode 100644 index 00000000..0b4b7cc2 --- /dev/null +++ b/ui/test/system-summary-link-press.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +const src = fs.readFileSync(new URL('../widgets/system-summary/index.html', import.meta.url), 'utf8'); + +function lift(name, sig) { + const m = src.match(new RegExp(`function ${name}\\(${sig}\\) \\{[\\s\\S]*?\\n\\}`)); + assert.ok(m, `${name} is defined in the widget`); + return new Function(`${m[0]}; return ${name};`)(); +} +const pressOpensLink = new Function( + `${/function pressOpensLink\(el\) \{.*\}/.exec(src)?.[0]}; return pressOpensLink;`, +)(); +const holdToShow = lift('holdToShow', 'target, show'); + +function node(classes = [], parent = null) { + const el = { + parent, + classes: new Set(classes), + classList: { add: c => el.classes.add(c) }, + addEventListener() {}, + closest(sel) { + const wanted = sel.split(',').map(s => s.trim().slice(1)); + for (let n = el; n; n = n.parent) if (wanted.some(c => n.classes.has(c))) return n; + return null; + }, + }; + return el; +} + +test('a press on a row that shows disk sizes does not open the link', () => { + const body = node(); + const row = node(['row'], body); + const label = node(['lbl'], row); + holdToShow(row, () => {}); + assert.equal(pressOpensLink(label), false); + assert.equal(pressOpensLink(row), false); +}); + +test('a press on a chart does not open the link', () => { + assert.equal(pressOpensLink(node([], node(['plot']))), false); +}); + +test('a press anywhere else opens the link', () => { + const body = node(); + assert.equal(pressOpensLink(node(['val'], node(['row'], body))), true); + assert.equal(pressOpensLink(body), true); +}); + +test('the body click handler opens the link only through that check', () => { + assert.match(src, /if \(target && pressOpensLink\(e\.target\)\) openUrl\(target\);/); +}); diff --git a/ui/widgets/system-summary/index.html b/ui/widgets/system-summary/index.html index 770731c7..46b96440 100644 --- a/ui/widgets/system-summary/index.html +++ b/ui/widgets/system-summary/index.html @@ -157,9 +157,9 @@ document.body.classList.toggle('linked', linkTo(linkEl, target)); }; setLink(null); - /* The charts take presses for their own detail, so a press there never opens the link. */ + /* The charts and disk rows take presses for their own detail, so a press there never opens the link. */ document.body.addEventListener('click', e => { - if (target && !e.target.closest('.plot, .disk')) openUrl(target); + if (target && pressOpensLink(e.target)) openUrl(target); }); poll({ fetch: fetchStats, interval: tickMs, @@ -276,7 +276,10 @@ } } +function pressOpensLink(el) { return !el.closest('.plot, .hold'); } + function holdToShow(target, show) { + target.classList.add('hold'); target.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') show(true); }); target.addEventListener('pointerdown', () => show(true)); target.addEventListener('pointerleave', () => show(false));