mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user