From d564267248449378a1badd84c3e98ec3e4806c97 Mon Sep 17 00:00:00 2001
From: rcourtman <8825017+rcourtman@users.noreply.github.com>
Date: Fri, 2 Oct 2026 21:34:32 +0100
Subject: [PATCH] Stop Proxmox tables clipping the values users came for
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On a 1440px screen the Mail Gateway table showed a spam count as "3…"
and the Ceph table showed its Services column as "mon…". Both tables
use fixed layout and sized only their always-on columns, so the
optional ones split the few percent left over. Every visible column
now takes a weighted share through the shared platform helper.
Replication hid the reason a job failed. The error column had 8% of
the row and clipped mid-word, mid-width layouts dropped the column,
and the phone expansion omitted it. Rows now expand at every width to
the full job record with the whole error first, the error column is
wider, and search matches error text.
Backups sortable headers rendered in sentence case beside uppercase
plain headers because a button resets text-transform. Coverage rows
truncated node and workload names while short cells sat half empty,
and empty evidence cells repeated their header ("No PVE backup file")
until they truncated too. They now read "None" with the long form on
hover.
On phones the Ceph row clipped every value and the pool usage bar
clipped "50.1%" to "0.1%". The phone projection keeps five values
that fit whole. The FSID column, which never fit, leaves the row and
stays searchable and whole in the expansion.
Contract-Neutral: presentation sizing and labels only; owning unified-resources clause updated; no storage-recovery or frontend-primitives clause, API or primitive change
---
.../internal/subsystems/unified-resources.md | 15 ++
frontend-modern/browser-verification.json | 85 ++++----
.../proxmox/ProxmoxCephClusterDrawer.tsx | 18 +-
.../src/features/proxmox/ProxmoxCephTable.tsx | 201 +++++++++---------
.../features/proxmox/ProxmoxCoverageTable.tsx | 33 ++-
.../proxmox/ProxmoxMailGatewayTable.tsx | 102 +++++----
.../proxmox/ProxmoxReplicationTable.tsx | 197 ++++++++++-------
.../__tests__/ProxmoxCephTable.test.tsx | 99 +++++++--
.../__tests__/ProxmoxCoverageTable.test.tsx | 35 +++
.../__tests__/ProxmoxMailGatewayTable.test.ts | 20 ++
.../ProxmoxReplicationTable.test.tsx | 38 +++-
.../proxmoxBackupsTableShared.test.tsx | 23 ++
.../proxmoxBackupsTablePresentation.ts | 54 ++---
.../proxmox/proxmoxBackupsTableShared.tsx | 5 +-
14 files changed, 606 insertions(+), 319 deletions(-)
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;