Normalize Proxmox drawer tables

This commit is contained in:
rcourtman
2026-05-20 12:48:36 +01:00
parent 435cf816fb
commit 6ac02408c0
3 changed files with 237 additions and 155 deletions
@@ -7,7 +7,9 @@ import kubernetesDeploymentsTableSource from '@/features/kubernetes/KubernetesDe
import kubernetesNodesTableSource from '@/features/kubernetes/KubernetesNodesTable.tsx?raw';
import kubernetesPageSurfaceSource from '@/features/kubernetes/KubernetesPageSurface.tsx?raw';
import proxmoxBackupsTableSource from '@/features/proxmox/ProxmoxBackupsTable.tsx?raw';
import proxmoxCephClusterDrawerSource from '@/features/proxmox/ProxmoxCephClusterDrawer.tsx?raw';
import proxmoxCephTableSource from '@/features/proxmox/ProxmoxCephTable.tsx?raw';
import proxmoxMailGatewayDrawerSource from '@/features/proxmox/ProxmoxMailGatewayDrawer.tsx?raw';
import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
import proxmoxPageSurfaceSource from '@/features/proxmox/ProxmoxPageSurface.tsx?raw';
@@ -60,6 +62,11 @@ const proxmoxBespokeTableSources = [
proxmoxReplicationTableSource,
];
const proxmoxInlineDetailTableSources = [
proxmoxCephClusterDrawerSource,
proxmoxMailGatewayDrawerSource,
];
describe('platform overview layout guardrails', () => {
it('keeps platform inventory tables on the shared dense table styling contract', () => {
expect(sharedPlatformPageSource).toContain('PLATFORM_TABLE_CARD_CLASS');
@@ -111,6 +118,20 @@ describe('platform overview layout guardrails', () => {
}
});
it('keeps Proxmox inline detail tables on shared platform table primitives', () => {
for (const source of proxmoxInlineDetailTableSources) {
expect(source).toContain("from '@/components/shared/Table'");
expect(source).toContain('PLATFORM_TABLE_HEADER_ROW_CLASS');
expect(source).toContain('PLATFORM_TABLE_BODY_CLASS');
expect(source).toContain('getPlatformTableHeadClassForKind');
expect(source).toContain('getPlatformTableCellClassForKind');
expect(source).not.toContain('<table');
expect(source).not.toContain('<thead');
expect(source).not.toContain('<tbody');
expect(source).not.toContain('divide-y divide-border-subtle');
}
});
it('keeps provider overview pages in the parent-table plus child-inventory stack', () => {
for (const source of overviewSurfaceSources) {
expect(source).toContain('<div class="space-y-4">');
@@ -3,6 +3,20 @@ import XIcon from 'lucide-solid/icons/x';
import { Card } from '@/components/shared/Card';
import { ProgressBar } from '@/components/shared/ProgressBar';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { StatusIndicatorVariant } from '@/utils/status';
import { formatBytes } from '@/utils/format';
import { getMetricColorClass } from '@/utils/metricThresholds';
@@ -48,11 +62,7 @@ function capacityToneFor(percent: number): string {
return getMetricColorClass(percent, 'disk');
}
function CapacityBar(props: {
used: number;
total: number;
percent: number;
}) {
function CapacityBar(props: { used: number; total: number; percent: number }) {
const clamped = Math.max(0, Math.min(100, props.percent));
return (
<ProgressBar
@@ -127,12 +137,7 @@ export const ProxmoxCephClusterDrawer: Component<{
</button>
</header>
<CapacityBar
used={usedCapacity()}
total={totalCapacity()}
percent={usagePercent()}
/>
<CapacityBar used={usedCapacity()} total={totalCapacity()} percent={usagePercent()} />
<div class="grid gap-3 lg:grid-cols-2">
<Card padding="md">
@@ -146,40 +151,58 @@ export const ProxmoxCephClusterDrawer: Component<{
when={pools().length > 0}
fallback={<p class="text-xs text-muted">No pools reported.</p>}
>
<table class="w-full text-xs">
<thead class="text-[10px] uppercase tracking-wide text-muted">
<tr>
<th class="pb-2 text-left font-medium">Pool</th>
<th class="pb-2 text-right font-medium">Objects</th>
<th class="pb-2 text-right font-medium">Stored</th>
<th class="pb-2 text-right font-medium">Avail</th>
<th class="pb-2 text-right font-medium">Used</th>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
<Table class="text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Pool</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Objects
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Stored
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Avail
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Used
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={pools()}>
{(pool) => (
<tr>
<td class="py-2 font-medium text-base-content">{pool.name || '—'}</td>
<td class="py-2 text-right text-base-content tabular-nums">
<TableRow>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} font-medium text-base-content`}
>
{pool.name || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{pool.objects.toLocaleString()}
</td>
<td class="py-2 text-right text-base-content tabular-nums">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{formatBytes(pool.storedBytes)}
</td>
<td class="py-2 text-right text-muted tabular-nums">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted tabular-nums`}
>
{formatBytes(pool.availableBytes)}
</td>
<td class="py-2 text-right">
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('numeric-value')}>
<div class="flex items-center justify-end">
<PoolUsageBar percent={pool.percentUsed} />
</div>
</td>
</tr>
</TableCell>
</TableRow>
)}
</For>
</tbody>
</table>
</TableBody>
</Table>
</Show>
</Card>
@@ -194,38 +217,59 @@ export const ProxmoxCephClusterDrawer: Component<{
when={services().length > 0}
fallback={<p class="text-xs text-muted">No services reported.</p>}
>
<table class="w-full text-xs">
<thead class="text-[10px] uppercase tracking-wide text-muted">
<tr>
<th class="pb-2 text-left font-medium">Service</th>
<th class="pb-2 text-left font-medium">Status</th>
<th class="pb-2 text-right font-medium">Running</th>
<th class="pb-2 text-right font-medium">Total</th>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
<Table class="text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Service</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Running
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={services()}>
{(svc) => {
const cls = classifyService(svc);
return (
<tr>
<td class="py-2 font-mono text-[11px] font-semibold text-base-content uppercase">
<TableRow>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} font-mono text-[11px] font-semibold text-base-content uppercase`}
>
{svc.type}
</td>
<td class="py-2">
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
<StatusDot size="sm" variant={cls.variant} title={cls.label} ariaHidden />
<span class="text-[11px] font-medium text-base-content">{cls.label}</span>
<StatusDot
size="sm"
variant={cls.variant}
title={cls.label}
ariaHidden
/>
<span class="text-[11px] font-medium text-base-content">
{cls.label}
</span>
</div>
</td>
<td class="py-2 text-right text-base-content tabular-nums">{svc.running}</td>
<td class="py-2 text-right text-muted tabular-nums">{svc.total}</td>
</tr>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{svc.running}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted tabular-nums`}
>
{svc.total}
</TableCell>
</TableRow>
);
}}
</For>
</tbody>
</table>
</TableBody>
</Table>
</Show>
</Card>
</div>
@@ -1,23 +1,26 @@
import {
For,
Show,
createMemo,
createResource,
type Component,
} from 'solid-js';
import { For, Show, createMemo, createResource, type Component } from 'solid-js';
import XIcon from 'lucide-solid/icons/x';
import { Card } from '@/components/shared/Card';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { StatusIndicatorVariant } from '@/utils/status';
import { formatBytes } from '@/utils/format';
import { asTrimmedString } from '@/utils/stringUtils';
import { apiFetch } from '@/utils/apiClient';
import type {
PMGInstance,
PMGNodeStatus,
PMGQueueStatus,
PMGSpamBucket,
} from '@/types/api';
import type { PMGInstance, PMGNodeStatus, PMGQueueStatus, PMGSpamBucket } from '@/types/api';
import type { Resource } from '@/types/resource';
// Inline drawer for a single Proxmox Mail Gateway instance. Drops the
@@ -113,9 +116,7 @@ function StackedBar(props: { segments: StackedSegment[]; ariaLabel: string }) {
<div class="space-y-2">
<Show
when={total() > 0}
fallback={
<div class="relative h-2.5 w-full overflow-hidden rounded-full bg-surface-alt" />
}
fallback={<div class="relative h-2.5 w-full overflow-hidden rounded-full bg-surface-alt" />}
>
<div
class="relative flex h-2.5 w-full overflow-hidden rounded-full bg-surface-alt"
@@ -162,15 +163,9 @@ function StackedBar(props: { segments: StackedSegment[]; ariaLabel: string }) {
// metric severity palette: 0 = clean = normal, mid-score = warning,
// high-score = critical.
function SpamHistogram(props: { buckets: PMGSpamBucket[] }) {
const maxCount = createMemo(() =>
Math.max(0, ...props.buckets.map((b) => Math.max(0, b.count))),
);
const maxCount = createMemo(() => Math.max(0, ...props.buckets.map((b) => Math.max(0, b.count))));
return (
<div
class="flex items-end gap-1 h-16"
role="img"
aria-label="Spam score distribution"
>
<div class="flex items-end gap-1 h-16" role="img" aria-label="Spam score distribution">
<For each={props.buckets}>
{(bucket) => {
const heightPct = maxCount() > 0 ? (bucket.count / maxCount()) * 100 : 0;
@@ -219,15 +214,9 @@ function InOutBar(props: {
class="relative flex h-4 w-full overflow-hidden rounded bg-surface-hover"
title={`In ${format(props.inValue)} · Out ${format(props.outValue)}`}
>
<div
class="relative bg-blue-500/60"
style={{ width: `${inWidth}%` }}
/>
<div class="relative bg-blue-500/60" style={{ width: `${inWidth}%` }} />
<div class="w-px bg-surface" />
<div
class="relative bg-purple-500/60"
style={{ width: `${outWidth}%` }}
/>
<div class="relative bg-purple-500/60" style={{ width: `${outWidth}%` }} />
<span class="pointer-events-none absolute inset-0 flex items-center justify-between px-1.5 text-[10px] font-semibold text-base-content leading-none tabular-nums">
<span>In {format(props.inValue)}</span>
<span>Out {format(props.outValue)}</span>
@@ -245,10 +234,7 @@ export const ProxmoxMailGatewayDrawer: Component<{
const meta = props.instanceRow.pmg;
return asTrimmedString(meta?.instanceId) || props.instanceRow.id;
};
const [instance, { refetch }] = createResource<PMGInstance | null, string>(
id,
fetchPMGInstance,
);
const [instance, { refetch }] = createResource<PMGInstance | null, string>(id, fetchPMGInstance);
const stats = createMemo(() => instance()?.mailStats);
const quarantine = createMemo(() => instance()?.quarantine);
@@ -268,9 +254,7 @@ export const ProxmoxMailGatewayDrawer: Component<{
asTrimmedString(props.instanceRow.name) ||
props.instanceRow.id;
const version = () =>
asTrimmedString(instance()?.version) ||
asTrimmedString(props.instanceRow.pmg?.version) ||
'—';
asTrimmedString(instance()?.version) || asTrimmedString(props.instanceRow.pmg?.version) || '—';
const hostname = () => asTrimmedString(instance()?.host);
const inboundSegments = createMemo<StackedSegment[]>(() => {
@@ -487,25 +471,33 @@ export const ProxmoxMailGatewayDrawer: Component<{
when={nodes().length > 0}
fallback={<p class="text-xs text-muted">No cluster nodes reported.</p>}
>
<table class="w-full text-xs">
<thead class="text-[10px] uppercase tracking-wide text-muted">
<tr>
<th class="pb-2 text-left font-medium">Node</th>
<th class="pb-2 text-left font-medium">Role</th>
<th class="pb-2 text-right font-medium">Uptime</th>
<th class="pb-2 text-right font-medium">Load</th>
<th class="pb-2 text-right font-medium">Queue</th>
<th class="pb-2 text-right font-medium">Oldest</th>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
<Table class="text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Node</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Role</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Uptime
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Load
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Queue
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Oldest
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={nodes()}>
{(node) => {
const cls = classifyNode(node);
const queue = queueCell(node.queueStatus);
return (
<tr>
<td class="py-2">
<TableRow>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
@@ -517,17 +509,25 @@ export const ProxmoxMailGatewayDrawer: Component<{
{node.name || '—'}
</span>
</div>
</td>
<td class="py-2 text-base-content text-[11px]">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content text-[11px]`}
>
{asTrimmedString(node.role) || '—'}
</td>
<td class="py-2 text-right text-base-content">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatUptime(node.uptime)}
</td>
<td class="py-2 text-right text-base-content tabular-nums text-[11px]">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums text-[11px]`}
>
{asTrimmedString(node.loadAvg) || '—'}
</td>
<td class="py-2 text-right tabular-nums">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} tabular-nums`}
>
<span class="text-base-content font-semibold">{queue.count}</span>
<span
class="ml-1 text-muted text-[10px] font-mono"
@@ -535,18 +535,20 @@ export const ProxmoxMailGatewayDrawer: Component<{
>
{queue.label}
</span>
</td>
<td class="py-2 text-right text-base-content">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{node.queueStatus?.oldestAge
? formatAge(node.queueStatus.oldestAge)
: '—'}
</td>
</tr>
</TableCell>
</TableRow>
);
}}
</For>
</tbody>
</table>
</TableBody>
</Table>
</Show>
</Card>
@@ -555,51 +557,66 @@ export const ProxmoxMailGatewayDrawer: Component<{
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
Top domains
</h4>
<span class="text-[10px] text-muted tabular-nums">
top {topDomains().length}
</span>
<span class="text-[10px] text-muted tabular-nums">top {topDomains().length}</span>
</div>
<Show
when={topDomains().length > 0}
fallback={<p class="text-xs text-muted">No domain stats reported.</p>}
>
<table class="w-full text-xs">
<thead class="text-[10px] uppercase tracking-wide text-muted">
<tr>
<th class="pb-2 text-left font-medium">Domain</th>
<th class="pb-2 text-right font-medium">Mail</th>
<th class="pb-2 text-right font-medium">Spam</th>
<th class="pb-2 text-right font-medium">Virus</th>
<th class="pb-2 text-right font-medium">Bytes</th>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
<Table class="text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Domain</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Mail
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Spam
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Virus
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Bytes
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={topDomains()}>
{(domain) => (
<tr>
<td
class="py-2 font-mono text-[11px] text-base-content truncate max-w-[14rem]"
title={domain.domain}
<TableRow>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} font-mono text-[11px] text-base-content`}
>
<span class="inline-block max-w-[14rem] truncate" title={domain.domain}>
{domain.domain || '—'}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{domain.domain || '—'}
</td>
<td class="py-2 text-right text-base-content tabular-nums">
{formatNumber(domain.mailCount)}
</td>
<td class="py-2 text-right text-base-content tabular-nums">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{formatNumber(domain.spamCount)}
</td>
<td class="py-2 text-right text-base-content tabular-nums">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{formatNumber(domain.virusCount)}
</td>
<td class="py-2 text-right text-muted tabular-nums">
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted tabular-nums`}
>
{domain.bytes && domain.bytes > 0 ? formatBytes(domain.bytes) : '—'}
</td>
</tr>
</TableCell>
</TableRow>
)}
</For>
</tbody>
</table>
</TableBody>
</Table>
</Show>
</Card>