diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 70ad3c7455..9b51163640 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -820,6 +820,21 @@ guest identity on one line and makes every row keyboard- and touch-operable so the complete guest identity and job context are available through an explicit detail disclosure rather than hover-only title text. Compact status, route, last-sync, and next-sync values must remain fully visible in the scanning row. +The same disclosure stays available at every container width and leads with +the job's complete error text: the scanning row keeps the error on one +truncated line, and the narrower layouts drop the error, schedule, duration, +and failure columns, so the reason a job failed must never depend on the +column being present or wide enough. Replication search matches that error +text. +`ProxmoxCephTable` and `ProxmoxMailGatewayTable` size every visible column +through `getPlatformTableWeightedColumnWidthStyle` above their phone +projections. A fixed-layout table that sizes only its always-on columns leaves +the optional ones to split the remainder, which clips their headers and values +on a full-width desktop table. The Ceph row keeps the health message ahead of +the daemon tally as width shrinks, keeps one five-value phone projection +(cluster, health, OSDs, pools, capacity) whose values each fit whole, and +leaves quorum membership and the FSID to the cluster disclosure, where the +FSID stays searchable and is shown in full. Optional numeric table cells follow the same split: unified-resource consumers own which count or replica field is meaningful, whether the domain should zero-default an absent scheduler/service/inventory count, whether a diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index d836db5f31..3db98d1922 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,22 +1,51 @@ { "version": 1, - "base_sha": "ad7f7a1063d45e26f77faf35bacbca6877f41cac", - "verified_at": "2026-10-02T18:37:50.309899Z", + "base_sha": "e5d0843851a3837a90d71afaced3a009c0606bbf", + "verified_at": "2026-10-02T20:33:38Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/historyChartModel.ts", - "frontend-modern/src/components/shared/useHistoryChartState.ts" + "frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx" ], "content_sha256": { - "frontend-modern/src/components/shared/historyChartModel.ts": "c96437081022b073dca0c730a8617954987f712ee84e058c6078ae3cad05f162", - "frontend-modern/src/components/shared/useHistoryChartState.ts": "38af5f416fb95874e708b08d66fdbcf27b85660840e0096cb1dd24d9bc744991" + "frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "9e828c5dde7d4cb461d7c936edb8f14f1c667eaa488a81802d14014aaf0ead6e", + "frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "ea2b08b041148b12e0aee0afc652a43ab2f4bb937820ec513edfea36a043305d", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "bd5d7b7838852b23c5561c3e722777eb24d4e3324de863eb380c080a5796f2c8", + "frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "0d0ed89d9d2dd164638dadd9f233d308d1cc52616fa2c5be4402c31659403a1d", + "frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "e9fe8c62cdad1f7759f5215044b30c086a3e37ebd50b83cb3f6c64a5c5d58feb", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "39b479e996c89716c095ea8cae7ecccc397989af3e3a0a83ec5c4c228a75e8b5", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "f4768c045d61983799c04245ba8bb2df7fc8da4af5a6f7d88f2968d7d1624c95" }, "routes": [ - "/browser-tests/history-sparse.html" + "/proxmox/replication", + "/proxmox/backups", + "/proxmox/ceph", + "/proxmox/mail" ], "viewports": [ { - "width": 1365, + "width": 1440, + "height": 900 + }, + { + "width": 1280, + "height": 900 + }, + { + "width": 1100, + "height": 900 + }, + { + "width": 900, + "height": 900 + }, + { + "width": 700, "height": 900 }, { @@ -25,37 +54,17 @@ } ], "states": [ - "Single stored 42% before inspection, pointer inspection, measured zero, multiple observations, successful empty response, restored singleton", - "Desktop Chromium light and phone WebKit dark-class presentation; 12 observations, no page errors or horizontal overflow" + "Mock estate (5 PVE nodes, 8 replication jobs with 2 failed, 3 PBS datastores, 69 backup targets, 1 Ceph cluster, 2 Mail Gateways), dark theme, Chromium", + "Replication: failed and healthy rows collapsed and expanded at full, expanded, operational, basic and compact container layouts", + "Backups: Coverage and By date views, protected, attention, unprotected and unknown rows, empty evidence cells", + "Ceph: single auto-opened cluster with pools and services, pool table in the half-width card and the phone card", + "Mail Gateway: healthy and degraded instances at every container layout" ], "interactions": [ - "Pointer at three plot positions resolves to the real singleton timestamp; no mouse live announcements", - "Tab, Home/End, Left/Right, Escape and focus exit; matching API refreshes across sparse, populated and empty results", - "Both actual predecessor engines reproduce zero series pixels and missing pointer tooltip. Parent phone and final phone singleton/tooltip/zero/empty plus final desktop multi screenshots inspected." + "Expanded a failed replication job by its disclosure button at 1440px and by row tap at 390px; the detail shows the whole error and the full job record", + "Switched Backups between Coverage and By date; sortable headers render uppercase beside the plain ones", + "Measured every table cell and header for clipped text (scrollWidth over clientWidth) at each viewport; at 1440px all four tabs report none, and no viewport scrolls horizontally", + "Compared before and after screenshots: Mail Gateway Version/Spam/Virus/Quarantine and Ceph Services no longer truncate, Ceph phone pool usage reads 50.1% instead of a clipped 0.1%" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-sparse.cjs", - "browser_versions": { - "playwright": "1.56.1", - "chromium": "141.0.7390.37", - "webkit": "26.0" - }, - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-chromium-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-webkit-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-pointer.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-zero.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-multiple.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-empty.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-recovered-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-pointer.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-zero.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-multiple.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-empty.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-recovered-single.png" - ], - "limitations": "Synthetic samples, not native PBS/TrueNAS collection, assistive-device testing or whole-application theme-transition acceptance." + "command": "playwright chromium against a private mock-mode stack built from this commit (backend :7685, vite :5203)" } diff --git a/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx b/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx index 4776205117..7cb4bb1846 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx @@ -152,6 +152,10 @@ export const ProxmoxCephClusterDrawer: Component<{ when={pools().length > 0} fallback={

No pools reported.

} > + {/* In a narrow card five columns clip every value, and a clipped + usage label reads as a different number ("0.1%" for 50.1%). The + object count is the least actionable figure, so it yields its + track there and stays in the pool expansion. */} - + Objects - + Stored Used @@ -208,7 +212,7 @@ export const ProxmoxCephClusterDrawer: Component<{ > = { - cluster: 30, - health: 15, - quorum: 13, - osds: 14, - pools: 14, - capacity: 14, + cluster: 36, + health: 21, + osds: 13, + pools: 12, + capacity: 18, }; -export const CEPH_NARROW_COLUMN_WIDTHS: Readonly> = { - cluster: 40, - health: 15, - quorum: 0, - osds: 15, - pools: 15, - capacity: 15, +// Above the phone projection every visible column takes a weighted share of +// the row. Sizing only the always-on columns left PGs, Services, and Detail to +// split the remainder, which truncated both their headers and their values on +// a full-width desktop table. +export const CEPH_COLUMN_WEIGHTS: Readonly> = { + cluster: 20, + health: 10, + quorum: 13, + osds: 12, + pgs: 6, + pools: 13, + capacity: 13, + services: 22, + detail: 14, }; const STATUS_FILTER_OPTIONS: PlatformTableFilterOption[] = [ @@ -154,7 +158,7 @@ function poolsLabel(resource: Resource): JSX.Element { return ( {pools.length} - + {' · '} {formatPlatformTableBytesValue(stored, '0 B')} stored @@ -177,7 +181,10 @@ function osdLabel(resource: Resource): JSX.Element { } > {up}/{total} - up · {inService} in + + {' '} + up · {inService} in + ); } @@ -201,7 +208,10 @@ function capacityLabel(resource: Resource): JSX.Element { return ( {formatPlatformTablePercentValue(pct)} - of {formatPlatformTableBytesValue(total)} + + {' '} + of {formatPlatformTableBytesValue(total)} + ); } @@ -212,7 +222,7 @@ function healthMessageCell(resource: Resource): JSX.Element { const msg = asTrimmedString(resource.ceph?.healthMessage); if (!msg) return —; return ( - + {msg} ); @@ -275,25 +285,38 @@ export const ProxmoxCephTable: Component<{ const layout = createMemo(() => getPlatformTableContainerLayout(observedWidth.width() ?? 1920, [520, 720, 960, 1200]), ); - const isNarrowPhone = createMemo(() => { - const width = observedWidth.width(); - return typeof width === 'number' && width > 0 && width < 360; - }); - // Quorum and pool count are high-value health context even on phones. - const showQuorum = createMemo(() => !isNarrowPhone()); - const showPools = createMemo(() => true); - const showOperational = createMemo(() => ['operational', 'expanded', 'full'].includes(layout())); + // Below the operational width the row cannot hold quorum beside the OSD, + // pool, and capacity figures without clipping all four. + const showQuorum = createMemo(() => ['operational', 'expanded', 'full'].includes(layout())); + // The health message says what is wrong, so it outranks the daemon tally, + // which needs the widest layout to fit and is itemised in the expansion. + // The FSID is forensic detail: searchable, and shown whole in the expansion. + const showPgs = createMemo(() => ['expanded', 'full'].includes(layout())); const showDetail = createMemo(() => ['expanded', 'full'].includes(layout())); - const showFsid = createMemo(() => layout() === 'full'); - const visibleColumnCount = createMemo( - () => - 4 + - Number(showQuorum()) + - Number(showPools()) + - Number(showOperational()) * 2 + - Number(showDetail()) + - Number(showFsid()), - ); + const showServices = createMemo(() => layout() === 'full'); + const visibleColumns = createMemo(() => { + if (layout() === 'compact') return CEPH_PHONE_COLUMNS; + const columns: CephColumn[] = ['cluster', 'health']; + if (showQuorum()) columns.push('quorum'); + columns.push('osds'); + if (showPgs()) columns.push('pgs'); + columns.push('pools', 'capacity'); + if (showServices()) columns.push('services'); + if (showDetail()) columns.push('detail'); + return columns; + }); + const visibleColumnCount = createMemo(() => visibleColumns().length); + const columnWidthStyle = (column: CephColumn) => { + if (layout() !== 'compact') { + return getPlatformTableWeightedColumnWidthStyle( + column, + CEPH_COLUMN_WEIGHTS, + visibleColumns(), + ); + } + const phoneWidths: Partial> = CEPH_PHONE_COLUMN_WIDTHS; + return { width: `${phoneWidths[column] ?? 0}%` }; + }; return ( - - - {(column) => ( - - )} - - - + + + {(column) => ( + + )} + + } header={ <> Cluster Health - - FSID - - - + + Quorum OSDs - + PGs - - - Pools - - - Capacity + Pools - + + + + Services @@ -410,7 +415,6 @@ export const ProxmoxCephTable: Component<{ {(cluster) => { const ind = indicatorFor(classify(cluster)); const name = asTrimmedString(cluster.name) || cluster.id; - const fsid = asTrimmedString(cluster.ceph?.fsid) || '—'; const isOpen = () => selectedId() === cluster.id; const detailRowId = () => `proxmox-ceph-detail-${cluster.id}`; return ( @@ -453,18 +457,9 @@ export const ProxmoxCephTable: Component<{ {ind.label} - - - - {fsid} - - - {quorumLabel(cluster.ceph)} @@ -474,7 +469,7 @@ export const ProxmoxCephTable: Component<{ > {osdLabel(cluster)} - + @@ -486,19 +481,17 @@ export const ProxmoxCephTable: Component<{ - - - {poolsLabel(cluster)} - - + + {poolsLabel(cluster)} + {capacityLabel(cluster)} - + diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx index b3e34b1b23..c963a6e98d 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx @@ -46,6 +46,11 @@ import { } from './proxmoxBackupsTablePresentation'; import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing'; +// Each evidence column is already named by its header, so an empty cell says +// "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 coveragePostureVariant = ( posture: WorkloadCoverageRow['posture'], ): StatusIndicatorVariant => { @@ -403,7 +408,11 @@ export function ProxmoxCoverageTable(props: { > No restore point} + fallback={ + + {COVERAGE_EMPTY_LABEL} + + } > {(artifact) => } @@ -415,7 +424,9 @@ export function ProxmoxCoverageTable(props: { {pbsSource.coverageFallbackLabel} + + {COVERAGE_EMPTY_LABEL} + } > {(artifact) => } @@ -429,8 +440,11 @@ export function ProxmoxCoverageTable(props: { - {archiveSource.coverageFallbackLabel} + + {COVERAGE_EMPTY_LABEL} } > @@ -445,8 +459,11 @@ export function ProxmoxCoverageTable(props: { - {snapshotSource.coverageFallbackLabel} + + {COVERAGE_EMPTY_LABEL} } > @@ -465,7 +482,9 @@ export function ProxmoxCoverageTable(props: { when={layoutMode() !== 'compact'} fallback={—} > - No recent task + + {COVERAGE_EMPTY_LABEL} + } > diff --git a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx index 15a80bfdf0..ec53c5a534 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx @@ -15,6 +15,7 @@ import { getPlatformTableCellClassForKind, getPlatformTableContainerLayout, getPlatformTableHeadClassForKind, + getPlatformTableWeightedColumnWidthStyle, PlatformResponsiveTableLabel, type PlatformResourceStatusFilter, PlatformTableEmptyState, @@ -34,6 +35,9 @@ import { ProxmoxMailGatewayDrawer } from './ProxmoxMailGatewayDrawer'; export type MailGatewayPhoneColumn = 'instance' | 'nodes' | 'uptime' | 'mail' | 'queue' | 'deferred'; +export type MailGatewayColumn = + MailGatewayPhoneColumn | 'version' | 'spam' | 'virus' | 'quarantine'; + export const MAIL_GATEWAY_PHONE_COLUMNS: readonly MailGatewayPhoneColumn[] = [ 'instance', 'nodes', @@ -71,6 +75,23 @@ export const MAIL_GATEWAY_NARROW_PHONE_COLUMN_WIDTHS: Readonly< deferred: 15, }; +// Above the phone projection every visible column takes a weighted share of +// the row. Sizing only the always-on columns left Version, Spam, Virus, and +// Quarantine to split the remainder, which clipped their values to a single +// digit on a full-width desktop table. +export const MAIL_GATEWAY_COLUMN_WEIGHTS: Readonly> = { + instance: 24, + version: 11, + nodes: 10, + uptime: 12, + mail: 12, + spam: 10, + virus: 10, + quarantine: 14, + queue: 12, + deferred: 13, +}; + // Proxmox Mail Gateway instances are mail-flow / quarantine appliances. // The generic infrastructure table renders dashes for Disk I/O / Uptime // / Temperature (PMG only exposes uptime, which we project now) and @@ -104,15 +125,35 @@ export const ProxmoxMailGatewayTable: Component<{ const showOperational = createMemo(() => ['operational', 'expanded', 'full'].includes(layout())); const showVirus = createMemo(() => ['expanded', 'full'].includes(layout())); const showVersion = createMemo(() => layout() === 'full'); - const visibleColumnCount = createMemo( - () => - 4 + - Number(showNodes()) + - Number(showUptime()) + - Number(showOperational()) * 2 + - Number(showVirus()) + - Number(showVersion()), - ); + const visibleColumns = createMemo(() => { + if (layout() === 'compact') { + return isNarrowPhone() ? MAIL_GATEWAY_NARROW_PHONE_COLUMNS : MAIL_GATEWAY_PHONE_COLUMNS; + } + const columns: MailGatewayColumn[] = ['instance']; + if (showVersion()) columns.push('version'); + if (showNodes()) columns.push('nodes'); + if (showUptime()) columns.push('uptime'); + columns.push('mail'); + if (showOperational()) columns.push('spam'); + if (showVirus()) columns.push('virus'); + if (showOperational()) columns.push('quarantine'); + columns.push('queue', 'deferred'); + return columns; + }); + const visibleColumnCount = createMemo(() => visibleColumns().length); + const columnWidthStyle = (column: MailGatewayColumn) => { + if (layout() !== 'compact') { + return getPlatformTableWeightedColumnWidthStyle( + column, + MAIL_GATEWAY_COLUMN_WEIGHTS, + visibleColumns(), + ); + } + const phoneWidths: Partial> = isNarrowPhone() + ? MAIL_GATEWAY_NARROW_PHONE_COLUMN_WIDTHS + : MAIL_GATEWAY_PHONE_COLUMN_WIDTHS; + return { width: `${phoneWidths[column] ?? 0}%` }; + }; return ( - - - {(column) => ( - - )} - - - + + + {(column) => ( + + )} + + } header={ <> Instance @@ -187,20 +211,20 @@ export const ProxmoxMailGatewayTable: Component<{ Nodes {layout() === 'compact' ? 'Age' : 'Uptime'} @@ -220,12 +244,12 @@ export const ProxmoxMailGatewayTable: Component<{ diff --git a/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx index 7c7b416f65..38f0d8e9c8 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx @@ -24,6 +24,10 @@ import { PlatformWindowedRows, withPlatformStatusCounts, } from '@/features/platformPage/sharedPlatformPage'; +import { + getPlatformResourceDetailRowInteractionProps, + PlatformResourceDetailToggleButton, +} from '@/features/platformPage/PlatformResourceDetailTableRow'; import { useObservedElementWidth } from '@/hooks/useObservedElementWidth'; import type { ReplicationJob, ReplicationJobsResponse } from '@/types/api'; import { buildPlatformSearchSuggestions } from '@/features/platformPage/platformSearchSuggestions'; @@ -68,6 +72,9 @@ export const REPLICATION_MOBILE_COLUMN_WIDTHS: Readonly< nextSync: 13, }; +// The guest column carries the disclosure control beside the name, and the +// error text is why a failed job needs attention, so both take the share the +// timing columns can spare. An 8% error column clipped the message mid-word. const REPLICATION_COLUMN_WIDTH_CLASS: Record< PlatformTableContainerLayout, Record @@ -85,52 +92,52 @@ const REPLICATION_COLUMN_WIDTH_CLASS: Record< error: 'w-0', }, basic: { - status: 'w-[14%]', - job: 'w-[11%]', - guest: 'w-[28%]', - route: 'w-[17%]', + status: 'w-[13%]', + job: 'w-[9%]', + guest: 'w-[31%]', + route: 'w-[16%]', schedule: 'w-0', - lastSync: 'w-[15%]', - nextSync: 'w-[15%]', + lastSync: 'w-[14%]', + nextSync: 'w-[17%]', duration: 'w-0', fails: 'w-0', error: 'w-0', }, operational: { status: 'w-[11%]', - job: 'w-[7%]', - guest: 'w-[21%]', - route: 'w-[12%]', - schedule: 'w-[9%]', + job: 'w-[8%]', + guest: 'w-[25%]', + route: 'w-[14%]', + schedule: 'w-0', lastSync: 'w-[11%]', - nextSync: 'w-[13%]', + nextSync: 'w-[14%]', duration: 'w-[11%]', - fails: 'w-[5%]', + fails: 'w-[6%]', error: 'w-0', }, expanded: { - status: 'w-[10%]', - job: 'w-[7%]', + status: 'w-[9%]', + job: 'w-[6%]', guest: 'w-[20%]', route: 'w-[11%]', schedule: 'w-[8%]', - lastSync: 'w-[10%]', - nextSync: 'w-[12%]', - duration: 'w-[10%]', - fails: 'w-[4%]', - error: 'w-[8%]', + lastSync: 'w-[8%]', + nextSync: 'w-[11%]', + duration: 'w-[8%]', + fails: 'w-[5%]', + error: 'w-[14%]', }, full: { - status: 'w-[10%]', + status: 'w-[9%]', job: 'w-[7%]', guest: 'w-[20%]', route: 'w-[11%]', schedule: 'w-[8%]', - lastSync: 'w-[10%]', - nextSync: 'w-[12%]', - duration: 'w-[10%]', + lastSync: 'w-[9%]', + nextSync: 'w-[11%]', + duration: 'w-[8%]', fails: 'w-[4%]', - error: 'w-[8%]', + error: 'w-[13%]', }, }; @@ -291,6 +298,8 @@ export const ProxmoxReplicationTable: Component<{ job.targetNode, job.instance, job.lastSyncStatus, + job.error, + job.comment, ] .filter(Boolean) .join(' ') @@ -329,21 +338,23 @@ export const ProxmoxReplicationTable: Component<{ const layout = createMemo(() => getPlatformTableContainerLayout(observedWidth.width() ?? 1920, [520, 720, 960, 1200]), ); - const canRevealDetails = createMemo(() => layout() === 'compact'); - const mobilePaddingClass = () => (canRevealDetails() ? '!px-1' : ''); - const mobileHeadClass = () => (canRevealDetails() ? '!px-1 !text-[9px]' : ''); - const mobileLastSyncClass = () => - canRevealDetails() ? '![padding-inline:2px] !tracking-normal' : ''; - const showJob = createMemo(() => !canRevealDetails()); + const isCompact = createMemo(() => layout() === 'compact'); + const mobilePaddingClass = () => (isCompact() ? '!px-1' : ''); + const mobileHeadClass = () => (isCompact() ? '!px-1 !text-[9px]' : ''); + const mobileLastSyncClass = () => (isCompact() ? '![padding-inline:2px] !tracking-normal' : ''); + const showJob = createMemo(() => !isCompact()); const showNext = createMemo(() => true); const showOperational = createMemo(() => ['operational', 'expanded', 'full'].includes(layout())); + // The cron-style schedule is the least actionable column, and the mid-width + // table cannot fit it without clipping the route and next-sync values. + const showSchedule = createMemo(() => ['expanded', 'full'].includes(layout())); const showError = createMemo(() => ['expanded', 'full'].includes(layout())); const columnWidthClass = (column: ReplicationColumn) => - canRevealDetails() ? '' : REPLICATION_COLUMN_WIDTH_CLASS[layout()][column]; + isCompact() ? '' : REPLICATION_COLUMN_WIDTH_CLASS[layout()][column]; const visibleColumnCount = createMemo(() => { if (layout() === 'compact') return REPLICATION_MOBILE_COLUMNS.length; if (layout() === 'basic') return 6; - return showError() ? 10 : 9; + return showError() ? 10 : 8; }); return ( @@ -430,7 +441,7 @@ export const ProxmoxReplicationTable: Component<{ - {canRevealDetails() ? 'State' : 'Status'} + {isCompact() ? 'State' : 'Status'} Route - + @@ -497,51 +508,46 @@ export const ProxmoxReplicationTable: Component<{ const guestLabel = formatGuestLabel(job); const jobKey = `${job.id}:${job.jobId}:${job.guestId ?? index()}`; const detailRowId = `replication-job-detail-${index()}`; - const isExpanded = () => canRevealDetails() && expandedJobKey() === jobKey; + const errorText = (job.error ?? '').trim(); + const comment = (job.comment ?? '').trim(); + const isExpanded = () => expandedJobKey() === jobKey; const toggleDetails = () => { - if (!canRevealDetails()) return; setExpandedJobKey(isExpanded() ? null : jobKey); }; return ( <> { - if ( - !canRevealDetails() || - (event.key !== 'Enter' && event.key !== ' ') - ) { - return; - } - event.preventDefault(); - toggleDetails(); - }} - tabIndex={canRevealDetails() ? 0 : undefined} + {...getPlatformResourceDetailRowInteractionProps({ + expanded: isExpanded(), + onToggle: toggleDetails, + class: getPlatformTableRowClass(), + })} > - - {canRevealDetails() - ? formatMobileReplicationGuestLabel(job) - : guestLabel} - +
+ + + {isCompact() + ? formatMobileReplicationGuestLabel(job) + : guestLabel} + +
-
- +
+ {ind.label} @@ -567,7 +573,7 @@ export const ProxmoxReplicationTable: Component<{ class={`${getPlatformTableCellClassForKind('text')} text-base-content ${mobilePaddingClass()}`} > {sourceNode} @@ -584,7 +590,7 @@ export const ProxmoxReplicationTable: Component<{ - + @@ -595,7 +601,7 @@ export const ProxmoxReplicationTable: Component<{ class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content ${mobilePaddingClass()} ${mobileLastSyncClass()}`} > } @@ -612,9 +618,9 @@ export const ProxmoxReplicationTable: Component<{ class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content ${mobilePaddingClass()}`} > - {canRevealDetails() + {isCompact() ? compactReplicationNextSyncText(next.text, next.tone) : next.text} @@ -647,14 +653,14 @@ export const ProxmoxReplicationTable: Component<{ class={`${getPlatformTableCellClassForKind('text')} text-base-content`} > —} > - {job.error} + {errorText} @@ -666,9 +672,21 @@ export const ProxmoxReplicationTable: Component<{ colspan={visibleColumnCount()} class="bg-surface-alt/60 hover:bg-surface-alt/60" cellClass="!whitespace-normal" - contentClass="px-2 py-2" + contentClass="px-2 py-2 sm:px-4 sm:py-3" > -
+ {/* The row truncates and narrower layouts drop columns, so + the expansion is where the whole job is always readable, + starting with why it failed. */} +
+ +
Error
+
+ {errorText} +
+
Guest
{guestLabel}
Job
@@ -679,6 +697,33 @@ export const ProxmoxReplicationTable: Component<{
{sourceNode} → {targetNode}
+
Schedule
+
{job.schedule || '—'}
+
Last sync
+
+ {formatPlatformTableRelativeTimeValue(syncTimeValue(job))} +
+
Next sync
+
+ {next.text} +
+
Duration
+
+ +
+
Failures
+
+ {job.failCount ?? 0} +
+ +
Comment
+
{comment}
+
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx index c30a97c97b..36f8bab9b4 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCephTable.test.tsx @@ -3,12 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { + CEPH_COLUMN_WEIGHTS, CEPH_PHONE_COLUMNS, CEPH_PHONE_COLUMN_WIDTHS, - CEPH_NARROW_COLUMNS, - CEPH_NARROW_COLUMN_WIDTHS, ProxmoxCephTable, } from '../ProxmoxCephTable'; +import cephClusterDrawerSource from '../ProxmoxCephClusterDrawer.tsx?raw'; const makeCluster = (id: string): Resource => ({ id, @@ -45,29 +45,20 @@ const makeCluster = (id: string): Resource => ({ afterEach(cleanup); describe('ProxmoxCephTable', () => { - it('keeps the phone projection dense and identity-led', () => { - expect(CEPH_PHONE_COLUMNS).toEqual([ - 'cluster', - 'health', - 'quorum', - 'osds', - 'pools', - 'capacity', - ]); + it('keeps one phone projection of five values that each fit whole', () => { + // Quorum yields its track on phones: six columns clipped the cluster name, + // the health word, and every figure, and the monitor and manager counts are + // itemised in the expansion's Services table. + expect(CEPH_PHONE_COLUMNS).toEqual(['cluster', 'health', 'osds', 'pools', 'capacity']); expect(CEPH_PHONE_COLUMN_WIDTHS).toEqual({ - cluster: 30, - health: 15, - quorum: 13, - osds: 14, - pools: 14, - capacity: 14, + cluster: 36, + health: 21, + osds: 13, + pools: 12, + capacity: 18, }); - }); - - it('uses a five-column identity-led projection below 360px', () => { - expect(CEPH_NARROW_COLUMNS).toEqual(['cluster', 'health', 'osds', 'pools', 'capacity']); expect( - CEPH_NARROW_COLUMNS.reduce((total, column) => total + CEPH_NARROW_COLUMN_WIDTHS[column], 0), + CEPH_PHONE_COLUMNS.reduce((total, column) => total + CEPH_PHONE_COLUMN_WIDTHS[column], 0), ).toBe(100); }); @@ -130,4 +121,68 @@ describe('ProxmoxCephTable', () => { await waitFor(() => expect(disclosure).toHaveFocus()); expect(disclosure).toHaveAccessibleName('Expand details for ceph-lab'); }); + + it('gives every desktop column a weighted share instead of the remainder', () => { + render(() => ( + } + emptyTitle="No Ceph clusters" + emptyDescription="No clusters" + /> + )); + + const columns = [...document.querySelectorAll('col[data-proxmox-ceph-column]')]; + expect(columns.map((column) => column.dataset.proxmoxCephColumn)).toEqual([ + 'cluster', + 'health', + 'quorum', + 'osds', + 'pgs', + 'pools', + 'capacity', + 'services', + 'detail', + ]); + const widths = columns.map((column) => Number.parseFloat(column.style.width)); + // The unsized Services column used to receive ~4% and clip to "mon…". + expect(Math.min(...widths)).toBeGreaterThan(4); + expect(widths.reduce((total, width) => total + width, 0)).toBeCloseTo(100, 1); + expect(Object.values(CEPH_COLUMN_WEIGHTS).every((weight) => weight > 0)).toBe(true); + }); + + it('keeps the FSID out of the row and whole in the expansion', () => { + render(() => ( + } + emptyTitle="No Ceph clusters" + emptyDescription="No clusters" + /> + )); + + expect(screen.queryByRole('columnheader', { name: 'FSID' })).toBeNull(); + expect(document.querySelector('[data-inline-detail-for="ceph-main"]')).toHaveTextContent( + '8f1c2d3e-fsid', + ); + }); + + it('drops the pool object count, not the usage figure, in a narrow card', () => { + expect(cephClusterDrawerSource).toMatch( + /platform-table-phone-hidden md:w-\[16%\]`\}\s*>\s*Objects/, + ); + expect(cephClusterDrawerSource).toMatch( + /platform-table-mobile-w-20 md:w-\[20%\]`\}\s*>\s*Used/, + ); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx index f2f43394f6..50adeb3dd8 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx @@ -213,4 +213,39 @@ describe('ProxmoxCoverageTable column visibility', () => { expect(document.body.textContent).toContain('workload-0'); expect(document.body.textContent).not.toContain('workload-599'); }); + + it('marks an empty evidence cell with a short label and the full reason on hover', () => { + render(() => ( + } + emptyTitle="" + emptyDescription="" + sortKey={(() => 'posture') as Accessor} + sortDirection={() => 'asc'} + onSort={() => {}} + expandedKeys={new Set()} + onToggleExpand={() => {}} + showPbsColumn={true} + showArchiveColumn={true} + showSnapshotColumn={true} + showTaskColumn={true} + layoutWidth={() => 1_200} + /> + )); + + // The header already names the source, so the cell does not repeat it and + // 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', + 'No PVE backup file', + 'No guest snapshot', + 'No recent task', + ]) { + expect(empty(title)).toHaveTextContent(/^None$/); + } + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts b/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts index 0347920fd5..5a741b4ee3 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; +import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage'; import { + MAIL_GATEWAY_COLUMN_WEIGHTS, + type MailGatewayColumn, MAIL_GATEWAY_NARROW_PHONE_COLUMNS, MAIL_GATEWAY_NARROW_PHONE_COLUMN_WIDTHS, MAIL_GATEWAY_PHONE_COLUMNS, @@ -53,4 +56,21 @@ describe('ProxmoxMailGatewayTable phone presentation', () => { ), ).toBe(100); }); + + it('gives every desktop column a weighted share instead of the remainder', () => { + const columns = Object.keys(MAIL_GATEWAY_COLUMN_WEIGHTS) as MailGatewayColumn[]; + expect(columns).toHaveLength(10); + const widths = columns.map((column) => + Number.parseFloat( + String( + getPlatformTableWeightedColumnWidthStyle(column, MAIL_GATEWAY_COLUMN_WEIGHTS, columns) + .width, + ), + ), + ); + // Version, Spam, Virus, and Quarantine used to split ~14% between them, + // which clipped a three-digit spam count to "3…" on a 1440px screen. + expect(Math.min(...widths)).toBeGreaterThan(7); + expect(widths.reduce((total, width) => total + width, 0)).toBeCloseTo(100, 1); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxReplicationTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxReplicationTable.test.tsx index 2992ff50df..8c37a8c681 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxReplicationTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxReplicationTable.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ReplicationJob } from '@/types/api'; import { @@ -95,4 +95,40 @@ describe('ProxmoxReplicationTable', () => { expect(screen.getByText('backend duration')).toBeInTheDocument(); }); + + it('reveals the whole error from the row disclosure at desktop width', async () => { + const error = + "command 'zfs send -- rpool/data/vm-100-disk-0@__replicate_100-0__' failed: exit code 255"; + render(() => ( + undefined} + emptyIcon={} + emptyTitle="No replication jobs" + emptyDescription="Replication jobs appear here." + /> + )); + + expect(document.querySelector('[data-replication-job-error]')).toBeNull(); + await fireEvent.click( + screen.getByRole('button', { name: 'Expand details for replication job 100-0' }), + ); + + const detail = document.querySelector('[data-replication-job-error]'); + expect(detail).toHaveTextContent(error); + // The expansion wraps; only the one-line row cell truncates. + expect(detail?.className).toContain('break-words'); + expect(detail?.className).not.toContain('truncate'); + }); + + it('gives the error column more room than the 8% that clipped it', () => { + expect(replicationTableSource).toContain("error: 'w-[13%]'"); + expect(replicationTableSource).not.toContain("error: 'w-[8%]'"); + expect(replicationTableSource).not.toContain('max-w-[18rem] truncate'); + }); + + it('matches jobs by their error text', () => { + expect(replicationTableSource).toMatch(/job\.lastSyncStatus,\s*job\.error,\s*job\.comment,/); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx index 8785d393c4..cb34f14569 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx @@ -9,6 +9,7 @@ import { ArtifactSourceBadge, ArtifactStateBadge, ProxmoxBackupWorkloadTypeBadge, + SortableHead, } from '../proxmoxBackupsTableShared'; import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel'; @@ -95,6 +96,28 @@ describe('proxmoxBackupsTableShared', () => { expect(proxmoxBackupsTableSharedSource).not.toContain('ArrowUpDownIcon'); expect(proxmoxBackupsTableSharedSource).not.toContain('SORT_ICON_CLASS'); }); + + it('keeps sortable headers in the same uppercase as their plain neighbours', () => { + render(() => ( + + + + 'posture'} + direction={() => 'asc'} + onSort={() => undefined} + headClass="" + /> + + +
+ )); + + // A button resets text-transform, so the header casing must be restated. + expect(screen.getByRole('button', { name: 'Sort by Workload' })).toHaveClass('uppercase'); + }); }); function artifact(overrides: Partial = {}): RecoverableArtifact { diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts index 1b15aea8ac..b1342d3396 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts @@ -126,15 +126,18 @@ const BACKUP_SERVER_WEIGHTS: Record< used: 18, backups: 9, }, + // Memory and Used carry a percentage plus a used/total pair, the longest + // values in the row, so they take the slack from the one-word status and + // version cells instead of truncating. full: { server: 15, - status: 10, - version: 8, + status: 8, + version: 7, cpu: 6, - memory: 11, + memory: 13.5, uptime: 7, datastore: 13, - used: 15, + used: 15.5, backups: 8, dedup: 7, }, @@ -196,30 +199,33 @@ const COVERAGE_WEIGHTS: Record< Partial> > = { compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 }, - basic: { workload: 31, node: 18, posture: 19, latest: 20, task: 12 }, - operational: { workload: 28, type: 9, node: 14, posture: 17, latest: 17, task: 15 }, + // 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 }, + operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 }, expanded: { - workload: 17, - type: 7, - node: 10, - posture: 13, - latest: 12, - pbs: 11, - archive: 10.5, - snapshot: 11.5, - task: 8, + workload: 19, + type: 6.5, + node: 13.5, + posture: 13.5, + latest: 9.5, + pbs: 9.5, + archive: 10, + snapshot: 9.5, + task: 9, }, full: { - workload: 14.5, - type: 6, - targetId: 8.5, - node: 9, + workload: 17, + type: 5.5, + targetId: 8, + node: 11.5, posture: 11, - latest: 10, - pbs: 11.5, - archive: 10, - snapshot: 12.5, - task: 8, + latest: 8.5, + pbs: 10.5, + archive: 8.5, + snapshot: 12, + task: 7.5, }, }; diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx b/frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx index 17b5bd4bcf..26e89ef9b5 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx +++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx @@ -342,8 +342,11 @@ export function ProxmoxBackupWorkloadTypeBadge(props: { // Sortable column header — matches the shared active-only table pattern. // Clicking an inactive column sorts it with the supplied default direction; // clicking the active column flips direction. +// Buttons reset text-transform and letter-spacing, so a sortable header has to +// restate the header casing or it renders in sentence case beside its +// uppercase non-sortable neighbours. const SORT_BUTTON_CLASS = - 'inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm outline-none transition-colors hover:text-base-content focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface'; + 'inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm uppercase tracking-[inherit] outline-none transition-colors hover:text-base-content focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface'; export function SortableHead(props: { label: string;