Compose retained Web and backup-safety work with current reviewed main

Preserve exact reviewed upstream, Core and Web histories, Tailwind 4, deterministic tags and additive browser-receipt governance. Resolve only the retired shared receipt by keeping its deletion. Combined frontend validation and fresh content receipts follow; native thaw and release acceptance remain separate.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 17:27:18 +01:00
126 changed files with 6187 additions and 378 deletions
@@ -3236,3 +3236,25 @@ The monitor delegates recovery eligibility to persisted occurrence/destination
receipts even when an old retry remains or restart has lost RAM cooldown state.
The partial-destination monitor control establishes a real local HTTP 200/503
split and preserves the newer firing and the old recipient's recovery.
### Notification activity reports each evidence source independently
The existing Notifications activity card distinguishes an initial or pending
read from a confirmed empty window. Held/deferred event reads have their own
loading and unavailable state; a failed read withdraws their prior rows and
keeps the warning until a later successful read. Only the current refresh owns
those states, and disposed readers cannot publish late completions. A slow
held-event read does not delay delivery attempts or disable their refresh.
A delivery-attempt failure withdraws only attempt rows, not independently
readable held events. Conversely, missing held evidence does not hide readable
attempts or imply that no notifications were held. The combined empty state
requires both sources to be readable and no refresh pending. Retention, event
limits, queue actions, permissions and notification delivery policy are unchanged.
`useNotificationDeliveryLog.test.tsx`, `useAlertDestinationsTabState.test.tsx`
and `AlertDeliveryLogCard.test.tsx` exercise independent failure, recovery,
initial loading and current-request ownership; the production Notifications
tab browser fixture verifies the connected read/retry states at desktop and
phone widths. These are synthetic frontend controls, not recipient delivery
or containing-release acceptance.
@@ -15,6 +15,23 @@
## Purpose
### Update History consent does not infer the backup's restore scope
The confirmation names the observed running server separately from the selected
entry's pre-/post-update versions. No version threshold or free-form notes stand
in for actual scope: the current History API does not describe the manifest or
active-store layout. Explain that installation-only recovery leaves active stores
in place but may restore independent installation files; legacy recovery can
also replace install-local configuration/data. Do not promise a settings/alerts
rewind. Full-state recovery still requires a stopped service and a consistent
backup of every active store, via the existing shipped recovery instructions.
Opening/cancelling consent sends no rollback request, pending consent rejects
duplicate actions and cancellation, and a rejected request retains the warning
and refreshes history. Mounted UpdateHistory tests and the production-CSS offline
browser fixture exercise those states; neither proves a binary/data rollback.
### Demo transactions share the service host, not just a workflow queue
Stable demo update and recovery keep their existing publication/activation,
@@ -20,6 +20,61 @@
## Purpose
### Guest disk deferral and retained observations
The existing workload row, disk breakdown tooltip/list and guest Overview use
one presentation for the fixed guest-read reason vocabulary. Operation locks,
unverified lock clearance, an in-flight request, cooldown, incomplete response
and capacity deferrals are not evidence that QGA is missing. Invalid VM identity
remains distinct. A timeout describes uncertain completion and must not recommend
restarting QGA during a backup or changing freeze settings.
`prev-` marks last-known disk values, not a fresh sample. Keep those values and
their identity visible, with the explanation beside the disk evidence, including
the breakdown tooltip and the phone-readable Overview. Show readable status even
before the first filesystem sample; do not hide it behind an empty disk list or
expose only a raw reason. A same-VM fresh observation withdraws the notice and
updates values without remounting the row. Permission, disabled-agent and actual
unavailable cases retain their own explanations.
Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`,
`DiskList`, `StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no
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.
### Composed operator tables and conservative rollback consent
Phone storage tables use the canonical weighted-width helper and matching
header/cell gutters. Compact labels shorten disk verdicts and capacity values,
not their underlying health or measured units; full reasons stay in the real
disk header and tooltips. Structured ZFS scan activity supplies a compact badge
only during reported rebuild activity; its complete provider summary remains
available. Stable disk History catalog/organisation/access ownership is unchanged.
Controller expansions carry the supplied kind-specific targets, absolute times,
duration and cluster/namespace when the phone row omits those columns. Proxmox
coverage distinguishes independent backups from guest-local snapshots; By date
retains archive identifiers in hover/search while showing the format as detail.
Phone identity weights leave every configured metric column visible.
UpdateHistory consent names both the requested target and the observed running
version, separately from the version on the selected history entry. The API does
not expose backup restore scope: do not infer it from a version cutoff, notes or
a retained path. Explain installation-only versus legacy recovery and stopped,
consistent full-state recovery without promising later settings will be reverted
or preserved. Opening/cancelling never starts a write, rejection retains consent,
and a pending request cannot be submitted again or cancelled.
Verification: the mounted UpdateHistory regression tests, existing kind-specific
controller/storage/backup/workload tests and production-CSS browser fixture
`frontend-modern/browser-tests/operator-tables-consent.cjs` exercise the composed
source at desktop, intermediate and phone widths. Synthetic HTTP/snapshots do
not establish native collectors, tenancy, installation recovery or release delivery.
### Organisation-scoped single-metric History
Fetched HistoryChart observations belong to the active organisation as well as
@@ -775,6 +830,28 @@ that manual-channel consequence must stay visible at desktop and narrow widths.
## Shared Boundaries
### Transport-independent operator rows
Proxmox/Storage and Docker presenters retain the shared table/detail, responsive
column weighting, relative-time and governed action primitives. Display names
for Proxmox storage plugins preserve their operator vocabulary (LVM-Thin, NFS,
SMB/CIFS and the other supported acronyms); only explicitly shared storage on a
synthetic cluster/shared location gets the shared label. That label does not
alter canonical identity, parent, History target or pool-to-disk ownership.
The Docker image phone projection pairs hidden headers and cells and gives
measured size and update verdict room without hiding identity or dropping the
existing detail disclosure. The container projection retains its shared 30%
phone identity reservation and five scan signals; off-row restart/host/consumer
evidence remains in detail. Swarm rollout tokens read as words, with compact
Running headings and wrapping rollout states on phones; raw rollout and
placement evidence remain reachable through keyboard/touch row disclosure.
`platformOverviewLayout.guardrails.test.ts`, mounted
Docker table tests and the desktop/touch-phone production-component browser
fixture check these priorities, rather than treating an old percentage as an
immutable primitive contract. No new public route or navigation entry is added.
### Maintenance schedule copy
Operator maintenance banners show local absolute start and end timestamps,
@@ -7856,3 +7933,21 @@ kind normalisation, the kind switch, the email-only payload, and the plain
cover the gating, the reduced form, the pinned fields, and the saved payload.
This is a presentation and form-state boundary; the schedule API contract,
report rendering, and email delivery are unchanged.
### Notifications activity has independent evidence-read states
The Notifications tab composes the delivery-log card with both attempt-read
and held/deferred-event loading and unavailable state from its owned reader.
Pending or failed evidence is never rendered as a confirmed empty activity
window. Each failed source withdraws its own rows without hiding independently
readable rows from the other source. Source-specific warnings have `role="alert"`
and loading text has `role="status"`; a slow held-event read does not block the
existing attempt-refresh button. Recovery clears a warning only after a
successful read, without remounting the destination editors or changing their
unfinished input.
The connected Notifications browser fixture uses the real tab/state/API/card
chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS,
403/503 controls and pending recovery. Unit controls separately cover current
request ownership and disposal. No mutation, route, navigation entry, queue
policy, permission or recipient-delivery contract is added by these states.
@@ -17,6 +17,47 @@
## Purpose
### Guest-agent coordination around backups — issue #2439
All PVE QEMU guest-agent reads, including disk diagnostics and legacy meminfo
callers, share fail-fast per-guest admission within this Pulse process. Clients
for the same endpoint and configured cluster aliases coordinate across methods;
VMIDs remain serial through node migration. Other VMs and independent endpoints
remain independent. No guest payload or credentials enter coordination state.
Before each command and before returning its fully received bounded payload,
Pulse reads the operation lock from VM config. Any nonempty lock (not just
backup), missing/malformed config or an unverifiable lock defers the command.
Known locks in node inventory, cluster resources or detailed status suppress
polling enrichment even when status fails and recent agent evidence exists.
Admission is held through the complete response, not just headers. A transport
failure, timeout, wrong-command-ID response or incomplete body defers every
command for that guest for a minute; callers neither immediately retry nor
replay through another cluster endpoint. Coordination retains at most 4,096
active/cooling endpoint-guest entries and expires cooldowns. Explicit API
permission refusals and unsupported commands remain errors, not successes.
Deferrals retain the original metadata/memory cache timestamps and supported-OS
failure evidence. Locked guests retain labelled last-known disks/identity and
previous-snapshot memory, or unavailable memory without trustworthy evidence.
Current non-QGA memory, CPU and I/O may still be observed. History never writes
retained disk values or deferred cached memory as newly observed telemetry;
normal unlocked polling resumes and replaces them with fresh data.
`TestGuestAgentBackupMonitoringContract` exercises healthy → status lock →
resource lock with failed status → node fallback → unlocked resumption through
the real client/builders and History. The client controls cover every read,
unknown lock state, diagnostic overlap, aliases/source/VM isolation, in-flight
lock changes, incomplete bodies, cooldown and absence of failover replay.
These reproduce missing Pulse safety controls, **not** the reported native
PVE/QGA command-ID collision or successful backup thaw. HTTP lock checks cannot
atomically reserve PVE's serial channel against a backup beginning afterwards,
cancel work already running in pvedaemon, detect a guest left frozen after its
lock clears, or coordinate separate Pulse processes. Native freeze/thaw acceptance
on a containing build remains required. Until then, stopping Pulse before a
freeze-enabled backup and restarting only after verified thaw remains the safe
workaround, with monitoring and alerts unavailable during that window.
### Exact TrueNAS subscription termination — issue #2396
JSON-RPC stream readers and the subscription-acknowledgement wait recognise
@@ -15,6 +15,26 @@
## Purpose
### Guest disk status updates retain row ownership
Disk deferral copy follows reactive same-VM props in the existing row and drawer,
without new guest commands, client polling, History writes or row remounts. Retained
values are labelled rather than replaced with invented fresh percentages; fresh
observations update values and remove the status. Mounted row/list/drawer controls
and `browser-tests/guest-disk-deferral.cjs` verify same-row ownership in bar and
sparkline modes with desktop and phone CSS. This is client behaviour proof, not
native backup coordination, new History samples or a fleet performance result.
### Phone workload identity weights
The existing phone guest projection allocates 38 percent to the name cell,
which includes disclosure, status and backup controls, while normalising every
remaining visible metric weight. Narrow/mobile/desktop modes and windowing
ownership remain unchanged. Guest column regressions verify the sums and the
production composed-table browser fixture checks identity and all configured
metric tracks at narrow width; this is readability, not a throughput claim.
### Bounded confidence/priority work in broadcast grouping — issue #2199
The general identity matcher retains every confidence, reason, ordering and
@@ -301,6 +321,21 @@ the existing bounded HTTP client and artifact-size checks.
## Shared Boundaries
### Multi-disk Bars summary
The existing vertical-bars workload/node disk cell shows the fullest measured
disk's percentage beside equal-weight per-disk micro-bars. Its title retains
the actual mount and percentage. Unknown/invalid usage cannot produce a new
summary; an aggregate fallback must not masquerade as measured member usage.
The displayed maximum uses the existing shared disk measurement and threshold
logic; no collector, sorting key, metric units, History request, resampling or
performance threshold changes. `StackedDiskBar.test.tsx` and the model branch
controls cover real/unknown measurements and unchanged per-member bars.
`browser-tests/proxmox-docker-rows.cjs` checks the actual label, bars and title
at desktop and phone widths; this is presentation proof, not native collection
or a measured fleet performance improvement.
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
@@ -21,6 +21,28 @@
## Purpose
### Backup identity, phone storage and History consent composition
The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE
backup files, never guest-local snapshots. Guest snapshots remain listed as
restore evidence but cannot make an old independent backup look current.
Archive detail uses its format (or filename fallback), preserving the full
provider volid for hover and search in both Coverage and By date.
Phone pool/disk rows preserve health and identity while using weighted widths,
consistent gutters and compact capacity/health labels. Full disk risk reasons
move into the existing expanded header; the ZFS activity badge uses supplied
scan data only when rebuild is reported and keeps the full summary as its title.
Collection-independent, organisation-owned disk History remains intact.
The UpdateHistory confirmation is a version-rollback consent, not a full-state
recovery promise. Its current API exposes no verified restore-scope metadata;
copy must retain installation-only and legacy differences and direct full-state
recovery to the stopped-service procedure. Regressions cover these distinctions,
archive/snapshot attribution and storage values; the composed browser fixture
checks real table/drawer rendering, not native or installed recovery.
### Disk History is independent of current collection
The existing physical-disk History catalog is selected by disk family, not by
@@ -378,6 +400,20 @@ command-capable profile.
## Shared Boundaries
### Operator storage vocabulary without identity changes
Storage topology names preserve Proxmox plugin vocabulary, including LVM-Thin,
NFS, SMB/CIFS, iSCSI, ZFS and BTRFS. Explicit topology overrides still win.
Only a storage explicitly marked shared whose retained location is the synthetic
cluster/shared marker reads Shared, with the supplied reporting-node count when
more than one node is present. Node-local storage and shared storage with a
real native location keep that location. This is host-label presentation, not
resource consolidation: IDs, parent/node hints, ownership evidence, metrics
targets, alerts, health and recovery authority are unchanged. Adapter/presentation
regressions and the production storage table browser fixture verify labels
without treating shared capacity as a backup or demonstrating native recovery.
- After password configuration has been persisted and the runtime local-admin identity changes, authorizer synchronisation must also occur if subsequent API-token persistence fails. Development first-run reset must clear the configured-admin bypass only on successful auth reset, not on its persistence-failure rollback path. No role-store deletion or migration is part of this recovery.
### PBS host history correlation
@@ -23,6 +23,36 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Guest disk freshness presentation
Existing workload disk evidence retains its source reason through the row,
breakdown and Overview. `prev-` values are explicitly last-known, while a
deferral with no previous disks explains the missing observation without a
measured percentage. Lock/uncertain-completion deferrals must not become an
install/restart recommendation. Human-readable Overview status remains visible
without filesystem rows and is cleared by a same-identity fresh observation.
The shared presentation and mounted regressions plus the production-component
`browser-tests/guest-disk-deferral.cjs` fixture verify this client behaviour;
they do not prove native QGA coordination, History freshness or delivery.
### Narrow controller and backup summaries retain their source facts
Kubernetes controller row expansions expose supplied kind-specific targets,
counts, absolute timestamps, valid elapsed durations and namespace/cluster when
responsive rows hide those columns. Missing facts are omitted and the ordinary
pluralisation model handles singular targets. Job/CronJob timestamp regressions
and the production composed-table browser fixture verify this existing summary.
Proxmox Coverage uses latestBackup for completed independent backup evidence,
with guest-local snapshots retained separately. Responsive backup-server, host
and guest rows allocate space through existing canonical weighted-width helpers;
archive format is presentation only, with the provider identifier retained in
hover and search. The recovery-model, table and guest regressions plus desktop/
phone browser rendering verify attribution, visible facts and source composition.
These frontend improvements change no collector, API authority or native status.
### Resource evidence fallback access boundaries
When the existing resource-facet reader receives a final HTTP 401/403, its
@@ -600,6 +630,29 @@ about the same disk cannot diverge.
## Shared Boundaries
### REST facets and container table projection
The unified-resource REST mapper carries PMG metadata, per-source status,
server action-readiness refusals and technology at the same top-level paths
used by canonical websocket rows. Existing platformData compatibility paths
remain; transport order must not decide whether mail counters or lifecycle
refusal explanations are present. A replaced REST snapshot clears removed
facets instead of retaining old counters or refusal reasons. Mapping does not
invent capabilities or bypass planning, review, server authorisation or execution.
`useUnifiedResources.test.ts` checks actual lifecycle refusal consumers for stale,
failed and server-denied observations alongside a no-invented-grant control.
The Docker phone container projection keeps identity, state when applicable,
CPU, memory and update; restart counts remain in the existing detail expansion
and return at mobile width. Images retain image, size and update verdict on
phones; host and consumers stay reachable in detail. Hidden header/body columns
are symmetric. Swarm rollout tokens render as words with raw state retained in
the title; task starts use the shared relative-time primitive with their exact
timestamp retained. Native table/model/guardrail tests and the production
`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing
a container action or claiming native appliance or release acceptance.
### PBS host history correlation
PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target.
@@ -0,0 +1,273 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/guest-disk-deferral-proof';
const runtime = [
'frontend-modern/src/utils/workloadGuestPresentation.ts',
'frontend-modern/src/components/Workloads/GuestRow.tsx',
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
'frontend-modern/src/components/Workloads/DiskList.tsx',
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
];
const deferrals = [
['vm-locked', 'VM operation lock'],
['lock-unverified', 'cannot verify that the VM is unlocked'],
['agent-busy', 'earlier guest request is still in progress'],
['agent-cooldown', 'after the cooldown'],
['agent-response-incomplete', 'previous response was incomplete'],
['agent-capacity', 'guest-read capacity'],
['invalid-guest-key', 'VM identity is invalid'],
['agent-timeout', 'Do not restart the guest agent during a backup'],
];
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5294, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop', chromium, 1365, false],
['phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
checks = [];
page.on('pageerror', (e) => {
errors.push(e.message);
process.stderr.write(`BROWSER_PAGE_ERROR ${e.message}\n`);
});
page.on('console', (m) => {
if (m.type() === 'error')
process.stderr.write(`BROWSER_CONSOLE ${m.text().slice(0, 1500)}\n`);
});
page.on('response', (r) => {
if (r.status() >= 400)
process.stderr.write(`BROWSER_HTTP ${r.status()} ${new URL(r.url()).pathname}\n`);
});
await page.route('http://127.0.0.1:5294/api/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: [],
alerts: [],
anomalies: [],
config: null,
}),
}),
);
await page.goto('http://127.0.0.1:5294/browser-tests/guest-disk-deferral.html', {
waitUntil: 'domcontentloaded',
});
try {
await page.waitForFunction(() => window.__guestDiskEvidence, null, { timeout: 15000 });
} catch (error) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify({ phase: 'fixture-readiness', errors }, null, 2),
);
throw error;
}
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]');
const cell = row.locator('[data-workload-col="disk"]');
await row.click();
const drawer = page.getByRole('region', { name: 'Guest Overview' });
await drawer.waitFor({ state: 'visible' });
await page.evaluate(
() =>
(window.__originalDiskRow = document.querySelector(
'[data-guest-id="fixture-pve:pve-a:101"]',
)),
);
const update = async (reason, data, usage = 50) => {
await page.evaluate((value) => window.__guestDiskEvidence.update(value), {
reason,
data,
usage,
});
await page.waitForFunction(
() =>
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') ===
window.__originalDiskRow,
);
assert.equal(
await page.evaluate(() => window.__guestDiskEvidence.identity()),
'fixture-pve:pve-a:101',
);
};
const capture = async (suffix) => {
const p = path.join(output, `${name}-${suffix}.png`);
await page.screenshot({ path: p, fullPage: true });
screenshots.push({
path: path.relative('/workspace', p),
sha256: crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'),
});
};
for (const [reason, expected] of deferrals) {
await update(`prev-${reason}`, true);
await page.waitForFunction(
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
expected,
);
const title = await cell.getAttribute('title');
assert(title.startsWith('Using last known disk stats. '));
assert(title.includes(expected));
assert(
!title.includes('may not be installed') && !title.includes('may need to be restarted'),
);
assert((await drawer.innerText()).includes(title));
const statusBox = await drawer.getByText(title, { exact: true }).boundingBox();
assert(
statusBox.width >= 100 && statusBox.height < 220,
'status explanation fits its card',
);
assert((await drawer.innerText()).includes('/data'));
assert(
(await page.getByRole('region', { name: 'Guest disk list' }).innerText()).includes(title),
);
if (name === 'desktop') {
// The disclosure click can land on this bar and dismiss its hover
// overlay on pointerdown. Leave it before exercising a new hover.
await page.locator('h1').hover();
await cell.locator('[data-stacked-disk-trigger]').hover();
await page.locator('[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
assert((await page.locator('[data-tooltip-portal="true"]').innerText()).includes(title));
await page.locator('h1').hover();
}
checks.push(`${reason}: retained row, tooltip, drawer, list and identity`);
if (reason === 'vm-locked') await capture('retained-backup');
await update(reason, false);
await page.waitForFunction(
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
expected,
);
assert(!(await cell.getAttribute('title')).includes('Using last known'));
assert((await drawer.innerText()).includes(expected));
assert(!(await drawer.innerText()).includes('/data'));
assert.equal(await cell.locator('[data-stacked-disk-trigger]').count(), 0);
checks.push(`${reason}: no previous disk values and no fresh percentage`);
if (reason === 'agent-timeout') await capture('uncertain-timeout');
}
for (const [reason, expected] of [
['permission-denied', 'Permission denied'],
['agent-disabled', 'disabled in VM configuration'],
['agent-not-running', 'Guest agent not running'],
['no-data', 'No disk data available'],
['vm-stopped', 'VM is stopped'],
['no-status', 'could not read the VM status'],
['agent-error', 'Error communicating with guest agent'],
]) {
await update(reason, false);
await page.waitForFunction(
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
expected,
);
assert((await drawer.innerText()).includes(expected));
checks.push(`${reason}: existing unavailable case remains distinct`);
}
await update('prev-agent-cooldown', true);
await page.evaluate(() => window.__guestDiskEvidence.mode('sparklines'));
assert((await cell.getAttribute('title')).includes('Using last known'));
await update('', true, 75);
await page.waitForFunction(
() => !document.querySelector('[data-workload-col="disk"]').hasAttribute('title'),
);
assert(!(await drawer.innerText()).includes('Using last known'));
assert(!(await drawer.innerText()).includes('Guest reads'));
assert.equal(await drawer.getByRole('progressbar').getAttribute('aria-valuenow'), '75');
await page.evaluate(() => window.__guestDiskEvidence.mode('bars'));
assert.equal(
await page.evaluate(
() =>
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') ===
window.__originalDiskRow,
),
true,
);
checks.push(
'same-VM fresh resumption removes notice and updates values in both row modes without remount',
);
await capture('fresh-resumption');
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
true,
);
assert.deepEqual(errors, []);
results.push({
name,
browser_version: browser.version(),
viewport: { width, height: width === 390 ? 844 : 900 },
checks,
page_errors: errors,
});
await browser.close();
browser = null;
}
const receipt = {
version: 1,
result: 'passed',
verified_at: new Date().toISOString(),
content_sha256: hashes,
playwright_version: playwrightVersion,
results,
screenshots,
limitation:
'Synthetic same-VM observations through production components and CSS; no native QGA/backup, History sample, installation or release acceptance.',
};
fs.writeFileSync(
path.join(output, 'browser-result.json'),
JSON.stringify(receipt, null, 2) + '\n',
);
process.stdout.write(JSON.stringify(receipt) + '\n');
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((e) => {
process.stderr.write(e.stack + '\n');
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Guest disk deferral verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-disk-deferral.tsx"></script>
</body>
</html>
@@ -0,0 +1,127 @@
// Production row, disk tooltip, list and disclosed Overview with synthetic
// same-VM observations. No collector, QGA, native backup or release proof.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview';
import { DiskList } from '../src/components/Workloads/DiskList';
import type { VM } from '../src/types/api';
import { DarkModeContext } from '../src/contexts/appRuntime';
import '../src/index.css';
function Fixture() {
const [observation, setObservation] = createSignal({
reason: 'prev-vm-locked',
data: true,
usage: 50,
});
const [expanded, setExpanded] = createSignal(false);
const [mode, setMode] = createSignal<'bars' | 'sparklines'>('bars');
const guest = (): VM => ({
id: 'fixture-pve:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'fixture-pve',
status: 'running',
type: 'qemu',
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
disk: {
total: 10 * 1024 ** 3,
used: observation().data ? (observation().usage / 100) * 10 * 1024 ** 3 : 0,
usage: observation().data ? observation().usage : -1,
},
disks: observation().data
? [
{
mountpoint: '/data',
type: 'ext4',
total: 10 * 1024 ** 3,
used: (observation().usage / 100) * 10 * 1024 ** 3,
usage: observation().usage,
},
]
: [],
diskStatusReason: observation().reason,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: 0,
tags: [],
lock: observation().reason.endsWith('vm-locked') ? 'backup' : '',
backupInProgress: observation().reason.endsWith('vm-locked'),
lastSeen: '2026-10-03T14:00:00Z',
});
(window as any).__guestDiskEvidence = {
update: setObservation,
mode: setMode,
identity: () => guest().id,
};
return (
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest disk observation</h1>
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
onClick={() => setExpanded(!expanded())}
isExpanded={expanded()}
metricDisplayMode={mode()}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
/>
</tbody>
</table>
<Show when={expanded()}>
<section aria-label="Guest Overview">
<GuestDrawerOverview
guest={guest()}
guestOsSummary="Linux"
agentHeading="Guest agent"
agentLabel=""
agentTitle=""
hasAgentInfo={false}
hasFilesystemDetails={guest().disks!.length > 0}
hasNetworkInterfaces={false}
hasOsInfo={false}
hasWorkloadActionAgent={false}
showInGuestAgentInstallCue={false}
ipAddresses={[]}
networkInterfaces={[]}
normalizedTags={[]}
backupPresentation={null}
workloadActionAgentTitle=""
/>
</section>
</Show>
<section aria-label="Guest disk list" class="max-w-xl">
<h2 class="text-sm font-medium">Filesystems</h2>
<DiskList disks={guest().disks!} diskStatusReason={guest().diskStatusReason} />
</section>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,383 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const output = '/workspace/tmp/notification-evidence-proof';
const root = '/workspace/frontend-modern';
const runtime = [
'frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx',
'frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts',
'frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts',
'frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx',
'frontend-modern/src/utils/alertDestinationsPresentation.ts',
];
const log = (entries = []) => ({
entries,
window_days: 30,
completed_retention_days: 7,
dead_letter_retention_days: 30,
});
const sent = {
notificationId: 'synthetic-attempt',
type: 'email',
outcome: 'sent',
alertIds: ['synthetic-disk-alert'],
alertCount: 1,
attempts: 1,
success: true,
timestamp: '2026-10-03T10:00:00Z',
};
const held = [
{
id: 42,
type: 'notification_deferred',
alertId: 'synthetic-held-alert',
resourceName: 'synthetic-nas',
alertType: 'usage',
reason: 'quiet_hours:performance',
occurredAt: '2026-10-03T10:01:00Z',
},
];
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5291, strictPort: true },
});
const results = [],
screens = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop', chromium, 1365, false],
['phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(20000);
const errors = [],
offOrigin = [],
writes = [],
requests = [],
checks = [],
releases = [];
let attemptMode = 'hold-empty',
heldMode = 'hold-empty';
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5291') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') {
writes.push({ method: request.method(), path: url.pathname });
return route.abort();
}
if (
url.pathname === '/api/notifications/delivery-log' ||
url.pathname === '/api/alerts/events'
) {
const isHeld = url.pathname === '/api/alerts/events';
const mode = isHeld ? heldMode : attemptMode;
requests.push({ path: url.pathname, query: url.search, mode });
if (mode.startsWith('hold-'))
await new Promise((resolve) => releases.push({ isHeld, resolve }));
if (mode.endsWith('denied'))
return route.fulfill({
status: isHeld ? 403 : 503,
json: { error: 'Synthetic unavailable evidence' },
});
return route.fulfill({
json: isHeld
? mode.endsWith('known')
? held
: []
: log(mode.endsWith('known') ? [sent] : []),
});
}
if (url.pathname === '/api/notifications/health')
return route.fulfill({
json: {
overall_healthy: true,
queue: {
pending: 0,
sending: 0,
sent: 1,
failed: 0,
dlq: 0,
healthy: true,
status: 'healthy',
attention_required: 0,
reason_codes: [],
completed_retention_days: 7,
dead_letter_retention_days: 30,
counts_are_retention_bounded: true,
},
},
});
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
},
});
if (url.pathname === '/api/license/entitlements')
return route.fulfill({ json: { entitlements: [] } });
if (url.pathname === '/api/security/status')
return route.fulfill({ json: { hasAuthentication: true, requiresAuth: false } });
return route.fulfill({ json: [] });
});
const check = async (label, action) => {
await action();
checks.push({ label, passed: true });
};
const activity = page.locator('#notification-delivery-activity');
const empty = activity.getByText(/No alert deliveries were attempted/);
const heldWarning = activity.getByText(/could not read held or deferred notifications/);
const attemptWarning = activity.getByText(/could not read the delivery log/);
const refresh = activity.getByRole('button', { name: 'Refresh delivery status' });
const release = (isHeld) => {
for (const pending of releases.splice(0).filter((item) => {
if (item.isHeld === isHeld) {
item.resolve();
return false;
}
return true;
}))
releases.push(pending);
};
const capture = async (state) => {
const filename = `${name}-${state}.png`;
await activity.screenshot({ path: path.join(output, filename) });
screens.push({
file: filename,
state,
viewport: { width, height: width === 390 ? 844 : 900 },
sha256: crypto
.createHash('sha256')
.update(fs.readFileSync(path.join(output, filename)))
.digest('hex'),
});
};
try {
await page.goto('http://127.0.0.1:5291/browser-tests/notification-evidence.html');
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
dark,
);
await check('Initial reads are loading, not empty', async () => {
await activity.getByText('Loading delivery attempts...').waitFor();
await activity.getByText('Loading held and deferred notifications...').waitFor();
assert.equal(await empty.count(), 0);
});
await check(
'A slow held read does not block the successful attempts read or its refresh',
async () => {
release(false);
await activity.getByText('Loading delivery attempts...').waitFor({ state: 'hidden' });
assert.equal(await refresh.isEnabled(), true);
assert.equal(await empty.count(), 0);
await activity.getByText('Loading held and deferred notifications...').waitFor();
},
);
await capture('held-pending');
await check('Only two completed empty reads establish the empty window', async () => {
release(true);
await empty.waitFor();
assert.equal(await activity.getByRole('status').count(), 0);
});
await check(
'Keyboard or touch refresh shows attempts and held events from their real API adapters',
async () => {
attemptMode = heldMode = 'known';
if (width === 390) await refresh.tap();
else {
await refresh.focus();
await page.keyboard.press('Enter');
}
await activity.getByText('Delivered', { exact: true }).waitFor();
await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
assert.equal(await empty.count(), 0);
},
);
await check(
'Held-event 403 withdraws held rows and warns without hiding delivered evidence',
async () => {
heldMode = 'denied';
await refresh.click();
await heldWarning.waitFor();
assert.equal(
await activity.getByText('synthetic-nas (usage)', { exact: true }).count(),
0,
);
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1);
assert.equal(await empty.count(), 0);
},
);
await capture('held-unavailable');
await check('The held warning persists during a pending retry', async () => {
heldMode = 'hold-empty';
await refresh.click();
await activity.getByText('Loading held and deferred notifications...').waitFor();
assert.equal(await heldWarning.count(), 1);
await refresh.waitFor({ state: 'visible' });
await page.waitForFunction(
() => !document.querySelector('#notification-delivery-activity button').disabled,
);
assert.equal(await empty.count(), 0);
release(true);
await heldWarning.waitFor({ state: 'hidden' });
});
await check(
'An attempt-read failure keeps independently readable held evidence',
async () => {
attemptMode = 'denied';
heldMode = 'known';
await refresh.click();
await attemptWarning.waitFor();
await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 0);
assert.equal(await empty.count(), 0);
},
);
await capture('attempts-unavailable');
await check('Both failed sources are reported, not called empty', async () => {
heldMode = 'denied';
await refresh.click();
await heldWarning.waitFor();
assert.equal(await attemptWarning.count(), 1);
assert.equal(await activity.getByRole('listitem').count(), 0);
assert.equal(await empty.count(), 0);
});
await check(
'Both successful reads clear the warnings and restore a genuine empty state',
async () => {
attemptMode = heldMode = 'empty';
await refresh.click();
await empty.waitFor();
assert.equal(await activity.getByRole('alert').count(), 0);
assert.equal(await activity.getByRole('status').count(), 0);
},
);
await check(
'No horizontal clipping, JavaScript page error, off-origin request or mutation',
async () => {
assert.equal(
await activity.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
true,
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.deepEqual(writes, []);
assert(
requests
.filter((r) => r.path === '/api/alerts/events')
.every(
(r) => r.query.includes('limit=100') && r.query.includes('notification_deferred'),
),
);
assert(
requests
.filter((r) => r.path === '/api/notifications/delivery-log')
.every((r) => r.query === '?limit=200'),
);
},
);
results.push({
name,
browser_version: browser.version(),
dark,
checks,
requests,
errors,
offOrigin,
writes,
});
} finally {
for (const pending of releases) pending.resolve();
await browser.close();
browser = null;
}
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
passed: true,
playwright_version: playwrightVersion,
content_sha256: hashes,
changed_paths: runtime,
results,
screenshots: screens,
},
null,
2,
) + '\n',
);
console.log(
JSON.stringify({
passed: true,
checks: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screens.length,
playwright_version: playwrightVersion,
results: results.map((r) => ({ name: r.name, browser_version: r.browser_version })),
}),
);
} catch (error) {
fs.writeFileSync(
path.join(output, 'failed.json'),
JSON.stringify(
{ passed: false, error: error.message, results, screenshots: screens },
null,
2,
) + '\n',
);
throw error;
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notification evidence verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./notification-evidence.tsx"></script>
</body>
</html>
@@ -0,0 +1,69 @@
// The production Notifications tab and API readers with synthetic read responses.
// No notification is sent, retried or dismissed by this fixture.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { DestinationsTab } from '../src/features/alerts/tabs/DestinationsTab';
import type { UIEmailConfig, UIAppriseConfig } from '../src/features/alerts/types';
import '../src/index.css';
function Fixture() {
const [emailConfig, setEmailConfig] = createSignal<UIEmailConfig>({
enabled: false,
provider: 'smtp',
server: '',
port: 587,
username: '',
password: '',
from: '',
to: [],
tls: true,
startTLS: true,
replyTo: '',
maxRetries: 3,
retryDelay: 5,
rateLimit: 60,
});
const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>({
enabled: false,
mode: 'cli',
targetsText: '',
cliPath: '',
timeoutSeconds: 20,
serverUrl: '',
configKey: '',
apiKey: '',
apiKeyHeader: 'X-API-KEY',
skipTlsVerify: false,
hasApiKey: false,
});
return (
<main class="mx-auto max-w-4xl p-3 text-base-content">
<h1 class="mb-4 text-xl font-semibold">Notifications</h1>
<DestinationsTab
emailConfig={emailConfig}
setEmailConfig={setEmailConfig}
appriseConfig={appriseConfig}
setAppriseConfig={setAppriseConfig}
setHasUnsavedChanges={() => {}}
configLoadError={() => null}
isRetrying={() => false}
isLoadingDestinations={() => false}
onRetryLoad={() => {}}
webhooks={() => []}
deadManPingUrl={() => ''}
setDeadManPingUrl={() => {}}
pushMinimumSeverity={() => 'all'}
setPushMinimumSeverity={() => {}}
/>
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,419 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/operator-tables-proof';
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwright = require('playwright/package.json').version;
assert.equal(
playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json')));
const hashes = Object.fromEntries(
paths.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5271, strictPort: true },
});
const results = [],
screens = [];
let browser;
try {
await server.listen();
for (const [name, engine, width] of [
['desktop', chromium, 1365],
['phone', webkit, 390],
['intermediate', chromium, 768],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
offOrigin = [],
checks = [],
requests = [];
let rollbackMode = 'reject',
finishRollback;
page.on('pageerror', (e) => errors.push(e.message));
page.setDefaultTimeout(20000);
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5271') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/updates/history')
return route.fulfill({
json: [
{
event_id: 'fixture-update-1',
timestamp: '2026-10-02T12:00:00Z',
action: 'update',
channel: 'stable',
version_from: '6.4.5',
version_to: '6.4.6',
deployment_type: 'systemd',
initiated_by: 'user',
initiated_via: 'ui',
status: 'success',
duration_ms: 30000,
backup_path: '/private/fixture-backup',
notes: 'untrusted restore-everything note',
},
],
});
if (url.pathname === '/api/version')
return route.fulfill({
json: {
version: '6.6.0-dev',
build: 'synthetic',
runtime: 'community',
isDocker: false,
isSourceBuild: true,
isDevelopment: false,
},
});
if (url.pathname === '/api/updates/rollback') {
requests.push({ method: route.request().method(), body: route.request().postDataJSON() });
if (rollbackMode === 'hold')
await new Promise((resolve) => {
finishRollback = resolve;
});
return rollbackMode === 'accept'
? route.fulfill({
json: { status: 'accepted', message: 'Synthetic acceptance, not recovery' },
})
: route.fulfill({
status: 409,
json: { error: 'Synthetic incompatible backup scope' },
});
}
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
},
});
if (url.pathname === '/api/license/entitlements')
return route.fulfill({ json: { entitlements: [] } });
if (url.pathname.includes('/facets'))
return route.fulfill({
json: { facets: [], capabilities: [], relationships: [], recentChanges: [] },
});
if (url.pathname === '/api/security/status')
return route.fulfill({
json: {
hasAuthentication: true,
requiresAuth: true,
settingsCapabilities: { authenticationWrite: true },
},
});
if (url.pathname === '/api/metrics-store/history')
return route.fulfill({ json: { points: [], metrics: {}, source: 'store' } });
return route.fulfill({
json: {
enabled: false,
data: [],
alerts: {},
facets: [],
capabilities: [],
relationships: [],
recentChanges: [],
},
});
});
const check = async (label, run) => {
try {
await run();
checks.push({ label, passed: true });
} catch (e) {
checks.push({ label, passed: false, error: e.message });
}
};
const select = async (name) => {
await page
.getByRole('navigation', { name: 'Fixture views' })
.getByRole('button', { name, exact: true })
.click();
};
const capture = async (state) => {
// Await the production entrance animation instead of capturing translucent frames.
if (await page.getByRole('dialog').count()) {
await page.waitForFunction(() => {
const panel = document.querySelector('[role=dialog]');
return panel && Number(getComputedStyle(panel).opacity) >= 0.999;
});
}
await page.waitForTimeout(100); // ResizeObserver layout, not freshness proof.
const p = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, p), fullPage: true });
const dimensions = await page.evaluate(() => ({
width: innerWidth,
scroll: document.documentElement.scrollWidth,
}));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
screens.push({ file: p, state, browser: name, width, dimensions });
};
await page.goto('http://127.0.0.1:5271/browser-tests/operator-tables-consent.html', {
waitUntil: 'domcontentloaded',
timeout: 120000,
});
if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
await check('Pool state and measured capacity survive the phone projection', async () => {
const pool = page.getByRole('region', { name: 'Pool table' });
await pool.getByText('archive-tank', { exact: true }).waitFor();
await pool.getByText('Degraded', { exact: true }).waitFor();
const state = pool.locator('tbody td[data-storage-column="state"]').first();
const bounds = await state.evaluate((e) => ({
width: e.clientWidth,
scroll: e.scrollWidth,
text: e.textContent,
}));
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
if (width >= 544) {
await pool.getByText('Resilvering 45%', { exact: true }).waitFor();
await pool.getByText('Scrubbing 45%', { exact: true }).waitFor();
assert.ok(
await pool
.locator('[title="ZFS pool archive-tank resilver is running (45.2%)"]')
.count(),
);
} else {
assert.ok((await pool.getByText('59%', { exact: true }).count()) >= 2);
}
await capture('pools');
});
if (width !== 768) {
await select('Disks');
await check('Disk row and expanded header retain the full SMART reason', async () => {
await page.getByText('Archive HDD', { exact: true }).waitFor();
await page
.locator('[data-row-id="disk-sda"]')
.getByText('Archive HDD', { exact: true })
.click();
const health = page.getByTestId('disk-detail-health');
await health.getByText('Replace Now', { exact: true }).waitFor();
assert.ok(
(await health.innerText()).includes('SMART failed. The full reason remains readable'),
);
const bounds = await health.evaluate((e) => ({
width: e.clientWidth,
scroll: e.scrollWidth,
}));
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
await capture('disk-reason');
});
await select('Controllers');
await check(
'Job target and absolute started/completed times are reachable from a narrow row',
async () => {
await page.getByText('nightly-import', { exact: true }).click();
const details = page.getByTestId('resource-kubernetes-controller-section');
await details.getByText('Started', { exact: true }).waitFor();
await details.getByText('Completed', { exact: true }).waitFor();
await details.getByText('5m', { exact: true }).waitFor();
await details.getByText('1 completion', { exact: true }).waitFor();
assert.ok((await details.innerText()).includes('2026'));
await capture('job-times');
},
);
await check(
'CronJob retains last-run, last-success, schedule and suspension in its expansion',
async () => {
await page.getByText('billing-rollup', { exact: true }).click();
const details = page.getByTestId('resource-kubernetes-controller-section');
await details.getByText('Last run', { exact: true }).waitFor();
await details.getByText('Last success', { exact: true }).waitFor();
await details.getByText('*/5 * * * *', { exact: true }).waitFor();
await details.getByText('Suspended', { exact: true }).waitFor();
await capture('cron-times');
},
);
}
await select('Backups');
await check(
'Coverage last backup remains older than a fresh local guest snapshot',
async () => {
const coverage = page.locator('[data-proxmox-backups-table="coverage"]');
await coverage.waitFor();
const columns = await coverage
.locator('colgroup col')
.evaluateAll((els) => els.map((e) => e.dataset.proxmoxBackupsColumn));
const row = coverage.locator('tr[data-proxmox-backup-row=coverage]');
const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText();
assert.ok(backupAge.includes('13d'), backupAge);
assert.ok(!backupAge.includes('30m'), backupAge);
if (columns.includes('snapshot')) {
assert.ok(
(await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes(
'30m',
),
);
}
await capture('backup-attribution');
},
);
await check(
'By date keeps archive format visible and full identifier available at wide widths',
async () => {
const byDate = page.locator('[data-proxmox-backups-table="recoverable"]');
await byDate.waitFor();
if (width >= 1120) {
await byDate.getByText('tar.zst', { exact: true }).waitFor();
assert.ok(
await byDate
.locator('[title="local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst"]')
.count(),
);
}
assert.ok((await page.getByText('artifact-cache-310', { exact: true }).count()) >= 2);
},
);
if (width !== 768) {
await select('Consent');
await check(
'Consent separates running/backup versions and does not promise active-data restoration',
async () => {
const action = page.getByRole('button', { name: 'Roll back', exact: true });
await action.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Confirm rollback' });
await dialog.getByText('Roll back to Pulse v6.4.5?', { exact: true }).waitFor();
await dialog.getByText('Running now: Pulse v6.6.0-dev.', { exact: true }).waitFor();
const text = await dialog.innerText();
assert.ok(text.includes('Restore scope is not reported for this backup.'));
assert.ok(text.includes('Older recovery can also replace install-local data'));
assert.ok(text.includes('Do not assume this will undo settings or alerts.'));
assert.ok(!text.includes('/private/fixture-backup'));
assert.ok(!text.includes('untrusted restore-everything note'));
assert.equal(
await dialog
.getByRole('link', { name: 'Read the recovery instructions' })
.getAttribute('href'),
'/docs/AUTO_UPDATE#manual-rollback',
);
assert.equal(requests.length, 0);
await capture('consent');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
assert.equal(await page.getByRole('dialog').count(), 0);
assert.equal(requests.length, 0);
assert.equal(await action.evaluate((e) => e === document.activeElement), true);
},
);
await check(
'Rejection retains consent and pending submission cannot be duplicated or cancelled',
async () => {
await page.getByRole('button', { name: 'Roll back', exact: true }).click();
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
await page
.getByText('Synthetic incompatible backup scope', { exact: true })
.first()
.waitFor();
assert.equal(requests.length, 1);
assert.ok(
(await page.getByRole('dialog').innerText()).includes(
'Restore scope is not reported',
),
);
await capture('consent-rejected');
rollbackMode = 'hold';
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
await page.waitForFunction(
() => document.querySelector('button:disabled')?.textContent,
);
assert.ok(
await page
.getByRole('button', { name: 'Starting rollback...', exact: true })
.isDisabled(),
);
assert.ok(await page.getByRole('button', { name: 'Cancel', exact: true }).isDisabled());
await page.keyboard.press('Escape');
assert.equal(await page.getByRole('dialog').count(), 1);
while (!finishRollback) await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(requests.length, 2);
rollbackMode = 'accept';
finishRollback();
await page.getByRole('dialog').waitFor({ state: 'hidden' });
assert.deepEqual(requests, [
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
]);
},
);
}
await check('No uncaught application errors or external requests', async () => {
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
});
results.push({
name,
width,
browser: browser.version(),
checks,
errors,
offOrigin,
rollbackRequests: requests,
});
await page.close();
await browser.close();
browser = undefined;
}
} finally {
if (browser) await browser.close();
await server.close();
}
const result = {
result: results.every((r) => r.checks.every((c) => c.passed)) ? 'passed' : 'failed',
playwright,
base_sha: '697ad83e19fa5e8afee634c212c945600d2292b4',
source_content_sha256: hashes,
results,
screens,
};
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
console.log(
JSON.stringify({
result: result.result,
checks: results.flatMap((r) => r.checks).length,
failed: results.flatMap((r) => r.checks.filter((c) => !c.passed)),
screens: screens.length,
}),
);
if (result.result !== 'passed') process.exitCode = 1;
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Operator tables and rollback consent verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./operator-tables-consent.tsx"></script>
</body>
</html>
@@ -0,0 +1,399 @@
// Production components and CSS with synthetic observations. No native, released or
// installed recovery claim; the current history API does not describe restore scope.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
import { DiskList } from '../src/components/Storage/DiskList';
import { KubernetesControllersTable } from '../src/features/kubernetes/KubernetesControllersTable';
import { ProxmoxCoverageTable } from '../src/features/proxmox/ProxmoxCoverageTable';
import { ProxmoxRecoverableTable } from '../src/features/proxmox/ProxmoxRecoverableTable';
import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
import { ProxmoxNodesTable } from '../src/features/proxmox/ProxmoxNodesTable';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { Table, TableBody } from '../src/components/shared/Table';
import { ToastContainer } from '../src/components/Toast/Toast';
import { UpdateHistorySection } from '../src/components/Settings/UpdateHistorySection';
import { buildProxmoxBackupRecoveryModel } from '../src/features/proxmox/proxmoxBackupRecoveryModel';
import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import { updateStore } from '../src/stores/updates';
import type { Resource } from '../src/types/resource';
import type { StorageRecord } from '../src/features/storageBackups/models';
import '../src/index.css';
const now = Date.parse('2026-10-03T12:00:00Z');
const resource = (id: string, type: Resource['type'], extra: Partial<Resource> = {}): Resource => ({
id,
type,
name: id,
displayName: id,
platformId: 'fixture-estate',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'online',
lastSeen: now,
...extra,
});
const pools: StorageRecord[] = [
['archive-tank', 'DEGRADED', 'resilver'],
['backup-pool', 'ONLINE', 'scrub'],
].map(([name, state, scan], index) => ({
id: name,
name,
category: 'pool',
health: index ? 'healthy' : 'warning',
location: { label: index ? 'backup-nas' : 'archive-nas', scope: 'host' },
source: {
platform: 'truenas',
family: 'onprem',
origin: 'resource',
adapterId: 'resource-storage',
},
capacity: { totalBytes: 2e12, usedBytes: 1.18e12, freeBytes: 0.82e12, usagePercent: 59 },
capabilities: ['capacity', 'health'],
observedAt: now,
protectionLabel: `ZFS pool ${name} ${scan} is running (45.2%)`,
protectionSummary: `ZFS pool ${name} ${scan} is running (45.2%)`,
rebuildInProgress: true,
details: {
zfsPool: {
state,
scan: `${scan} in progress`,
scanDetails: {
function: scan.toUpperCase(),
state: 'SCANNING',
percentage: 45.2,
},
devices: [],
},
},
}));
const disks = [
resource('disk-sda', 'physical_disk', {
identity: { hostname: 'archive-nas' },
canonicalIdentity: { hostname: 'archive-nas' },
physicalDisk: {
devPath: '/dev/sda',
model: 'Archive HDD',
serial: 'FIXTURE-1',
diskType: 'hdd',
sizeBytes: 2e12,
health: 'FAILED',
temperature: 42,
storageRole: 'data',
risk: {
level: 'critical',
reasons: [
{
code: 'smart-failed',
severity: 'critical',
summary: 'SMART failed. The full reason remains readable in the expanded disk header.',
},
],
},
},
}),
resource('disk-nvme', 'physical_disk', {
identity: { hostname: 'backup-nas' },
canonicalIdentity: { hostname: 'backup-nas' },
physicalDisk: {
devPath: '/dev/nvme0n1',
model: 'Backup SSD',
serial: 'FIXTURE-2',
diskType: 'nvme',
sizeBytes: 1e12,
health: 'PASSED',
temperature: 44,
wearout: 2,
storageRole: 'cache',
},
}),
];
const controllers = [
resource('nightly-import', 'k8s-job', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'Job',
clusterName: 'prod',
namespace: 'batch',
startTime: '2026-10-03T11:00:00Z',
completionTime: '2026-10-03T11:05:00Z',
desiredReplicas: 1,
succeeded: 1,
active: 0,
failed: 0,
},
}),
resource('billing-rollup', 'k8s-cronjob', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'CronJob',
clusterName: 'prod',
namespace: 'batch',
schedule: '*/5 * * * *',
lastScheduleTime: '2026-10-03T11:00:00Z',
lastSuccessfulTime: '2026-10-03T11:05:00Z',
suspend: true,
active: 0,
},
}),
resource('node-exporter', 'k8s-daemonset', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'DaemonSet',
clusterName: 'prod',
namespace: 'observability',
desiredNumberScheduled: 3,
currentNumberScheduled: 3,
numberReady: 2,
numberUnavailable: 1,
},
}),
resource('database', 'k8s-statefulset', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'StatefulSet',
clusterName: 'prod',
namespace: 'apps',
desiredReplicas: 1,
readyReplicas: 1,
serviceName: 'database-headless',
},
}),
];
const workload = resource('ct-310', 'system-container', {
name: 'artifact-cache-310',
displayName: 'artifact-cache-310',
status: 'running',
proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' },
});
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload],
pbsBackups: [],
tasks: [],
nowMs: now,
archives: [
{
id: 'file-310',
storage: 'local',
node: 'pve-edge',
instance: 'homelab',
type: 'ct',
vmid: 310,
time: '2026-09-20T02:00:00Z',
ctime: Date.parse('2026-09-20T02:00:00Z') / 1000,
size: 1048576,
format: 'tar.zst',
protected: false,
volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst',
isPBS: false,
verified: false,
},
],
snapshots: [
{
id: 'snap-310',
name: 'fresh-local-snapshot',
node: 'pve-edge',
instance: 'homelab',
type: 'ct',
vmid: 310,
time: '2026-10-03T11:30:00Z',
vmstate: false,
},
],
});
const pbs = resource('pbs-archive', 'pbs', {
platformType: 'proxmox-pbs',
cpu: { current: 14 },
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
pbs: {
instance: 'pbs-archive',
datastores: [
{
name: 'archive',
total: 2e12,
used: 1.18e12,
free: 0.82e12,
usage: 59,
status: 'available',
},
],
},
});
const node = resource('pve-edge', 'node', {
cpu: { current: 12, cores: 4 },
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
disk: { current: 30, total: 1e12, used: 0.3e12, free: 0.7e12 },
proxmox: { node: 'pve-edge', instance: 'homelab', uptime: 86400 },
});
function Fixture() {
const [view, setView] = createSignal('Pools');
const [disk, setDisk] = createSignal<string | null>(null);
const [pool, setPool] = createSignal<string | null>(null);
const [expanded, setExpanded] = createSignal(new Set<string>());
const ws = {
state: { pmg: [], pbs: [], nodes: [], resources: [], vms: [], containers: [] },
activeAlerts: () => ({}),
connected: () => true,
initialDataReceived: () => true,
};
return (
<WebSocketContext.Provider value={ws as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<ToastContainer />
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Operator tables and rollback consent</h1>
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
{['Pools', 'Disks', 'Controllers', 'Backups', 'Consent'].map((name) => (
<button
type="button"
class="min-h-11 rounded border border-border px-3"
onClick={() => setView(name)}
>
{name}
</button>
))}
</nav>
<Show when={view() === 'Pools'}>
<section aria-label="Pool table">
<StoragePoolsTable
groupedRecords={groupStorageRecords(pools, 'none')}
groupBy="none"
expandedGroups={new Set(['All'])}
toggleGroup={() => {}}
expandedPoolId={pool()}
setExpandedPoolId={setPool}
storageGrowthBySeriesId={new Map()}
storageGrowthColumnLabel="Growth"
physicalDisks={[]}
nodeOnlineByLabel={new Map()}
highlightedRecordId={null}
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
isLoading={false}
/>
</section>
</Show>
<Show when={view() === 'Disks'}>
<section aria-label="Disk table">
<DiskList
disks={disks}
nodes={[]}
selectedNode={null}
searchTerm=""
selectedDiskId={disk()}
onSelectedDiskChange={setDisk}
/>
</section>
</Show>
<Show when={view() === 'Controllers'}>
<section aria-label="Controller table">
<KubernetesControllersTable
resources={controllers}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
</section>
</Show>
<Show when={view() === 'Backups'}>
<section aria-label="Backup tables" class="space-y-4">
<h2>Coverage</h2>
<ProxmoxCoverageTable
rows={model.coverageRows}
hasAnyRows
emptyIcon={<span />}
emptyTitle="No backups"
emptyDescription="No backups"
sortKey={() => 'posture'}
sortDirection={() => 'asc'}
onSort={() => {}}
expandedKeys={expanded()}
onToggleExpand={(key) =>
setExpanded((current) =>
current.has(key)
? new Set([...current].filter((item) => item !== key))
: new Set([...current, key]),
)
}
showPbsColumn
showArchiveColumn
showSnapshotColumn
showTaskColumn={false}
/>
<h2>By date</h2>
<ProxmoxRecoverableTable
artifacts={model.recoverableArtifacts}
hasAnyArtifacts
emptyIcon={<span />}
emptyTitle="No artifacts"
emptyDescription="No artifacts"
sortKey={() => 'created'}
sortDirection={() => 'desc'}
onSort={() => {}}
sizeMaxBytes={1048576}
/>
<h2>Backup servers</h2>
<ProxmoxBackupServersTable servers={[pbs]} />
<h2>Proxmox nodes</h2>
<ProxmoxNodesTable
nodes={[node]}
guests={[workload]}
emptyIcon={<span />}
emptyTitle="No nodes"
emptyDescription="No nodes"
/>
<h2>Workload row</h2>
<Table>
<TableBody>
<GuestRow
guest={
{
id: 'ct-310',
vmid: 310,
name: 'artifact-cache-310',
node: 'pve-edge',
instance: 'homelab',
status: 'running',
type: 'lxc',
cpu: 0.12,
cpus: 2,
memory: { usage: 30, total: 1e9, used: 0.3e9, free: 0.7e9 },
disk: { usage: 20, total: 1e10, used: 2e9, free: 8e9 },
uptime: 86400,
template: false,
lastBackup: 0,
tags: null,
lock: '',
lastSeen: new Date(now).toISOString(),
} as any
}
visibleColumnIds={['name', 'cpu', 'memory', 'disk', 'uptime']}
workloadTableLayoutMode={innerWidth < 440 ? 'phone' : 'compact'}
/>
</TableBody>
</Table>
</section>
</Show>
<Show when={view() === 'Consent'}>
<section aria-label="Rollback history">
<UpdateHistorySection />
</section>
</Show>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
);
}
void updateStore.checkForUpdates();
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,497 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const now = '2026-10-03T12:00:00Z';
const base = (id, type, extra = {}) => ({
id,
type,
name: id,
status: 'online',
lastSeen: now,
sources: ['docker'],
...extra,
});
const observations = (mode) => [
base('mail-gateway-eu', 'pmg', {
sources: ['pmg'],
technology: 'pmg',
uptime: 86400,
sourceStatus: { pmg: { status: 'online', lastSeen: now } },
...(mode === 'missing'
? {}
: {
pmg: {
instanceId: 'pmg-eu',
hostname: 'mail-gateway-eu',
version: '8.1-2',
nodeCount: 2,
mailCountTotal: 2740,
spamIn: 321,
virusIn: 14,
quarantine: 3,
queueTotal: mode === 'fresh' ? 19 : 11,
queueDeferred: 5,
},
}),
}),
base('nfs-iso', 'storage', {
sources: ['proxmox'],
parentName: 'cluster',
parentId: 'pve-cluster-a',
technology: 'nfs',
metrics: { disk: { total: 1e12, used: 3e11, percent: 30 } },
storage: { type: 'nfs', shared: true, nodes: ['pve1', 'pve2', 'pve3'] },
}),
base('local-lvm', 'storage', {
sources: ['proxmox'],
parentName: 'pve2',
parentId: 'pve2',
metrics: { disk: { total: 1e12, used: 6e11, percent: 60 } },
storage: { type: 'lvmthin', shared: false },
}),
base('edge-web', 'app-container', {
status: 'running',
technology: 'docker',
sourceStatus: { docker: { status: 'online', lastSeen: now } },
actionReadiness: [{ name: 'restart', available: false, reason: 'Command agent disconnected' }],
metrics: { cpu: { percent: 42 }, memory: { percent: 50, used: 5e8, total: 1e9 } },
docker: {
agentId: 'agent-edge',
hostSourceId: 'agent-edge',
containerId: 'native-edge-web',
hostname: 'edge-long-hostname',
runtime: 'docker',
runtimeVersion: '27.5.1',
image: 'nginx:latest',
containerState: 'running',
restartCount: 7,
updateStatus: {
updateAvailable: true,
currentDigest: 'sha256:current',
latestDigest: 'sha256:latest',
lastChecked: now,
},
},
}),
base('edge-cache', 'app-container', {
status: 'offline',
metrics: { cpu: { percent: 0 }, memory: { percent: 0 } },
docker: {
agentId: 'agent-edge',
hostname: 'edge-long-hostname',
runtime: 'podman',
runtimeVersion: '5.2.1',
image: 'redis:7.4',
containerState: 'exited',
restartCount: 0,
},
}),
base('nginx:latest', 'docker-image', {
docker: {
hostname: 'edge-long-hostname',
imageId: 'image-nginx',
repoTags: ['nginx:latest'],
sizeBytes: 2e8,
containers: ['edge-web'],
containerCount: 1,
},
}),
base('web-service', 'docker-service', {
docker: {
hostname: 'edge-long-hostname',
image: 'nginx:latest',
serviceMode: 'replicated',
desiredTasks: 4,
runningTasks: 2,
mode: 'replicated',
serviceUpdate: { state: 'rollback_started', message: 'Synthetic rollout observation' },
},
}),
base('web.2', 'docker-task', {
docker: {
hostname: 'edge-long-hostname',
serviceName: 'web-service',
nodeName: 'worker-1',
currentState: 'running',
desiredState: 'running',
startedAt: '2026-10-03T11:30:00.123456Z',
},
}),
];
(async () => {
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/proxmox-docker-rows-final-source';
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwright = require('playwright/package.json').version;
assert.equal(
playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
// Keep the fixture runnable from clean committed source, without requiring
// an invocation-specific untracked runtime-paths input.
const paths = [
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
'frontend-modern/src/features/docker/DockerImagesTable.tsx',
'frontend-modern/src/features/docker/DockerServicesTable.tsx',
'frontend-modern/src/features/docker/DockerTasksTable.tsx',
'frontend-modern/src/features/docker/dockerContainerTableModel.ts',
'frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts',
'frontend-modern/src/features/storageBackups/storageAdapters.ts',
'frontend-modern/src/hooks/useUnifiedResources.ts',
];
const hashes = Object.fromEntries(
paths.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5272, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width] of [
['desktop', chromium, 1440],
['phone', webkit, 390],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
await page.clock.setFixedTime(new Date(now));
page.setDefaultTimeout(15000);
const checks = [],
errors = [],
offOrigin = [],
mutations = [];
let mode = 'initial';
page.on('pageerror', (e) => errors.push(e.message));
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5272') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') {
mutations.push({ path: url.pathname, method: request.method() });
return route.fulfill({ status: 409, json: { error: 'No fixture mutation' } });
}
if (url.pathname === '/api/resources')
return route.fulfill({ json: { data: observations(mode) } });
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
blocked_capabilities: [],
runtime: { build: 'community' },
},
});
if (url.pathname === '/api/security/status')
return route.fulfill({ json: { hasAuthentication: true, requiresAuth: true } });
return route.fulfill({
json: {
enabled: false,
data: [],
capabilities: [],
facets: [],
relationships: [],
recentChanges: [],
alerts: {},
},
});
});
const check = async (label, run) => {
try {
await run();
checks.push({ label, passed: true });
} catch (e) {
checks.push({ label, passed: false, error: e.message });
}
};
const select = (view) =>
page
.getByRole('navigation', { name: 'Fixture views' })
.getByRole('button', { name: view, exact: true })
.click();
const capture = async (state) => {
await page.waitForTimeout(180); // ResizeObserver and entrance layout, not freshness evidence.
const dimensions = await page.evaluate(() => ({
width: innerWidth,
scroll: document.documentElement.scrollWidth,
}));
checks.push({
label: `No horizontal page overflow: ${state}`,
passed: dimensions.scroll <= dimensions.width + 1,
dimensions,
});
const file = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, file), fullPage: true });
screenshots.push({ file, state, engine: name, width, dimensions });
};
await page.goto('http://127.0.0.1:5272/browser-tests/proxmox-docker-rows.html', {
waitUntil: 'domcontentloaded',
timeout: 120000,
});
if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByText('8 REST-mapped rows', { exact: true }).waitFor();
await check(
'REST-first PMG counters and facets are present before any websocket hydration',
async () => {
const table = page.getByRole('region', { name: 'Mail table' });
await table.getByText('2,740', { exact: true }).waitFor();
await table.getByText('11', { exact: true }).waitFor();
const row = await page.evaluate(() =>
window.__operatorRows.rows().find((r) => r.type === 'pmg'),
);
assert.equal(row.technology, 'pmg');
assert.equal(row.sourceStatus.pmg.status, 'online');
assert.equal(row.pmg.queueTotal, 11);
assert.deepEqual(row.pmg, row.platformData.pmg);
if (width > 390) await table.getByText('8.1-2', { exact: true }).waitFor();
},
);
await capture('rest-mail');
await check(
'Canonical store hydration followed by REST refresh keeps the new PMG counters',
async () => {
await page.evaluate(() => window.__operatorRows.seedWebsocket());
mode = 'fresh';
await page.evaluate(() => window.__operatorRows.refresh());
await page
.getByRole('region', { name: 'Mail table' })
.getByText('19', { exact: true })
.waitFor();
},
);
await capture('refreshed-mail');
await check('A removed REST PMG facet does not retain obsolete queue evidence', async () => {
mode = 'missing';
await page.evaluate(() => window.__operatorRows.refresh());
assert.equal(
await page
.getByRole('region', { name: 'Mail table' })
.getByText('19', { exact: true })
.count(),
0,
);
const row = await page.evaluate(() =>
window.__operatorRows.rows().find((r) => r.type === 'pmg'),
);
assert.equal(row.pmg, undefined);
mode = 'fresh';
await page.evaluate(() => window.__operatorRows.refresh());
});
await select('Storage');
await check('Shared storage vocabulary preserves native locations and IDs', async () => {
const table = page.getByRole('region', { name: 'Storage table' });
await table.getByText('NFS', { exact: true }).waitFor({ state: 'attached' });
await table.getByText('LVM-Thin', { exact: true }).waitFor({ state: 'attached' });
await table.getByText('Shared · 3 nodes', { exact: true }).waitFor({ state: 'attached' });
await table.getByText('pve2', { exact: true }).waitFor({ state: 'attached' });
const rows = await page.evaluate(() =>
window.__operatorRows.rows().filter((r) => r.type === 'storage'),
);
assert.deepEqual(
rows.map((r) => [r.id, r.parentId]),
[
['nfs-iso', 'pve-cluster-a'],
['local-lvm', 'pve2'],
],
);
});
await capture('storage');
await select('Containers');
await check(
'REST lifecycle refusal remains available; phone row keeps update and moves restarts to detail',
async () => {
await page
.locator('[data-lifecycle-refusal]')
.getByText('Command agent disconnected', { exact: true })
.waitFor();
const table = page.getByRole('region', { name: 'Container table' });
const update = table.locator('.docker-container-update-cell button');
await update.waitFor();
assert.match(await update.innerText(), /Update/);
const bounds = await update.evaluate((e) => ({
w: e.getBoundingClientRect().width,
cell: e.closest('td').getBoundingClientRect().width,
scroll: e.scrollWidth,
client: e.clientWidth,
}));
assert.ok(
bounds.w <= bounds.cell + 1 && bounds.scroll <= bounds.client + 1,
JSON.stringify(bounds),
);
const headers = await table.locator('th:visible').allTextContents();
if (width === 390) {
assert.ok(!headers.some((h) => h.includes('Restarts')));
assert.ok(headers.some((h) => h.includes('State')));
}
},
);
await capture('containers');
await check(
'Restart count and native identity remain reachable via row disclosure',
async () => {
const row = page.locator('[data-docker-container-row="edge-web"]');
if (width === 390) await row.locator('td').first().tap();
else await row.getByRole('button', { name: /details/i }).click();
const detail = page.getByTestId('resource-docker-container-section');
await detail.getByText('Restarts', { exact: true }).waitFor();
await detail.getByText('7', { exact: true }).waitFor();
},
);
await capture('container-detail');
await select('Images');
await check('Image verdict fits; phone hidden headers and cells are symmetric', async () => {
const table = page.getByRole('region', { name: 'Image table' });
await table.getByText('nginx:latest', { exact: true }).waitFor();
const headers = await table.locator('th:visible').allTextContents();
const row = table.locator('[data-docker-image-row]');
assert.equal(await row.locator('td:visible').count(), headers.length);
if (width === 390) {
assert.ok(!headers.some((h) => h.includes('Host') || h.includes('Used by')));
assert.ok(headers.some((h) => h.includes('Size')));
}
});
await capture('images');
await select('Swarm');
await check(
'Swarm state and started time use operator wording with raw evidence retained',
async () => {
const state = page.getByText('Rollback started', { exact: true });
await state.waitFor({ state: 'attached' });
assert.match(
await state.locator('xpath=ancestor-or-self::*[@title][1]').getAttribute('title'),
/rollback_started/,
);
const visibleState = await state.evaluate((element) => {
const cell = element.closest('td').getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(element);
return [...range.getClientRects()].every(
(line) =>
line.left >= cell.left &&
line.right <= cell.right &&
line.top >= cell.top &&
line.bottom <= cell.bottom,
);
});
assert.equal(visibleState, true, 'Every word of the rollout state must fit its cell');
if (width === 390) {
await page.getByText('Run', { exact: true }).waitFor({ state: 'visible' });
}
const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]');
await started.waitFor({ state: 'attached' });
assert.match(await started.innerText(), /ago/);
await page
.getByRole('region', { name: 'Swarm tasks' })
.getByTitle('running', { exact: true })
.waitFor();
},
);
await capture('swarm');
await check(
'Swarm touch or keyboard disclosure exposes raw rollout and placement evidence',
async () => {
const row = page.locator('[data-docker-service-row="web-service"]');
if (width === 390) await row.locator('td').first().tap();
else {
const toggle = row.getByRole('button', {
name: 'Expand details for web-service',
exact: true,
});
await toggle.focus();
await page.keyboard.press('Enter');
}
const detail = page.locator('[data-inline-docker-service-detail-for="web-service"]');
await detail
.getByText('rollback_started | Synthetic rollout observation', { exact: true })
.waitFor();
await detail.getByText('edge-long-hostname', { exact: true }).waitFor();
await detail.getByText('4', { exact: true }).waitFor();
await detail.getByText('2', { exact: true }).waitFor();
},
);
await capture('swarm-detail');
await select('Disk');
await check(
'Multi-disk Bars show the measured fullest disk and unchanged equal-weight bars',
async () => {
const label = page.locator('[data-stacked-disk-max-label]');
assert.equal(await label.innerText(), '92%');
assert.equal(await label.getAttribute('title'), 'Highest usage: /data 92%');
assert.equal(await page.locator('[data-stacked-disk-fill="vertical"]').count(), 2);
},
);
await capture('disks');
checks.push({
label: 'No page errors, external requests or mutation requests',
passed: !errors.length && !offOrigin.length && !mutations.length,
errors,
offOrigin,
mutations,
});
results.push({ engine: name, version: browser.version(), width, checks });
await browser.close();
browser = undefined;
}
} finally {
if (browser) await browser.close();
await server.close();
}
const result = {
playwright,
verified_at: new Date().toISOString(),
paths,
hashes,
results,
screenshots,
};
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
const failures = results.flatMap((r) =>
r.checks.filter((c) => !c.passed).map((c) => ({ engine: r.engine, ...c })),
);
console.log(
JSON.stringify({
checked: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screenshots.length,
failures,
}),
);
assert.equal(failures.length, 0, JSON.stringify(failures));
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transport facets and operator rows verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./proxmox-docker-rows.tsx"></script>
</body>
</html>
@@ -0,0 +1,182 @@
// Synthetic REST observations through the real hook, tables, detail disclosures
// and CSS. No real websocket, appliance or installed/released acceptance claim.
import { createSignal, Show } from 'solid-js';
import { createStore, unwrap } from 'solid-js/store';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { useUnifiedResources } from '../src/hooks/useUnifiedResources';
import { ProxmoxMailGatewayTable } from '../src/features/proxmox/ProxmoxMailGatewayTable';
import { DockerContainersTable } from '../src/features/docker/DockerContainersTable';
import { DockerImagesTable } from '../src/features/docker/DockerImagesTable';
import { DockerServicesTable } from '../src/features/docker/DockerServicesTable';
import { DockerTasksTable } from '../src/features/docker/DockerTasksTable';
import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar';
import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
import { Table, TableBody, TableRow, TableCell } from '../src/components/shared/Table';
import { getDockerContainerLifecycleDisabledReason } from '../src/features/docker/dockerContainerLifecycleActions';
import { buildStorageRecords } from '../src/features/storageBackups/storageAdapters';
import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
const [wsState, setWsState] = createStore({
resources: [] as Resource[],
lastUpdate: 0,
nodes: [],
pbs: [],
pmg: [],
vms: [],
containers: [],
});
const [connected, setConnected] = createSignal(false);
const ws = {
state: wsState,
connected,
initialDataReceived: () => true,
resourceChange: () => ({ version: wsState.lastUpdate, changedIds: null, changedKeys: null }),
activeAlerts: () => ({}),
shutdown: () => {},
};
window.__pulseWsStore = ws as any;
function Fixture() {
const [view, setView] = createSignal('Mail');
const [expandedPool, setExpandedPool] = createSignal<string | null>(null);
const rows = useUnifiedResources({ query: '', cacheKey: 'operator-rows-browser' });
const ofType = (type: string) => rows.resources().filter((row) => row.type === type);
const container = () => ofType('app-container')[0];
const records = () =>
buildStorageRecords({ state: wsState as any, resources: ofType('storage') });
const grouped = () => groupStorageRecords(records(), 'none');
(window as any).__operatorRows = {
rows: () => JSON.parse(JSON.stringify(unwrap(rows.resources()))),
refresh: () => rows.refetch(),
seedWebsocket: () => {
setWsState('resources', JSON.parse(JSON.stringify(unwrap(rows.resources()))));
setWsState('lastUpdate', Date.now());
setConnected(true);
},
};
const nativeProps = {
emptyIcon: <span />,
emptyTitle: 'No fixture rows',
emptyDescription: 'No fixture rows',
showToolbar: false,
};
return (
<WebSocketContext.Provider value={ws as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Transport facets and operator rows</h1>
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
{['Mail', 'Storage', 'Containers', 'Images', 'Swarm', 'Disk'].map((name) => (
<button
type="button"
class="min-h-11 rounded border border-border px-3"
onClick={() => setView(name)}
>
{name}
</button>
))}
</nav>
<p data-fixture-loaded>{rows.resources().length} REST-mapped rows</p>
<Show when={view() === 'Mail'}>
<section aria-label="Mail table">
<ProxmoxMailGatewayTable
resources={ofType('pmg')}
emptyTitle="No mail"
emptyDescription="No mail"
/>
</section>
</Show>
<Show when={view() === 'Storage'}>
<section aria-label="Storage table">
<StoragePoolsTable
groupedRecords={grouped()}
groupBy="none"
expandedGroups={new Set(['All'])}
toggleGroup={() => {}}
expandedPoolId={expandedPool()}
setExpandedPoolId={setExpandedPool}
storageGrowthBySeriesId={new Map()}
storageGrowthColumnLabel="Growth"
physicalDisks={[]}
nodeOnlineByLabel={new Map()}
highlightedRecordId={null}
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
isLoading={false}
/>
</section>
</Show>
<Show when={view() === 'Containers'}>
<section aria-label="Container table">
<p data-lifecycle-refusal>
{container() && getDockerContainerLifecycleDisabledReason(container(), 'restart')}
</p>
<DockerContainersTable resources={ofType('app-container')} {...nativeProps} />
</section>
</Show>
<Show when={view() === 'Images'}>
<section aria-label="Image table">
<DockerImagesTable resources={ofType('docker-image')} {...nativeProps} />
</section>
</Show>
<Show when={view() === 'Swarm'}>
<section aria-label="Swarm services">
<DockerServicesTable resources={ofType('docker-service')} {...nativeProps} />
</section>
<section aria-label="Swarm tasks">
<DockerTasksTable resources={ofType('docker-task')} {...nativeProps} />
</section>
</Show>
<Show when={view() === 'Disk'}>
<section aria-label="Multi-disk Bars">
<Table>
<TableBody>
<TableRow>
<TableCell>pve-edge</TableCell>
<TableCell>
<StackedDiskBar
mode="vertical-bars"
disks={[
{
device: '/dev/sda',
mountpoint: '/',
usage: 24,
total: 100,
used: 24,
free: 76,
type: 'ext4',
},
{
device: '/dev/sdb',
mountpoint: '/data',
usage: 92,
total: 100,
used: 92,
free: 8,
type: 'zfs',
},
]}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</section>
</Show>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -13,6 +13,7 @@ import { TagBadges } from '@/components/shared/TagBadges';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
import { formatInteger } from './resourceDetailMappers';
import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
interface ResourceSummaryPresentationProps {
@@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
}
: null,
props.content !== 'overview' && docker ? dockerSection(docker) : null,
props.content !== 'overview' ? buildKubernetesControllerSection(props.resource) : null,
props.content !== 'overview'
? {
label: 'Identity',
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import {
buildKubernetesControllerSection,
buildKubernetesDetailSections,
buildKubernetesDetailsSummary,
hasKubernetesDetailSections,
@@ -177,3 +179,203 @@ describe('buildKubernetesDetailsSummary', () => {
).toBe('https://prod.k8s.local:6443');
});
});
describe('buildKubernetesControllerSection', () => {
afterEach(() => {
vi.useRealTimers();
});
const absolute = (timestamp: string): string =>
formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } });
it('returns nothing for resources that are not workload controllers', () => {
expect(
buildKubernetesControllerSection(
makeResource({ id: 'pod-1', type: 'pod', kubernetes: { namespace: 'apps' } }),
),
).toBeNull();
expect(buildKubernetesControllerSection(makeResource({ id: 'vm-1', type: 'vm' }))).toBeNull();
expect(
buildKubernetesControllerSection(makeResource({ id: 'job-1', type: 'k8s-job' })),
).toBeNull();
});
it('leads a Job with its absolute start and completion times, then the counts', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
const section = buildKubernetesControllerSection(
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
desiredReplicas: 10,
active: 1,
succeeded: 8,
failed: 2,
startTime: '2026-05-24T12:55:00Z',
completionTime: '2026-05-24T13:00:00Z',
},
}),
);
expect(section?.label).toBe('Job');
expect(section?.testId).toBe('resource-kubernetes-controller-section');
expect(section?.rows).toEqual([
{ label: 'Started', value: `${absolute('2026-05-24T12:55:00Z')} (36m ago)`, wrap: true },
{ label: 'Completed', value: `${absolute('2026-05-24T13:00:00Z')} (31m ago)`, wrap: true },
{ label: 'Duration', value: '5m' },
{ label: 'Target', value: '10 completions' },
{ label: 'Active', value: '1' },
{ label: 'Succeeded', value: '8' },
{ label: 'Failed', value: '2', tone: 'danger' },
{ label: 'Namespace', value: 'batch' },
{ label: 'Cluster', value: 'prod' },
]);
});
it('keeps a running Job on its start time without a completion or duration row', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
const section = buildKubernetesControllerSection(
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
namespace: 'batch',
desiredReplicas: 1,
active: 1,
failed: 0,
startTime: '2026-05-24T13:29:30Z',
},
}),
);
expect(section?.rows.map((row) => row.label)).toEqual([
'Started',
'Target',
'Active',
'Failed',
'Namespace',
]);
expect(section?.rows[0]).toEqual({
label: 'Started',
value: `${absolute('2026-05-24T13:29:30Z')} (1m ago)`,
wrap: true,
});
expect(section?.rows[3]).toEqual({ label: 'Failed', value: '0', tone: 'default' });
});
it('leads a CronJob with its schedule and the last run and last success times', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
const section = buildKubernetesControllerSection(
makeResource({
id: 'billing-rollup',
type: 'k8s-cronjob',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
schedule: '0 2 * * *',
suspend: true,
active: 0,
lastScheduleTime: '2026-05-24T02:00:00Z',
lastSuccessfulTime: '2026-05-23T02:00:00Z',
},
}),
);
expect(section?.label).toBe('CronJob');
expect(section?.rows).toEqual([
{ label: 'Schedule', value: '0 2 * * *', valueClass: 'font-mono' },
{ label: 'Last run', value: `${absolute('2026-05-24T02:00:00Z')} (11h ago)`, wrap: true },
{ label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true },
{ label: 'Suspended', value: 'Yes', tone: 'warning' },
{ label: 'Active', value: '0' },
{ label: 'Namespace', value: 'batch' },
{ label: 'Cluster', value: 'prod' },
]);
});
it('carries the service name and counts for StatefulSets and DaemonSets', () => {
const statefulSet = buildKubernetesControllerSection(
makeResource({
id: 'checkout-api-stateful',
type: 'k8s-statefulset',
kubernetes: {
namespace: 'apps',
desiredReplicas: 3,
currentReplicas: 3,
readyReplicas: 2,
availableReplicas: 2,
updatedReplicas: 2,
serviceName: 'checkout-headless',
},
}),
);
expect(statefulSet?.label).toBe('StatefulSet');
expect(statefulSet?.rows).toEqual([
{ label: 'Service', value: 'checkout-headless' },
{ label: 'Target', value: '3 pods' },
{ label: 'Current', value: '3' },
{ label: 'Ready', value: '2' },
{ label: 'Available', value: '2' },
{ label: 'Updated', value: '2' },
{ label: 'Namespace', value: 'apps' },
]);
const daemonSet = buildKubernetesControllerSection(
makeResource({
id: 'node-exporter',
type: 'k8s-daemonset',
kubernetes: {
namespace: 'observability',
desiredNumberScheduled: 6,
currentNumberScheduled: 6,
numberReady: 5,
numberAvailable: 5,
numberUnavailable: 1,
numberMisscheduled: 0,
updatedReplicas: 5,
},
}),
);
expect(daemonSet?.label).toBe('DaemonSet');
expect(daemonSet?.rows).toEqual([
{ label: 'Target', value: '6 nodes' },
{ label: 'Current', value: '6' },
{ label: 'Ready', value: '5' },
{ label: 'Available', value: '5' },
{ label: 'Updated', value: '5' },
{ label: 'Unavailable', value: '1', tone: 'warning' },
{ label: 'Misscheduled', value: '0', tone: 'default' },
{ label: 'Namespace', value: 'observability' },
]);
});
it('shows the ReplicaSet labelling and generation fields the Detail column carries', () => {
const section = buildKubernetesControllerSection(
makeResource({
id: 'checkout-api-replicaset',
type: 'k8s-replicaset',
kubernetes: {
desiredReplicas: 4,
readyReplicas: 3,
fullyLabeledReplicas: 4,
observedGeneration: 7,
},
}),
);
expect(section?.label).toBe('ReplicaSet');
expect(section?.rows).toEqual([
{ label: 'Target', value: '4 pods' },
{ label: 'Ready', value: '3' },
{ label: 'Fully labeled', value: '4' },
{ label: 'Observed generation', value: '7' },
]);
});
});
@@ -3,9 +3,18 @@ import {
compactDetailRows as compactRows,
compactDetailSections as compactSections,
formatDetailBytesValue,
formatDetailCountValue,
formatDetailIntegerValue,
makeDetailRow as makeRow,
type DetailRow,
type DetailSection,
type DetailValueTone,
} from '@/components/shared/detailSectionModel';
import {
formatPlatformTableDateTimeValue,
formatPlatformTableDurationValue,
formatPlatformTableRelativeTimeValue,
} from '@/features/platformPage/sharedPlatformPage';
export type ResourceDetailDrawerKubernetesSection = DetailSection;
@@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = (
return compactSections(sections);
};
const countRow = (
label: string,
value: number | undefined,
options: { noun?: string; tone?: (count: number) => DetailValueTone } = {},
): DetailRow | null => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
const text = options.noun
? formatDetailCountValue(value, options.noun)
: formatDetailIntegerValue(value);
return makeRow(label, text, options.tone ? { tone: options.tone(value) } : {});
};
const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default');
const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default');
// Kubernetes reports controller timestamps as RFC 3339 strings. The table row
// only has room for an age, so the expansion carries the absolute time first
// and the age after it: "Oct 2, 2026, 09:13 PM (1h ago)". The value wraps
// because a phone-width detail cell is too narrow for both on one line.
const timestampRow = (label: string, timestamp?: string | null): DetailRow | null => {
const raw = asString(timestamp);
if (!raw) return null;
const absolute = formatPlatformTableDateTimeValue(raw, {
emptyText: '',
dateTimeFormat: { year: 'numeric' },
});
if (!absolute) return makeRow(label, raw, { wrap: true });
const relative = formatPlatformTableRelativeTimeValue(raw, { emptyText: '' });
return makeRow(label, relative ? `${absolute} (${relative})` : absolute, { wrap: true });
};
const elapsedSeconds = (start?: string | null, end?: string | null): number | undefined => {
const startedAt = Date.parse(asString(start) ?? '');
const endedAt = Date.parse(asString(end) ?? '');
if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) {
return undefined;
}
return (endedAt - startedAt) / 1000;
};
type ControllerRows = { label: string; rows: Array<DetailRow | null> };
// Each kind leads with what its narrow table row drops: the Detail column
// (service name, timestamps) below the large layout and Target on a phone.
const controllerRows = (resource: Resource): ControllerRows | null => {
const k = resource.kubernetes;
if (!k) return null;
switch (resource.type) {
case 'k8s-replicaset':
return {
label: 'ReplicaSet',
rows: [
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
countRow('Current', k.currentReplicas),
countRow('Ready', k.readyReplicas),
countRow('Available', k.availableReplicas),
countRow('Fully labeled', k.fullyLabeledReplicas),
countRow('Observed generation', k.observedGeneration),
],
};
case 'k8s-statefulset':
return {
label: 'StatefulSet',
rows: [
makeRow('Service', k.serviceName),
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
countRow('Current', k.currentReplicas),
countRow('Ready', k.readyReplicas),
countRow('Available', k.availableReplicas),
countRow('Updated', k.updatedReplicas),
],
};
case 'k8s-daemonset':
return {
label: 'DaemonSet',
rows: [
countRow('Target', k.desiredNumberScheduled, { noun: 'node' }),
countRow('Current', k.currentNumberScheduled),
countRow('Ready', k.numberReady),
countRow('Available', k.numberAvailable),
countRow('Updated', k.updatedReplicas),
countRow('Unavailable', k.numberUnavailable, { tone: warnWhenPositive }),
countRow('Misscheduled', k.numberMisscheduled, { tone: warnWhenPositive }),
],
};
case 'k8s-job':
return {
label: 'Job',
rows: [
timestampRow('Started', k.startTime),
timestampRow('Completed', k.completionTime),
makeRow(
'Duration',
formatPlatformTableDurationValue(elapsedSeconds(k.startTime, k.completionTime), {
emptyText: '',
}),
),
countRow('Target', k.desiredReplicas, { noun: 'completion' }),
countRow('Active', k.active),
countRow('Succeeded', k.succeeded),
countRow('Failed', k.failed, { tone: dangerWhenPositive }),
],
};
case 'k8s-cronjob':
return {
label: 'CronJob',
rows: [
makeRow('Schedule', k.schedule, { valueClass: 'font-mono' }),
timestampRow('Last run', k.lastScheduleTime),
timestampRow('Last success', k.lastSuccessfulTime),
makeRow('Suspended', k.suspend === true ? 'Yes' : null, { tone: 'warning' }),
countRow('Active', k.active),
],
};
default:
return null;
}
};
// Workload-controller facts for the always-visible summary of the resource
// drawer, the same slot Docker containers use for their timestamps. The
// controllers table hides Scope and Detail below its large layout and Target
// on a phone, so without this a Job's completion time or a CronJob's last
// success is unreachable from a narrow row.
export const buildKubernetesControllerSection = (resource: Resource): DetailSection | null => {
const controller = controllerRows(resource);
if (!controller) return null;
const rows = compactRows([
...controller.rows,
makeRow('Namespace', resource.kubernetes?.namespace),
makeRow('Cluster', resource.kubernetes?.clusterName),
]);
if (rows.length === 0) return null;
return {
label: controller.label,
rows,
testId: 'resource-kubernetes-controller-section',
};
};
export const buildKubernetesDetailsSummary = (resource: Resource): string | null => {
const k = resource.kubernetes;
if (!k) return null;
@@ -5,6 +5,7 @@ import { updateStore } from '@/stores/updates';
import { Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import { getShippedDocUrl } from '@/utils/docsLinks';
const HISTORY_LIMIT = 20;
@@ -45,7 +46,7 @@ const statusPresentation = (status: string): { label: string; className: string
}
};
// A rollback restores the backup taken before this update was applied, so it
// A rollback uses the backup taken before this update was applied, so it
// is only offered where that backup is still retained on disk (the backend
// clears backup_path when retention prunes it) and the update actually landed.
const canRollBack = (entry: UpdateHistoryEntry): boolean =>
@@ -89,7 +90,7 @@ export const UpdateHistorySection: Component = () => {
Update History
</h4>
<p class="mt-1 text-xs text-muted">
Updates applied through Pulse, with rollback to the backup taken before each one.
Updates applied through Pulse, with version rollback using a retained backup.
</p>
</div>
@@ -187,14 +188,30 @@ export const UpdateHistorySection: Component = () => {
</h2>
</div>
<div class="px-6 py-4 space-y-3 text-sm text-base-content">
<p>
This restores the binary, configuration, and data from the backup taken before the
update to {formatVersionLabel(entry().version_to)} on{' '}
{formatTimestamp(entry().timestamp)}.
<p class="text-muted">
Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.
</p>
<p>
Settings and alert changes made since that backup will be reverted, and Pulse will
restart to complete the rollback.
This requests an installation rollback using the backup taken before the update to{' '}
{formatVersionLabel(entry().version_to)} on {formatTimestamp(entry().timestamp)}.
Pulse will restart if the rollback succeeds.
</p>
<p>
Restore scope is not reported for this backup. A scope-aware server keeps active
runtime data in place and restores only the binary and independent installation
files. Older recovery can also replace install-local data and configuration,
reverting later changes.
</p>
<p>
Do not assume this will undo settings or alerts. Full-state recovery requires
Pulse to be stopped and a consistent backup of every active store.{' '}
<a
class="text-primary underline"
href={`${getShippedDocUrl('AUTO_UPDATE.md')}#manual-rollback`}
>
Read the recovery instructions
</a>
.
</p>
</div>
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
@@ -4,6 +4,7 @@ import type { UpdateHistoryEntry } from '@/api/updates';
const mockListUpdateHistory = vi.fn();
const mockStoreRollbackUpdate = vi.fn();
let runningVersion: string | undefined = '6.0.5';
vi.mock('@/api/updates', () => ({
UpdatesAPI: {
@@ -13,7 +14,7 @@ vi.mock('@/api/updates', () => ({
vi.mock('@/stores/updates', () => ({
updateStore: {
versionInfo: () => ({ version: '6.0.5' }),
versionInfo: () => (runningVersion ? { version: runningVersion } : null),
rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args),
},
}));
@@ -39,6 +40,7 @@ describe('UpdateHistorySection', () => {
beforeEach(() => {
mockListUpdateHistory.mockReset();
mockStoreRollbackUpdate.mockReset();
runningVersion = '6.0.5';
});
afterEach(() => {
@@ -102,6 +104,24 @@ describe('UpdateHistorySection', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument();
expect(screen.getByText('Running now: Pulse v6.0.5.')).toBeInTheDocument();
expect(screen.getByText(/Restore scope is not reported for this backup/)).toHaveTextContent(
'A scope-aware server keeps active runtime data in place',
);
expect(screen.getByText(/Older recovery can also/)).toHaveTextContent(
'reverting later changes',
);
expect(screen.getByText(/Do not assume this will undo settings or alerts/)).toHaveTextContent(
'Full-state recovery requires Pulse to be stopped',
);
expect(screen.getByRole('link', { name: 'Read the recovery instructions' })).toHaveAttribute(
'href',
'/docs/AUTO_UPDATE#manual-rollback',
);
expect(
screen.queryByText(/Settings and alert changes made since that backup will be reverted/),
).not.toBeInTheDocument();
expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
@@ -116,4 +136,62 @@ describe('UpdateHistorySection', () => {
expect(screen.queryByText('Roll back to Pulse v6.0.4?')).not.toBeInTheDocument(),
);
});
it('distinguishes the running version from an older history entry without guessing restore scope', async () => {
runningVersion = 'v6.6.0-rc.1';
mockListUpdateHistory.mockResolvedValue([{ ...baseEntry, notes: 'restore everything' }]);
render(() => <UpdateHistorySection />);
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
expect(screen.getByText('Running now: Pulse v6.6.0-rc.1.')).toBeInTheDocument();
expect(screen.getByText(/This requests an installation rollback/)).toHaveTextContent(
'before the update to v6.0.5',
);
expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
expect(screen.queryByText('restore everything')).not.toBeInTheDocument();
expect(screen.queryByText(baseEntry.backup_path!)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
});
it('does not infer the running version from the selected update when version information is missing', async () => {
runningVersion = undefined;
mockListUpdateHistory.mockResolvedValue([baseEntry]);
render(() => <UpdateHistorySection />);
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
expect(screen.getByText('Running now: Pulse unknown.')).toBeInTheDocument();
expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
});
it('retains the consent and refreshes history after a rejected rollback', async () => {
mockListUpdateHistory.mockResolvedValue([baseEntry]);
mockStoreRollbackUpdate.mockResolvedValue(false);
render(() => <UpdateHistorySection />);
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
await waitFor(() => expect(mockListUpdateHistory).toHaveBeenCalledTimes(2));
expect(screen.getByRole('dialog')).toHaveTextContent('Restore scope is not reported');
expect(screen.getByRole('button', { name: 'Roll back to v6.0.4' })).not.toBeDisabled();
});
it('prevents duplicate requests and cancellation while the rollback request is pending', async () => {
mockListUpdateHistory.mockResolvedValue([baseEntry]);
let finish!: (accepted: boolean) => void;
mockStoreRollbackUpdate.mockReturnValue(
new Promise<boolean>((resolve) => {
finish = resolve;
}),
);
render(() => <UpdateHistorySection />);
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
expect(screen.getByRole('button', { name: 'Starting rollback...' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Starting rollback...' }));
fireEvent.keyDown(document, { key: 'Escape' });
expect(mockStoreRollbackUpdate).toHaveBeenCalledTimes(1);
expect(screen.getByRole('dialog')).toBeInTheDocument();
finish(true);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
});
@@ -11,6 +11,10 @@ import {
resolveHistoryRangeWithinLimit,
} from '@/components/Storage/historyRangeAccess';
import {
DISK_DETAIL_HEADER_STACK_CLASS,
DISK_DETAIL_HEALTH_LABEL_CLASS,
DISK_DETAIL_HEALTH_ROW_CLASS,
DISK_DETAIL_HEALTH_SUMMARY_CLASS,
DISK_DETAIL_HISTORY_RANGE_OPTIONS,
DISK_DETAIL_LIVE_CHARTS,
getDiskAttributeValueTextClass,
@@ -55,6 +59,7 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
historyResourceId,
attributeCards,
historyCharts,
healthPresentation,
metricResourceId,
collectionMessages,
liveIOAvailable,
@@ -91,19 +96,32 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
return (
<div class={STORAGE_DISK_DETAIL_ROOT_CLASS}>
<div class={STORAGE_DISK_DETAIL_HEADER_CLASS}>
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>{diskData().model || 'Unknown Disk'}</span>
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
<span
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
title="Data source"
>
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
</span>
<Show when={diskData().serial}>
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
</Show>
<div class={DISK_DETAIL_HEADER_STACK_CLASS}>
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>
{diskData().model || 'Unknown Disk'}
</span>
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
<span
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
title="Data source"
>
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
</span>
<Show when={diskData().serial}>
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
</Show>
</div>
{/* The verdict the row shows as a single word, with its reason in full. */}
<div class={DISK_DETAIL_HEALTH_ROW_CLASS} data-testid="disk-detail-health">
<span class={`${DISK_DETAIL_HEALTH_LABEL_CLASS} ${healthPresentation().tone}`}>
{healthPresentation().label}
</span>
<Show when={healthPresentation().summary}>
<span class={DISK_DETAIL_HEALTH_SUMMARY_CLASS}>{healthPresentation().summary}</span>
</Show>
</div>
</div>
</div>
@@ -55,7 +55,6 @@ import {
PHYSICAL_DISK_HEADER_SIZE_CLASS,
PHYSICAL_DISK_HEADER_TEMP_CLASS,
PHYSICAL_DISK_HEALTH_LABEL_CLASS,
PHYSICAL_DISK_HEALTH_SUMMARY_CLASS,
PHYSICAL_DISK_HEALTH_WRAP_CLASS,
PHYSICAL_DISK_DEVICE_TEXT_CLASS,
PHYSICAL_DISK_LIFE_CLASS,
@@ -72,8 +71,10 @@ import {
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS,
PHYSICAL_DISK_TABLE_ROW_STYLE,
getPhysicalDiskColumnWidthPercent,
getPhysicalDiskCellPaddingClass,
getPhysicalDiskColumnWidthStyle,
getPhysicalDiskEmptyStatePresentation,
getPhysicalDiskHealthCompactLabel,
getPhysicalDiskHealthStatus,
getPhysicalDiskHealthSummary,
getPhysicalDiskHostLabel,
@@ -117,11 +118,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
baseClass: string,
columnId: PhysicalDiskTableColumnId,
visibleClass: 'table-column' | 'table-cell',
) =>
`${baseClass} ${isPhysicalDiskColumnVisible(layoutMode(), columnId) ? visibleClass : 'hidden'}`.trim();
const columnStyle = (columnId: PhysicalDiskTableColumnId) => ({
width: `${getPhysicalDiskColumnWidthPercent(layoutMode(), columnId)}%`,
});
) => {
const visibility = isPhysicalDiskColumnVisible(layoutMode(), columnId)
? visibleClass
: 'hidden';
// Rendered cells and headers shed the shared primitives' desktop padding
// on the phone layouts; `<col>` elements carry no padding.
const padding =
visibleClass === 'table-cell' ? getPhysicalDiskCellPaddingClass(layoutMode()) : '';
return `${baseClass} ${padding} ${visibility}`.replace(/\s+/g, ' ').trim();
};
const columnStyle = (columnId: PhysicalDiskTableColumnId) =>
getPhysicalDiskColumnWidthStyle(layoutMode(), columnId);
const { getDiskTemperatureThresholds } = useAlertsActivation();
const model = useDiskListModel({
disks: () => props.disks,
@@ -327,6 +335,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status()));
const healthCompactLabel = createMemo(() =>
getPhysicalDiskHealthCompactLabel(status().label),
);
const healthLabelClass = () =>
`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`;
const isSelected = () => model.selectedDisk()?.id === disk.id;
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
const isSummaryHighlighted = () =>
@@ -446,15 +459,30 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="health"
>
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`}>
{status().label}
</span>
<Show when={healthSummary()}>
{/* The row shows the verdict word only; the reason is the
cell's title here and is spelled out in the disk drawer.
The phone projection of the table container shows the
shorter word; the tone stays on each span so the rendered
label is the styled element in either projection. */}
<Show
when={healthCompactLabel() !== status().label}
fallback={
<span class={healthLabelClass()} title={healthSummary() || undefined}>
{status().label}
</span>
}
>
<span
class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS}
title={healthSummary()}
class={`${healthLabelClass()} platform-table-label-compact`}
title={healthSummary() || undefined}
>
{healthSummary()}
{healthCompactLabel()}
</span>
<span
class={`${healthLabelClass()} platform-table-label-full`}
title={healthSummary() || undefined}
>
{status().label}
</span>
</Show>
</div>
@@ -25,6 +25,7 @@ import {
getStorageBarTooltipTitle,
getStorageBarZfsHeadingLabel,
} from '@/features/storageBackups/storageBarPresentation';
import { PlatformResponsiveTableLabel } from '@/features/platformPage/sharedPlatformPage';
import { useTooltip } from '@/hooks/useTooltip';
import { ProgressBar } from '@/components/shared/ProgressBar';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
@@ -42,13 +43,14 @@ interface EnhancedStorageBarProps {
export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
const tip = useTooltip();
const alertsActivation = useAlertsActivation();
const { usagePercent, barColor, label, tooltipRows, zfsSummary } = useEnhancedStorageBarModel({
used: () => props.used,
total: () => props.total,
free: () => props.free,
zfsPool: () => props.zfsPool,
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
});
const { usagePercent, barColor, label, compactLabel, tooltipRows, zfsSummary } =
useEnhancedStorageBarModel({
used: () => props.used,
total: () => props.total,
free: () => props.free,
zfsPool: () => props.zfsPool,
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
});
return (
<div class={STORAGE_BAR_ROOT_CLASS}>
@@ -71,7 +73,12 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
}
label={
<span class={STORAGE_BAR_LABEL_WRAP_CLASS}>
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>{label()}</span>
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>
{/* The phone projection of the table container keeps the
percentage only; the used/total sublabel returns with the
wider container and always lives in the tooltip. */}
<PlatformResponsiveTableLabel compact={compactLabel()} full={label()} />
</span>
</span>
}
/>
@@ -26,6 +26,7 @@ import {
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import type { Resource } from '@/types/resource';
import {
getStoragePoolCellPaddingClass,
isStoragePoolColumnVisible,
type StoragePoolTableColumnId,
type StoragePoolTableLayoutMode,
@@ -62,7 +63,11 @@ interface StoragePoolRowProps {
export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
const columnClass = (baseClass: string, columnId: StoragePoolTableColumnId) =>
`${baseClass} ${isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'}`.trim();
`${baseClass} ${getStoragePoolCellPaddingClass(props.layoutMode)} ${
isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'
}`
.replace(/\s+/g, ' ')
.trim();
const row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null));
const detailControlsId = createMemo(() =>
buildSummaryDisclosureControlsId(props.summarySeriesId),
@@ -9,7 +9,7 @@ import {
} from '@/components/shared/Table';
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
import {
getStoragePoolColumnWidthPercent,
getStoragePoolColumnWidthStyle,
getStoragePoolTableColumns,
getStoragePoolTableLayoutModeForContainer,
getStorageEmptyStateMessage,
@@ -106,9 +106,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
{(column) => (
<col
class={columnClass(column.colClassName, column.id, 'table-column')}
style={{
width: `${getStoragePoolColumnWidthPercent(layoutMode(), column.id)}%`,
}}
style={getStoragePoolColumnWidthStyle(layoutMode(), column.id)}
data-storage-column={column.id}
/>
)}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@solidjs/testing-library';
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
import { createSignal, type JSX } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { DiskDetail } from '@/components/Storage/DiskDetail';
@@ -135,6 +135,33 @@ describe('DiskDetail', () => {
expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument();
});
it('shows the health verdict and its reason in the drawer header', () => {
const disk = buildDisk();
disk.physicalDisk!.health = 'FAILED';
render(() => <DiskDetail disk={disk} nodes={[]} />);
const health = screen.getByTestId('disk-detail-health');
expect(within(health).getByText('Replace Now')).toHaveClass('text-red-700');
expect(within(health).getByText('Disk health has degraded to a critical state.')).toHaveClass(
'min-w-0',
'max-w-full',
'whitespace-normal',
'wrap-break-word',
);
});
it('keeps the drawer header to the verdict alone when the disk is healthy', () => {
const disk = buildDisk();
disk.physicalDisk!.health = 'PASSED';
render(() => <DiskDetail disk={disk} nodes={[]} />);
const health = screen.getByTestId('disk-detail-health');
expect(within(health).getByText('Healthy')).toBeInTheDocument();
expect(within(health).queryByText('No active disk-health issues.')).not.toBeInTheDocument();
});
it('shows an explicit overview fallback when no detail readings are available', () => {
const disk = buildDisk();
delete disk.physicalDisk!.smart;
@@ -134,12 +134,18 @@ describe('DiskList', () => {
'archive',
'Cache Pool',
'Archive Pool',
'SMART failed.',
'4%',
'63°C',
]) {
expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument();
}
// The reason is not a second truncating string in the 32px row: it rides
// on the verdict as its title and is spelled out in the disk drawer.
expect(within(row as HTMLElement).queryByText('SMART failed.')).not.toBeInTheDocument();
expect(within(row as HTMLElement).getByText('Replace Now')).toHaveAttribute(
'title',
'SMART failed.',
);
expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument();
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
expect(screen.queryByText('96%')).not.toBeInTheDocument();
@@ -275,7 +281,13 @@ describe('DiskList', () => {
expect(screen.getByText('Parity')).toBeInTheDocument();
expect(screen.getByText('Tower Array')).toBeInTheDocument();
expect(screen.getByText('Needs Attention')).toBeInTheDocument();
expect(screen.getByText('Pending sectors detected.')).toBeInTheDocument();
// The reason stays one hover away on the verdict rather than as a second
// truncating string in the row; the drawer spells it out.
expect(screen.queryByText('Pending sectors detected.')).not.toBeInTheDocument();
expect(screen.getByText('Needs Attention')).toHaveAttribute(
'title',
'Pending sectors detected.',
);
});
it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => {
@@ -1154,10 +1154,11 @@ describe('Storage', () => {
render(() => <Storage />);
// zpool's upper-case state is presented titleized, like every other state.
await waitFor(() => {
expect(screen.getAllByText('DEGRADED').length).toBeGreaterThan(0);
expect(screen.getAllByText('Degraded').length).toBeGreaterThan(0);
});
expect(screen.getAllByText('DEGRADED')[0]).toHaveAttribute(
expect(screen.getAllByText('Degraded')[0]).toHaveAttribute(
'title',
'1 read, 2 checksum errors',
);
@@ -28,6 +28,7 @@ describe('useEnhancedStorageBarModel', () => {
expect(result.usagePercent()).toBe(40);
expect(result.label()).toBe('40% (400 B/1000 B)');
expect(result.compactLabel()).toBe('40%');
expect(result.tooltipRows()).toEqual([
{ label: 'Used', value: '400 B' },
{ label: 'Free', value: '600 B' },
@@ -7,6 +7,7 @@ import {
} from '@/features/storageBackups/diskPresentation';
import {
getDiskDetailAttributeCards,
getDiskDetailHealthPresentation,
getDiskDetailHistoryCharts,
} from '@/features/storageBackups/diskDetailPresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
@@ -29,6 +30,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
);
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData()));
const metricResourceId = createMemo(() => historyResourceId());
const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData()));
const liveIOAvailable = createMemo(() => {
@@ -43,6 +45,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
historyResourceId,
attributeCards,
historyCharts,
healthPresentation,
metricResourceId,
collectionMessages,
liveIOAvailable,
@@ -3,6 +3,7 @@ import type { ZFSPool } from '@/types/api';
import { getMetricColorClass } from '@/utils/metricThresholds';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import {
getStorageBarCompactLabel,
getStorageBarLabel,
getStorageBarTooltipRows,
getStorageBarUsagePercent,
@@ -23,6 +24,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()),
);
const label = createMemo(() => getStorageBarLabel(options.used(), options.total()));
const compactLabel = createMemo(() => getStorageBarCompactLabel(options.used(), options.total()));
const tooltipRows = createMemo(() =>
getStorageBarTooltipRows(options.used(), options.free(), options.total()),
);
@@ -32,6 +34,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
usagePercent,
barColor,
label,
compactLabel,
tooltipRows,
zfsSummary,
};
@@ -15,6 +15,9 @@ export function DiskList(props: DiskListProps) {
}
>
<div class="flex flex-col gap-1.5">
<Show when={props.diskStatusReason}>
<p class="text-xs text-muted">{diskStatusTooltip()}</p>
</Show>
<For each={diskPresentation()}>
{(disk) => {
return (
@@ -13,7 +13,10 @@ import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/Availa
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
import { formatBytes } from '@/utils/format';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { getWorkloadsGuestProtectionPresentation } from '@/utils/workloadGuestPresentation';
import {
getWorkloadGuestDiskStatusMessage,
getWorkloadsGuestProtectionPresentation,
} from '@/utils/workloadGuestPresentation';
import { AvailabilityProbeSuggestionCard } from './AvailabilityProbeSuggestionCard';
import { buildWorkloadsDiskPresentation } from './diskListModel';
@@ -119,6 +122,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
const diskRows = (props.guest.disks ?? []).map((disk, index) =>
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds),
);
const diskStatusMessage =
isGuestDrawerVM(props.guest) && props.guest.diskStatusReason
? getWorkloadGuestDiskStatusMessage(props.guest.diskStatusReason)
: null;
return compactDetailSections([
discovery
@@ -187,7 +194,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
.map((row) => makeDetailRow(row.label, row.value)),
),
},
props.hasFilesystemDetails && diskRows.length > 0
(props.hasFilesystemDetails && diskRows.length > 0) || diskStatusMessage
? {
label: 'Filesystems',
rows: compactDetailRows([
@@ -212,11 +219,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
},
),
),
makeDetailRow(
'Status',
isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : null,
{ wrap: true },
),
makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true }),
]),
}
: null,
@@ -553,7 +553,13 @@ export function GuestRow(props: GuestRowProps) {
{/* Disk */}
<Show when={isColVisible('disk')}>
<td class="px-1.5 sm:px-2 py-0.5 align-middle" data-workload-col="disk">
<td
class="px-1.5 sm:px-2 py-0.5 align-middle"
data-workload-col="disk"
title={
isVM(props.guest) && props.guest.diskStatusReason ? getDiskStatusTooltip() : undefined
}
>
<Show when={isSparklineMode()}>
{renderMetricSparkline(
'disk',
@@ -585,6 +591,11 @@ export function GuestRow(props: GuestRowProps) {
aggregateDisk={props.guest.disk}
anomaly={diskAnomaly()}
thresholds={diskThresholds()}
statusMessage={
isVM(props.guest) && props.guest.diskStatusReason
? getDiskStatusTooltip()
: undefined
}
/>
}
>
@@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
</Show>
}
>
{/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold */}
{/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold.
The fullest disk's percentage sits beside them so the cell still reads
as a number at a glance; every bar keeps equal weight. */}
<div
data-stacked-disk-trigger
class="h-full w-full"
onMouseEnter={state.handleMouseEnter}
onMouseLeave={state.handleMouseLeave}
>
<div class="flex h-full items-end justify-center gap-[3px]">
<For each={presentation().verticalBars}>
{(bar) => (
<div
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08]"
title={bar.title}
>
<div class="flex h-full items-center justify-center gap-1.5">
<Show when={presentation().verticalBarsLabel}>
<span
data-stacked-disk-max-label
class="shrink-0 text-[10px] font-semibold leading-none tabular-nums text-base-content"
title={presentation().verticalBarsLabelTitle}
>
{presentation().verticalBarsLabel}
</span>
</Show>
<div class="flex h-full items-end justify-center gap-[3px]">
<For each={presentation().verticalBars}>
{(bar) => (
<div
data-stacked-disk-fill="vertical"
class="absolute inset-0 rounded-xs brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08]"
title={bar.title}
>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
<div
data-stacked-disk-fill="vertical"
class="absolute inset-0 rounded-xs brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
>
<rect
class="metric-fill-geometry"
x="0"
y={invertPercent(bar.fillPercent)}
width="100"
height={clampPercent(bar.fillPercent)}
rx="2"
fill={bar.color}
/>
</svg>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
class="metric-fill-geometry"
x="0"
y={invertPercent(bar.fillPercent)}
width="100"
height={clampPercent(bar.fillPercent)}
rx="2"
fill={bar.color}
/>
</svg>
</div>
</div>
</div>
)}
</For>
)}
</For>
</div>
</div>
</div>
</Show>
@@ -242,6 +255,9 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
<div class="font-medium mb-1 text-slate-300 border-b border-border pb-1">
{presentation().tooltipTitle}
</div>
<Show when={props.statusMessage}>
<p class="mb-1 text-xs text-muted">{props.statusMessage}</p>
</Show>
<For each={presentation().tooltipContent}>
{(item, idx) => (
<div
@@ -0,0 +1,32 @@
// Fixed client/builder reason vocabulary, not a diagnosis of a native QGA fault.
export const guestDiskDeferrals = [
[
'vm-locked',
'Guest reads paused while Proxmox reports a VM operation lock, such as a backup. Pulse will check again on a later poll.',
],
[
'lock-unverified',
'Guest reads deferred because Pulse cannot verify that the VM is unlocked. Pulse will check again on a later poll.',
],
[
'agent-busy',
'Guest reads deferred while an earlier guest request is still in progress. Pulse will check again on a later poll.',
],
[
'agent-cooldown',
'Guest reads paused after an earlier request did not complete reliably. Pulse will check again after the cooldown.',
],
[
'agent-response-incomplete',
'Guest reads paused because the previous response was incomplete. Pulse will check again on a later poll.',
],
[
'agent-capacity',
'Guest reads deferred because Pulse has reached its guest-read capacity. Pulse will check again on a later poll.',
],
['invalid-guest-key', 'Guest reads unavailable because the VM identity is invalid.'],
[
'agent-timeout',
'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.',
],
] as const;
@@ -3,6 +3,8 @@ import { render, screen, cleanup } from '@solidjs/testing-library';
import { DiskList } from '../DiskList';
import { buildWorkloadsDiskPresentation, getWorkloadsDiskUsagePercent } from '../diskListModel';
import type { Disk } from '@/types/api';
import { createSignal } from 'solid-js';
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
function makeDisk(overrides: Partial<Disk> = {}): Disk {
return {
@@ -46,7 +48,10 @@ describe('DiskList', () => {
'agent-not-running',
'Guest agent not running. Install and start qemu-guest-agent in the VM.',
],
['agent-timeout', 'Guest agent timeout. Agent may need to be restarted.'],
[
'agent-timeout',
'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.',
],
[
'permission-denied',
'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).',
@@ -59,6 +64,11 @@ describe('DiskList', () => {
],
['agent-error', 'Error communicating with guest agent.'],
['no-data', 'No disk data available from Proxmox API.'],
['vm-stopped', 'Guest filesystem stats unavailable while the VM is stopped.'],
[
'no-status',
'Guest filesystem stats unavailable because Pulse could not read the VM status from Proxmox.',
],
[
'prev-no-filesystems',
'Using last known disk stats. No filesystems found. VM may be booting or using a Live ISO.',
@@ -78,6 +88,30 @@ describe('DiskList', () => {
});
describe('rendering disks', () => {
it.each(guestDiskDeferrals)(
'keeps retained values and explains %s until fresh data resumes',
(reason, message) => {
const [disks, setDisks] = createSignal([makeDisk()]);
const [status, setStatus] = createSignal<string | undefined>(`prev-${reason}`);
render(() => <DiskList disks={disks()} diskStatusReason={status()} />);
expect(screen.getByText(`Using last known disk stats. ${message}`)).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
setDisks([makeDisk({ used: 80530636800, usage: 75 })]);
setStatus(undefined);
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
expect(screen.getByText('75%')).toBeInTheDocument();
},
);
it.each(guestDiskDeferrals)(
'explains %s without claiming previous values when none exist',
(reason, message) => {
render(() => <DiskList disks={[]} diskStatusReason={reason} />);
expect(screen.getByText('-')).toHaveAttribute('title', message);
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
},
);
it('renders a single disk with correct label, usage, and type', () => {
const disk = makeDisk({ mountpoint: '/data', type: 'xfs' });
render(() => <DiskList disks={[disk]} />);
@@ -1,7 +1,8 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import type { ComponentProps } from 'solid-js';
import { createSignal, type ComponentProps } from 'solid-js';
import { afterEach, describe, expect, it } from 'vitest';
import { GuestDrawerOverview } from '../GuestDrawerOverview';
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
const paths = ['/mnt/Plex/Media/Animation/Films', '/mnt/Plex/Media/Animation/Series'];
const props: ComponentProps<typeof GuestDrawerOverview> = {
@@ -55,6 +56,47 @@ const props: ComponentProps<typeof GuestDrawerOverview> = {
describe('GuestDrawerOverview filesystem labels', () => {
afterEach(cleanup);
it.each(guestDiskDeferrals)(
'explains %s in the existing drawer even before any disk sample',
(reason, message) => {
render(() => (
<GuestDrawerOverview
{...props}
hasFilesystemDetails={false}
guest={{ ...props.guest, type: 'qemu', disks: [], diskStatusReason: reason }}
/>
));
expect(screen.getByText('Filesystems')).toBeInTheDocument();
expect(screen.getByText(message)).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
},
);
it('retains disk rows with readable status, then withdraws the notice on fresh resumption', () => {
const [guest, setGuest] = createSignal({
...props.guest,
type: 'qemu' as const,
diskStatusReason: 'prev-vm-locked',
});
render(() => <GuestDrawerOverview {...props} guest={guest()} />);
expect(
screen.getByText(/^Using last known disk stats\. Guest reads paused/).closest('td'),
).toHaveAttribute('colspan', '2');
expect(screen.queryByText('prev-vm-locked')).not.toBeInTheDocument();
for (const path of paths) expect(screen.getByText(path)).toBeInTheDocument();
setGuest({
...guest(),
diskStatusReason: '',
disks: guest().disks!.map((disk) => ({ ...disk, used: (disk.total ?? 0) * 0.75, usage: 75 })),
});
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
expect(screen.queryByText('Status')).not.toBeInTheDocument();
expect(
screen.getByRole('progressbar', { name: `Filesystem ${paths[0]} utilization` }),
).toHaveAttribute('aria-valuenow', '75');
});
it('shows linked-agent RAID arrays beside the guest storage evidence', () => {
render(() => (
<GuestDrawerOverview
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, cleanup } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import type { WorkloadGuest } from '@/types/workloads';
import type { Memory, Disk } from '@/types/api';
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
// ── Hoisted mocks ──────────────────────────────────────────────────────
@@ -75,7 +76,9 @@ vi.mock('@/components/Workloads/EnhancedCPUBar', () => ({
}));
vi.mock('../StackedDiskBar', () => ({
StackedDiskBar: () => <div data-testid="disk-bar" />,
StackedDiskBar: (props: { statusMessage?: string }) => (
<div data-testid="disk-bar" data-status-message={props.statusMessage} />
),
}));
vi.mock('../StackedMemoryBar', () => ({
@@ -668,6 +671,39 @@ describe('GuestRow', () => {
});
describe('lock label', () => {
it.each(guestDiskDeferrals)(
'annotates retained row and disk tooltip for %s',
(reason, message) => {
const { container } = renderGuestRow({
guest: makeGuest({ diskStatusReason: `prev-${reason}` }),
});
const expected = `Using last known disk stats. ${message}`;
expect(container.querySelector('[data-workload-col="disk"]')).toHaveAttribute(
'title',
expected,
);
expect(screen.getByTestId('disk-bar')).toHaveAttribute('data-status-message', expected);
},
);
it('clears retained status for a same-VM fresh update in sparkline mode without remounting', () => {
const [guest, setGuest] = createSignal(
makeGuest({ diskStatusReason: 'prev-agent-cooldown' }),
);
const { container } = render(() => (
<table>
<tbody>
<GuestRow guest={guest()} metricDisplayMode="sparklines" />
</tbody>
</table>
));
const cell = container.querySelector('[data-workload-col="disk"]');
expect(cell).toHaveAttribute('title', expect.stringContaining('Using last known'));
setGuest(makeGuest({ diskStatusReason: undefined, disk: makeDisk({ usage: 75 }) }));
expect(container.querySelector('[data-workload-col="disk"]')).toBe(cell);
expect(cell).not.toHaveAttribute('title');
});
it('shows lock label when guest is locked', () => {
renderGuestRow({ guest: makeGuest({ lock: 'migrate' }) });
expect(screen.getByText(/Lock:.*migrate/)).toBeTruthy();
@@ -1308,23 +1344,26 @@ describe('GUEST_COLUMNS', () => {
});
it('derives mobile overrides from the canonical guest column model', () => {
// The phone name cell carries the toggle, status dot, and backup badge, so
// it takes a wider anchor than the shared 30% and the metric chips share
// the rest.
expect(getGuestColumnStyle('name', true)).toEqual({
width: '30%',
'max-width': '30%',
width: '38%',
'max-width': '38%',
});
expect(getGuestColumnStyle('cpu', true)).toEqual({
width: '11.3235%',
'max-width': '11.3235%',
width: '10.0294%',
'max-width': '10.0294%',
});
expect(getGuestColumnStyle('availability', true)).toEqual({
width: '7.2059%',
'max-width': '7.2059%',
width: '6.3824%',
'max-width': '6.3824%',
});
expect(getGuestColumnStyle('type', true)).toEqual({
width: '9.2647%',
'max-width': '9.2647%',
width: '8.2059%',
'max-width': '8.2059%',
});
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' });
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' });
expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' });
});
@@ -343,6 +343,17 @@ describe('StackedDiskBar', () => {
// ── Tooltip ──────────────────────────────────────────────────────────────
describe('tooltip', () => {
it('includes caller-owned freshness context beside unchanged disk evidence', async () => {
const message = 'Using last known disk stats. Guest reads paused while a backup is running.';
const { container } = render(() => (
<StackedDiskBar disks={[makeDisk()]} statusMessage={message} />
));
await fireEvent.mouseEnter(getBarTrigger(container));
expect(screen.getByText(message)).toBeInTheDocument();
expect(screen.getByText('/')).toBeInTheDocument();
expect(screen.getByText('Disk Usage')).toBeInTheDocument();
});
it('shows tooltip on mouse enter with disk details', async () => {
const disk = makeDisk({
used: 53687091200,
@@ -587,4 +598,36 @@ describe('StackedDiskBar', () => {
expect(bar?.getAttribute('fill')).toContain('239, 68, 68');
});
});
describe('vertical-bars mode', () => {
it('labels the micro-bars with the fullest disk so the cell reads as a number', () => {
const { container } = render(() => (
<StackedDiskBar
mode="vertical-bars"
disks={[
makeDisk({ mountpoint: '/', usage: 24, used: 24, total: 100 }),
makeDisk({ mountpoint: '/data', usage: 52, used: 52, total: 100 }),
]}
/>
));
const label = container.querySelector('[data-stacked-disk-max-label]');
expect(label).toHaveTextContent('52%');
expect(label).toHaveAttribute('title', 'Highest usage: /data 52%');
// Every disk keeps its own bar with equal weight; the label is derived.
expect(container.querySelectorAll('[data-stacked-disk-fill="vertical"]')).toHaveLength(2);
});
it('omits the label when no disk has measured usage', () => {
const { container } = render(() => (
<StackedDiskBar
mode="vertical-bars"
disks={[makeDisk({ mountpoint: '/', usage: -1 })]}
aggregateDisk={makeDisk({ usage: 30 })}
/>
));
expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull();
});
});
});
@@ -1661,7 +1661,11 @@ describe('Workloads performance contract', () => {
);
expect(filesystemSectionSource).toContain("layout: 'stacked'");
expect(filesystemSectionSource).toContain('wrap: true');
expect(guestDrawerOverviewSource.match(/layout: 'stacked'/g)).toHaveLength(1);
// Both filesystem paths and their longer freshness explanation need
// the card's full width. Do not couple that layout to a global count.
expect(guestDrawerOverviewSource).toContain(
"makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true })",
);
expect(guestDrawerOverviewSource).toContain('TechnicalDetailsSection');
expect(nodeDrawerOverviewSource).toContain('TechnicalDetailsSection');
@@ -372,6 +372,10 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
title: '/x: 40% (40.0 B/100 B)',
});
expect(p.verticalBars[1].color).toBe(CRITICAL); // 120% -> critical
// The fullest disk's usage labels the cell so a multi-disk host still
// reads as a number without a hover.
expect(p.verticalBarsLabel).toBe('120%');
expect(p.verticalBarsLabelTitle).toBe('Highest usage: /y 120%');
expect(p.tooltipContent).toHaveLength(3);
expect(p.tooltipContent[2]).toMatchObject({ label: '/z', percent: '—', used: '?' });
});
@@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({
maxWidth: width,
});
// The phone name cell carries the expansion toggle, status dot, and backup
// badge before the name, so the shared 30% identity anchor left a 390px phone
// 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". The
// four metric chips beside it need far less than an even split, so the name
// takes the same wider phone anchor the Proxmox host table uses.
const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
// Responsive weights are normalized against the currently visible column set.
// That keeps each workload view mode full-width without assuming one fixed set.
const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
@@ -548,9 +555,11 @@ const getResponsiveColumnOverride = (
activeIds,
layoutMode === 'narrow'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
: layoutMode === 'phone'
? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'mobile'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
);
return typeof style.width === 'string' ? percentageColumn(style.width) : undefined;
};
@@ -18,6 +18,7 @@ export interface StackedDiskBarProps {
showDiskCount?: boolean;
anomaly?: AnomalyReport | null;
thresholds?: MetricDisplayThresholds | null;
statusMessage?: string;
}
export interface StackedDiskSegment {
@@ -87,6 +88,10 @@ export interface StackedDiskBarPresentation {
tooltipTitle: string;
useStackedSegments: boolean;
verticalBars: StackedDiskVerticalBar[];
// The fullest disk's usage, shown beside the micro-bars so a multi-disk
// host answers "how close is this host to running out?" without a hover.
verticalBarsLabel: string;
verticalBarsLabelTitle: string;
verticalBarsMode: boolean;
}
@@ -359,6 +364,8 @@ export function buildStackedDiskBarPresentation(
};
})
: [];
const verticalBarsLabel = verticalBarsMode && maxInfo ? formatPercent(maxInfo.percent) : '';
const verticalBarsLabelTitle = verticalBarsMode && maxInfo ? maxLabelFull : '';
const tooltipContent = buildTooltipContent(allDisks, {
aggregateDisk: props.aggregateDisk,
aggregateMode,
@@ -402,6 +409,8 @@ export function buildStackedDiskBarPresentation(
tooltipTitle: allDisks.length > 1 ? 'Disk Breakdown' : 'Disk Usage',
useStackedSegments,
verticalBars,
verticalBarsLabel,
verticalBarsLabelTitle,
verticalBarsMode,
};
}
@@ -273,7 +273,7 @@ describe('AlertDeliveryLogCard', () => {
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
it('hides held rows during an outage and restores their current state on recovery', () => {
it('hides held rows when their own read fails and restores them on recovery', () => {
const [unavailable, setUnavailable] = createSignal(false);
const [heldEvents, setHeldEvents] = createSignal<AlertEvent[]>([
{
@@ -290,7 +290,8 @@ describe('AlertDeliveryLogCard', () => {
render(() => (
<AlertDeliveryLogCard
log={{ ...log, entries: [] }}
unavailable={unavailable()}
unavailable={false}
heldEventsUnavailable={unavailable()}
refreshing={false}
onRefresh={vi.fn()}
webhooks={[]}
@@ -300,7 +301,9 @@ describe('AlertDeliveryLogCard', () => {
expect(screen.getByText('Deferred')).toBeInTheDocument();
setUnavailable(true);
expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/);
expect(screen.getByRole('alert')).toHaveTextContent(
/could not read held or deferred notifications/,
);
expect(screen.queryByRole('list')).not.toBeInTheDocument();
expect(screen.queryByText('nas-quiet (usage)')).not.toBeInTheDocument();
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
@@ -332,4 +335,106 @@ describe('AlertDeliveryLogCard', () => {
expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/);
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
});
it('keeps independently readable held evidence when delivery attempts are unavailable', () => {
render(() => (
<AlertDeliveryLogCard
log={log}
unavailable={true}
refreshing={false}
heldEvents={[
{
id: 7,
type: 'notification_deferred',
alertId: 'held-known',
occurredAt: '2026-10-03T10:00:00Z',
reason: 'quiet_hours:performance',
},
]}
onRefresh={vi.fn()}
webhooks={webhooks}
/>
));
expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/);
expect(screen.getByText('held-known')).toBeInTheDocument();
expect(screen.getByText('Quiet hours')).toBeInTheDocument();
expect(screen.queryByText('Delivered')).not.toBeInTheDocument();
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
});
it('reports missing held evidence without hiding readable attempts', () => {
render(() => (
<AlertDeliveryLogCard
log={log}
unavailable={false}
refreshing={false}
heldEventsUnavailable={true}
heldEvents={[
{
id: 8,
type: 'notification_suppressed',
alertId: 'stale-held',
occurredAt: '2026-10-03T10:00:00Z',
},
]}
onRefresh={vi.fn()}
webhooks={webhooks}
/>
));
expect(screen.getByRole('alert')).toHaveTextContent(
/could not read held or deferred notifications/,
);
expect(screen.getByText('Delivered')).toBeInTheDocument();
expect(screen.getByText('Ops Discord')).toBeInTheDocument();
expect(screen.queryByText('stale-held')).not.toBeInTheDocument();
});
it('does not claim an empty activity window while held evidence is pending', () => {
render(() => (
<AlertDeliveryLogCard
log={{ ...log, entries: [] }}
unavailable={false}
refreshing={false}
refreshingHeldEvents={true}
onRefresh={vi.fn()}
webhooks={[]}
/>
));
expect(screen.getByRole('status')).toHaveTextContent(
'Loading held and deferred notifications...',
);
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
// A slow held read must not prevent a new delivery-attempt refresh.
expect(screen.getByRole('button', { name: 'Refresh delivery status' })).toBeEnabled();
});
it('does not claim an empty activity window when held evidence is unavailable', () => {
render(() => (
<AlertDeliveryLogCard
log={{ ...log, entries: [] }}
unavailable={false}
refreshing={false}
heldEventsUnavailable={true}
onRefresh={vi.fn()}
webhooks={[]}
/>
));
expect(screen.getByRole('alert')).toHaveTextContent(
/could not read held or deferred notifications/,
);
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
});
it('labels the initial attempt read as loading rather than empty', () => {
render(() => (
<AlertDeliveryLogCard
log={null}
unavailable={false}
refreshing={true}
onRefresh={vi.fn()}
webhooks={[]}
/>
));
expect(screen.getByRole('status')).toHaveTextContent('Loading delivery attempts...');
expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
});
});
@@ -15,6 +15,9 @@ import {
getAlertDestinationsDeliveryLogEmpty,
getAlertDestinationsDeliveryLogTitle,
getAlertDestinationsDeliveryLogUnavailable,
getAlertDestinationsDeliveryLogLoading,
getAlertDestinationsHeldEventsLoading,
getAlertDestinationsHeldEventsUnavailable,
getAlertDestinationsDeliveryRefreshLabel,
} from '@/utils/alertDestinationsPresentation';
import { formatRelativeTime } from '@/utils/format';
@@ -28,6 +31,8 @@ interface AlertDeliveryLogCardProps {
onRefresh: () => void;
webhooks: Webhook[];
heldEvents?: AlertEvent[];
heldEventsUnavailable?: boolean;
refreshingHeldEvents?: boolean;
}
type DeliveryLogRow =
@@ -99,10 +104,18 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) {
return rest.length > 0 ? `${first} +${rest.length} more` : first;
};
const entries = () => props.log?.entries ?? [];
const entries = () => (props.unavailable ? [] : (props.log?.entries ?? []));
const completedRetentionDays = () => props.log?.completedRetentionDays ?? 7;
const deadLetterRetentionDays = () => props.log?.deadLetterRetentionDays ?? 30;
const rows = () => mergeDeliveryLogRows(entries(), props.heldEvents ?? []);
const rows = () =>
mergeDeliveryLogRows(entries(), props.heldEventsUnavailable ? [] : (props.heldEvents ?? []));
const attemptsLoading = () => props.refreshing || (!props.log && !props.unavailable);
const confirmedEmpty = () =>
props.log !== null &&
!props.unavailable &&
!attemptsLoading() &&
!props.heldEventsUnavailable &&
!props.refreshingHeldEvents;
const absoluteTimestamp = (value: string): string => {
const parsed = new Date(value);
@@ -149,100 +162,113 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) {
</button>
</div>
<Show when={props.unavailable}>
<p class="text-sm text-red-800 dark:text-red-300" role="alert">
{getAlertDestinationsDeliveryLogUnavailable()}
</p>
</Show>
<Show when={props.heldEventsUnavailable}>
<p class="text-sm text-red-800 dark:text-red-300" role="alert">
{getAlertDestinationsHeldEventsUnavailable()}
</p>
</Show>
<Show when={attemptsLoading() && entries().length === 0}>
<p class="text-sm text-gray-600 dark:text-gray-400" role="status">
{getAlertDestinationsDeliveryLogLoading()}
</p>
</Show>
<Show when={props.refreshingHeldEvents}>
<p class="text-sm text-gray-600 dark:text-gray-400" role="status">
{getAlertDestinationsHeldEventsLoading()}
</p>
</Show>
<Show
when={!props.unavailable}
when={rows().length > 0}
fallback={
<p class="text-sm text-red-800 dark:text-red-300" role="alert">
{getAlertDestinationsDeliveryLogUnavailable()}
</p>
}
>
<Show
when={rows().length > 0}
fallback={
<Show when={confirmedEmpty()}>
<p class="text-sm text-gray-600 dark:text-gray-400">
{getAlertDestinationsDeliveryLogEmpty()}
</p>
}
>
<ul class="max-h-80 divide-y divide-gray-200 overflow-y-auto dark:divide-gray-700">
<For each={rows()}>
{(row) =>
row.kind === 'attempt' ? (
<li class="flex flex-col gap-1 py-2">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${outcomeBadgeClasses[row.entry.outcome]}`}
>
{getAlertDeliveryLogOutcomeLabel(row.entry.outcome)}
</span>
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{destinationLabel(row.entry)}
</span>
<span
class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400"
title={row.entry.alertIds.join(', ')}
>
{alertSummary(row.entry)}
</span>
<time
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
dateTime={row.entry.timestamp}
title={formatRelativeTime(row.entry.timestamp)}
>
{absoluteTimestamp(row.entry.timestamp)}
</time>
</div>
<Show
when={
!row.entry.success && (row.entry.failureClass || row.entry.errorMessage)
}
</Show>
}
>
<ul class="max-h-80 divide-y divide-gray-200 overflow-y-auto dark:divide-gray-700">
<For each={rows()}>
{(row) =>
row.kind === 'attempt' ? (
<li class="flex flex-col gap-1 py-2">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${outcomeBadgeClasses[row.entry.outcome]}`}
>
<p class="text-xs leading-5 text-red-700 dark:text-red-300">
<Show when={row.entry.failureClass}>
{(failureClass) => (
<span class="font-medium">
{getAlertDeliveryLogFailureClassLabel(failureClass())}
{row.entry.errorMessage ? ': ' : ''}
</span>
)}
</Show>
{row.entry.errorMessage}
</p>
</Show>
</li>
) : (
<li class="flex flex-col gap-1 py-2" title={row.event.message}>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${
row.event.type === 'notification_deferred'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300'
}`}
>
{row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'}
</span>
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{heldResourceLabel(row.event)}
</span>
<span class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400">
{describeAlertEventReason(row.event.reason)}
</span>
<time
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
dateTime={row.event.occurredAt}
title={formatRelativeTime(row.event.occurredAt)}
>
{absoluteTimestamp(row.event.occurredAt)}
</time>
</div>
</li>
)
}
</For>
</ul>
</Show>
{getAlertDeliveryLogOutcomeLabel(row.entry.outcome)}
</span>
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{destinationLabel(row.entry)}
</span>
<span
class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400"
title={row.entry.alertIds.join(', ')}
>
{alertSummary(row.entry)}
</span>
<time
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
dateTime={row.entry.timestamp}
title={formatRelativeTime(row.entry.timestamp)}
>
{absoluteTimestamp(row.entry.timestamp)}
</time>
</div>
<Show
when={
!row.entry.success && (row.entry.failureClass || row.entry.errorMessage)
}
>
<p class="text-xs leading-5 text-red-700 dark:text-red-300">
<Show when={row.entry.failureClass}>
{(failureClass) => (
<span class="font-medium">
{getAlertDeliveryLogFailureClassLabel(failureClass())}
{row.entry.errorMessage ? ': ' : ''}
</span>
)}
</Show>
{row.entry.errorMessage}
</p>
</Show>
</li>
) : (
<li class="flex flex-col gap-1 py-2" title={row.event.message}>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${
row.event.type === 'notification_deferred'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300'
}`}
>
{row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'}
</span>
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{heldResourceLabel(row.event)}
</span>
<span class="min-w-0 truncate text-sm text-gray-600 dark:text-gray-400">
{describeAlertEventReason(row.event.reason)}
</span>
<time
class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400"
dateTime={row.event.occurredAt}
title={formatRelativeTime(row.event.occurredAt)}
>
{absoluteTimestamp(row.event.occurredAt)}
</time>
</div>
</li>
)
}
</For>
</ul>
</Show>
</div>
</Card>
@@ -259,7 +259,7 @@ describe('DestinationsTab recovery while editing SMTP', () => {
).toBeInTheDocument();
expect(
await screen.findByText(
'Pulse could not read the delivery log, so recent delivery activity cannot be shown.',
'Pulse could not read the delivery log. Refresh to check delivery attempts.',
),
).toBeInTheDocument();
expect(screen.queryByText('SMTP fixture rejected')).not.toBeInTheDocument();
@@ -156,6 +156,17 @@ describe('useAlertDestinationsTabState', () => {
await Promise.resolve();
expect(result.deliveryLog()).toEqual(current);
expect(result.deliveryLogUnavailable()).toBe(false);
expect(result.heldEventsUnavailable()).toBe(false);
expect(result.refreshingHeldEvents()).toBe(false);
vi.mocked(AlertsAPI.getEvents).mockRejectedValueOnce(new Error('held events denied'));
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValueOnce(current);
await result.loadDeliveryLog();
expect(result.heldEvents()).toEqual([]);
expect(result.heldEventsUnavailable()).toBe(true);
expect(result.refreshingHeldEvents()).toBe(false);
expect(result.deliveryLogUnavailable()).toBe(false);
expect(result.deliveryLog()).toEqual(current);
expect(result.refreshingDeliveryLog()).toBe(false);
} finally {
cleanup();
@@ -107,6 +107,8 @@ describe('useNotificationDeliveryLog', () => {
expect(state.deliveryLog()).toEqual(emptyLog);
expect(state.refreshingDeliveryLog()).toBe(false);
expect(state.heldEvents()).toEqual([]);
expect(state.refreshingHeldEvents()).toBe(true);
expect(state.heldEventsUnavailable()).toBe(false);
} finally {
held.resolve([]);
dispose();
@@ -132,6 +134,8 @@ describe('useNotificationDeliveryLog', () => {
expect(state.heldEvents()).toHaveLength(1);
await state.loadDeliveryLog();
expect(state.heldEvents()).toEqual([]);
expect(state.heldEventsUnavailable()).toBe(true);
expect(state.refreshingHeldEvents()).toBe(false);
expect(state.deliveryLog()).toEqual(emptyLog);
expect(state.deliveryLogUnavailable()).toBe(false);
expect(state.refreshingDeliveryLog()).toBe(false);
@@ -222,6 +226,8 @@ describe('useNotificationDeliveryLog', () => {
else older.reject(new Error('stale held read'));
await Promise.resolve();
expect(state.heldEvents()).toEqual(current);
expect(state.heldEventsUnavailable()).toBe(false);
expect(state.refreshingHeldEvents()).toBe(false);
} finally {
dispose();
}
@@ -264,4 +270,56 @@ describe('useNotificationDeliveryLog', () => {
expect(AlertsAPI.getEvents).toHaveBeenCalledTimes(1);
}),
);
it('retains the held-read warning during retry until that read succeeds', () =>
createRoot(async (dispose) => {
const recovery = deferred<Awaited<ReturnType<typeof AlertsAPI.getEvents>>>();
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog);
vi.mocked(AlertsAPI.getEvents)
.mockRejectedValueOnce(new Error('permission denied'))
.mockReturnValueOnce(recovery.promise);
const state = useNotificationDeliveryLog();
try {
await state.loadDeliveryLog();
expect(state.heldEventsUnavailable()).toBe(true);
await state.loadDeliveryLog();
expect(state.heldEventsUnavailable()).toBe(true);
expect(state.refreshingHeldEvents()).toBe(true);
expect(state.refreshingDeliveryLog()).toBe(false);
recovery.resolve([]);
await recovery.promise;
expect(state.heldEventsUnavailable()).toBe(false);
expect(state.refreshingHeldEvents()).toBe(false);
} finally {
dispose();
}
}));
it.each(['success', 'failure'])(
'keeps the newest held read pending after an older %s',
(outcome) =>
createRoot(async (dispose) => {
const older = deferred<Awaited<ReturnType<typeof AlertsAPI.getEvents>>>();
const newer = deferred<Awaited<ReturnType<typeof AlertsAPI.getEvents>>>();
vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog);
vi.mocked(AlertsAPI.getEvents)
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const state = useNotificationDeliveryLog();
try {
await state.loadDeliveryLog();
await state.loadDeliveryLog();
if (outcome === 'success') older.resolve([]);
else older.reject(new Error('stale denied request'));
await Promise.resolve();
expect(state.refreshingHeldEvents()).toBe(true);
expect(state.heldEventsUnavailable()).toBe(false);
newer.resolve([]);
await newer.promise;
expect(state.refreshingHeldEvents()).toBe(false);
expect(state.heldEventsUnavailable()).toBe(false);
} finally {
dispose();
}
}),
);
});
@@ -102,6 +102,8 @@ export function DestinationsTab(props: DestinationsTabProps) {
onRefresh={() => void state.loadDeliveryLog()}
webhooks={state.webhooks()}
heldEvents={state.heldEvents()}
heldEventsUnavailable={state.heldEventsUnavailable()}
refreshingHeldEvents={state.refreshingHeldEvents()}
/>
<Show when={state.hasLoadError()}>
@@ -53,6 +53,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
deliveryLogUnavailable,
refreshingDeliveryLog,
heldEvents,
heldEventsUnavailable,
refreshingHeldEvents,
loadDeliveryLog,
} = useNotificationDeliveryLog();
const webhookState = useAlertWebhookDestinationsState({
@@ -173,6 +175,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
deliveryLogUnavailable,
deliveryNeedsAttention,
heldEvents,
heldEventsUnavailable,
refreshingHeldEvents,
dismissTerminalFailures,
dismissingTerminalFailures,
handleRetry,
@@ -19,12 +19,15 @@ const DELIVERY_LOG_LIMIT = 200;
// Held events are the third half of that story: notifications that were
// deliberately not attempted, with the mechanism that held them. They come
// from the alert event log and degrade independently — a failed events read
// hides the held rows without marking delivery attempts unavailable.
// withdraws the held rows and reports that gap without hiding readable attempts.
export function useNotificationDeliveryLog() {
const [deliveryLog, setDeliveryLog] = createSignal<NotificationDeliveryLog | null>(null);
const [deliveryLogUnavailable, setDeliveryLogUnavailable] = createSignal(false);
const [refreshingDeliveryLog, setRefreshingDeliveryLog] = createSignal(false);
const [heldEvents, setHeldEvents] = createSignal<AlertEvent[]>([]);
const [heldEventsUnavailable, setHeldEventsUnavailable] = createSignal(false);
// Before the first read, an empty array means unknown, not no held alerts.
const [refreshingHeldEvents, setRefreshingHeldEvents] = createSignal(true);
// Both reads belong to the same refresh, but held events never block the log.
let latestRequest = 0;
@@ -35,6 +38,7 @@ export function useNotificationDeliveryLog() {
const ownsRequest = (request: number) => !disposed && request === latestRequest;
const loadHeldEvents = async (request: number) => {
setRefreshingHeldEvents(true);
try {
const since = new Date(
Date.now() - HELD_EVENT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
@@ -44,11 +48,16 @@ export function useNotificationDeliveryLog() {
since,
limit: HELD_EVENT_LIMIT,
});
if (ownsRequest(request)) setHeldEvents(events);
if (!ownsRequest(request)) return;
setHeldEvents(events);
setHeldEventsUnavailable(false);
} catch (error) {
if (!ownsRequest(request)) return;
logger.error('Failed to load held alert notification events', error);
setHeldEvents([]);
setHeldEventsUnavailable(true);
} finally {
if (ownsRequest(request)) setRefreshingHeldEvents(false);
}
};
@@ -82,6 +91,8 @@ export function useNotificationDeliveryLog() {
deliveryLogUnavailable,
refreshingDeliveryLog,
heldEvents,
heldEventsUnavailable,
refreshingHeldEvents,
loadDeliveryLog,
};
}
@@ -152,7 +152,7 @@ export const DockerImagesTable: Component<
kind="text"
sort={sort}
sortKey="host"
class="platform-table-mobile-w-15 md:w-[18%]"
class="platform-table-phone-hidden md:w-[18%]"
>
Host
</PlatformSortableTableHead>
@@ -160,7 +160,7 @@ export const DockerImagesTable: Component<
kind="text"
sort={sort}
sortKey="usedBy"
class="platform-table-mobile-w-15 md:w-[24%]"
class="platform-table-phone-hidden md:w-[24%]"
>
Used by
</PlatformSortableTableHead>
@@ -168,7 +168,7 @@ export const DockerImagesTable: Component<
kind="numeric-value"
sort={sort}
sortKey="size"
class="platform-table-mobile-w-15 md:w-[12%]"
class="platform-table-mobile-w-20 md:w-[12%]"
>
Size
</PlatformSortableTableHead>
@@ -176,7 +176,7 @@ export const DockerImagesTable: Component<
kind="badge"
sort={sort}
sortKey="update"
class="platform-table-mobile-w-15 md:w-[16%]"
class="platform-table-mobile-w-25 md:w-[16%]"
>
<PlatformResponsiveTableLabel compact="Update" full="Update check" />
</PlatformSortableTableHead>
@@ -211,13 +211,15 @@ export const DockerImagesTable: Component<
/>
}
/>
{/* Host and consumers fit the row expansion on a phone; the
row keeps the image, its size and the update verdict. */}
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
>
{dockerHostName(resource)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
>
<span
class="inline-block max-w-[20rem] truncate"
@@ -7,6 +7,7 @@ import {
PlatformWindowedRows,
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformSortableTableHead,
PlatformResponsiveTableLabel,
PlatformTableNumberValue,
PlatformTableToolbar,
PlatformTableEmptyState,
@@ -74,7 +75,12 @@ const formatServiceUpdate = (
return { label: 'Stable', title: 'No active service update reported' };
}
const label = state || 'Updating';
// Docker reports the rollout state as a snake_case token
// ("rollback_started"); read it as words in the cell and keep the raw
// token, message and completion time in the title.
const label = state
? state.replace(/[_-]+/g, ' ').replace(/^./, (c) => c.toUpperCase())
: 'Updating';
const title = [state, message, completedAt].filter(Boolean).join(' | ') || label;
return { label, title };
};
@@ -256,7 +262,7 @@ export const DockerServicesTable: Component<{
sortKey="running"
class="platform-table-mobile-w-10 w-[10%] md:w-[8%]"
>
Running
<PlatformResponsiveTableLabel compact="Run" full="Running" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -354,7 +360,10 @@ export const DockerServicesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="truncate inline-block max-w-40" title={update().title}>
<span
class="inline-block max-w-full whitespace-normal wrap-break-word leading-3 md:max-w-40 md:truncate md:leading-normal"
title={update().title}
>
{update().label}
</span>
</TableCell>
@@ -12,6 +12,7 @@ import {
createPlatformTableSortState,
getPlatformTableCellClassForKind,
getPlatformTableDateTimeSortValue,
PlatformTableRelativeTimeValue,
PlatformTableShell,
type PlatformTableSortValue,
withPlatformStatusCounts,
@@ -255,11 +256,10 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-narrow-hidden hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-48 truncate"
title={dockerTextValue(resource.docker?.startedAt)}
>
{dockerTextValue(resource.docker?.startedAt)}
{/* Swarm reports a raw RFC3339 stamp with microseconds; an age
reads at a glance and the stamp stays in the title. */}
<span title={dockerTextValue(resource.docker?.startedAt)}>
<PlatformTableRelativeTimeValue value={resource.docker?.startedAt} />
</span>
</TableCell>
</TableRow>
@@ -1505,6 +1505,8 @@ describe('Docker native tables', () => {
));
expect(screen.getByText('Update')).toBeInTheDocument();
expect(screen.getByText('Run')).toHaveClass('platform-table-label-compact');
expect(screen.getByText('Running')).toHaveClass('platform-table-label-full');
expect(screen.getByText('Stack')).toBeInTheDocument();
expect(screen.getByText('shop')).toBeInTheDocument();
expect(screen.getByText('checkout-api')).toBeInTheDocument();
@@ -1512,7 +1514,15 @@ describe('Docker native tables', () => {
expect(screen.getByText('replicated')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('rollback_started')).toBeInTheDocument();
// The snake_case rollout token reads as words; the raw token stays in the title.
expect(screen.getByText('Rollback started')).toBeInTheDocument();
expect(screen.getByText('Rollback started')).toHaveClass(
'whitespace-normal',
'wrap-break-word',
);
expect(
screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'),
).toContain('rollback_started');
expect(screen.getByText('18080:8080/tcp')).toBeInTheDocument();
expect(screen.getByText('manager-1')).toBeInTheDocument();
expect(document.querySelector('[data-docker-service-row="service-1"]')).not.toBeNull();
@@ -1651,7 +1661,10 @@ describe('Docker native tables', () => {
expect(screen.getByText('web.2')).toBeInTheDocument();
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText('running 2 minutes')).toBeInTheDocument();
expect(screen.getByText('2026-05-24T13:05:00Z')).toBeInTheDocument();
// The raw RFC3339 stamp moves to the title; the cell shows an age.
const started = screen.getByTitle('2026-05-24T13:05:00Z');
expect(started).toBeInTheDocument();
expect(started.textContent).toMatch(/ago$/);
});
it('renders Docker Swarm secret API metadata without secret data', () => {
@@ -30,7 +30,9 @@ describe('dockerContainerTableModel', () => {
const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true);
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates']);
// Restarts waits for mobile width: a phone row could not fit its header
// beside the Update control, and the count stays in the row expansion.
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']);
expect(getDockerContainerColumnWidthStyle('container', 'phone', ids)).toEqual({
width: '30%',
});
@@ -70,7 +70,9 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
state: 'narrow',
cpu: 'narrow',
memory: 'narrow',
restarts: 'phone',
// A phone row cannot fit the Restarts header beside the Update control;
// the count stays in the row expansion there and returns at mobile width.
restarts: 'mobile',
updates: 'narrow',
actions: 'mobile',
host: 'tablet',
@@ -126,11 +128,10 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
},
phone: {
container: 32,
state: 13,
cpu: 15,
memory: 17,
restarts: 10,
updates: 13,
state: 14,
cpu: 14,
memory: 14,
updates: 16,
},
mobile: {
container: 30,
@@ -151,17 +152,20 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
updates: 9,
actions: 6,
},
// Compact is what a 1280-1536px laptop window gets. Host, Engine and the
// Update control are short values that were clipped ("docker 2…") while the
// percentage-only CPU and memory bars had room to spare.
compact: {
container: 18,
host: 10,
runtime: 8,
image: 18,
host: 12,
runtime: 9.5,
image: 16.5,
state: 7,
cpu: 11,
memory: 12,
restarts: 9,
cpu: 9.5,
memory: 10,
restarts: 8.5,
ports: 12,
updates: 9,
updates: 10,
actions: 8,
},
};
@@ -16,6 +16,7 @@ import {
formatPlatformTableDateTimeValue,
formatPlatformTableRelativeTimeValue,
formatPlatformTableTextValue,
getPlatformResourceCountNoun,
getPlatformTableCellClassForKind,
PlatformTableShell,
type PlatformTableSortValue,
@@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string =>
const controllerKind = (resource: Resource): string =>
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
const countedTarget = (count: number | undefined, noun: string): string => {
const value = count ?? 0;
return `${value} ${getPlatformResourceCountNoun(noun, value)}`;
};
const targetValue = (resource: Resource): string => {
switch (resource.type) {
case 'k8s-replicaset':
case 'k8s-statefulset':
return `${resource.kubernetes?.desiredReplicas ?? 0} pods`;
return countedTarget(resource.kubernetes?.desiredReplicas, 'pods');
case 'k8s-daemonset':
return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`;
return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes');
case 'k8s-job':
return `${resource.kubernetes?.desiredReplicas ?? 0} completions`;
return countedTarget(resource.kubernetes?.desiredReplicas, 'completions');
case 'k8s-cronjob':
return formatPlatformTableTextValue(resource.kubernetes?.schedule);
default:
@@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="kind"
class="platform-table-mobile-w-15 md:w-[8%]"
class="platform-table-mobile-w-25 md:w-[8%]"
>
Kind
</PlatformSortableTableHead>
@@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{
>
Scope
</PlatformSortableTableHead>
{/* The phone row keeps controller, kind, ready and issues. A
15% kind track clipped "DaemonSet" and "StatefulSet" and the
target track clipped "1 completions", so target moves to the
row expansion, which also carries the schedule and job
timestamps the narrower layouts drop. */}
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="target"
class="platform-table-mobile-w-20 md:w-[12%]"
class="platform-table-phone-hidden md:w-[12%]"
>
Target
</PlatformSortableTableHead>
@@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="exceptions"
class="platform-table-mobile-w-20 md:w-[11%]"
class="platform-table-mobile-w-30 md:w-[11%]"
>
<PlatformResponsiveTableLabel compact="Issues" full="Exceptions" />
</PlatformSortableTableHead>
@@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span class="inline-block max-w-48 truncate" title={target()}>
{target()}
@@ -1,6 +1,16 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/resources', () => ({
ResourceAPI: {
getFacetBundle: vi.fn().mockResolvedValue({
capabilities: [],
relationships: [],
recentChanges: [],
}),
},
}));
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import { KubernetesControllersTable } from '../KubernetesControllersTable';
@@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => {
expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument();
});
it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => {
render(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'node-exporter',
type: 'k8s-daemonset',
kubernetes: {
clusterName: 'prod',
namespace: 'observability',
resourceKind: 'DaemonSet',
desiredNumberScheduled: 6,
numberReady: 6,
},
}),
]}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
));
// "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and
// "1 completions" clipped in the target track, so the phone row widens kind
// and issues and demotes target to the row expansion.
expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25');
expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden');
expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden');
expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30');
expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30');
expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15');
expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden');
});
it('shows the absolute Job and CronJob times in the row expansion', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
render(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'Job',
desiredReplicas: 10,
active: 1,
succeeded: 8,
failed: 2,
startTime: '2026-05-24T12:55:00Z',
completionTime: '2026-05-24T13:00:00Z',
},
}),
makeResource({
id: 'billing-rollup',
type: 'k8s-cronjob',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'CronJob',
schedule: '*/5 * * * *',
lastScheduleTime: '2026-05-24T13:25:00Z',
lastSuccessfulTime: '2026-05-24T12:55:00Z',
},
}),
]}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
));
// Tapping the row opens the expansion; the Detail column is hidden below a
// large container, so this is where a phone or half-width pane reads the
// completion time.
fireEvent.click(screen.getByText('nightly-import'));
const job = screen.getByTestId('resource-kubernetes-controller-section');
expect(within(job).getByText('Started')).toBeInTheDocument();
expect(
within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
).toBeInTheDocument();
expect(within(job).getByText('Completed')).toBeInTheDocument();
expect(
within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`),
).toBeInTheDocument();
expect(within(job).getByText('Duration')).toBeInTheDocument();
expect(within(job).getByText('5m')).toBeInTheDocument();
expect(within(job).getByText('Target')).toBeInTheDocument();
expect(within(job).getByText('10 completions')).toBeInTheDocument();
expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2');
fireEvent.click(screen.getByText('billing-rollup'));
const cron = screen.getByTestId('resource-kubernetes-controller-section');
expect(within(cron).getByText('Schedule')).toBeInTheDocument();
expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument();
expect(within(cron).getByText('Last run')).toBeInTheDocument();
expect(
within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`),
).toBeInTheDocument();
expect(within(cron).getByText('Last success')).toBeInTheDocument();
expect(
within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
).toBeInTheDocument();
expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch');
});
it('falls back to start and last-run times when a Job or CronJob has not completed', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
@@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => {
/>
));
// A single desired completion reads as "1 completion", matching the
// expansion's Target row.
expect(screen.getByText('1 completion')).toBeInTheDocument();
expect(screen.getByText('Started 1m ago')).toHaveAttribute(
'title',
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,
@@ -322,7 +322,7 @@ describe('platform overview layout guardrails', () => {
expect(source).toContain('platform-table-mobile-w-30');
}
expect(dockerImagesTableSource).toMatch(
/sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[12%\]"/,
/sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/,
);
expect(dockerServicesTableSource).toMatch(
/sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/,
@@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: {
}
header={
<>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[15%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('name')}`}>
<PlatformResponsiveTableLabel compact="Server" full="Backup server" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[10%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
<PlatformResponsiveTableLabel compact="State" full="Status" />
</TableHead>
<Show when={columnVisible('version')}>
@@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: {
</TableHead>
</Show>
<Show when={columnVisible('datastore')}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[13%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
<PlatformResponsiveTableLabel compact="Store" full="Datastore" />
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[15%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')}`}>
Used
</TableHead>
<Show when={columnVisible('backups')}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[8%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')}`}
aria-label="Backups"
title="Backups"
>
@@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: {
>
{(datastore) => (
<div class="flex items-center justify-end gap-2">
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
<Show when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}>
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
</Show>
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
<PlatformTablePercentValue value={pct()} />
</span>
@@ -43,6 +43,7 @@ import {
import {
buildProxmoxBackupRecoveryModel,
coverageRowMatchesSearch,
isBackupArtifact,
recoverableArtifactMatchesSearch,
type RecoverableArtifact,
type WorkloadCoverageRow,
@@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{
return recoveryModel().coverageRows.flatMap((row) => {
const artifacts = row.artifacts.filter(locationMatches);
if (artifacts.length === 0) return [];
// Same rule as the model: running or failed artifacts stay listed but
// never become a "latest" pointer, and snapshots never become the backup.
const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed);
return [
{
...row,
artifacts,
latestRecovery: newestArtifact(artifacts),
latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')),
latestBackup: newestArtifact(completed.filter(isBackupArtifact)),
latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')),
latestArchive: newestArtifact(
artifacts.filter((artifact) => artifact.sourceKind === 'archive'),
completed.filter((artifact) => artifact.sourceKind === 'archive'),
),
latestSnapshot: newestArtifact(
artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'),
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
),
pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length,
archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length,
@@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{
case 'workload':
return cmpString(a.workload.label, b.workload.label, direction);
case 'latest':
return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction);
return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
case 'pbs':
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
case 'archive':
@@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing
// "None" instead of restating the source ("No PVE backup file"). The long form
// truncated at most widths and stays available as the hover title.
const COVERAGE_EMPTY_LABEL = 'None';
const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file';
// Matches the posture engine's own wording for a snapshot-only subject.
const COVERAGE_SNAPSHOT_ONLY_TITLE =
'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.';
const coveragePostureVariant = (
posture: WorkloadCoverageRow['posture'],
@@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: {
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label={layoutMode() === 'compact' ? 'Age' : 'Restore'}
label={
layoutMode() === 'compact'
? 'Age'
: layoutMode() === 'basic'
? 'Backup'
: 'Last backup'
}
sortKey="latest"
currentSort={props.sortKey}
direction={props.sortDirection}
@@ -407,14 +417,26 @@ export function ProxmoxCoverageTable(props: {
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<Show
when={row.latestRecovery}
when={row.latestBackup}
fallback={
<span class="text-muted" title="No restore point">
<span
class="text-muted"
title={
row.latestSnapshot
? COVERAGE_SNAPSHOT_ONLY_TITLE
: COVERAGE_NO_BACKUP_TITLE
}
>
{COVERAGE_EMPTY_LABEL}
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
<Show when={columnVisible('pbs')}>
@@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: {
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
</Show>
@@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: {
>
<span
class="inline-block max-w-[24rem] truncate"
title={artifact.detail}
title={artifact.detailTitle ?? artifact.detail}
>
{artifact.detail || '—'}
</span>
@@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: {
artifact.workload.name || artifact.workload.label,
artifact.workload.typeLabel,
artifact.workload.vmid,
// The phone projection has no location column, so the row's
// hover title carries the repository instead.
columnVisible('location') ? undefined : artifact.location,
]
.filter(Boolean)
.join(' · ')}
@@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: {
</TableCell>
</Show>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
<ProxmoxBackupAgeText artifact={artifact} />
<ProxmoxBackupAgeText artifact={artifact} compact={layoutMode() === 'compact'} />
</TableCell>
<Show when={columnVisible('size')}>
<TableCell class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}>
@@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: {
</TableCell>
<Show when={columnVisible('details')}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
<span class="inline-block max-w-[20rem] truncate" title={artifact.detail}>
<span
class="inline-block max-w-[20rem] truncate"
title={artifact.detailTitle ?? artifact.detail}
>
{artifact.detail || '—'}
</span>
</TableCell>
@@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
expect(headers).toContain('Target ID');
expect(headers).toContain('Node');
expect(headers).toContain('Posture▲');
expect(headers).toContain('Restore');
expect(headers).toContain('Last backup');
expect(headers).toContain('PBS snapshot');
expect(headers).toContain('Guest snapshot');
expect(headers).not.toContain('PVE file');
@@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
// cannot truncate to "No PVE backup f…".
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
for (const title of [
'No restore point',
'No PBS snapshot or PVE backup file',
'No PBS snapshot',
'No PVE backup file',
'No guest snapshot',
@@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => {
expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([
'Workload',
'Via',
'Loc',
'Age▼',
'State',
]);
expect(document.body.textContent).toContain('VM 100');
expect(document.body.textContent).toContain('main / pve1');
// The phone projection has no location column; the row title carries it.
expect(document.body.textContent).not.toContain('main / pve1');
expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull();
expect(document.body.textContent).not.toContain('5 PBS files');
});
@@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () =>
expect(model.coverageSummary.attention).toBe(0);
});
it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => {
it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => {
const postures = new Map([
['vm-830', posture('vm-830', 'unprotected')],
['vm-840', posture('vm-840', 'protected')],
@@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => {
);
});
it('shows the archive format as its detail and keeps the volid on hover and in search', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
pbsBackups: [],
archives: [archive({ format: 'tar.zst' })],
snapshots: [],
tasks: [],
nowMs: Date.parse('2026-05-26T08:00:00Z'),
});
const [artifact] = model.recoverableArtifacts;
expect(artifact.detail).toBe('tar.zst');
expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true);
});
it('falls back to the archive file name when the provider reports no format', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
pbsBackups: [],
archives: [archive({ format: '' })],
snapshots: [],
tasks: [],
nowMs: Date.parse('2026-05-26T08:00:00Z'),
});
expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
});
it('keeps a terminally incomplete PBS artifact out of latest recovery', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
@@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => {
const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed'));
expect(failed).toMatchObject({ failed: true, running: false, verified: undefined });
expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z');
expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z');
expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true);
});
it('keeps a fresh guest snapshot from standing in for the last backup', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })],
archives: [],
snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })],
tasks: [],
nowMs: Date.parse('2026-05-26T08:00:00Z'),
});
const row = model.coverageRows[0];
expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z');
expect(row.latestBackup?.sourceKind).toBe('pbs');
expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z');
expect(
getRecoveryAgeBand(
row.latestBackup?.createdMs,
model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'),
),
).toBe('aging');
});
it('reports no last backup for a snapshot-only workload', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
pbsBackups: [],
archives: [],
snapshots: [snapshot()],
tasks: [],
nowMs: Date.parse('2026-05-26T08:00:00Z'),
});
const row = model.coverageRows[0];
expect(row.latestBackup).toBeUndefined();
expect(row.latestSnapshot?.sourceKind).toBe('snapshot');
expect(row.artifacts).toHaveLength(1);
expect(model.coverageSummary.recoverableArtifacts).toBe(1);
});
it('uses canonical workload attention while retaining the failed task as evidence', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
@@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => {
'status',
'datastore',
'used',
'backups',
]);
expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' });
expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' });
expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' });
expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' });
expect(ids(getBackupServerColumns('basic'))).toEqual([
'server',
'status',
@@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => {
expect(ids(getRecoverableColumns('compact'))).toEqual([
'workload',
'source',
'location',
'created',
'state',
]);
expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' });
expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' });
expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' });
expect(ids(getRecoverableColumns('basic'))).toEqual([
'workload',
'source',
@@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra
import {
ArtifactSourceBadge,
ArtifactStateBadge,
ProxmoxBackupAgeText,
ProxmoxBackupWorkloadTypeBadge,
SortableHead,
formatCompactBackupAge,
} from '../proxmoxBackupsTableShared';
import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel';
@@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => {
}
});
it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => {
const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString();
expect(formatCompactBackupAge(createdAt)).toBe('18h');
expect(formatCompactBackupAge(new Date().toISOString())).toBe('now');
const artifact = {
id: 'pbs:1',
nativeId: '1',
sourceKind: 'pbs',
sourceLabel: 'PBS',
workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' },
createdAt,
createdMs: Date.parse(createdAt),
location: 'main',
detail: '',
protected: false,
} as RecoverableArtifact;
render(() => <ProxmoxBackupAgeText artifact={artifact} compact />);
const age = screen.getByText('18h');
expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt);
});
it('keeps backup age text on the shared relative-time primitive', () => {
expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue');
expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime(');
@@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => {
expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' });
});
it('shows the full operational metric track at phone width', () => {
it('matches the guest table columns and keeps node names whole at phone width', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('phone');
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']);
expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-0');
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' });
expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' });
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' });
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' });
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' });
});
it('prioritizes live utilization columns in the mobile host table', () => {
@@ -47,6 +47,8 @@ export interface RecoverableArtifact {
/** Operator-facing source/repository label used by the backup-location filter. */
locationLabel?: string;
detail: string;
/** Full provider identifier behind a shortened `detail`, shown on hover. */
detailTitle?: string;
protected: boolean;
verified?: boolean;
/** A still-running backup is writing this artifact; it is not recoverable yet. */
@@ -70,7 +72,13 @@ export interface WorkloadCoverageRow {
key: string;
workload: WorkloadReference;
artifacts: RecoverableArtifact[];
latestRecovery?: RecoverableArtifact;
/**
* Newest completed PBS snapshot or PVE backup file. Guest snapshots never
* fill this slot: they live on the guest's own storage and the protection
* posture engine does not count them as independent recovery, so a fresh
* snapshot must not read as a fresh backup beside a stale posture.
*/
latestBackup?: RecoverableArtifact;
latestPBS?: RecoverableArtifact;
latestArchive?: RecoverableArtifact;
latestSnapshot?: RecoverableArtifact;
@@ -138,12 +146,25 @@ export function getRecoveryAgeBand(
return 'stale';
}
// A guest snapshot shares the guest's storage, so it is listed as restore
// evidence but never counted as a backup. Mirrors the server posture rule that
// "snapshots alone do not prove independent recovery".
export function isBackupArtifact(artifact: RecoverableArtifact): boolean {
return artifact.sourceKind !== 'snapshot';
}
function parseTimestampMs(value: string | undefined): number | undefined {
if (!value) return undefined;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : undefined;
}
function archiveFileName(volid: string | undefined): string {
const trimmed = volid?.trim() ?? '';
if (!trimmed) return '';
return trimmed.slice(trimmed.lastIndexOf('/') + 1);
}
function pbsBackupFileDetailLabel(fileCount: number): string {
if (fileCount <= 0) return 'PBS files not listed';
if (fileCount === 1) return '1 PBS file';
@@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel(
size: archive.size,
location: archive.storage || archive.node || '—',
...artifactLocationScope('archive', archive.instance, archive.storage || archive.node),
detail: archive.volid || archive.format || archiveSource.detailFallbackLabel,
// The volid repeats the storage, guest type, VMID, and timestamp the row
// already shows in their own columns and clips at every width; the
// archive format is the one fact it adds, so that is the cell and the
// volid stays on hover and in search.
detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel,
detailTitle: archive.volid || undefined,
protected: archive.protected,
verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined,
running: archive.inProgress === true,
@@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel(
// the "latest" pointers (which answer "what can I recover to?") skip them.
// The artifacts themselves stay listed with an honest state.
const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed);
row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs);
row.latestPBS = newest(
completed.filter((artifact) => artifact.sourceKind === 'pbs'),
(artifact) => artifact.createdMs,
@@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel(
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
(artifact) => artifact.createdMs,
);
row.latestRecovery = newest(completed, (artifact) => artifact.createdMs);
}
const coverageRows = Array.from(rows.values()).map((row) => {
@@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel(
coverageRows.sort((left, right) => {
if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank;
return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0);
return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0);
});
artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0));
@@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch(
artifact.sourceTitle,
artifact.location,
artifact.detail,
artifact.detailTitle,
artifact.verified === true
? 'verified'
: artifact.verified === false
@@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record<
> = {
// Reachability and datastore exhaustion are the two risks that can stop all
// future backups. Host telemetry and space-efficiency context return only as
// the table gains enough room to keep those headline answers readable.
compact: ['server', 'status', 'datastore', 'used', 'backups'],
// the table gains enough room to keep those headline answers readable. The
// phone projection keeps those two answers whole ("Healthy", "33.6%") and
// lets the backup count return with the first wider layout.
compact: ['server', 'status', 'datastore', 'used'],
basic: ['server', 'status', 'datastore', 'used', 'backups'],
operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'],
expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'],
@@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record<
ProxmoxBackupsTableLayoutMode,
Partial<Record<BackupServerColumnId, number>>
> = {
compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 },
basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 },
compact: { server: 40, status: 23, datastore: 22, used: 15 },
// Just above the phone projection the used cell carries its used/total
// pair again, the longest value in the row, so it takes the most room.
basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 },
operational: {
server: 20,
status: 12,
@@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
{ id: 'targetId', label: 'Target ID', kind: 'text' },
{ id: 'node', label: 'Node', kind: 'text' },
{ id: 'posture', label: 'Posture', kind: 'text' },
{ id: 'latest', label: 'Restore', kind: 'numeric-value' },
{ id: 'latest', label: 'Last backup', kind: 'numeric-value' },
{ id: 'pbs', label: 'PBS snapshot', kind: 'text' },
{ id: 'archive', label: 'PVE file', kind: 'text' },
{ id: 'snapshot', label: 'Guest snapshot', kind: 'text' },
@@ -184,7 +188,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
// On narrow surfaces answer: which workload, what posture, how recent is the
// newest restore point, and did the latest task succeed? Provider-by-provider
// newest independent backup, and did the latest task succeed? Provider-by-provider
// evidence is progressive detail in the expansion row; target identity folds
// beneath the name so the scan stays legible at 320px.
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
@@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record<
ProxmoxBackupsTableLayoutMode,
Partial<Record<CoverageColumnId, number>>
> = {
compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 },
// Posture keeps its whole compact word ("Unknown"), the two age cells hold
// the suffix-free compact age, and the job dot takes what a "Job" header
// needs.
compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 },
// Above the phone projection the identity columns (workload, node) take the
// slack that the short type badge, target id, and age cells cannot use, so
// names stay whole instead of truncating beside empty space.
basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 },
basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 },
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
expanded: {
workload: 19,
@@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly Recove
// A recovery-point feed starts with identity, source, location, age, and
// verification. Size and verbose details progressively return with usable
// room; the full values remain available through their title affordance.
compact: ['workload', 'source', 'location', 'created', 'state'],
// The phone projection keeps the source and state badges and the age whole;
// the repository location returns with the first wider layout and stays in
// the row's hover title meanwhile.
compact: ['workload', 'source', 'created', 'state'],
basic: ['workload', 'source', 'location', 'created', 'state'],
operational: ['workload', 'type', 'source', 'location', 'created', 'size', 'state'],
expanded: ['workload', 'type', 'targetId', 'source', 'location', 'created', 'size', 'state'],
@@ -301,7 +311,7 @@ const RECOVERABLE_WEIGHTS: Record<
// Reserve enough phone-width space for complete state badges (including
// Failed and Running) rather than clipping the recovery answer at the
// scroll edge. Identity remains on the canonical 40% anchor.
compact: { workload: 40, source: 12, location: 17, created: 15, state: 16 },
compact: { workload: 40, source: 16, created: 14, state: 30 },
basic: { workload: 28, source: 14, location: 23, created: 18, state: 17 },
operational: {
workload: 24,
@@ -1,4 +1,4 @@
import type { Accessor } from 'solid-js';
import { Show, type Accessor } from 'solid-js';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
@@ -6,7 +6,10 @@ import { ProgressBar } from '@/components/shared/ProgressBar';
import { TableHead } from '@/components/shared/Table';
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
import { WorkloadTypeBadge as SharedWorkloadTypeBadge } from '@/components/shared/WorkloadTypeBadge';
import { PlatformTableRelativeTimeValue } from '@/features/platformPage/sharedPlatformPage';
import {
PlatformTableRelativeTimeValue,
formatPlatformTableRelativeTimeValue,
} from '@/features/platformPage/sharedPlatformPage';
import {
getRecoveryAgeBand,
@@ -219,7 +222,15 @@ export function RowMetricBar(props: {
);
}
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
// Phone columns are too narrow for "18h ago", so the compact form drops the
// suffix ("18h", "now") and keeps the full age band and timestamp on hover.
export function formatCompactBackupAge(createdAt: string): string {
const relative = formatPlatformTableRelativeTimeValue(createdAt);
if (relative === 'just now') return 'now';
return relative.replace(/ ago$/, '');
}
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact; compact?: boolean }) {
const band = () => getRecoveryAgeBand(props.artifact.createdMs);
const title = () => {
const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean);
@@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
return (
<span class={`font-semibold tabular-nums ${recoveryAgeClassByBand[band()]}`} title={title()}>
<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />
<Show
when={props.compact}
fallback={<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />}
>
{formatCompactBackupAge(props.artifact.createdAt)}
</Show>
</span>
);
}
@@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
cpu: 'narrow',
memory: 'narrow',
disk: 'narrow',
temp: 'phone',
temp: 'mobile',
uptime: 'narrow',
cluster: 'tablet',
vms: 'compact',
@@ -46,8 +46,8 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
// pill) take only what they need.
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
node: 18,
version: 7,
node: 20,
version: 6,
uptime: 7,
cpu: 13,
memory: 13,
@@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
temp: 6,
vms: 5,
cts: 5,
cluster: 13,
cluster: 12,
};
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
@@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
disk: 15,
uptime: 15,
},
// Phones carry the same five columns as the guest table below them. A sixth
// (temperature) left the node name with four characters, which made a
// cluster of "Analytics A".."F" rows unreadable.
phone: {
node: 32,
cpu: 13,
memory: 13,
disk: 13,
temp: 15,
uptime: 14,
cpu: 15,
memory: 15,
disk: 15,
uptime: 15,
},
mobile: {
node: 32,
@@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = (
);
};
// The node cell carries the expansion toggle and status dot before the name,
// so it needs more than the shared 30% phone identity anchor to show a
// typical node name whole.
const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
export const getProxmoxHostColumnWidthStyle = (
columnId: ProxmoxHostTableColumnId,
layoutMode: WorkloadTableLayoutMode,
@@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = (
visibleColumnIds,
layoutMode === 'narrow'
? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
: layoutMode === 'phone'
? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'mobile'
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
);
};
@@ -4,6 +4,7 @@ import {
DISK_DETAIL_LIVE_CHARTS,
getDiskAttributeValueTextClass,
getDiskDetailAttributeCards,
getDiskDetailHealthPresentation,
getDiskDetailHistoryCharts,
getDiskDetailHistoryFallbackMessage,
getDiskDetailLiveBadgeLabel,
@@ -34,6 +35,41 @@ describe('diskDetailPresentation', () => {
);
});
it('carries the row verdict and its reason into the drawer header', () => {
const disk = {
node: 'tower',
instance: 'cluster-main',
devPath: '/dev/sda',
model: 'Archive HDD',
serial: 'SERIAL-1',
wwn: '',
size: 1,
health: 'PASSED',
wearout: -1,
type: 'hdd',
temperature: 42,
rpm: 7200,
used: '',
riskReasons: [] as string[],
};
expect(getDiskDetailHealthPresentation(disk)).toEqual({
label: 'Healthy',
summary: '',
tone: 'text-emerald-700 dark:text-emerald-300',
});
expect(
getDiskDetailHealthPresentation({
...disk,
health: 'FAILED',
riskReasons: ['SMART failed.'],
}),
).toEqual({
label: 'Replace Now',
summary: 'SMART failed.',
tone: 'text-red-700 dark:text-red-300',
});
});
it('builds canonical SATA and NVMe attribute cards', () => {
expect(
getDiskDetailAttributeCards({
@@ -19,9 +19,11 @@ import {
PHYSICAL_DISK_TABLE_CLASS,
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
getPhysicalDiskEmptyStatePresentation,
getPhysicalDiskColumnWidthPercent,
getPhysicalDiskCellPaddingClass,
getPhysicalDiskColumnWidthStyle,
getPhysicalDiskCollectionMessages,
getPhysicalDiskFieldStatusMessage,
getPhysicalDiskHealthCompactLabel,
getPhysicalDiskHealthStatus,
getPhysicalDiskHealthSummary,
getPhysicalDiskHostLabel,
@@ -158,14 +160,55 @@ describe('diskPresentation', () => {
),
),
).toBe(true);
expect(getPhysicalDiskColumnWidthPercent('compact', 'disk')).toBe(33);
expect(getPhysicalDiskColumnWidthPercent('compact', 'size')).toBe(14);
expect(getPhysicalDiskColumnWidthPercent('operational', 'life')).toBe(9);
expect(getPhysicalDiskColumnWidthPercent('compact', 'device')).toBe(0);
expect(getPhysicalDiskColumnWidthStyle('compact', 'disk')).toEqual({ width: '34%' });
expect(getPhysicalDiskColumnWidthStyle('compact', 'temp')).toEqual({ width: '12%' });
expect(getPhysicalDiskColumnWidthStyle('compact', 'size')).toEqual({ width: '15%' });
expect(getPhysicalDiskColumnWidthStyle('operational', 'life')).toEqual({ width: '9%' });
expect(getPhysicalDiskColumnWidthStyle('compact', 'device')).toEqual({ width: '0%' });
expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false);
expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true);
expect(getPhysicalDiskColumnWidthPercent('narrow', 'disk')).toBe(40);
expect(getPhysicalDiskColumnWidthPercent('narrow', 'size')).toBe(15);
expect(getPhysicalDiskColumnWidthStyle('narrow', 'disk')).toEqual({ width: '41%' });
expect(getPhysicalDiskColumnWidthStyle('narrow', 'temp')).toEqual({ width: '12%' });
expect(getPhysicalDiskColumnWidthStyle('narrow', 'size')).toEqual({ width: '16%' });
});
it('keeps the desktop disk column widths on the canonical weighted helper', () => {
const columns = [
'disk',
'device',
'host',
'role',
'parent',
'health',
'life',
'temp',
'size',
] as const;
expect(columns.map((column) => getPhysicalDiskColumnWidthStyle('full', column).width)).toEqual([
'19%',
'9%',
'10%',
'8%',
'12%',
'17%',
'6%',
'7%',
'12%',
]);
});
it('sheds the shared cell padding only on the phone disk layouts', () => {
expect(getPhysicalDiskCellPaddingClass('narrow')).toBe('px-1!');
expect(getPhysicalDiskCellPaddingClass('compact')).toBe('px-1!');
expect(getPhysicalDiskCellPaddingClass('basic')).toBe('');
expect(getPhysicalDiskCellPaddingClass('full')).toBe('');
});
it('shortens only the health words that cannot fit a phone health column', () => {
expect(getPhysicalDiskHealthCompactLabel('Needs Attention')).toBe('Attention');
expect(getPhysicalDiskHealthCompactLabel('Replace Now')).toBe('Replace');
expect(getPhysicalDiskHealthCompactLabel('Healthy')).toBe('Healthy');
expect(getPhysicalDiskHealthCompactLabel('Unknown')).toBe('Unknown');
});
it('detects SMART warnings from counters', () => {
@@ -62,6 +62,14 @@ describe('resourceStoragePresentation', () => {
),
).toBe('Datastore');
expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage');
// Proxmox plugin names are acronyms or compound words; title-casing them
// produced "Lvmthin" for every default local-lvm storage and "Nfs".
expect(getResourceStorageTopologyLabel(makeResource(), 'lvmthin')).toBe('LVM-Thin');
expect(getResourceStorageTopologyLabel(makeResource(), 'lvm')).toBe('LVM');
expect(getResourceStorageTopologyLabel(makeResource(), 'nfs')).toBe('NFS');
expect(getResourceStorageTopologyLabel(makeResource(), 'cifs')).toBe('SMB/CIFS');
expect(getResourceStorageTopologyLabel(makeResource(), 'iscsi')).toBe('iSCSI');
expect(getResourceStorageTopologyLabel(makeResource(), 'btrfs')).toBe('BTRFS');
expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe(
'Rebuild Target',
);
@@ -219,12 +219,12 @@ describe('getStoragePoolStateLabel branch coverage', () => {
expect(getStoragePoolStateLabel(record)).toBe('Online');
});
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
const record = {
...baseRecord(),
details: { zfsPool: { state: 'FAULTED', devices: [] } },
} as unknown as StorageRecord;
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
expect(getStoragePoolStateLabel(record)).toBe('Faulted');
});
it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => {
@@ -239,7 +239,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
expect(getStoragePoolStateLabel(record)).toBe('Online');
});
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
const record = {
...baseRecord(),
details: {
@@ -249,7 +249,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
},
},
} as unknown as StorageRecord;
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
expect(getStoragePoolStateLabel(record)).toBe('Faulted');
});
it('titleizes the derived status when arrayState and pool are both absent', () => {
@@ -101,6 +101,47 @@ describe('storage row presentation', () => {
expect(getCompactStoragePoolProtectionTitle(record)).not.toContain('without parity');
});
it('shows a zfs rebuild as its activity word and keeps the sentence as the title', () => {
const resilvering = {
...baseRecord(),
rebuildInProgress: true,
protectionLabel: 'ZFS pool tank is resilvering',
details: {
zfsPool: { state: 'DEGRADED', scan: 'resilver in progress', devices: [] },
},
} as unknown as StorageRecord;
expect(getCompactStoragePoolProtectionLabel(resilvering)).toBe('Resilvering');
expect(getCompactStoragePoolProtectionTitle(resilvering)).toBe('ZFS pool tank is resilvering');
const scrubbing = {
...baseRecord(),
rebuildInProgress: true,
protectionLabel: 'ZFS pool tank scrub is running (45.2%)',
protectionSummary: 'ZFS pool tank scrub is running (45.2%)',
details: {
zfsPool: {
state: 'ONLINE',
scan: '',
scanDetails: { function: 'SCRUB', state: 'SCANNING', percentage: 45.2 },
devices: [],
},
},
} as unknown as StorageRecord;
expect(getCompactStoragePoolProtectionLabel(scrubbing)).toBe('Scrubbing 45%');
expect(getCompactStoragePoolProtectionTitle(scrubbing)).toBe(
'ZFS pool tank scrub is running (45.2%)',
);
// A rebuild without zfs scan data keeps the label its platform supplied.
const parity = {
...baseRecord(),
rebuildInProgress: true,
protectionLabel: 'Parity sync 12%',
} as unknown as StorageRecord;
expect(getCompactStoragePoolProtectionLabel(parity)).toBe('Parity sync 12%');
expect(getCompactStoragePoolProtectionTitle(parity)).toBe('Parity sync 12%');
});
it('derives zfs issue fallback from pool state and errors', () => {
const record = {
...baseRecord(),
@@ -467,6 +467,37 @@ describe('storageAdapters', () => {
expect(records[0].hostLabel).toBe('pve1');
});
it('labels a poller-merged shared storage as shared with its node count', () => {
const records = buildStorageRecords({
state: baseState(),
resources: [
makeResourceStorage({
id: 'cluster-a-cluster-nfs-iso',
name: 'nfs-iso',
parentName: 'cluster',
platformData: { type: 'nfs', node: 'cluster', instance: 'cluster-a', shared: true },
storage: {
type: 'nfs',
shared: true,
nodes: ['pve1', 'pve2', 'pve3'],
} as Resource['storage'],
}),
makeResourceStorage({
id: 'pve2-local-lvm',
name: 'local-lvm',
parentName: 'pve2',
platformData: { type: 'lvmthin', node: 'pve2', instance: 'cluster-a', shared: false },
storage: { type: 'lvmthin', shared: false } as Resource['storage'],
}),
],
});
// The poller folds a shared storage into one entry on the synthetic
// "cluster" node; a node-local storage keeps its node name.
expect(records.map((record) => record.hostLabel)).toEqual(['Shared · 3 nodes', 'pve2']);
expect(records.map((record) => record.topologyLabel)).toEqual(['NFS', 'LVM-Thin']);
});
it('keeps dependency impact separate from healthy storage primary issues', () => {
const records = buildStorageRecords({
state: baseState(),
@@ -13,6 +13,7 @@ import {
STORAGE_BAR_ZFS_SECTION_CLASS,
STORAGE_BAR_ZFS_STATE_LABEL_CLASS,
STORAGE_BAR_ZFS_STATE_ROW_CLASS,
getStorageBarCompactLabel,
getStorageBarLabel,
getStorageBarTooltipRowClass,
getStorageBarTooltipRows,
@@ -39,6 +40,7 @@ describe('storageBarPresentation', () => {
expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400');
expect(getStorageBarUsagePercent(40, 100)).toBe(40);
expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)');
expect(getStorageBarCompactLabel(40, 100)).toBe('40%');
expect(getStorageBarTooltipTitle()).toBe('Storage Details');
expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 ');
expect(getStorageBarTooltipRowClass(true)).toBe(
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
getStoragePoolColumnWidthPercent,
getStoragePoolCellPaddingClass,
getStoragePoolColumnWidthStyle,
getStoragePoolTableColumns,
getStoragePoolTableLayoutModeForContainer,
getStorageTableHeading,
@@ -46,22 +47,44 @@ describe('storagePagePresentation', () => {
expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact');
expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow');
expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact');
expect(getStoragePoolTableLayoutModeForContainer(559)).toBe('compact');
expect(getStoragePoolTableLayoutModeForContainer(560)).toBe('operational');
// The compact/operational boundary is the shared phone container query
// (34rem), so the usage bar label and the column set change together.
expect(getStoragePoolTableLayoutModeForContainer(543)).toBe('compact');
expect(getStoragePoolTableLayoutModeForContainer(544)).toBe('operational');
expect(getStoragePoolTableLayoutModeForContainer(1_039)).toBe('operational');
expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full');
expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true);
expect(isStoragePoolColumnVisible('operational', 'growth')).toBe(false);
expect(getStoragePoolColumnWidthPercent('operational', 'usage')).toBe(21);
expect(getStoragePoolColumnWidthStyle('operational', 'usage')).toEqual({ width: '25%' });
expect(getStoragePoolColumnWidthStyle('operational', 'name')).toEqual({ width: '27%' });
expect(isStoragePoolColumnVisible('compact', 'host')).toBe(true);
expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false);
expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false);
expect(getStoragePoolColumnWidthPercent('compact', 'name')).toBe(35);
expect(getStoragePoolColumnWidthPercent('compact', 'usage')).toBe(25);
expect(getStoragePoolColumnWidthPercent('compact', 'growth')).toBe(0);
expect(getStoragePoolColumnWidthStyle('compact', 'name')).toEqual({ width: '37%' });
expect(getStoragePoolColumnWidthStyle('compact', 'host')).toEqual({ width: '31.5%' });
expect(getStoragePoolColumnWidthStyle('compact', 'usage')).toEqual({ width: '13%' });
expect(getStoragePoolColumnWidthStyle('compact', 'growth')).toEqual({ width: '0%' });
expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false);
expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false);
expect(getStoragePoolColumnWidthPercent('narrow', 'name')).toBe(40);
expect(getStoragePoolColumnWidthPercent('narrow', 'usage')).toBe(20);
expect(getStoragePoolColumnWidthStyle('narrow', 'name')).toEqual({ width: '35%' });
expect(getStoragePoolColumnWidthStyle('narrow', 'usage')).toEqual({ width: '13%' });
});
it('sheds the desktop cell gutter only on the phone pool layouts', () => {
expect(getStoragePoolCellPaddingClass('narrow')).toBe('px-1!');
expect(getStoragePoolCellPaddingClass('compact')).toBe('px-1!');
expect(getStoragePoolCellPaddingClass('operational')).toBe('');
expect(getStoragePoolCellPaddingClass('full')).toBe('');
});
it('keeps the desktop pool column widths on the canonical weighted helper', () => {
const fullWidths = ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'].map(
(column) =>
getStoragePoolColumnWidthStyle(
'full',
column as 'name' | 'state' | 'type' | 'host' | 'protection' | 'usage' | 'growth',
).width,
);
expect(fullWidths).toEqual(['20%', '14%', '10%', '12%', '13%', '20%', '11%']);
});
});
@@ -1,4 +1,8 @@
import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
import {
getPhysicalDiskHealthStatus,
getPhysicalDiskHealthSummary,
type PhysicalDiskPresentationData,
} from '@/features/storageBackups/diskPresentation';
import type { HistoryTimeRange } from '@/api/charts';
import { formatPowerOnHours } from '@/utils/format';
import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds';
@@ -78,6 +82,32 @@ export const getDiskDetailLiveBadgeLabel = (): string => 'Real-time';
export const getDiskDetailHistoryFallbackMessage = (): string =>
'Historical disk charts are unavailable until Pulse can resolve a stable identity for this disk.';
export type DiskDetailHealthPresentation = {
label: string;
summary: string;
tone: string;
};
// The drawer header carries the disk's verdict and its reason. The table row
// shows only the verdict word, so this is where the reason is read in full.
export const DISK_DETAIL_HEADER_STACK_CLASS = 'min-w-0 space-y-1';
export const DISK_DETAIL_HEALTH_ROW_CLASS =
'flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[11px]';
export const DISK_DETAIL_HEALTH_LABEL_CLASS = 'font-semibold';
export const DISK_DETAIL_HEALTH_SUMMARY_CLASS =
'min-w-0 max-w-full whitespace-normal wrap-break-word text-muted';
export function getDiskDetailHealthPresentation(
disk: PhysicalDiskPresentationData,
): DiskDetailHealthPresentation {
const status = getPhysicalDiskHealthStatus(disk);
return {
label: status.label,
summary: getPhysicalDiskHealthSummary(status),
tone: status.tone,
};
}
export function getDiskDetailAttributeCards(
disk: PhysicalDiskPresentationData,
diskTempThresholds?: MetricDisplayThresholds | null,
@@ -1,8 +1,10 @@
import type { JSX } from 'solid-js';
import type {
PhysicalDiskCollectionStatus,
PhysicalDiskFieldStatus,
Resource,
} from '@/types/resource';
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
import {
getSourcePlatformLabel,
getSourcePlatformPresentation,
@@ -141,12 +143,16 @@ const PHYSICAL_DISK_VISIBLE_COLUMNS: Record<
full: ['disk', 'device', 'host', 'role', 'parent', 'health', 'life', 'temp', 'size'],
};
const PHYSICAL_DISK_COLUMN_WIDTHS: Record<
// Relative weights per layout, resolved through the canonical weighted-width
// helper. The phone layouts are sized from the measured values they show:
// a temperature ("100°C"), a capacity ("5.46 TB"), a compact health word and
// a short node name must each fit whole, and the disk model takes the rest.
const PHYSICAL_DISK_COLUMN_WEIGHTS: Record<
PhysicalDiskTableLayoutMode,
Partial<Record<PhysicalDiskTableColumnId, number>>
> = {
narrow: { disk: 40, host: 15, health: 20, temp: 10, size: 15 },
compact: { disk: 33, host: 15, health: 18, life: 10, temp: 10, size: 14 },
narrow: { disk: 41, host: 12, health: 19, temp: 12, size: 16 },
compact: { disk: 34, host: 11, health: 17, life: 11, temp: 12, size: 15 },
basic: { disk: 33, host: 17, health: 22, temp: 12, size: 16 },
operational: { disk: 25, host: 12, parent: 15, health: 17, life: 9, temp: 9, size: 13 },
expanded: { disk: 22, host: 10, role: 9, parent: 14, health: 15, life: 8, temp: 8, size: 14 },
@@ -168,10 +174,31 @@ export const isPhysicalDiskColumnVisible = (
columnId: PhysicalDiskTableColumnId,
): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId);
export const getPhysicalDiskColumnWidthPercent = (
export const getPhysicalDiskColumnWidthStyle = (
layout: PhysicalDiskTableLayoutMode,
columnId: PhysicalDiskTableColumnId,
): number => PHYSICAL_DISK_COLUMN_WIDTHS[layout][columnId] ?? 0;
): JSX.CSSProperties =>
getPlatformTableWeightedColumnWidthStyle(
columnId,
PHYSICAL_DISK_COLUMN_WEIGHTS[layout],
PHYSICAL_DISK_VISIBLE_COLUMNS[layout],
);
// The shared table cell primitives pad every cell for desktop density. On the
// phone layouts that padding alone is a fifth of the row, so rendered cells
// and headers shed it the same way the Proxmox replication table does.
export const PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS = 'px-1!';
export const getPhysicalDiskCellPaddingClass = (layout: PhysicalDiskTableLayoutMode): string =>
layout === 'narrow' || layout === 'compact' ? PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS : '';
// Health words that do not fit a phone-width health column fall back to a
// shorter form; the full label stays in the wider projections and the detail.
export const getPhysicalDiskHealthCompactLabel = (label: string): string => {
if (label === 'Needs Attention') return 'Attention';
if (label === 'Replace Now') return 'Replace';
return label;
};
export const PHYSICAL_DISK_COL_DISK_CLASS = '';
export const PHYSICAL_DISK_COL_DEVICE_CLASS = '';
@@ -221,8 +248,10 @@ export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted';
export const PHYSICAL_DISK_LIFE_CLASS = 'text-[11px] font-medium';
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'min-w-0 truncate text-[11px] font-semibold';
export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted';
// The health word is the row's verdict and always renders whole. Its reason
// lives in the cell title and in the disk drawer, not in a truncating sibling
// that would compete with it for the track.
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold';
export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium';
export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content';
@@ -244,6 +244,30 @@ export const getResourceStorageTopologyLabel = (
case 'rbd':
case 'cephfs':
return 'Cluster Storage';
// Proxmox storage plugin names are acronyms or compound words that
// title-casing mangles ("Lvmthin" for the default local-lvm, "Nfs"); use
// the names the Proxmox UI shows for them.
case 'nfs':
return 'NFS';
case 'cifs':
case 'smb':
return 'SMB/CIFS';
case 'lvm':
return 'LVM';
case 'lvmthin':
case 'lvm-thin':
return 'LVM-Thin';
case 'iscsi':
case 'iscsidirect':
return 'iSCSI';
case 'zfs':
return 'ZFS';
case 'btrfs':
return 'BTRFS';
case 'glusterfs':
return 'GlusterFS';
case 'esxi':
return 'ESXi';
default:
return titleize(storageType) || titleize(resource.type) || 'Storage';
}
@@ -66,9 +66,32 @@ export function getStoragePoolStateTextClass(record: StorageRecord): string {
return 'text-base-content';
}
// The rebuild summary the backend promotes into the protection label is a
// sentence ("ZFS pool tank is resilvering (45.2%)"). The protection column is
// a badge, so a ZFS scan reads as the activity word, derived from the pool's
// own scan state (the same source the backend keys its risk reason on); the
// sentence stays available as the cell title. Non-ZFS rebuilds keep the label
// their platform supplies.
export function getStoragePoolRebuildLabel(record: StorageRecord): string {
const pool = getStorageRecordZfsPool(record);
if (!pool) return '';
const scan = `${pool.scanDetails?.function || ''} ${pool.scan || ''}`.toLowerCase();
const percentage = pool.scanDetails?.percentage;
const progress =
typeof percentage === 'number' && Number.isFinite(percentage) && percentage > 0
? ` ${Math.round(percentage)}%`
: '';
if (scan.includes('resilver')) return `Resilvering${progress}`;
if (scan.includes('scrub')) return `Scrubbing${progress}`;
return '';
}
export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string {
const label = (record.protectionLabel || '').trim();
if (record.rebuildInProgress || record.protectionReduced) {
if (record.rebuildInProgress) {
return getStoragePoolRebuildLabel(record) || label || '—';
}
if (record.protectionReduced) {
return label || '—';
}
if (label && label.toLowerCase() !== 'healthy') {
@@ -87,7 +110,9 @@ export function getStoragePoolStateLabel(record: StorageRecord): string {
}
const pool = getStorageRecordZfsPool(record);
if (pool?.state) {
return pool.state === 'ONLINE' ? 'Online' : pool.state;
// zpool reports upper-case states; present them like every other state
// path so a DEGRADED pool reads the same as a degraded array.
return titleize(pool.state);
}
const status = getStorageRecordStatus(record);
return status ? titleize(status) : '—';
@@ -122,7 +147,9 @@ export function getCompactStoragePoolProtectionTitle(record: StorageRecord): str
return issueSummary;
}
}
return label;
// When the badge shows a derived word, the platform's full label is the title.
const fullLabel = (record.protectionLabel || '').trim();
return fullLabel && fullLabel.toLowerCase() !== label.toLowerCase() ? fullLabel : label;
}
export function getCompactStoragePoolImpactLabel(record: StorageRecord): string {
@@ -129,7 +129,16 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag
const usedBytes = asNumberOrNull(resource.disk?.used);
const freeBytes = asNumberOrNull(resource.disk?.free);
const canonicalPlatform = getCanonicalStoragePlatformKey(resource, resource.storage?.platform);
const hostLabel = locationLabel;
// The poller folds a shared storage into one entry whose node is the
// synthetic "cluster" marker (the mock uses "shared"); show that as a
// shared storage with its reporting node count rather than a node name.
const sharedLocationMarkers = new Set(['cluster', 'shared']);
const hostLabel =
shared && sharedLocationMarkers.has(locationLabel.trim().toLowerCase())
? storageNodes.length > 1
? `Shared · ${storageNodes.length} nodes`
: 'Shared'
: locationLabel;
const platformLabel = getResourceStoragePlatformLabel(canonicalPlatform);
const issueLabel = getResourceStorageIssueLabel(resource);
const issueSummary = getResourceStorageIssueSummary(resource);
@@ -38,8 +38,13 @@ export const getStorageBarUsagePercent = (used: number, total: number): number =
return (used / total) * 100;
};
// Phone-width tables only have room for the percentage inside the bar; the
// used/total sublabel stays in the tooltip and in the wider projections.
export const getStorageBarCompactLabel = (used: number, total: number): string =>
formatPercent(getStorageBarUsagePercent(used, total));
export const getStorageBarLabel = (used: number, total: number): string =>
`${formatPercent(getStorageBarUsagePercent(used, total))} (${formatBytes(used)}/${formatBytes(total)})`;
`${getStorageBarCompactLabel(used, total)} (${formatBytes(used)}/${formatBytes(total)})`;
export const getStorageBarTooltipTitle = (): string => 'Storage Details';
@@ -1,5 +1,7 @@
import type { JSX } from 'solid-js';
import type { StorageSortKey } from './storageModelCore';
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
export type StorageViewOption = {
value: 'pools' | 'disks';
@@ -21,11 +23,14 @@ export type StoragePoolTableColumn = {
export type StoragePoolTableLayoutMode = 'narrow' | 'compact' | 'operational' | 'full';
// The compact/operational boundary matches the shared phone projection of the
// table container (`@container (max-width: 33.999rem)` = 544px), so the
// columns and the in-cell labels always change projection together.
export const getStoragePoolTableLayoutModeForContainer = (
containerWidth: number,
): StoragePoolTableLayoutMode => {
if (containerWidth >= 1_040) return 'full';
if (containerWidth >= 560) return 'operational';
if (containerWidth >= 544) return 'operational';
if (containerWidth > 0 && containerWidth < 360) return 'narrow';
return 'compact';
};
@@ -43,13 +48,20 @@ const STORAGE_POOL_VISIBLE_COLUMNS: Record<
full: ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'],
};
const STORAGE_POOL_COLUMN_WIDTHS: Record<
// Relative weights per layout, resolved through the canonical weighted-width
// helper. On phones the usage bar carries only its percentage, so most of the
// row goes to the two identity strings (pool name and host); a state word such
// as "Degraded" must still fit whole. The shared 30% identity anchor is not
// used here because a four-column row cannot keep the host readable with it.
const STORAGE_POOL_COLUMN_WEIGHTS: Record<
StoragePoolTableLayoutMode,
Partial<Record<StoragePoolTableColumnId, number>>
> = {
narrow: { name: 40, state: 20, host: 20, usage: 20 },
compact: { name: 35, state: 20, host: 20, usage: 25 },
operational: { name: 29, state: 20, host: 15, protection: 15, usage: 21 },
narrow: { name: 35, state: 21, host: 31, usage: 13 },
compact: { name: 37, state: 18.5, host: 31.5, usage: 13 },
// The full "59% (1.18 TB/2.00 TB)" bar label returns at 544px, so the usage
// track must hold ~120px of text from the first operational width.
operational: { name: 27, state: 18, host: 15, protection: 15, usage: 25 },
full: { name: 20, state: 14, type: 10, host: 12, protection: 13, usage: 20, growth: 11 },
};
@@ -58,10 +70,24 @@ export const isStoragePoolColumnVisible = (
columnId: StoragePoolTableColumnId,
): boolean => STORAGE_POOL_VISIBLE_COLUMNS[layout].includes(columnId);
export const getStoragePoolColumnWidthPercent = (
// On phones the pool row sheds the desktop cell gutter the same way its
// headers (via the shared container query) and the physical-disk rows do:
// four cells at 12px each were 48px of a 361px row, and the measured values
// fit the tracks with only a pixel or two to spare.
export const STORAGE_POOL_PHONE_CELL_PADDING_CLASS = 'px-1!';
export const getStoragePoolCellPaddingClass = (layout: StoragePoolTableLayoutMode): string =>
layout === 'narrow' || layout === 'compact' ? STORAGE_POOL_PHONE_CELL_PADDING_CLASS : '';
export const getStoragePoolColumnWidthStyle = (
layout: StoragePoolTableLayoutMode,
columnId: StoragePoolTableColumnId,
): number => STORAGE_POOL_COLUMN_WIDTHS[layout][columnId] ?? 0;
): JSX.CSSProperties =>
getPlatformTableWeightedColumnWidthStyle(
columnId,
STORAGE_POOL_COLUMN_WEIGHTS[layout],
STORAGE_POOL_VISIBLE_COLUMNS[layout],
);
const STORAGE_POOL_TABLE_HEADER_CLASS =
'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] lg:text-xs uppercase tracking-wider';
@@ -2,6 +2,7 @@ import { batch, createRoot, createSignal } from 'solid-js';
import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildProxmoxPageModel } from '@/features/proxmox/proxmoxPageModel';
import { getDockerContainerLifecycleDisabledReason } from '@/features/docker/dockerContainerLifecycleActions';
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
import useUnifiedResourcesSource from '../useUnifiedResources.ts?raw';
@@ -1626,6 +1627,173 @@ describe('useUnifiedResources', () => {
dispose();
});
it('keeps PMG metadata on the canonical resource for REST-first paints', async () => {
// The Mail Gateway table reads `resource.pmg`. The mapper used to keep it
// only under platformData, so a REST-sourced row rendered every PMG column
// as a dash until a websocket row happened to replace it.
apiFetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{
id: 'pmg-1',
type: 'pmg',
name: 'mail-gateway-eu',
status: 'online',
lastSeen: '2026-02-06T12:00:00Z',
sources: ['pmg'],
uptime: 1_555_222,
technology: 'pmg',
sourceStatus: { pmg: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
actionReadiness: [{ name: 'restart', available: true }],
pmg: {
instanceId: 'pmg-eu',
hostname: 'pmg.example.lan',
version: '8.1-2',
nodeCount: 1,
mailCountTotal: 2_740,
spamIn: 321,
virusIn: 14,
queueTotal: 11,
queueDeferred: 5,
},
},
],
}),
});
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({ query: 'type=pmg&source=pmg', cacheKey: 'pmg-first-paint' });
});
await result!.refetch();
const row = result!.resources().find((resource) => resource.id === 'pmg-1');
expect(row?.pmg).toEqual(
expect.objectContaining({ version: '8.1-2', spamIn: 321, queueDeferred: 5 }),
);
expect(row?.platformData?.pmg).toEqual(row?.pmg);
// The per-source status, action readiness, and technology facets ride the
// same race: Docker lifecycle actions and storage freshness read them at
// the top level, where only websocket rows used to carry them.
expect(row?.sourceStatus?.pmg?.status).toBe('online');
expect(row?.actionReadiness?.[0]?.name).toBe('restart');
expect(row?.technology).toBe('pmg');
dispose();
});
it.each([
{
label: 'stale inventory',
sourceStatus: { docker: { status: 'stale', lastSeen: '2026-02-06T12:00:00Z' } },
actionReadiness: [],
expected: 'Docker inventory is stale. Refresh inventory before running lifecycle actions.',
},
{
label: 'failed inventory',
sourceStatus: {
docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z', error: 'Collector refused' },
},
actionReadiness: [],
expected: 'Docker inventory is not healthy: Collector refused',
},
{
label: 'server action refusal',
sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
actionReadiness: [
{ name: 'restart', available: false, reason: 'Command agent disconnected' },
],
expected: 'Command agent disconnected',
},
{
label: 'no invented action grant',
sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
actionReadiness: [],
expected:
'Pulse does not currently advertise a fresh restart command capability for this container.',
},
])('preserves $label through REST mapping into lifecycle controls', async (testCase) => {
apiFetchMock.mockResolvedValue(
resourceResponse([
{
id: 'docker-container-1',
type: 'app-container',
name: 'edge-web',
status: 'running',
lastSeen: '2026-02-06T12:00:00Z',
sources: ['docker'],
technology: 'docker',
docker: { runtime: 'docker', agentId: 'agent-edge', containerState: 'running' },
sourceStatus: testCase.sourceStatus,
actionReadiness: testCase.actionReadiness,
},
]),
);
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({ query: 'type=app-container', cacheKey: testCase.label });
});
try {
await result!.refetch();
const row = result!.resources()[0];
expect(row.sourceStatus).toEqual(testCase.sourceStatus);
expect(row.actionReadiness).toEqual(testCase.actionReadiness);
expect(row.technology).toBe('docker');
expect(getDockerContainerLifecycleDisabledReason(row, 'restart')).toBe(testCase.expected);
} finally {
dispose();
}
});
it('replaces REST facets without retaining a removed PMG counter or action refusal', async () => {
const base = {
id: 'pmg-1',
type: 'pmg',
name: 'mail-gateway',
status: 'online',
sources: ['pmg'],
lastSeen: '2026-02-06T12:00:00Z',
};
apiFetchMock.mockResolvedValue(
resourceResponse([
{
...base,
technology: 'pmg',
pmg: { version: '8.1-2', queueTotal: 11 },
sourceStatus: { pmg: { status: 'stale', lastSeen: base.lastSeen } },
actionReadiness: [{ name: 'restart', available: false, reason: 'Old refusal' }],
},
]),
);
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({ query: 'type=pmg', cacheKey: 'removed-rest-facets' });
});
try {
await result!.refetch();
expect(result!.resources()[0].pmg?.queueTotal).toBe(11);
apiFetchMock.mockResolvedValue(
resourceResponse([{ ...base, actionReadiness: [], sourceStatus: {} }]),
);
await result!.refetch();
const row = result!.resources()[0];
expect(row.pmg).toBeUndefined();
expect(row.platformData?.pmg).toBeUndefined();
expect(row.actionReadiness).toEqual([]);
expect(row.sourceStatus).toEqual({});
expect(row.technology).toBeUndefined();
} finally {
dispose();
}
});
it('falls back to canonical resource.uptime when no platform-specific field is set (vSphere)', async () => {
// vSphere ESXi hosts and VMs populate only the canonical
// Resource.Uptime field — there's no vmware-specific UptimeSeconds
@@ -18,8 +18,10 @@ import type {
ResourceDockerMeta,
ResourceHealth,
ResourceMetricsTarget,
ResourceActionReadiness,
ResourceAvailabilityMeta,
ResourcePBSMeta,
ResourcePMGMeta,
ResourcePolicyPostureSummary,
ResourcePhysicalDiskMeta,
ResourcePoolHealth,
@@ -210,6 +212,8 @@ type APIResource = {
sources?: string[];
platformScopes?: string[];
sourceStatus?: Record<string, { status: string; lastSeen: string; error?: string }>;
actionReadiness?: ResourceActionReadiness[];
technology?: string;
health?: ResourceHealth;
identity?: {
machineId?: string;
@@ -868,6 +872,17 @@ const toResource = (v2: APIResource): Resource => {
truenas: v2.truenas as ResourceTrueNASMeta | undefined,
vmware: v2.vmware as ResourceVMwareMeta | undefined,
pbs: v2.pbs as ResourcePBSMeta | undefined,
// Mail Gateway rows read `resource.pmg` directly. Leaving it only under
// platformData meant a REST-first paint rendered every PMG column as a
// dash until a websocket row happened to replace it, and never did when
// the REST snapshot landed last.
pmg: v2.pmg as ResourcePMGMeta | undefined,
// Same race for the per-source status, action readiness, and technology
// facets: Docker lifecycle actions and storage freshness read them from
// the top level, and the websocket rows carry them there.
sourceStatus: v2.sourceStatus,
actionReadiness: v2.actionReadiness,
technology: v2.technology,
availability: v2.availability as ResourceAvailabilityMeta | undefined,
availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined,
physicalDisk: v2.physicalDisk,
@@ -35,6 +35,9 @@ import {
getAlertDestinationsDeliveryLogEmpty,
getAlertDestinationsDeliveryLogTitle,
getAlertDestinationsDeliveryLogUnavailable,
getAlertDestinationsDeliveryLogLoading,
getAlertDestinationsHeldEventsLoading,
getAlertDestinationsHeldEventsUnavailable,
getAlertDestinationsTestPausedWarning,
getAlertDestinationsAppriseTargetsHelp,
getAlertDestinationsAppriseTestLabel,
@@ -267,6 +270,28 @@ describe('alert destinations delivery log copy', () => {
);
});
it('distinguishes pending and unavailable activity sources without implying delivery failure', () => {
expect(getAlertDestinationsDeliveryLogLoading()).toBe('Loading delivery attempts...');
expect(getAlertDestinationsHeldEventsLoading()).toBe(
'Loading held and deferred notifications...',
);
expect(getAlertDestinationsHeldEventsUnavailable()).toBe(
'Pulse could not read held or deferred notifications. Refresh to try again.',
);
expect(getAlertDestinationsDeliveryLogUnavailable()).toBe(
'Pulse could not read the delivery log. Refresh to check delivery attempts.',
);
for (const message of [
getAlertDestinationsDeliveryLogLoading(),
getAlertDestinationsHeldEventsLoading(),
getAlertDestinationsHeldEventsUnavailable(),
getAlertDestinationsDeliveryLogUnavailable(),
]) {
expect(message).not.toContain('No alert deliveries');
expect(message).not.toContain('not delivered');
}
});
it('warns that a passing test does not mean live alerts flow while delivery is paused', () => {
const warning = getAlertDestinationsTestPausedWarning();
expect(warning).toContain('Test sent');
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
import {
getWorkloadsGuestBackupStatusPresentation,
getWorkloadsGuestBackupTooltip,
@@ -8,6 +9,18 @@ import {
} from '@/utils/workloadGuestPresentation';
describe('workloadGuestPresentation', () => {
it.each(guestDiskDeferrals)(
'explains %s with and without retained disk evidence',
(reason, message) => {
expect(getWorkloadGuestDiskStatusMessage(reason)).toBe(message);
expect(getWorkloadGuestDiskStatusMessage(`prev-${reason}`)).toBe(
`Using last known disk stats. ${message}`,
);
expect(message).not.toContain('may not be installed');
expect(message).not.toContain('may need to be restarted');
},
);
it('returns canonical guest backup status presentation', () => {
expect(getWorkloadsGuestBackupStatusPresentation('fresh')).toEqual({
color: 'text-green-600 dark:text-green-400',

Some files were not shown because too many files have changed in this diff Show More