diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c51dd7ca1..7d2acc9e0 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,17 +1,19 @@ { "version": 1, - "base_sha": "de5a1b20df6e825dfc590dee334b8877569a74a2", - "verified_at": "2026-08-03T22:40:33Z", + "base_sha": "3a24d9694f3bb4062170149d02e0eb8241cf398b", + "verified_at": "2026-08-03T23:30:46Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/docker/DockerHostsTable.tsx", - "frontend-modern/src/features/docker/DockerPageSurface.tsx" + "frontend-modern/src/components/Workloads/GuestRowCells.tsx", + "frontend-modern/src/components/Workloads/guestRowModel.tsx" ], "content_sha256": { - "frontend-modern/src/features/docker/DockerHostsTable.tsx": "c4042eb72e027b97761f5cd02ba7021e0d7b3669e05e9db9f52af46ecb5bdbbf", - "frontend-modern/src/features/docker/DockerPageSurface.tsx": "f4fe3a202d92efaedf61409a4602340f82411ff01252cc399cff758eff23857f" + "frontend-modern/src/components/Workloads/GuestRowCells.tsx": "f34d05f6731bacafb07613ac982ba621865e0f3e1db7baae42cff7ac994917d8", + "frontend-modern/src/components/Workloads/guestRowModel.tsx": "6be3cc7e2958f3bd8ead420dac8467b11941b1037721e881a4f1e85e1f5c8cd1" }, - "routes": ["/standalone/machines", "/docker/overview"], + "routes": [ + "/proxmox" + ], "viewports": [ { "width": 1440, @@ -23,15 +25,19 @@ } ], "states": [ - "Desktop Docker Overview fully loaded with the scoped Docker hosts title and View all machines action", - "Desktop Machines inventory after following the View all machines action, showing all five current machines", - "Phone-width Docker Overview fully loaded with the scoped title and action aligned without clipping", - "Phone-width Machines inventory after following the View all machines action" + "Proxmox Overview grouped workload table against the live 7655 backend with 34 guests across delly, minipc and pi", + "Avail column populated for probed guests and blank for unprobed guests such as iventoy, ollama and tails-anon", + "Backup column with all 34 rows in the fresh state rendering a bare green shield with no drawn age and no pill chrome", + "View preferences popover open with the Columns section expanded and the new Avail toggle listed", + "Avail column hidden through the new toggle and then restored to the original hidden set" ], "interactions": [ - "Followed View all machines from Docker Overview at desktop width and confirmed navigation to /standalone/machines with all five rows", - "Repeated the View all machines handoff at phone width and confirmed the complete machine table rendered", - "Inspected the Docker hosts title, action placement, table containment, mobile navigation, and footer at both widths", - "Confirmed the browser console reported no warnings or errors during either responsive journey" + "Signed in at 1440x900 and read the Proxmox Overview table through DOM assertions on the availability cells and the backup status badges", + "Confirmed availability cells render bare latency such as 3ms, 1ms and 5ms with no trailing freshness suffix", + "Confirmed all 34 fresh backup badges render empty text, carry the green shield colour, and have no rounded-full pill chrome, while their aria-label still reports the backup age", + "Opened View then Columns, clicked the Avail checkbox off, and confirmed the availability header and every availability cell were removed and the preference persisted to workloadsHiddenColumns", + "Clicked Avail back on and confirmed workloadsHiddenColumns returned to its original value of aiContext, os, ip", + "Resized to 390x844 and confirmed both columns render unchanged with no horizontal document overflow", + "Read console messages filtered to errors and found none across both viewports" ] } diff --git a/frontend-modern/src/components/Workloads/GuestRowCells.tsx b/frontend-modern/src/components/Workloads/GuestRowCells.tsx index 288c9fa21..5cf3be70e 100644 --- a/frontend-modern/src/components/Workloads/GuestRowCells.tsx +++ b/frontend-modern/src/components/Workloads/GuestRowCells.tsx @@ -86,18 +86,24 @@ function getBackupAgeBadgeLabel( } function getBackupAgeBadgeClass(status: BackupInfo['status']): string { - const base = - 'inline-flex h-5 min-w-[3.25rem] items-center justify-center gap-1 rounded-full border px-1.5 text-[10px] font-semibold leading-none tabular-nums cursor-help'; + const layout = + 'inline-flex h-5 min-w-[3.25rem] items-center justify-center gap-1 px-1.5 text-[10px] font-semibold leading-none tabular-nums cursor-help'; + // A healthy backup only needs its shield. The colour already carries the + // verdict and the threshold already decides what counts as acceptable, so the + // exact age is decoration on the rows nobody has to act on; it stays in the + // tooltip and the aria-label. Age and pill chrome are reserved for the states + // that need attention. + const pill = `${layout} rounded-full border`; switch (status) { case 'fresh': - return `${base} border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/40 dark:text-green-300`; + return `${layout} text-green-600 dark:text-green-400`; case 'stale': - return `${base} border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/70 dark:bg-yellow-950/40 dark:text-yellow-300`; + return `${pill} border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/70 dark:bg-yellow-950/40 dark:text-yellow-300`; case 'critical': - return `${base} border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-950/40 dark:text-red-300`; + return `${pill} border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-950/40 dark:text-red-300`; case 'never': - return `${base} border-border bg-surface-alt text-muted`; + return `${pill} border-border bg-surface-alt text-muted`; } } @@ -326,7 +332,7 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine aria-label={ariaLabel()} > - {badgeLabel()} + + {badgeLabel()} + @@ -421,8 +429,12 @@ function AvailabilityProbeCell(props: { presentation: AvailabilityProbePresentat : result === 'not checked' ? 'pending' : result; + // 'fresh' is Pulse vouching for its own probe pipeline, not a fact about the + // guest. It is true on nearly every row, so printing it turns the column into + // repeated noise. Surface freshness only when it is a reason to distrust the + // result. const freshness = p().freshnessLabel === 'freshness unknown' ? 'unknown' : p().freshnessLabel; - return `${compactResult} · ${freshness}`; + return freshness === 'fresh' ? compactResult : `${compactResult} · ${freshness}`; }); return ( diff --git a/frontend-modern/src/components/Workloads/__tests__/BackupStatusCell.test.tsx b/frontend-modern/src/components/Workloads/__tests__/BackupStatusCell.test.tsx new file mode 100644 index 000000000..f22c33e12 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/BackupStatusCell.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@solidjs/testing-library'; + +import { BackupStatusCell } from '../GuestRowCells'; + +const HOUR_MS = 60 * 60 * 1000; + +const renderCell = (lastBackup: string | number | null) => { + const { container } = render(() => ); + const badge = container.querySelector('span[aria-label^="Backup status"]'); + if (!badge) throw new Error('backup badge not rendered'); + return badge as HTMLElement; +}; + +describe('BackupStatusCell', () => { + afterEach(() => { + cleanup(); + }); + + // The healthy state is the overwhelming majority of rows. It carries the + // verdict in the shield colour alone; the age would be repeated decoration. + it('renders a bare green shield with no age text when the backup is fresh', () => { + const badge = renderCell(Date.now() - 2 * HOUR_MS); + + expect(badge.textContent?.trim()).toBe(''); + expect(badge.className).toContain('text-green-600'); + expect(badge.className).not.toContain('rounded-full'); + expect(badge.className).not.toContain('border'); + expect(badge.querySelector('svg')).not.toBeNull(); + }); + + it('keeps the age available to assistive tech even when it is not drawn', () => { + const badge = renderCell(Date.now() - 2 * HOUR_MS); + + expect(badge.getAttribute('aria-label')).toMatch(/^Backup status: fresh, last backup /); + }); + + it('draws the age and pill chrome when the backup is stale', () => { + const badge = renderCell(Date.now() - 48 * HOUR_MS); + + expect(badge.textContent?.trim()).not.toBe(''); + expect(badge.className).toContain('rounded-full'); + expect(badge.className).toContain('border-yellow-200'); + }); + + it('draws the age and pill chrome when the backup is critical', () => { + const badge = renderCell(Date.now() - 200 * HOUR_MS); + + expect(badge.textContent?.trim()).not.toBe(''); + expect(badge.className).toContain('rounded-full'); + expect(badge.className).toContain('border-red-200'); + }); + + it('labels a guest that has never been backed up', () => { + const badge = renderCell(null); + + expect(badge.textContent?.trim()).toBe('None'); + expect(badge.className).toContain('rounded-full'); + expect(badge.getAttribute('aria-label')).toBe('Backup status: never'); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 4f96ae63f..3526ef3e4 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -1337,7 +1337,7 @@ describe('context column for PVE workloads', () => { }); describe('backup column', () => { - it('shows compact age text for supported guests with a backup', () => { + it('keeps a fresh backup age accessible without drawing it in the row', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-26T12:00:00Z')); @@ -1350,12 +1350,32 @@ describe('backup column', () => { visibleColumnIds: ['name', 'backup'], }); - expect(screen.getByText('5h')).toBeTruthy(); + // A healthy backup is carried by the shield colour alone. The age stays in + // the aria-label and the tooltip so nothing is lost. + expect(screen.queryByText('5h')).toBeNull(); expect( container.querySelector('[aria-label="Backup status: fresh, last backup 5 hours ago"]'), ).toBeTruthy(); }); + it('draws the compact age for supported guests whose backup is stale', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-26T12:00:00Z')); + + const { container } = renderGuestRow({ + guest: makeGuest({ + type: 'qemu', + workloadType: 'vm', + lastBackup: Date.parse('2026-05-24T12:00:00Z'), + }), + visibleColumnIds: ['name', 'backup'], + }); + + const badge = container.querySelector('[aria-label^="Backup status: stale"]'); + expect(badge).toBeTruthy(); + expect(badge?.textContent?.trim()).toMatch(/\d/); + }); + it('shows None for supported guests without a backup', () => { renderGuestRow({ guest: makeGuest({ type: 'qemu', workloadType: 'vm', lastBackup: 0 }), diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index be5603386..05d6aa687 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -183,6 +183,7 @@ export const GUEST_COLUMNS: ColumnDef[] = [ width: '56px', minWidth: '48px', maxWidth: '64px', + toggleable: true, kind: 'badge', }, {