diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index fefabf9828..20fd82c066 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -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.
diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md
index f1b2c505e5..39ff236c60 100644
--- a/docs/release-control/v6/internal/subsystems/deployment-installability.md
+++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md
@@ -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,
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 7f1fbe6186..23f91dd104 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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.
diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index 9660e69b59..24cf2bb889 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 80cd898e5a..9d9e0731fd 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -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.
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index c0a85bd7b9..ef5654ebc6 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 975c62f408..cf80b93be1 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -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.
diff --git a/frontend-modern/browser-tests/guest-disk-deferral.cjs b/frontend-modern/browser-tests/guest-disk-deferral.cjs
new file mode 100644
index 0000000000..349adae709
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-disk-deferral.cjs
@@ -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;
+});
diff --git a/frontend-modern/browser-tests/guest-disk-deferral.html b/frontend-modern/browser-tests/guest-disk-deferral.html
new file mode 100644
index 0000000000..16926a6671
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-disk-deferral.html
@@ -0,0 +1,12 @@
+
+
+
- Updates applied through Pulse, with rollback to the backup taken before each one.
+ Updates applied through Pulse, with version rollback using a retained backup.
- This restores the binary, configuration, and data from the backup taken before the
- update to {formatVersionLabel(entry().version_to)} on{' '}
- {formatTimestamp(entry().timestamp)}.
+
- 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.
+
+
+ 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.
+
+
+ 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.{' '}
+
+ Read the recovery instructions
+
+ .
diff --git a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx
index 4f6ab77192..20a5b255e5 100644
--- a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx
+++ b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx
@@ -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(() => );
+ 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(() => );
+ 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(() => );
+ 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((resolve) => {
+ finish = resolve;
+ }),
+ );
+ render(() => );
+ 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());
+ });
});
diff --git a/frontend-modern/src/components/Storage/DiskDetail.tsx b/frontend-modern/src/components/Storage/DiskDetail.tsx
index 71c6cedb3a..3588836cc0 100644
--- a/frontend-modern/src/components/Storage/DiskDetail.tsx
+++ b/frontend-modern/src/components/Storage/DiskDetail.tsx
@@ -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 = (props) => {
historyResourceId,
attributeCards,
historyCharts,
+ healthPresentation,
metricResourceId,
collectionMessages,
liveIOAvailable,
@@ -91,19 +96,32 @@ export const DiskDetail: Component = (props) => {
return (
-
- {status().label}
-
-
+ {/* 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. */}
+
+ {status().label}
+
+ }
+ >
- {healthSummary()}
+ {healthCompactLabel()}
+
+
+ {status().label}
{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
+ }
/>
}
>
diff --git a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx
index d50486f26b..90913e7afa 100644
--- a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx
+++ b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx
@@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
}
>
- {/* 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. */}
-
-
- {(bar) => (
-
+
+
+
+ {presentation().verticalBarsLabel}
+
+
+
+
+ {(bar) => (
-
+
+
-
- )}
-
+ )}
+
+
@@ -242,6 +255,9 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
{presentation().tooltipTitle}
+
+
{props.statusMessage}
+
{(item, idx) => (
= {}): 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(`prev-${reason}`);
+ render(() => );
+ 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(() => );
+ 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(() => );
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
index 69a89b4926..820a69d0c4 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
@@ -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 = {
@@ -55,6 +56,47 @@ const props: ComponentProps = {
describe('GuestDrawerOverview filesystem labels', () => {
afterEach(cleanup);
+ it.each(guestDiskDeferrals)(
+ 'explains %s in the existing drawer even before any disk sample',
+ (reason, message) => {
+ render(() => (
+
+ ));
+ 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(() => );
+ 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(() => (
({
}));
vi.mock('../StackedDiskBar', () => ({
- StackedDiskBar: () => ,
+ StackedDiskBar: (props: { statusMessage?: string }) => (
+
+ ),
}));
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(() => (
+
+
+
+
+
+ ));
+ 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' });
});
diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
index e550f069a0..9cd200c640 100644
--- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
@@ -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(() => (
+
+ ));
+ 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(() => (
+
+ ));
+
+ 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(() => (
+
+ ));
+
+ expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull();
+ });
+ });
});
diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx
index 961906663b..fc3877ae3a 100644
--- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx
@@ -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');
diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts
index 054068a04c..b56964dfe4 100644
--- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts
@@ -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: '?' });
});
diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx
index 40b81bf47e..0c7c67ea17 100644
--- a/frontend-modern/src/components/Workloads/guestRowModel.tsx
+++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx
@@ -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;
};
diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
index 7b2adc3f65..3dbf3531b6 100644
--- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
+++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
@@ -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,
};
}
diff --git a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx
index 312340f152..4fb6b60f9a 100644
--- a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx
+++ b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx
@@ -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([
{
@@ -290,7 +290,8 @@ describe('AlertDeliveryLogCard', () => {
render(() => (
{
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(() => (
+
+ ));
+ 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(() => (
+
+ ));
+ 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(() => (
+
+ ));
+ 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(() => (
+
+ ));
+ 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(() => (
+
+ ));
+ expect(screen.getByRole('status')).toHaveTextContent('Loading delivery attempts...');
+ expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument();
+ });
});
diff --git a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx
index 1b7bfb411b..cb1be99e27 100644
--- a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx
+++ b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx
@@ -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) {