From c5e89849bdec09ff4eef5f198e57654d8e16dfc2 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:40:54 +0100 Subject: [PATCH] Show a ZFS scan in the protection badge as a word, not a sentence The protection column is a badge column, but a resilvering or scrubbing ZFS pool showed the backend's risk-reason sentence in it ("ZFS pool local-zfs is resilvering") because StorageRiskSemantics promotes that summary into the rebuild label. The sentence needs 172px and clipped at 1440 in the 156px badge track; the one word a scan row needs there is what the pool is doing. The compact label now derives the activity from the pool's own scan state (scan string or scanDetails function, with the percentage when reported), the same source the backend keys zfs_resilver_active and zfs_scrub_active on, so it never parses the sentence. The sentence stays as the cell title. Rebuilds without ZFS scan data (Unraid parity sync, TrueNAS) keep the label their platform supplies. Contract-Neutral: Presentation-only compact label for ZFS scan activity in the storage protection badge; no storage, recovery, or contract clause behaviour changes. --- frontend-modern/browser-verification.json | 26 ++++-------- .../__tests__/rowPresentation.test.ts | 41 +++++++++++++++++++ .../storageBackups/rowPresentation.ts | 29 ++++++++++++- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 73ded89bb5..536b4521b3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,15 +1,13 @@ { "version": 1, - "base_sha": "6c7cd72cb2fd2dc4b769fb1b7da6423b4f37edb0", - "verified_at": "2026-10-02T22:36:43Z", + "base_sha": "572ea0158b7911bdef9e3405608c7a631b3d7648", + "verified_at": "2026-10-02T22:40:54Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Storage/StoragePoolRow.tsx", - "frontend-modern/src/features/storageBackups/storagePagePresentation.ts" + "frontend-modern/src/features/storageBackups/rowPresentation.ts" ], "content_sha256": { - "frontend-modern/src/components/Storage/StoragePoolRow.tsx": "09417822bd543ddbc312d0c15a4ea74971066d6623d421d889858e56262b9d3d", - "frontend-modern/src/features/storageBackups/storagePagePresentation.ts": "2a93a0176b912283c77b384ee2c66c4bc62c21a04b645eb93917a41740ad2d6e" + "frontend-modern/src/features/storageBackups/rowPresentation.ts": "cabd37e3f736467aecff68359fcb97be08bcabbe5c5ec6e0a0da2fe67d2e3261" }, "routes": [ "/proxmox/storage" @@ -19,25 +17,17 @@ "width": 1440, "height": 900 }, - { - "width": 600, - "height": 844 - }, { "width": 390, "height": 844 - }, - { - "width": 360, - "height": 844 } ], "states": [ - "Proxmox Storage tab pools table in compact (390), narrow (360), operational (600) and full (1440) container layouts with mock data including Online and Degraded pools", - "Pool rows at 390 measured per column with Range rects after the gutter change: name 126px for a 122px need, state 59 for 54, host 106 for 98, usage 39 for 28" + "Proxmox Storage tab pools table at 1440 (full layout, 13 rows) rendering the protection badges \"Protected\" and \"Backup Repository\" with matching titles and state words Online/Unknown", + "The mock backend did not emit its random ZFS-issue scenario (DEGRADED pool with \"resilver in progress\") in six boots during this pass, so the derived Resilvering/Scrubbing word was not observed live; it is pinned by rowPresentation.test.ts (scan string, scanDetails with percentage, and the non-ZFS fallback)" ], "interactions": [ - "Ran the Playwright scrollWidth-over-clientWidth sweep over every th/td leaf at 1440 (nothing reported), 600 (unchanged pre-existing operational host/protection clips), 390 (only the two long pool identities) and 360 (identity strings only)", - "Confirmed the !px-1 gutter applies only in the narrow and compact layouts and the operational/full column geometry is byte-identical to the previous sweep" + "Ran the Playwright scrollWidth-over-clientWidth sweep over every th/td leaf at 1440: nothing reported in the pools table", + "Listed every rendered protection badge text and title at 1440 to confirm non-rebuild labels and titles are unchanged by the compact-label change" ] } diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts index 1afe8dff92..8787f8637d 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts @@ -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(), diff --git a/frontend-modern/src/features/storageBackups/rowPresentation.ts b/frontend-modern/src/features/storageBackups/rowPresentation.ts index 164517aaa2..f9bacbab41 100644 --- a/frontend-modern/src/features/storageBackups/rowPresentation.ts +++ b/frontend-modern/src/features/storageBackups/rowPresentation.ts @@ -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') { @@ -124,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 {