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;