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.
This commit is contained in:
rcourtman
2026-10-02 23:40:54 +01:00
parent 572ea0158b
commit c5e89849bd
3 changed files with 76 additions and 20 deletions
+8 -18
View File
@@ -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"
]
}
@@ -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(),
@@ -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 {