Drop guest row captions that repeat an empty cell

The Workloads table put a second line under memory and disk cells on
every stopped guest ("N/A / Unavailable", "- / Unavailable"), on every
agentless VM's disk, and "May include cache" under every agentless VM's
memory bar, even at 20%. None of them said why or what to do, and they
broke the row rhythm.

Memory that cannot be read now renders the same dash as an unreadable
disk, with its reason in the cell title, the sparkline's hover title
and assistive text. Disk shows a visible label only for a retained
reading (Last known) or a read the user can fix (No permission, Agent
error). The cache caption appears only when the guest's own memory is
at its warning or critical threshold, the figure that colours the bar
in host-share mode too, which is the case #2762 and #2639 were about;
below that the caveat stays in the title and assistive text.

An unavailable memory explanation names what is known instead of
"Unknown source": a powered-off guest reads "Unavailable while the
guest is powered off." MetricMiniSparkline gains an optional tooltip
so the chart covering the cell can carry that reason without changing
its accessible name.
This commit is contained in:
rcourtman
2026-10-13 21:08:59 +01:00
parent 9adc157928
commit 160701bb42
17 changed files with 577 additions and 131 deletions
+7 -5
View File
@@ -313,16 +313,18 @@ custom monitoring role if the required read privileges are absent.
### Current, retained and unavailable disk readings
In **Workloads**, read the disk cell's explanation or open the guest's details
and look under **Filesystems → Status**. Compact rows use **Prior** and **N/A**;
wider rows use **Last known** and **Unavailable**. Where an observation time is
shown, keep it with the reading; an unavailable time leaves freshness unknown.
In **Workloads**, hover the disk cell for its explanation or open the guest's
details and look under **Filesystems → Status**. A retained reading is labelled
**Last known** (**Prior** in compact rows). A reading Pulse could not take shows
a dash; only a read you can fix is labelled, **No permission** or **Agent error**
(**Denied** or **Error** in compact rows). Where an observation time is shown,
keep it with the reading; an unavailable time leaves freshness unknown.
| Reading | What it establishes |
| --- | --- |
| Current filesystem usage | Usage for the reported guest and filesystem, from its collection source. It does not establish that every filesystem is covered or that a backup can be restored. |
| **Prior**, **Last known**, or **Using last known disk stats** | Retained evidence, not current free space. A visible number or History sample does not make it fresh. |
| **N/A**, **Unavailable**, a dash, or a paused/deferred read | Usage is unknown, not zero and not proof that the disk is full. A cooldown ending does not establish a successful new read. |
| A dash, **Usage unavailable**, **No permission**, **Agent error**, or a paused/deferred read | Usage is unknown, not zero and not proof that the disk is full. A cooldown ending does not establish a successful new read. |
**A numeric disk search or disk sort is not a monitoring-coverage check.** To
review missing readings, clear numeric disk conditions and restrictive table
@@ -227,7 +227,10 @@ Retained and freshness-unknown values keep a visible cue in bars, sparklines,
the hover History lens and host-capacity mode; compact layouts shorten only the
visible label, not the accessible original source/time explanation. Sparkline
accessible values never call retained, unknown or unavailable memory current.
Unavailable numeric carriers remain N/A while recorded History stays intact.
An unavailable reading shows the cell's dash in bars mode (N/A only as a
sparkline value) while recorded History stays intact; it has no visible label,
because no number is left to qualify, and its reason stays in the cell title,
the chart's native hover title and assistive text.
An independent qualified memory reading is not paused by a disk deferral.
Neither Last seen, backup completion nor running power state renews memory.
Unannotated unrelated platforms and actual zero readings retain their meaning.
@@ -237,11 +240,18 @@ the backend marks `MayIncludeCache`) keeps its numbers, colour and thresholds;
the shared presentation adds `mayIncludeCache` and a `cacheNote` saying a high
percentage alone does not show memory pressure and which fix can supply the
guest's own figure. A VM row shows `May include cache` (`Cache?` in compact
layouts); a container, whose Proxmox reading is always the cgroup value, keeps
the caveat in the bar tooltip and drawer instead of a label on every row. A
retained or freshness-unknown reading keeps its own freshness label with the
caveat in its explanation, an unavailable reading is never qualified, and the
drawer states the note as visible text in `About this reading`.
layouts) only while the guest's own memory meets its warning or critical
threshold, the figure that colours the bar in host-share mode too, which is
when a cache-inclusive figure reads as pressure; below that the caveat is the
cell title, the chart's native hover title and assistive text, not a label on every
agentless VM. A container, whose Proxmox reading is always the cgroup value,
keeps the caveat in the bar tooltip and drawer instead of a label on every
row. A retained or freshness-unknown reading keeps its own freshness label
with the caveat in its explanation, an unavailable reading is never qualified,
and the drawer states the note as visible text in `About this reading`. An
unavailable explanation names only what is known: a powered-off guest reads
`Unavailable while the guest is powered off.`, and no message says
`Unknown source`.
Agent setup advice first states that setup, restarts and live guest-agent probes
must be deferred during backups or a guest incident. This precaution stays with
VM/container fallback guidance regardless of freshness or a reported backup lock;
@@ -522,10 +532,14 @@ previous data and fresh resumption with production components and CSS on desktop
Chromium and touch WebKit. Synthetic observations do not establish the native
command-ID cause, guest thaw, fresh History collection or containing release.
The shipped row also keeps a visible filesystem provenance cue in every disk
The shipped row also keeps a filesystem provenance cue in every disk
presentation: resting bars, selected sparklines, the pointer/focus history lens
and the pre-sample empty state. `prev-` is labelled **Last known** (**Prior** in
compact rows); an unavailable read is **Unavailable** (**N/A** in compact rows).
compact rows). An unavailable read shows the cell's dash with no visible label
(a stopped VM or missing guest agent is a setup state, not news), except a
broken read the user can fix: **No permission** or **Agent error** (**Denied**
or **Error** in compact rows), in amber. Every reason stays in the cell title
and the sparkline's native hover title.
The full fixed-reason explanation is ordinary assistive text as well as the
existing cell title and drawer content, not a live region repeated across a
fleet. Retained sparkline values are announced as **last known**, never current;
@@ -327,8 +327,8 @@ proof, not a native CPU improvement, QGA/thaw result or delivery acceptance.
### Guest filesystem provenance preserves dense row geometry
Visible retained/unavailable filesystem cues must fit the existing dense row
rhythm, including canonical phone/narrow column weights, without adding a new
Visible retained and fixable-read filesystem cues must fit the existing dense row
rhythm (an unavailable setup-gap read adds no visible cue at all), including canonical phone/narrow column weights, without adding a new
collector, history request, layout observer, focus target or event path. Keep
compact copy on narrow rows and the full safe reason in assistive text and the
existing drawer. Inline sparkline plots may shrink to preserve their numeric
@@ -3101,9 +3101,11 @@ composition, balloon and swap geometry for inspection, in a neutral tone;
their composition tooltip carries the original source/time caveat. A current
linked-agent memory observation remains current while QEMU disk reads defer.
A current cache-inclusive Proxmox reading still reaches the row presentation,
so the VM row can say `May include cache`; rows whose reading is current and
cache-aware keep returning no presentation without formatting a date or source
label (`memoryObservation.test.ts` pins both paths).
so the VM row can say `May include cache` once the guest's own memory meets
its warning threshold (below it the caveat is title and assistive text only);
rows whose reading is current and cache-aware keep returning no presentation
without formatting a date or source label (`memoryObservation.test.ts` pins
both paths).
Tooltip headings, numeric values and neutral labels use theme-aware text so
the source caveat and composition remain readable on both light and dark surfaces.
An unavailable reading, including the shared bar's explicit unavailable input,
@@ -176,12 +176,22 @@ const settle = (page) =>
};
const check = async (retained, message) => {
assert.equal(await notice.count(), 1);
assert.equal(
await notice.locator('[aria-hidden="true"]').innerText(),
retained ? (compact ? 'Prior' : 'Last known') : compact ? 'N/A' : 'Unavailable',
);
assert.equal(await notice.locator('.sr-only').textContent(), message);
const geometry = await notice.locator('[aria-hidden="true"]').evaluate((el) => {
// A deferral with no retained reading shows the cell's dash; its reason
// is assistive text and the cell title, not a visible caption.
if (retained) {
assert.equal(
await notice.locator('[aria-hidden="true"]').innerText(),
compact ? 'Prior' : 'Last known',
);
assert.equal(await notice.locator('.sr-only').textContent(), message);
} else {
assert.equal(await notice.locator('[aria-hidden="true"]').count(), 0);
assert.equal(await notice.textContent(), message);
}
const measured = retained
? notice.locator('[aria-hidden="true"]')
: notice.locator('xpath=ancestor::td');
const geometry = await measured.evaluate((el) => {
const rect = el.getBoundingClientRect();
const cellRect = el.closest('td').getBoundingClientRect();
return {
@@ -219,7 +219,8 @@ assert.equal(
assert.equal(await memory.locator('svg[role="img"] path').getAttribute('d'), plot);
await capture('unavailable-sparkline');
await page.getByLabel('Metric display').selectOption('bars');
await memory.getByText('N/A', { exact: true }).first().waitFor();
// Bars mode shows the same dash as an unavailable disk, not an empty bar.
await memory.getByText('-', { exact: true }).first().waitFor();
assert.equal(
await memory.locator('[data-stacked-memory-segment]').count(),
0,
@@ -75,7 +75,7 @@ const settle = (page) => page.evaluate(async () => {
await apply({ reason: '', lock: '', status: 'stopped', data: true, usage: 90 });
for (const mode of ['bars', 'sparklines']) {
await page.evaluate((value) => window.__guestDiskProvenance.mode(value), mode); await settle(page);
assert.match(await cell.innerText(), /Unavailable|N\/A/);
assert.equal(await cell.locator('[data-workload-disk-read-status] [aria-hidden="true"]').count(), 0);
assert.equal(await cell.getAttribute('title'), 'Guest filesystem stats unavailable while the VM is stopped.');
assert.equal(await cell.getByRole('progressbar').count(), 0);
for (const label of await cell.getByRole('img').evaluateAll((items) => items.map((item) => item.getAttribute('aria-label')))) assert.doesNotMatch(label, /current 90%/);
@@ -83,7 +83,7 @@ const settle = (page) => page.evaluate(async () => {
}
record.checks.push('Both row modes withdraw stopped-VM current usage; the independent running control is unchanged.');
const disclosure = row.locator('[data-row-action]');
if (width < 768) await click(cell.locator('[data-workload-disk-read-status]'));
if (width < 768) await click(cell);
else { await disclosure.focus(); await page.keyboard.press('Enter'); }
const drawer = page.getByRole('region', { name: 'Guest details' });
await drawer.waitFor({ state: 'visible' });
@@ -228,10 +228,10 @@ const resource = (read) => {
}
assert.equal(await badge.count(), reason ? 1 : 0);
if (reason) {
assert.match(
await badge.innerText(),
retained ? /Last known|Prior/ : /Unavailable|N\/A/,
);
// Only a retained reading gets a visible label; an unavailable
// read keeps its reason as assistive text and the cell title.
assert.equal(await badge.locator('[aria-hidden="true"]').count(), retained ? 1 : 0);
if (retained) assert.match(await badge.innerText(), /Last known|Prior/);
assert.match(
await cell.getAttribute('title'),
retained ? /Using last known/ : /Guest reads|Guest request/,
@@ -0,0 +1,46 @@
{
"version": 1,
"base_sha": "9adc1579282d48cfd60d2808f46c51bd5fe203d6",
"verified_at": "2026-10-12T23:15:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/MetricMiniSparkline.tsx",
"frontend-modern/src/utils/memoryObservation.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestRow.tsx": "cf79e50b7c7286abf444644a5f5a38e191d5bfe6d72cbf63fc78a97db879bf3c",
"frontend-modern/src/components/Workloads/MetricMiniSparkline.tsx": "b066231d8c835bd72c5de46046b06f105e1a59cd59ebc9f891b834a15ca87533",
"frontend-modern/src/utils/memoryObservation.ts": "69eb7fd90bca00dd2ffdfc542f08a41508d1353d43f0fdb03ac654e1b4cfcc15"
},
"routes": [
"/proxmox"
],
"viewports": [
{
"width": 1900,
"height": 1100
},
{
"width": 375,
"height": 812
}
],
"states": [
"Private mock stack built from main (backend 7676, sidecar Vite 5194), guest memory warning set to 75%",
"Bars, desktop: stopped VM and container (postgres-replica-01, dev-portal-01) show a dash in MEM and DISK with no caption; MEM title \"Unavailable while the guest is powered off.\"",
"Bars, desktop: running VM without guest agent (checkout-web-01) shows a dash in DISK with no caption; title carries the Proxmox reason",
"Bars, desktop: cache-inclusive VMs below warning (orders-api-01 52%, postgres-primary-01 64%) show no caption; caveat is the cell title and sr-only text",
"Bars, desktop: cache-inclusive VM at warning (redis-cache-01 77%) shows \"May include cache\"; guest-agent VM at 83% (checkout-web-02) shows none",
"Trends, desktop: sparkline hover title carries the memory/disk reason for stopped and agentless rows; current rows keep \"<name> memory history\"; accessible names unchanged",
"Rows stay 32px on desktop (33px only on the one caption row) and 44px on phone; no horizontal page overflow",
"Phone bars and trends: redis-cache-01 shows compact \"Cache?\" unclipped; stopped row shows dashes",
"Before/after headless captures of the same rows against main GuestRow/memoryObservation for comparison"
],
"interactions": [
"Switched View between Bars and Trends",
"Reloaded at 1900x1100 and at the 375x812 mobile preset",
"Lowered the private instance guest memory threshold to 75% via PUT /api/alerts/config and reloaded",
"Read cell, caption and sparkline title attributes and row heights from the DOM"
]
}
@@ -313,16 +313,18 @@ custom monitoring role if the required read privileges are absent.
### Current, retained and unavailable disk readings
In **Workloads**, read the disk cell's explanation or open the guest's details
and look under **Filesystems → Status**. Compact rows use **Prior** and **N/A**;
wider rows use **Last known** and **Unavailable**. Where an observation time is
shown, keep it with the reading; an unavailable time leaves freshness unknown.
In **Workloads**, hover the disk cell for its explanation or open the guest's
details and look under **Filesystems → Status**. A retained reading is labelled
**Last known** (**Prior** in compact rows). A reading Pulse could not take shows
a dash; only a read you can fix is labelled, **No permission** or **Agent error**
(**Denied** or **Error** in compact rows). Where an observation time is shown,
keep it with the reading; an unavailable time leaves freshness unknown.
| Reading | What it establishes |
| --- | --- |
| Current filesystem usage | Usage for the reported guest and filesystem, from its collection source. It does not establish that every filesystem is covered or that a backup can be restored. |
| **Prior**, **Last known**, or **Using last known disk stats** | Retained evidence, not current free space. A visible number or History sample does not make it fresh. |
| **N/A**, **Unavailable**, a dash, or a paused/deferred read | Usage is unknown, not zero and not proof that the disk is full. A cooldown ending does not establish a successful new read. |
| A dash, **Usage unavailable**, **No permission**, **Agent error**, or a paused/deferred read | Usage is unknown, not zero and not proof that the disk is full. A cooldown ending does not establish a successful new read. |
**A numeric disk search or disk sort is not a monitoring-coverage check.** To
review missing readings, clear numeric disk conditions and restrictive table
@@ -33,6 +33,7 @@ import {
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
import { createRowTextSelectionGuard, nativeRowClickTarget } from '@/components/shared/Table';
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
import { getMetricSeverity } from '@/utils/metricThresholds';
import {
getWorkloadMemoryGuestKind,
getWorkloadMemoryObservationPresentation,
@@ -182,35 +183,54 @@ export function GuestRow(props: GuestRowProps) {
memoryReading()?.state === 'unavailable' ||
(isHostMemoryBasis() && hostMemoryTotal() <= 0),
);
// The guest's own memory severity, even when the bar shows its host share:
// the bar is coloured by the same figure.
const guestMemoryAtWarning = createMemo(() => {
const memory = props.guest.memory;
const percent =
typeof memory?.used === 'number' && typeof memory.total === 'number' && memory.total > 0
? (memory.used / memory.total) * 100
: memory?.usage;
if (typeof percent !== 'number' || !Number.isFinite(percent)) return false;
return getMetricSeverity(percent, 'memory', memoryThresholds()) !== 'normal';
});
const memoryReadStatus = createMemo(() => {
const reading = memoryReading();
if (!reading) return undefined;
// A current reading needs no label, except a VM's cache-inclusive one: the
// user can set up the guest agent for the VM's own figure. Containers have
// no such step, so their caveat stays in the tooltip and drawer.
const cacheCue =
// With no number shown for another reason (memory telemetry off, no host
// total for a host share), there is no reading to qualify.
if (memoryDisplayUnavailable() && reading.state !== 'unavailable') return undefined;
// A current reading needs no qualification, except a VM's cache-inclusive
// one: the guest agent can supply the VM's own figure. Containers have no
// such step, so their caveat stays in the tooltip and drawer.
const cacheReading =
reading.state === 'current' &&
reading.mayIncludeCache &&
getWorkloadMemoryGuestKind(props.guest) === 'vm';
if (reading.state === 'current' && !cacheCue) return undefined;
if (reading.state === 'current' && !cacheReading) return undefined;
return {
message: reading.message,
label: cacheCue
? usesCompactTableLayout()
? 'Cache?'
: 'May include cache'
// Visible only where it changes what the user concludes: a cache
// reading that looks like memory pressure, or a number that is not
// current. An unavailable reading already shows no number, so a label
// would only repeat it. Every reason stays in the title, assistive text
// and drawer.
label: cacheReading
? guestMemoryAtWarning()
? usesCompactTableLayout()
? 'Cache?'
: 'May include cache'
: null
: reading.state === 'last-known'
? usesCompactTableLayout()
? 'Prior'
: 'Last known'
: reading.state === 'unavailable'
? usesCompactTableLayout()
? 'N/A'
: 'Unavailable'
? null
: usesCompactTableLayout()
? 'Unknown'
: 'Freshness unknown',
valueLabelContext: cacheCue
valueLabelContext: cacheReading
? ('current' as const)
: reading.state === 'last-known'
? ('last known' as const)
@@ -219,6 +239,14 @@ export function GuestRow(props: GuestRowProps) {
: ('freshness unknown' as const),
};
});
// The cell title carries a reading's reason; the dash explains only what
// the cell does not.
const memoryUnavailableTooltip = () =>
memoryReadStatus()
? undefined
: isHostMemoryBasis() && hostMemoryTotal() <= 0
? 'Host memory total unavailable'
: 'Memory usage unavailable';
const memoryValueLabelContext = (): MetricMiniSparklineValueLabelContext =>
memoryDisplayUnavailable()
? 'unavailable'
@@ -254,6 +282,7 @@ export function GuestRow(props: GuestRowProps) {
options: {
valueLabel: () => string;
title: () => string;
tooltip?: () => string | undefined;
unit?: string;
formatValue: (value: number) => string;
seriesOptions?: () => Parameters<WorkloadMetricHistoryReader['getGuestMetricSeries']>[2];
@@ -271,6 +300,7 @@ export function GuestRow(props: GuestRowProps) {
valueLabelContext={options.valueLabelContext?.()}
valueLabelMode="inline"
title={options.title()}
tooltip={options.tooltip?.()}
unit={options.unit ?? '%'}
formatValue={options.formatValue}
cursorRatio={historyCursorRatio()}
@@ -300,6 +330,10 @@ export function GuestRow(props: GuestRowProps) {
isHostMemoryBasis()
? `${props.guest.name} host memory share history`
: `${props.guest.name} memory history`,
// The chart covers the cell, so it carries the reading's reason on hover.
tooltip: () =>
memoryReadStatus()?.message ??
(memoryDisplayUnavailable() ? memoryUnavailableTooltip() : undefined),
formatValue: formatMetricPercent,
seriesOptions: () => ({
memoryDisplayBasis: props.memoryDisplayBasis,
@@ -311,6 +345,7 @@ export function GuestRow(props: GuestRowProps) {
renderMetricSparkline('disk', {
valueLabel: () => (hasDiskUsage() ? formatMetricPercent(props.guest.disk?.usage) : '—'),
title: () => `${props.guest.name} disk usage history`,
tooltip: () => diskReadStatus()?.message,
formatValue: formatMetricPercent,
valueLabelContext: () => diskValueLabelContext(),
});
@@ -340,18 +375,30 @@ export function GuestRow(props: GuestRowProps) {
};
// A native title or bar tooltip is not a visible freshness cue on touch,
// and disappears from the chart's accessible value when the history lens opens.
// A retained number and a broken read the user can fix get a visible label.
// Other unavailable reads (VM stopped, no guest agent) already show a dash,
// so their reason stays in the title, assistive text and drawer.
const diskReadStatus = createMemo(() => {
const read = diskUsageRead();
if (read.state === 'current') return undefined;
const retained = read.state === 'last-known';
const reason = isVM(props.guest)
? (props.guest.diskStatusReason ?? '').replace(/^prev-/, '')
: '';
return {
label: retained
? usesCompactTableLayout()
? 'Prior'
: 'Last known'
: usesCompactTableLayout()
? 'N/A'
: 'Unavailable',
: !read.needsAction
? null
: reason === 'permission-denied'
? usesCompactTableLayout()
? 'Denied'
: 'No permission'
: usesCompactTableLayout()
? 'Error'
: 'Agent error',
message: read.message,
valueLabelContext: retained ? ('last known' as const) : ('current' as const),
// Most reasons are a setup gap (no guest agent, VM stopped) rather than a
@@ -612,38 +659,55 @@ export function GuestRow(props: GuestRowProps) {
<Show
when={historyLensVisible()}
fallback={
<div title={isHostMemoryBasis() ? undefined : (memoryTooltip() ?? undefined)}>
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={memoryDisplayTotal()}
unavailable={memoryDisplayUnavailable()}
reading={memoryReading()}
percentOnly={isHostMemoryBasis() ? undefined : memoryPercentOnly()}
cache={isHostMemoryBasis() ? 0 : (props.guest.memory?.cache ?? 0)}
cacheInclusiveLabel={isHostMemoryBasis() ? undefined : 'Shown in Proxmox'}
balloon={isHostMemoryBasis() ? 0 : (props.guest.memory?.balloon ?? 0)}
swapUsed={isHostMemoryBasis() ? 0 : (props.guest.memory?.swapUsed ?? 0)}
swapTotal={isHostMemoryBasis() ? 0 : (props.guest.memory?.swapTotal ?? 0)}
resourceId={metricsKey()}
anomaly={isHostMemoryBasis() ? null : memoryAnomaly()}
thresholds={memoryThresholds()}
severityPercent={
isHostMemoryBasis()
? usagePercent(
props.guest.memory?.used,
props.guest.memory?.total,
props.guest.memory?.usage,
)
: undefined
}
comparisonTotalLabel={isHostMemoryBasis() ? 'Host total' : undefined}
tooltipTitle={
isHostMemoryBasis()
? `${props.parentNodeName?.trim() || 'Host'} memory share`
: undefined
}
/>
</div>
<Show
when={!memoryDisplayUnavailable()}
fallback={
<div class="flex justify-center">
<span class="text-xs text-slate-400" title={memoryUnavailableTooltip()}>
-
</span>
</div>
}
>
<div
title={isHostMemoryBasis() ? undefined : (memoryTooltip() ?? undefined)}
>
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={memoryDisplayTotal()}
unavailable={memoryDisplayUnavailable()}
reading={memoryReading()}
percentOnly={isHostMemoryBasis() ? undefined : memoryPercentOnly()}
cache={isHostMemoryBasis() ? 0 : (props.guest.memory?.cache ?? 0)}
cacheInclusiveLabel={
isHostMemoryBasis() ? undefined : 'Shown in Proxmox'
}
balloon={isHostMemoryBasis() ? 0 : (props.guest.memory?.balloon ?? 0)}
swapUsed={isHostMemoryBasis() ? 0 : (props.guest.memory?.swapUsed ?? 0)}
swapTotal={
isHostMemoryBasis() ? 0 : (props.guest.memory?.swapTotal ?? 0)
}
resourceId={metricsKey()}
anomaly={isHostMemoryBasis() ? null : memoryAnomaly()}
thresholds={memoryThresholds()}
severityPercent={
isHostMemoryBasis()
? usagePercent(
props.guest.memory?.used,
props.guest.memory?.total,
props.guest.memory?.usage,
)
: undefined
}
comparisonTotalLabel={isHostMemoryBasis() ? 'Host total' : undefined}
tooltipTitle={
isHostMemoryBasis()
? `${props.parentNodeName?.trim() || 'Host'} memory share`
: undefined
}
/>
</div>
</Show>
}
>
<div class="h-4 animate-in fade-in-0 duration-100 motion-reduce:animate-none">
@@ -657,14 +721,25 @@ export function GuestRow(props: GuestRowProps) {
</Show>
<Show when={memoryReadStatus()}>
{(status) => (
<p
data-workload-memory-read-status
class="mt-0.5 text-center text-[10px] leading-none text-muted"
title={status().message}
<Show
when={status().label}
fallback={
<span data-workload-memory-read-status class="sr-only">
{status().message}
</span>
}
>
<span aria-hidden="true">{status().label}</span>
<span class="sr-only">{status().message}</span>
</p>
{(label) => (
<p
data-workload-memory-read-status
class="mt-0.5 text-center text-[10px] leading-none text-muted"
title={status().message}
>
<span aria-hidden="true">{label()}</span>
<span class="sr-only">{status().message}</span>
</p>
)}
</Show>
)}
</Show>
</td>
@@ -712,16 +787,27 @@ export function GuestRow(props: GuestRowProps) {
</Show>
<Show when={diskReadStatus()}>
{(status) => (
<p
data-workload-disk-read-status
class={`mt-0.5 text-center text-[10px] leading-none ${
status().needsAction ? 'text-amber-700 dark:text-amber-300' : 'text-muted'
}`}
title={status().message}
<Show
when={status().label}
fallback={
<span data-workload-disk-read-status class="sr-only">
{status().message}
</span>
}
>
<span aria-hidden="true">{status().label}</span>
<span class="sr-only">{status().message}</span>
</p>
{(label) => (
<p
data-workload-disk-read-status
class={`mt-0.5 text-center text-[10px] leading-none ${
status().needsAction ? 'text-amber-700 dark:text-amber-300' : 'text-muted'
}`}
title={status().message}
>
<span aria-hidden="true">{label()}</span>
<span class="sr-only">{status().message}</span>
</p>
)}
</Show>
)}
</Show>
</td>
@@ -27,6 +27,8 @@ interface MetricMiniSparklineProps {
valueLabelMode?: MetricMiniSparklineValueLabelMode;
valueLabelContext?: MetricMiniSparklineValueLabelContext;
title?: string;
/** Hover text when it should say more than the accessible title, such as a reading's reason. */
tooltip?: string;
unit?: string;
emptyLabel?: string;
formatValue?: (value: number) => string;
@@ -149,7 +151,7 @@ export const MetricMiniSparkline: Component<MetricMiniSparklineProps> = (props)
return (
<div
class={`grid h-4 w-full min-w-0 ${rootColumns()} items-center gap-1 overflow-hidden`}
title={props.title}
title={props.tooltip ?? props.title}
data-testid="metric-mini-sparkline"
data-value-label-mode={valueLabelMode()}
data-rendered-series-count={paths().length}
@@ -274,7 +274,7 @@ describe('GuestRow', () => {
});
it('renders unknown canonical telemetry as unavailable instead of reported zero', () => {
renderGuestRow({
const { container } = renderGuestRow({
guest: makeGuest({
cpu: 0,
networkIn: 0,
@@ -295,7 +295,10 @@ describe('GuestRow', () => {
});
expect(screen.queryByTestId('cpu-bar')).toBeNull();
expect(screen.getByTestId('memory-bar')).toHaveAttribute('data-unavailable', 'true');
expect(screen.queryByTestId('memory-bar')).toBeNull();
const memoryCell = container.querySelector('[data-workload-col="memory"]')!;
expect(memoryCell).toHaveAttribute('title', 'Unavailable.');
expect(memoryCell).toHaveTextContent(/^-/);
expect(screen.queryByText('0 B/s')).toBeNull();
expect(screen.queryByText('0s')).toBeNull();
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(4);
@@ -357,7 +360,18 @@ describe('GuestRow', () => {
],
);
renderGuestRow({
guest: makeGuest({ memory: makeMemory({ used: 2 * 1024 ** 3, total: 4 * 1024 ** 3 }) }),
guest: makeGuest({
memory: makeMemory({
used: 2 * 1024 ** 3,
total: 4 * 1024 ** 3,
// A current reading: the chart keeps its own title on hover.
observation: {
state: 'current',
source: 'guest-agent-meminfo',
observedAt: '2026-09-30T11:00:00Z',
},
}),
}),
visibleColumnIds: ['name', 'memory'],
memoryDisplayBasis: 'host',
parentMemoryTotal: 16 * 1024 ** 3,
@@ -388,7 +402,8 @@ describe('GuestRow', () => {
memoryDisplayBasis: 'host',
});
expect(screen.getByTestId('memory-bar')).toHaveAttribute('data-unavailable', 'true');
expect(screen.queryByTestId('memory-bar')).toBeNull();
expect(screen.getByTitle('Host memory total unavailable')).toHaveTextContent('-');
});
it('does not inflate a sub-one-percent host memory share', () => {
@@ -755,7 +770,13 @@ describe('GuestRow', () => {
setGuest(makeGuest({ status: 'stopped', diskStatusReason: undefined }));
const cell = container.querySelector('[data-workload-col="disk"]')!;
expect(screen.queryByTestId('disk-bar')).toBeNull();
expect(cell).toHaveTextContent('Unavailable');
// A stopped VM has no filesystem reading to give: the dash says so,
// and the reason is the title and assistive text, not a caption.
expect(cell.querySelector('[data-workload-disk-read-status]')).toHaveClass('sr-only');
expect(cell.querySelector('[aria-hidden="true"]')).toBeNull();
expect(cell).toHaveTextContent(
'Guest filesystem stats unavailable while the VM is stopped.',
);
expect(cell).toHaveAccessibleDescription();
expect(cell.getAttribute('title')).toBe(
'Guest filesystem stats unavailable while the VM is stopped.',
@@ -924,7 +945,7 @@ describe('GuestRow', () => {
});
expect(screen.queryByTestId('disk-bar')).toBeNull();
const cell = container.querySelector('[data-workload-col="disk"]')!;
expect(cell).toHaveTextContent('Unavailable');
expect(cell.querySelector('[data-workload-disk-read-status]')).toHaveClass('sr-only');
const chart = cell.querySelector('[role="img"]');
if (chart) expect(chart.getAttribute('aria-label')).not.toMatch(/50%/);
},
@@ -1005,15 +1026,21 @@ describe('GuestRow', () => {
metricDisplayMode: mode,
});
const notice = container.querySelector('[data-workload-disk-read-status]');
expect(notice).toHaveTextContent('Unavailable');
// A deferral is not a fault the user can fix, so no visible caption.
expect(notice).toHaveClass('sr-only');
expect(notice).toHaveTextContent(message);
expect(notice).not.toHaveTextContent('Last known');
// The reason is on hover too, not only for screen readers.
expect(notice).toHaveAttribute('title', message);
expect(container.querySelector('[data-workload-col="disk"]')).toHaveAttribute(
'title',
message,
);
if (mode === 'sparklines')
expect(screen.getByTestId('metric-mini-sparkline')).toHaveAttribute('title', message);
},
);
it(`keeps a setup gap muted and a broken ${mode} read amber`, () => {
it(`captions only a broken ${mode} read, in amber, and leaves a setup gap to the dash`, () => {
const render = (diskStatusReason: string) =>
renderGuestRow({
guest: makeGuest({
@@ -1024,10 +1051,24 @@ describe('GuestRow', () => {
visibleColumnIds: ['name', 'disk'],
metricDisplayMode: mode,
}).container.querySelector('[data-workload-disk-read-status]');
expect(render('vm-stopped')?.className).toContain('text-muted');
expect(render('agent-disabled')?.className).toContain('text-muted');
expect(render('permission-denied')?.className).toContain('text-amber-700');
expect(render('prev-agent-error')?.className).toContain('text-amber-700');
for (const reason of ['vm-stopped', 'agent-disabled', 'agent-not-running']) {
const notice = render(reason);
expect(notice).toHaveClass('sr-only');
expect(notice?.querySelector('[aria-hidden="true"]')).toBeNull();
}
const denied = render('permission-denied');
expect(denied?.className).toContain('text-amber-700');
expect(denied?.querySelector('[aria-hidden="true"]')).toHaveTextContent(/^No permission$/);
expect(denied?.querySelector('.sr-only')).toHaveTextContent('Permission denied.');
const agentError = render('agent-error');
expect(agentError?.className).toContain('text-amber-700');
expect(agentError?.querySelector('[aria-hidden="true"]')).toHaveTextContent(
/^Agent error$/,
);
// A retained reading says so first; its reason stays amber.
const retained = render('prev-agent-error');
expect(retained?.className).toContain('text-amber-700');
expect(retained?.querySelector('[aria-hidden="true"]')).toHaveTextContent(/^Last known$/);
});
it(`withdraws the ${mode} notice only on a fresh same-VM observation`, () => {
@@ -1127,10 +1168,19 @@ describe('GuestRow', () => {
}),
);
notice = container.querySelector('[data-workload-disk-read-status]');
expect(notice?.querySelector('[aria-hidden="true"]')).toHaveTextContent('N/A');
expect(notice?.querySelector('.sr-only')).toHaveTextContent(
expect(notice?.querySelector('[aria-hidden="true"]')).toBeNull();
expect(notice).toHaveTextContent(
'Guest reads paused while Proxmox reports a VM operation lock',
);
setGuest(
makeGuest({
diskStatusReason: 'permission-denied',
disk: makeDisk({ total: 0, used: 0, usage: -1 }),
}),
);
notice = container.querySelector('[data-workload-disk-read-status]');
expect(notice?.querySelector('[aria-hidden="true"]')).toHaveTextContent(/^Denied$/);
expect(notice?.querySelector('.sr-only')).toHaveTextContent('Permission denied.');
});
it('leaves independent fresh data and non-VM rows unmarked', () => {
@@ -1190,8 +1240,11 @@ describe('GuestRow', () => {
setGuest(agentGuest({ agentStale: true, status: 'stopped', diskStatusReason: 'vm-stopped' }));
expect(screen.queryByTestId('disk-bar')).toBeNull();
notice = container.querySelector('[data-workload-disk-read-status]');
expect(notice?.querySelector('[aria-hidden="true"]')).toHaveTextContent('Unavailable');
expect(notice).toHaveAttribute(
expect(notice?.querySelector('[aria-hidden="true"]')).toBeNull();
expect(notice).toHaveTextContent(
'Guest filesystem stats unavailable while the VM is stopped.',
);
expect(container.querySelector('[data-workload-col="disk"]')).toHaveAttribute(
'title',
'Guest filesystem stats unavailable while the VM is stopped.',
);
@@ -1319,27 +1372,45 @@ describe('GuestRow', () => {
visibleColumnIds: ['name', 'memory'],
metricHistory: history,
});
expect(container.querySelector(noticeSelector)).toHaveTextContent('Unavailable');
// No number is shown, so a visible "Unavailable" would only repeat
// the empty cell; the reason stays assistive text and the cell title.
const notice = container.querySelector(noticeSelector);
expect(notice).toHaveClass('sr-only');
expect(notice).toHaveTextContent(
'Unavailable. Source: QEMU guest agent. Observed: 2026-09-30 11:00:00 UTC.',
);
expect(container.querySelector('[data-workload-col="memory"]')).toHaveAttribute(
'title',
notice?.textContent ?? '',
);
if (mode === 'sparklines') {
expect(
screen.getByRole('img', { name: 'test-vm memory history, unavailable N/A' }),
).toBeInTheDocument();
// The chart fills the cell, so it carries the reason on hover too.
expect(screen.getByTestId('metric-mini-sparkline')).toHaveAttribute(
'title',
notice?.textContent ?? '',
);
expect(container.querySelector('path')?.getAttribute('d')).toContain('M');
expect(screen.queryByText('50%')).not.toBeInTheDocument();
} else {
expect(screen.getByTestId('memory-bar')).toHaveAttribute('data-unavailable', 'true');
expect(screen.getByTestId('memory-bar')).toHaveAttribute(
'data-reading-state',
'unavailable',
);
// An empty bar reading N/A becomes the dash the disk cell uses.
expect(screen.queryByTestId('memory-bar')).not.toBeInTheDocument();
expect(container.querySelector('[data-workload-col="memory"]')).toHaveTextContent('-');
expect(screen.queryByText('50%')).not.toBeInTheDocument();
}
});
it(`says a current VM reading from a cache-inclusive source may include cache in ${mode}`, () => {
it(`says a high current VM reading from a cache-inclusive source may include cache in ${mode}`, () => {
const { container } = renderGuestRow({
guest: makeGuest({
type: 'qemu',
memory: observation('current', observedAt, 'status-mem'),
memory: {
...observation('current', observedAt, 'status-mem'),
used: 0.8 * 4294967296,
usage: 80,
},
}),
metricDisplayMode: mode,
visibleColumnIds: ['name', 'memory'],
@@ -1355,13 +1426,129 @@ describe('GuestRow', () => {
if (mode === 'sparklines') {
// The value itself is a current reading; only its source is qualified.
expect(
screen.getByRole('img', { name: 'test-vm memory history, current 50%' }),
screen.getByRole('img', { name: 'test-vm memory history, current 80%' }),
).toBeInTheDocument();
} else {
expect(screen.getByTestId('memory-bar')).toHaveAttribute('data-reading-state', 'current');
}
});
it(`leaves a normal cache-inclusive VM reading unlabelled in ${mode}`, () => {
// Nothing reads as pressure below the guest's warning threshold, so
// the caveat stays in the bar tooltip and drawer.
const { container } = renderGuestRow({
guest: makeGuest({
type: 'qemu',
memory: observation('current', observedAt, 'status-mem'),
}),
metricDisplayMode: mode,
visibleColumnIds: ['name', 'memory'],
});
const notice = container.querySelector(noticeSelector);
expect(notice).toHaveClass('sr-only');
expect(notice).toHaveTextContent('may include cached memory');
expect(container.querySelector('[data-workload-col="memory"]')).toHaveAttribute(
'title',
notice?.textContent ?? '',
);
if (mode === 'bars') {
expect(screen.getByTestId('memory-bar').getAttribute('data-reading-message')).toContain(
'may include cached memory',
);
} else {
expect(
screen.getByRole('img', { name: 'test-vm memory history, current 50%' }),
).toBeInTheDocument();
}
});
it(`captions a cache reading by the configured warning and the guest's bytes in ${mode}`, () => {
// The mocked guest thresholds warn at 70%, below the 75% default, and
// the bar is coloured by used/total, not a disagreeing usage field.
const { container } = renderGuestRow({
guest: makeGuest({
type: 'qemu',
memory: {
...observation('current', observedAt, 'status-mem'),
used: 0.72 * 4294967296,
usage: 40,
},
}),
metricDisplayMode: mode,
visibleColumnIds: ['name', 'memory'],
});
expect(
container.querySelector(noticeSelector)?.querySelector('[aria-hidden]'),
).toHaveTextContent('May include cache');
});
it(`keeps a high container cache reading free of a ${mode} caption`, () => {
const { container } = renderGuestRow({
guest: makeGuest({
type: 'lxc',
memory: {
...observation('current', observedAt, 'cluster-resources'),
used: 0.9 * 4294967296,
usage: 90,
},
}),
metricDisplayMode: mode,
visibleColumnIds: ['name', 'memory'],
});
expect(container.querySelector(noticeSelector)).toBeNull();
});
it(`adds and withdraws the ${mode} cache caption as the same guest crosses warning`, () => {
const memoryAt = (percent: number) => ({
...observation('current', observedAt, 'status-mem'),
used: (percent / 100) * 4294967296,
usage: percent,
});
const [guest, setGuest] = createSignal(makeGuest({ type: 'qemu', memory: memoryAt(50) }));
const { container } = render(() => (
<table>
<tbody>
<GuestRow
guest={guest()}
metricDisplayMode={mode}
visibleColumnIds={['name', 'memory']}
/>
</tbody>
</table>
));
const row = container.querySelector('tr');
const caption = () => container.querySelector(`${noticeSelector} [aria-hidden]`);
expect(caption()).toBeNull();
setGuest(makeGuest({ type: 'qemu', memory: memoryAt(90) }));
expect(caption()).toHaveTextContent('May include cache');
setGuest(makeGuest({ type: 'qemu', memory: memoryAt(30) }));
expect(caption()).toBeNull();
expect(container.querySelector(noticeSelector)).toHaveClass('sr-only');
expect(container.querySelector('tr')).toBe(row);
});
it(`judges the cache cue by the guest's own memory in host-share ${mode}`, () => {
// 80% of a 4 GiB guest is a small share of a 64 GiB host, but the bar
// is coloured by the guest's own figure, and so is the cue.
const { container } = renderGuestRow({
guest: makeGuest({
type: 'qemu',
memory: {
...observation('current', observedAt, 'status-mem'),
used: 0.8 * 4294967296,
usage: 80,
},
}),
metricDisplayMode: mode,
visibleColumnIds: ['name', 'memory'],
memoryDisplayBasis: 'host',
parentMemoryTotal: 64 * 1024 ** 3,
});
expect(
container.querySelector(noticeSelector)?.querySelector('[aria-hidden]'),
).toHaveTextContent('May include cache');
});
it(`keeps a container's cache caveat out of the ${mode} row label`, () => {
const { container } = renderGuestRow({
guest: makeGuest({
@@ -1391,6 +1578,11 @@ describe('GuestRow', () => {
visibleColumnIds: ['name', 'memory'],
});
expect(container.querySelector(noticeSelector)).toBeNull();
if (mode === 'sparklines')
expect(screen.getByTestId('metric-mini-sparkline')).toHaveAttribute(
'title',
'test-vm memory history',
);
});
it(`withdraws the ${mode} cue only for a qualified same-guest reading`, () => {
@@ -1487,7 +1679,6 @@ describe('GuestRow', () => {
it.each([
['last-known', 'Prior'],
['future-state', 'Unknown'],
['unavailable', 'N/A'],
])('fits compact %s copy without losing the accessible source and time', (state, label) => {
const { container } = renderGuestRow({
guest: makeGuest({ memory: observation(state) }),
@@ -1501,11 +1692,27 @@ describe('GuestRow', () => {
expect(notice).not.toHaveTextContent('future-state');
});
it('keeps a compact unavailable reading to the dash and its accessible reason', () => {
const { container } = renderGuestRow({
guest: makeGuest({ memory: observation('unavailable') }),
visibleColumnIds: ['name', 'memory'],
workloadTableLayoutMode: 'phone',
});
const notice = container.querySelector(noticeSelector);
expect(notice).toHaveClass('sr-only');
expect(notice).toHaveTextContent('QEMU guest agent');
expect(notice).toHaveTextContent('2026-09-30 11:00:00 UTC');
});
it('fits the compact cache cue without losing the accessible caveat', () => {
const { container } = renderGuestRow({
guest: makeGuest({
type: 'qemu',
memory: observation('current', observedAt, 'cluster-resources'),
memory: {
...observation('current', observedAt, 'cluster-resources'),
used: 0.9 * 4294967296,
usage: 90,
},
}),
visibleColumnIds: ['name', 'memory'],
workloadTableLayoutMode: 'phone',
@@ -98,6 +98,25 @@ describe('MetricMiniSparkline', () => {
},
);
it('shows a separate hover reason without changing the accessible name', () => {
render(() => (
<MetricMiniSparkline
title="Memory history"
tooltip="Unavailable while the guest is powered off."
valueLabel="N/A"
valueLabelContext="unavailable"
series={[]}
/>
));
expect(screen.getByTestId('metric-mini-sparkline')).toHaveAttribute(
'title',
'Unavailable while the guest is powered off.',
);
expect(
screen.getByRole('img', { name: 'Memory history, unavailable N/A' }),
).toBeInTheDocument();
});
it('keeps the label visible when history has no renderable line', () => {
render(() => (
<MetricMiniSparkline
@@ -210,6 +210,43 @@ describe('cache-inclusive Proxmox memory sources', () => {
expect(presentation?.cacheNote).toBeUndefined();
});
it('says a powered-off guest has no memory reading, without an unknown source', () => {
const presentation = getWorkloadMemoryObservationPresentation(
guest({
status: 'stopped',
memory: {
total: 100,
used: 0,
free: 0,
usage: 0,
observation: { state: 'unavailable', source: 'powered-off' },
},
}),
)!;
expect(presentation.state).toBe('unavailable');
expect(presentation.message).toBe('Unavailable while the guest is powered off.');
expect(presentation.summary).toBe('Unavailable · guest powered off');
});
it.each([
[{ state: 'unavailable', source: 'unavailable' }, 'Unavailable.', 'Unavailable'],
[
{ state: 'unavailable', source: 'agent', observedAt },
'Unavailable. Source: Pulse Agent. Observed: 2026-09-30 11:00:00 UTC.',
'Unavailable · Pulse Agent · 2026-09-30 11:00:00 UTC',
],
])(
'names only what is known about an unavailable reading (%j)',
(observation, message, summary) => {
const presentation = getWorkloadMemoryObservationPresentation(
guest({ memory: { ...guest().memory, observation } }),
)!;
expect(presentation.message).toBe(message);
expect(presentation.summary).toBe(summary);
expect(presentation.message).not.toContain('Unknown source');
},
);
it('leaves the selected number unchanged', () => {
expect(getCurrentWorkloadMemoryUsage(reading('status-mem'))).toBe(25);
expect(getCurrentWorkloadMemoryUsage(reading('cluster-resources'))).toBe(25);
@@ -180,6 +180,23 @@ export const getMemoryObservationPresentation = (
: 'Unknown source';
const time = observed ? `Observed: ${observed}.` : 'Observation time unknown.';
const cacheNote = mayIncludeCache ? getCacheNote(options.guestKind) : undefined;
if (state === 'unavailable') {
// No number is shown, so say why when the producer knows (the guest is
// off) and otherwise name only what is known, never "Unknown source".
const poweredOff = observation?.source === 'powered-off';
const knownSource = source !== 'Unknown source';
return {
state,
summary: poweredOff
? 'Unavailable · guest powered off'
: `Unavailable${knownSource ? ` · ${source}` : ''}${observed ? ` · ${observed}` : ''}`,
// Consumers prefix their own metric name ("Memory reading: ...").
message: poweredOff
? 'Unavailable while the guest is powered off.'
: `Unavailable.${knownSource ? ` Source: ${source}.` : ''}${observed ? ` ${time}` : ''}`,
mayIncludeCache,
};
}
return {
state,
summary: `${label} · ${source}${mayIncludeCache ? ' (may include cache)' : ''}${observed ? ` · ${observed}` : ' · time unknown'}`,
@@ -21,7 +21,7 @@ class GuestDiskReadingDocsTest(unittest.TestCase):
row = (ROOT / "frontend-modern/src/components/Workloads/GuestRow.tsx").read_text()
drawer = (ROOT / "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx").read_text()
presentation = (ROOT / "frontend-modern/src/utils/workloadGuestPresentation.ts").read_text()
for label in ("Prior", "N/A", "Last known", "Unavailable"):
for label in ("Prior", "Last known", "No permission", "Agent error", "Denied", "Error"):
with self.subTest(label=label):
self.assertIn(label, text)
self.assertIn(f"'{label}'", row)
@@ -190,7 +190,8 @@ test("hybrid guest memory follows canonical snapshots in the browser", async ({
vm("cluster-b", 80),
];
send();
await expect(memoryCell).toContainText("N/A");
// The bar shows a dash; an open history lens still reads N/A.
await expect(memoryCell).toContainText(/^-|N\/A/);
await expect(memoryCell).not.toContainText("100%");
await expect(memoryDetails).toHaveCount(0);
await expect(drawer.getByText("Free", { exact: true })).toHaveCount(0);