Stop Proxmox tables clipping the values users came for (#2409)

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
This commit is contained in:
rcourtman
2026-10-02 21:52:34 +01:00
committed by GitHub
14 changed files with 606 additions and 319 deletions
@@ -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
+47 -38
View File
@@ -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)"
}
@@ -152,6 +152,10 @@ export const ProxmoxCephClusterDrawer: Component<{
when={pools().length > 0}
fallback={<p class="text-xs text-muted">No pools reported.</p>}
>
{/* 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. */}
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead
@@ -160,22 +164,22 @@ export const ProxmoxCephClusterDrawer: Component<{
Pool
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[16%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-phone-hidden md:w-[16%]`}
>
<PlatformResponsiveTableLabel compact="Obj" full="Objects" />
Objects
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[20%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-25 md:w-[20%]`}
>
<PlatformResponsiveTableLabel compact="Store" full="Stored" />
Stored
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[20%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-25 md:w-[20%]`}
>
<PlatformResponsiveTableLabel compact="Avail" full="Available" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[20%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[20%]`}
>
Used
</TableHead>
@@ -208,7 +212,7 @@ export const ProxmoxCephClusterDrawer: Component<{
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content platform-table-phone-hidden`}
>
<PlatformTableNumberValue
value={pool.objects}
@@ -20,6 +20,7 @@ import {
getPlatformTableCellClassForKind,
getPlatformTableContainerLayout,
getPlatformTableHeadClassForKind,
getPlatformTableWeightedColumnWidthStyle,
PlatformResponsiveTableLabel,
type PlatformTableFilterOption,
PlatformTableEmptyState,
@@ -50,18 +51,15 @@ import { matchesSearchTermSplit, splitSearchExclusions } from '@/utils/searchQue
type CephStatusFilter = 'all' | 'healthy' | 'warning' | 'critical';
export type CephPhoneColumn = 'cluster' | 'health' | 'quorum' | 'osds' | 'pools' | 'capacity';
export type CephPhoneColumn = 'cluster' | 'health' | 'osds' | 'pools' | 'capacity';
export type CephColumn = CephPhoneColumn | 'quorum' | 'pgs' | 'services' | 'detail';
// One phone projection for every compact width: five values that each fit
// whole. A sixth column left the cluster name, the health word, and every
// figure clipped ("Heal", "MO…", "15/1…"); the monitor and manager counts it
// carried are itemised in the expansion's Services table.
export const CEPH_PHONE_COLUMNS: readonly CephPhoneColumn[] = [
'cluster',
'health',
'quorum',
'osds',
'pools',
'capacity',
];
export const CEPH_NARROW_COLUMNS: readonly CephPhoneColumn[] = [
'cluster',
'health',
'osds',
@@ -70,21 +68,27 @@ export const CEPH_NARROW_COLUMNS: readonly CephPhoneColumn[] = [
];
export const CEPH_PHONE_COLUMN_WIDTHS: Readonly<Record<CephPhoneColumn, number>> = {
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<Record<CephPhoneColumn, number>> = {
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<Record<CephColumn, number>> = {
cluster: 20,
health: 10,
quorum: 13,
osds: 12,
pgs: 6,
pools: 13,
capacity: 13,
services: 22,
detail: 14,
};
const STATUS_FILTER_OPTIONS: PlatformTableFilterOption<CephStatusFilter>[] = [
@@ -154,7 +158,7 @@ function poolsLabel(resource: Resource): JSX.Element {
return (
<span class="tabular-nums">
{pools.length}
<span class="text-muted text-[10px]">
<span class="platform-table-phone-hidden-inline text-muted text-[10px]">
{' · '}
{formatPlatformTableBytesValue(stored, '0 B')} stored
</span>
@@ -177,7 +181,10 @@ function osdLabel(resource: Resource): JSX.Element {
}
>
{up}/{total}
<span class="text-muted text-[10px]"> up · {inService} in</span>
<span class="platform-table-phone-hidden-inline text-muted text-[10px]">
{' '}
up · {inService} in
</span>
</span>
);
}
@@ -201,7 +208,10 @@ function capacityLabel(resource: Resource): JSX.Element {
return (
<span class="tabular-nums">
{formatPlatformTablePercentValue(pct)}
<span class="text-muted text-[10px]"> of {formatPlatformTableBytesValue(total)}</span>
<span class="platform-table-phone-hidden-inline text-muted text-[10px]">
{' '}
of {formatPlatformTableBytesValue(total)}
</span>
</span>
);
}
@@ -212,7 +222,7 @@ function healthMessageCell(resource: Resource): JSX.Element {
const msg = asTrimmedString(resource.ceph?.healthMessage);
if (!msg) return <span class="text-muted">—</span>;
return (
<span class="inline-block max-w-[20rem] truncate" title={msg}>
<span class="block truncate" title={msg}>
{msg}
</span>
);
@@ -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<readonly CephColumn[]>(() => {
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<Record<CephColumn, number>> = CEPH_PHONE_COLUMN_WIDTHS;
return { width: `${phoneWidths[column] ?? 0}%` };
};
return (
<Show
@@ -333,70 +356,52 @@ export const ProxmoxCephTable: Component<{
<PlatformTableShell
tableClass="min-w-[0px] table-fixed text-xs"
colgroup={
<Show when={layout() === 'compact'}>
<colgroup>
<For each={isNarrowPhone() ? CEPH_NARROW_COLUMNS : CEPH_PHONE_COLUMNS}>
{(column) => (
<col
style={{
width: `${
(isNarrowPhone()
? CEPH_NARROW_COLUMN_WIDTHS
: CEPH_PHONE_COLUMN_WIDTHS)[column]
}%`,
}}
data-proxmox-ceph-column={column}
/>
)}
</For>
</colgroup>
</Show>
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col style={columnWidthStyle(column)} data-proxmox-ceph-column={column} />
)}
</For>
</colgroup>
}
header={
<>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[18%]`}
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30`}
>
Cluster
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[12%]`}
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15`}
>
Health
</TableHead>
<Show when={showFsid()}>
<TableHead class={getPlatformTableHeadClassForKind('text')}>FSID</TableHead>
</Show>
<Show when={showQuorum()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-10 md:w-[13%] platform-table-narrow-hidden`}
>
<PlatformResponsiveTableLabel compact="Qrm" full="Quorum" />
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')}`}>
Quorum
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[13%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15`}
>
OSDs
</TableHead>
<Show when={showOperational()}>
<Show when={showPgs()}>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
PGs
</TableHead>
</Show>
<Show when={showPools()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
>
Pools
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15`}
>
Capacity
Pools
</TableHead>
<Show when={showOperational()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15`}
>
<PlatformResponsiveTableLabel compact="Used" full="Capacity" />
</TableHead>
<Show when={showServices()}>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Services</TableHead>
</Show>
<Show when={showDetail()}>
@@ -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<{
<span class={`text-[11px] font-medium ${ind.tone}`}>{ind.label}</span>
</div>
</TableCell>
<Show when={showFsid()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
<span class="inline-block max-w-[10rem] truncate" title={fsid}>
{fsid}
</span>
</TableCell>
</Show>
<Show when={showQuorum()}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content platform-table-narrow-hidden`}
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{quorumLabel(cluster.ceph)}
</TableCell>
@@ -474,7 +469,7 @@ export const ProxmoxCephTable: Component<{
>
{osdLabel(cluster)}
</TableCell>
<Show when={showOperational()}>
<Show when={showPgs()}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
@@ -486,19 +481,17 @@ export const ProxmoxCephTable: Component<{
</Show>
</TableCell>
</Show>
<Show when={showPools()}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{poolsLabel(cluster)}
</TableCell>
</Show>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{poolsLabel(cluster)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{capacityLabel(cluster)}
</TableCell>
<Show when={showOperational()}>
<Show when={showServices()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
@@ -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: {
>
<Show
when={row.latestRecovery}
fallback={<span class="text-muted">No restore point</span>}
fallback={
<span class="text-muted" title="No restore point">
{COVERAGE_EMPTY_LABEL}
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
</Show>
@@ -415,7 +424,9 @@ export function ProxmoxCoverageTable(props: {
<Show
when={row.latestPBS}
fallback={
<span class="text-muted">{pbsSource.coverageFallbackLabel}</span>
<span class="text-muted" title={pbsSource.coverageFallbackLabel}>
{COVERAGE_EMPTY_LABEL}
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
@@ -429,8 +440,11 @@ export function ProxmoxCoverageTable(props: {
<Show
when={row.latestArchive}
fallback={
<span class="text-muted">
{archiveSource.coverageFallbackLabel}
<span
class="text-muted"
title={archiveSource.coverageFallbackLabel}
>
{COVERAGE_EMPTY_LABEL}
</span>
}
>
@@ -445,8 +459,11 @@ export function ProxmoxCoverageTable(props: {
<Show
when={row.latestSnapshot}
fallback={
<span class="text-muted">
{snapshotSource.coverageFallbackLabel}
<span
class="text-muted"
title={snapshotSource.coverageFallbackLabel}
>
{COVERAGE_EMPTY_LABEL}
</span>
}
>
@@ -465,7 +482,9 @@ export function ProxmoxCoverageTable(props: {
when={layoutMode() !== 'compact'}
fallback={<span class="block text-center text-muted">—</span>}
>
<span class="text-muted">No recent task</span>
<span class="text-muted" title="No recent task">
{COVERAGE_EMPTY_LABEL}
</span>
</Show>
}
>
@@ -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<Record<MailGatewayColumn, number>> = {
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<readonly MailGatewayColumn[]>(() => {
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<Record<MailGatewayColumn, number>> = isNarrowPhone()
? MAIL_GATEWAY_NARROW_PHONE_COLUMN_WIDTHS
: MAIL_GATEWAY_PHONE_COLUMN_WIDTHS;
return { width: `${phoneWidths[column] ?? 0}%` };
};
return (
<Show
@@ -150,35 +191,18 @@ export const ProxmoxMailGatewayTable: Component<{
<PlatformTableShell
tableClass="min-w-[0px] table-fixed text-xs"
colgroup={
<Show when={layout() === 'compact'}>
<colgroup>
<For
each={
isNarrowPhone()
? MAIL_GATEWAY_NARROW_PHONE_COLUMNS
: MAIL_GATEWAY_PHONE_COLUMNS
}
>
{(column) => (
<col
style={{
width: `${
(isNarrowPhone()
? MAIL_GATEWAY_NARROW_PHONE_COLUMN_WIDTHS
: MAIL_GATEWAY_PHONE_COLUMN_WIDTHS)[column]
}%`,
}}
data-proxmox-mail-column={column}
/>
)}
</For>
</colgroup>
</Show>
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col style={columnWidthStyle(column)} data-proxmox-mail-column={column} />
)}
</For>
</colgroup>
}
header={
<>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[18%]`}
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30`}
>
Instance
</TableHead>
@@ -187,20 +211,20 @@ export const ProxmoxMailGatewayTable: Component<{
</Show>
<Show when={showNodes()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[12%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15`}
>
Nodes
</TableHead>
</Show>
<Show when={showUptime()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15`}
>
{layout() === 'compact' ? 'Age' : 'Uptime'}
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15`}
>
<PlatformResponsiveTableLabel compact="In" full="Mail in" />
</TableHead>
@@ -220,12 +244,12 @@ export const ProxmoxMailGatewayTable: Component<{
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15`}
>
<PlatformResponsiveTableLabel compact="Q" full="Queue" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-10 md:w-[14%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-10`}
>
<PlatformResponsiveTableLabel compact="Def" full="Deferred" />
</TableHead>
@@ -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<ReplicationColumn, string>
@@ -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<{
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('status')} ${mobileHeadClass()}`}
>
{canRevealDetails() ? 'State' : 'Status'}
{isCompact() ? 'State' : 'Status'}
</TableHead>
<Show when={showJob()}>
<TableHead
@@ -445,7 +456,7 @@ export const ProxmoxReplicationTable: Component<{
>
Route
</TableHead>
<Show when={showOperational()}>
<Show when={showSchedule()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('schedule')}`}
>
@@ -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 (
<>
<TableRow
class={`${getPlatformTableRowClass()} ${canRevealDetails() ? 'cursor-pointer' : ''}`}
aria-controls={isExpanded() ? detailRowId : undefined}
aria-expanded={
canRevealDetails() ? (isExpanded() ? 'true' : 'false') : undefined
}
onClick={toggleDetails}
onKeyDown={(event) => {
if (
!canRevealDetails() ||
(event.key !== 'Enter' && event.key !== ' ')
) {
return;
}
event.preventDefault();
toggleDetails();
}}
tabIndex={canRevealDetails() ? 0 : undefined}
{...getPlatformResourceDetailRowInteractionProps({
expanded: isExpanded(),
onToggle: toggleDetails,
class: getPlatformTableRowClass(),
})}
>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content ${mobilePaddingClass()}`}
title={guestLabel}
>
<span
class={`block truncate ${canRevealDetails() ? 'text-[10px]' : ''}`}
>
{canRevealDetails()
? formatMobileReplicationGuestLabel(job)
: guestLabel}
</span>
<div class="flex min-w-0 items-center gap-2">
<PlatformResourceDetailToggleButton
expanded={isExpanded()}
resourceLabel={`replication job ${job.jobId || job.id}`}
controlsId={detailRowId}
onToggle={toggleDetails}
/>
<span
class={`min-w-0 truncate ${isCompact() ? 'text-[10px]' : ''}`}
>
{isCompact()
? formatMobileReplicationGuestLabel(job)
: guestLabel}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} ${mobilePaddingClass()}`}
>
<div
class={`flex items-center ${canRevealDetails() ? 'gap-0' : 'gap-2'}`}
>
<Show when={!canRevealDetails()}>
<div class={`flex items-center ${isCompact() ? 'gap-0' : 'gap-2'}`}>
<Show when={!isCompact()}>
<StatusDot
size="sm"
variant={ind.variant}
@@ -550,7 +556,7 @@ export const ProxmoxReplicationTable: Component<{
/>
</Show>
<span
class={`${canRevealDetails() ? 'text-[10px]' : 'text-[11px]'} font-medium ${ind.tone}`}
class={`${isCompact() ? 'text-[10px]' : 'text-[11px]'} font-medium ${ind.tone}`}
>
{ind.label}
</span>
@@ -567,7 +573,7 @@ export const ProxmoxReplicationTable: Component<{
class={`${getPlatformTableCellClassForKind('text')} text-base-content ${mobilePaddingClass()}`}
>
<Show
when={canRevealDetails()}
when={isCompact()}
fallback={
<span class="inline-flex items-center gap-1 font-mono text-[11px]">
<span>{sourceNode}</span>
@@ -584,7 +590,7 @@ export const ProxmoxReplicationTable: Component<{
</span>
</Show>
</TableCell>
<Show when={showOperational()}>
<Show when={showSchedule()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
@@ -595,7 +601,7 @@ export const ProxmoxReplicationTable: Component<{
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content ${mobilePaddingClass()} ${mobileLastSyncClass()}`}
>
<Show
when={canRevealDetails()}
when={isCompact()}
fallback={
<PlatformTableRelativeTimeValue value={syncTimeValue(job)} />
}
@@ -612,9 +618,9 @@ export const ProxmoxReplicationTable: Component<{
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content ${mobilePaddingClass()}`}
>
<span
class={`${NEXT_SYNC_TONE_CLASS[next.tone]} ${canRevealDetails() ? 'text-[10px]' : ''}`}
class={`${NEXT_SYNC_TONE_CLASS[next.tone]} ${isCompact() ? 'text-[10px]' : ''}`}
>
{canRevealDetails()
{isCompact()
? compactReplicationNextSyncText(next.text, next.tone)
: next.text}
</span>
@@ -647,14 +653,14 @@ export const ProxmoxReplicationTable: Component<{
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={!!job.error?.trim()}
when={errorText}
fallback={<span class="text-muted">—</span>}
>
<span
class="inline-block max-w-[18rem] truncate text-red-600 dark:text-red-300"
title={job.error ?? ''}
class="block truncate text-red-600 dark:text-red-300"
title={errorText}
>
{job.error}
{errorText}
</span>
</Show>
</TableCell>
@@ -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"
>
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 text-[10px] leading-4">
{/* The row truncates and narrower layouts drop columns, so
the expansion is where the whole job is always readable,
starting with why it failed. */}
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-[10px] leading-4 sm:text-xs sm:leading-5">
<Show when={errorText}>
<dt class="font-semibold text-muted">Error</dt>
<dd
class="break-words text-red-600 dark:text-red-300"
data-replication-job-error
>
{errorText}
</dd>
</Show>
<dt class="font-semibold text-muted">Guest</dt>
<dd class="break-all text-base-content">{guestLabel}</dd>
<dt class="font-semibold text-muted">Job</dt>
@@ -679,6 +697,33 @@ export const ProxmoxReplicationTable: Component<{
<dd class="font-mono text-base-content">
{sourceNode} → {targetNode}
</dd>
<dt class="font-semibold text-muted">Schedule</dt>
<dd class="font-mono text-base-content">{job.schedule || '—'}</dd>
<dt class="font-semibold text-muted">Last sync</dt>
<dd class="text-base-content">
{formatPlatformTableRelativeTimeValue(syncTimeValue(job))}
</dd>
<dt class="font-semibold text-muted">Next sync</dt>
<dd
class={NEXT_SYNC_TONE_CLASS[next.tone] || 'text-base-content'}
>
{next.text}
</dd>
<dt class="font-semibold text-muted">Duration</dt>
<dd class="text-base-content">
<PlatformTableDurationValue
seconds={job.lastSyncDurationSeconds}
fallbackText={job.lastSyncDurationHuman}
/>
</dd>
<dt class="font-semibold text-muted">Failures</dt>
<dd class="tabular-nums text-base-content">
{job.failCount ?? 0}
</dd>
<Show when={comment}>
<dt class="font-semibold text-muted">Comment</dt>
<dd class="break-words text-base-content">{comment}</dd>
</Show>
</dl>
</InlineDetailTableRow>
</Show>
@@ -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(() => (
<ProxmoxCephTable
resources={[
{
...makeCluster('ceph-main'),
ceph: { ...makeCluster('ceph-main').ceph!, fsid: '8f1c2d3e-fsid' },
},
]}
emptyIcon={<span />}
emptyTitle="No Ceph clusters"
emptyDescription="No clusters"
/>
));
const columns = [...document.querySelectorAll<HTMLElement>('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(() => (
<ProxmoxCephTable
resources={[
{
...makeCluster('ceph-main'),
ceph: { ...makeCluster('ceph-main').ceph!, fsid: '8f1c2d3e-fsid' },
},
]}
emptyIcon={<span />}
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/,
);
});
});
@@ -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(() => (
<ProxmoxCoverageTable
rows={[row]}
hasAnyRows
emptyIcon={<span />}
emptyTitle=""
emptyDescription=""
sortKey={(() => 'posture') as Accessor<CoverageSortKey>}
sortDirection={() => 'asc'}
onSort={() => {}}
expandedKeys={new Set<string>()}
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$/);
}
});
});
@@ -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);
});
});
@@ -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(() => (
<ProxmoxReplicationTable
jobs={[replicationJob({ lastSyncStatus: 'error', failCount: 3, error })]}
error={undefined}
onRetry={() => undefined}
emptyIcon={<span />}
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,/);
});
});
@@ -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(() => (
<table>
<thead>
<tr>
<SortableHead
label="Workload"
sortKey="workload"
currentSort={() => 'posture'}
direction={() => 'asc'}
onSort={() => undefined}
headClass=""
/>
</tr>
</thead>
</table>
));
// 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> = {}): RecoverableArtifact {
@@ -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<Record<CoverageColumnId, number>>
> = {
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,
},
};
@@ -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<K extends string>(props: {
label: string;