Polish Proxmox table and settings consistency

This commit is contained in:
rcourtman
2026-05-20 11:59:02 +01:00
parent 111af289ab
commit 29fc340d6a
5 changed files with 215 additions and 139 deletions
@@ -27,15 +27,20 @@ interface SettingsProps {
setThemePreference: (pref: 'light' | 'dark' | 'system') => void;
}
const SettingsPanelLoadingFallback = () => (
<div class="px-1 py-1 text-xs text-muted" role="status">
{getSettingsLoadingState().text}
</div>
);
const Settings: Component<SettingsProps> = (props) => {
const { state, connected: _connected } = useWebSocket();
const navigate = useNavigate();
const location = useLocation();
const { activeTab, selectedAgent, setActiveTab, handleSelectAgent } =
useSettingsNavigation({
navigate,
location,
});
const { activeTab, selectedAgent, setActiveTab, handleSelectAgent } = useSettingsNavigation({
navigate,
location,
});
const {
headerMeta,
sidebarCollapsed,
@@ -166,22 +171,10 @@ const Settings: Component<SettingsProps> = (props) => {
>
<Show
when={activeSettingsPanelEntry()}
fallback={
securityStatusLoading() ? (
<div class="flex items-center justify-center rounded-md border border-dashed border-border bg-surface-alt py-12 text-sm text-muted">
{getSettingsLoadingState().text}
</div>
) : null
}
fallback={securityStatusLoading() ? <SettingsPanelLoadingFallback /> : null}
>
{(entry) => (
<Suspense
fallback={
<div class="flex items-center justify-center rounded-md border border-dashed border-border bg-surface-alt py-12 text-sm text-muted">
{getSettingsLoadingState().text}
</div>
}
>
<Suspense fallback={<SettingsPanelLoadingFallback />}>
<Dynamic component={entry().component} {...(entry().getProps?.() ?? {})} />
</Suspense>
)}
@@ -83,6 +83,8 @@ describe('settings architecture guardrails', () => {
expect(settingsSource).toContain('useSettingsNavigation({');
expect(settingsSource).toContain('<SettingsPageShell');
expect(settingsSource).toContain('<SettingsDialogs');
expect(settingsSource).toContain('const SettingsPanelLoadingFallback');
expect(settingsSource).not.toContain('border-dashed border-border bg-surface-alt py-12');
expect(settingsSource).not.toContain('<PageHeader');
expect(settingsPageShellSource).toContain('import { PageHeader } from');
@@ -6,8 +6,10 @@ import kubernetesClustersTableSource from '@/features/kubernetes/KubernetesClust
import kubernetesDeploymentsTableSource from '@/features/kubernetes/KubernetesDeploymentsTable.tsx?raw';
import kubernetesNodesTableSource from '@/features/kubernetes/KubernetesNodesTable.tsx?raw';
import kubernetesPageSurfaceSource from '@/features/kubernetes/KubernetesPageSurface.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';
import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplicationTable.tsx?raw';
import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.tsx?raw';
import truenasPageSurfaceSource from '@/features/truenas/TrueNASPageSurface.tsx?raw';
import truenasSystemsTableSource from '@/features/truenas/TrueNASSystemsTable.tsx?raw';
@@ -43,6 +45,8 @@ const overviewSurfaceSources = [
vmwarePageSurfaceSource,
];
const proxmoxDetailTableSources = [proxmoxMailGatewayTableSource, proxmoxReplicationTableSource];
describe('platform overview layout guardrails', () => {
it('keeps platform inventory tables on the shared dense table styling contract', () => {
expect(sharedPlatformPageSource).toContain('PLATFORM_TABLE_CARD_CLASS');
@@ -74,6 +78,22 @@ describe('platform overview layout guardrails', () => {
}
});
it('keeps Proxmox detail tables on the shared platform table primitives', () => {
for (const source of proxmoxDetailTableSources) {
expect(source).toContain('TableCard');
expect(source).toContain('PlatformTableToolbar');
expect(source).toContain('PLATFORM_TABLE_CARD_CLASS');
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("from '@/components/shared/SearchInput'");
expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'");
expect(source).not.toContain('border-collapse text-xs');
expect(source).not.toContain('bg-surface-alt text-muted border-b border-border');
}
});
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">');
@@ -1,9 +1,8 @@
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { For, Show, createSignal, type Component, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import {
Table,
TableBody,
@@ -15,7 +14,15 @@ import {
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformTableToolbar,
createPlatformTableFilterState,
filterPlatformResources,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
@@ -28,13 +35,6 @@ import { ProxmoxMailGatewayDrawer } from './ProxmoxMailGatewayDrawer';
// operator columns. This bespoke table reuses canonical shared
// primitives and surfaces those PMG-native columns.
const STATUS_FILTER_OPTIONS: FilterOption<PlatformResourceStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'online', label: 'Healthy' },
{ value: 'degraded', label: 'Degraded' },
{ value: 'offline', label: 'Offline' },
];
const formatUptime = (seconds: number | undefined): string => {
if (!seconds || seconds <= 0) return '—';
const days = Math.floor(seconds / 86_400);
@@ -54,15 +54,13 @@ export const ProxmoxMailGatewayTable: Component<{
emptyTitle: string;
emptyDescription: string;
}> = (props) => {
const [search, setSearch] = createSignal('');
const [status, setStatus] = createSignal<PlatformResourceStatusFilter>('all');
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const [selectedId, setSelectedId] = createSignal<string | null>(null);
const toggleSelected = (id: string) =>
setSelectedId((current) => (current === id ? null : id));
const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status()));
const visible = createMemo(() => filtered().length);
const total = createMemo(() => props.resources.length);
const toggleSelected = (id: string) => setSelectedId((current) => (current === id ? null : id));
return (
<Show
@@ -74,28 +72,20 @@ export const ProxmoxMailGatewayTable: Component<{
}
>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-[200px] flex-1 sm:max-w-xs">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search Mail Gateways"
/>
</div>
<FilterButtonGroup
options={STATUS_FILTER_OPTIONS}
value={status()}
onChange={setStatus}
/>
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
<Show when={visible() !== total()} fallback={<>{total()} instances</>}>
{visible()} of {total()} instances
</Show>
</span>
</div>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Mail Gateways"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="instances"
/>
<Show
when={filtered().length > 0}
when={tableState.filtered().length > 0}
fallback={
<Card padding="lg">
<EmptyState
@@ -105,24 +95,40 @@ export const ProxmoxMailGatewayTable: Component<{
</Card>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[1080px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Instance</TableHead>
<TableHead class="px-3 py-2 font-medium">Version</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Nodes</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Uptime</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Mail in</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Spam</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Virus</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Quarantine</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Queue</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Deferred</TableHead>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1080px] text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Instance</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Version</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Nodes
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Uptime
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Mail in
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Spam
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Virus
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Quarantine
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Queue
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Deferred
</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={filtered()}>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(instance) => {
const pmg = () => instance.pmg;
const name = () => asTrimmedString(instance.name) || instance.id;
@@ -138,7 +144,7 @@ export const ProxmoxMailGatewayTable: Component<{
onClick={() => toggleSelected(instance.id)}
aria-expanded={isOpen()}
>
<TableCell class="px-3 py-2">
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
@@ -151,31 +157,49 @@ export const ProxmoxMailGatewayTable: Component<{
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{version()}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{countCell(pmg()?.nodeCount)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatUptime(instance.uptime ?? pmg()?.uptimeSeconds)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{countCell(pmg()?.mailCountTotal)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{countCell(pmg()?.spamIn)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{countCell(pmg()?.virusIn)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{countCell(pmg()?.quarantine)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{countCell(pmg()?.queueTotal ?? pmg()?.queueActive)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{countCell(pmg()?.queueDeferred)}
</TableCell>
</TableRow>
@@ -185,10 +209,7 @@ export const ProxmoxMailGatewayTable: Component<{
colspan={10}
class="p-0 border-b border-border bg-surface-alt"
>
<div
class="px-4 py-4"
onClick={(event) => event.stopPropagation()}
>
<div class="px-4 py-4" onClick={(event) => event.stopPropagation()}>
<ProxmoxMailGatewayDrawer
instanceRow={instance}
onClose={() => setSelectedId(null)}
@@ -203,7 +224,7 @@ export const ProxmoxMailGatewayTable: Component<{
</For>
</TableBody>
</Table>
</Card>
</TableCard>
</Show>
</div>
</Show>
@@ -10,9 +10,8 @@ import {
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import type { StatusIndicatorVariant } from '@/utils/status';
import {
Table,
@@ -24,6 +23,14 @@ import {
} from '@/components/shared/Table';
import { apiFetch } from '@/utils/apiClient';
import { formatRelativeTime } from '@/utils/format';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableToolbar,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { ReplicationJob, ReplicationJobsResponse } from '@/types/api';
// Replication is a Proxmox-specific concept (zfs send/receive scheduled
@@ -35,13 +42,13 @@ import type { ReplicationJob, ReplicationJobsResponse } from '@/types/api';
type ReplicationStatusFilter = 'all' | 'healthy' | 'failed' | 'pending' | 'disabled';
const STATUS_FILTER_OPTIONS: FilterOption<ReplicationStatusFilter>[] = [
const STATUS_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'healthy', label: 'Healthy' },
{ value: 'failed', label: 'Failed' },
{ value: 'pending', label: 'Pending' },
{ value: 'disabled', label: 'Disabled' },
];
] satisfies Array<{ value: ReplicationStatusFilter; label: string }>;
interface ReplicationStatusIndicator {
variant: StatusIndicatorVariant;
@@ -61,7 +68,11 @@ function classifyJob(job: ReplicationJob): ReplicationStatusFilter {
function indicatorFor(classification: ReplicationStatusFilter): ReplicationStatusIndicator {
switch (classification) {
case 'healthy':
return { variant: 'success', label: 'Healthy', tone: 'text-emerald-600 dark:text-emerald-300' };
return {
variant: 'success',
label: 'Healthy',
tone: 'text-emerald-600 dark:text-emerald-300',
};
case 'failed':
return { variant: 'danger', label: 'Failed', tone: 'text-red-600 dark:text-red-300' };
case 'pending':
@@ -194,25 +205,17 @@ export const ProxmoxReplicationTable: Component<{
}
>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-[200px] flex-1 sm:max-w-xs">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search jobs, guests, nodes"
/>
</div>
<FilterButtonGroup
options={STATUS_FILTER_OPTIONS}
value={status()}
onChange={setStatus}
/>
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
<Show when={visible() !== total()} fallback={<>{total()} jobs</>}>
{visible()} of {total()} jobs
</Show>
</span>
</div>
<PlatformTableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search jobs, guests, nodes"
status={status()}
onStatusChange={setStatus}
statusOptions={STATUS_FILTER_OPTIONS}
visible={visible()}
total={total()}
rowNoun="jobs"
/>
<Show
when={filtered().length > 0}
@@ -226,22 +229,32 @@ export const ProxmoxReplicationTable: Component<{
</Card>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[1100px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Status</TableHead>
<TableHead class="px-3 py-2 font-medium">Job</TableHead>
<TableHead class="px-3 py-2 font-medium">Guest</TableHead>
<TableHead class="px-3 py-2 font-medium">Source → Target</TableHead>
<TableHead class="px-3 py-2 font-medium">Schedule</TableHead>
<TableHead class="px-3 py-2 font-medium">Last sync</TableHead>
<TableHead class="px-3 py-2 font-medium">Duration</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Fail count</TableHead>
<TableHead class="px-3 py-2 font-medium">Error</TableHead>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1100px] text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Job</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Guest</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Source → Target
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Schedule
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Last sync
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Duration
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Fail count
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={filtered()}>
{(job) => {
const classification = classifyJob(job);
@@ -250,7 +263,7 @@ export const ProxmoxReplicationTable: Component<{
const targetNode = (job.targetNode ?? '').trim() || '—';
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
@@ -258,36 +271,63 @@ export const ProxmoxReplicationTable: Component<{
title={ind.label}
ariaHidden
/>
<span class={`text-[11px] font-medium ${ind.tone}`}>{ind.label}</span>
<span class={`text-[11px] font-medium ${ind.tone}`}>
{ind.label}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
<span title={job.id}>{job.jobId || job.id}</span>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">{formatGuestLabel(job)}</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content`}
>
{formatGuestLabel(job)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-flex items-center gap-1 font-mono text-[11px]">
<span>{sourceNode}</span>
<ArrowRightIcon class="h-3 w-3 text-muted" aria-hidden="true" />
<span>{targetNode}</span>
</span>
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{job.schedule || '—'}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">{formatSyncTime(job)}</TableCell>
<TableCell class="px-3 py-2 text-base-content">
{formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)}
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatSyncTime(job)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatDuration(
job.lastSyncDurationSeconds,
job.lastSyncDurationHuman,
)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
<Show
when={(job.failCount ?? 0) > 0}
fallback={<span class="text-muted">0</span>}
>
<span class="text-red-600 dark:text-red-300 font-semibold">{job.failCount}</span>
<span class="text-red-600 dark:text-red-300 font-semibold">
{job.failCount}
</span>
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={!!job.error?.trim()}
fallback={<span class="text-muted">—</span>}
@@ -306,7 +346,7 @@ export const ProxmoxReplicationTable: Component<{
</For>
</TableBody>
</Table>
</Card>
</TableCard>
</Show>
</div>
</Show>