diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 4add9cde99..f6a1e8b25e 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -6,6 +6,7 @@ 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 proxmoxCephTableSource from '@/features/proxmox/ProxmoxCephTable.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'; @@ -45,7 +46,11 @@ const overviewSurfaceSources = [ vmwarePageSurfaceSource, ]; -const proxmoxDetailTableSources = [proxmoxMailGatewayTableSource, proxmoxReplicationTableSource]; +const proxmoxDetailTableSources = [ + proxmoxCephTableSource, + proxmoxMailGatewayTableSource, + proxmoxReplicationTableSource, +]; describe('platform overview layout guardrails', () => { it('keeps platform inventory tables on the shared dense table styling contract', () => { diff --git a/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx index b51546d5e7..5ad79b30fe 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx @@ -1,6 +1,7 @@ import { For, Show, + createEffect, createMemo, createSignal, type Component, @@ -8,10 +9,9 @@ import { } 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 type { StatusIndicatorVariant } from '@/utils/status'; +import { TableCard } from '@/components/shared/TableCard'; import { Table, TableBody, @@ -22,6 +22,14 @@ import { } from '@/components/shared/Table'; import { formatBytes } from '@/utils/format'; import { asTrimmedString } from '@/utils/stringUtils'; +import { + PLATFORM_TABLE_BODY_CLASS, + PLATFORM_TABLE_CARD_CLASS, + PLATFORM_TABLE_HEADER_ROW_CLASS, + PlatformTableToolbar, + getPlatformTableCellClassForKind, + getPlatformTableHeadClassForKind, +} from '@/features/platformPage/sharedPlatformPage'; import type { Resource, ResourceCephServiceMeta } from '@/types/resource'; import { ProxmoxCephClusterDrawer } from './ProxmoxCephClusterDrawer'; @@ -38,12 +46,12 @@ import { ProxmoxCephClusterDrawer } from './ProxmoxCephClusterDrawer'; type CephStatusFilter = 'all' | 'healthy' | 'warning' | 'critical'; -const STATUS_FILTER_OPTIONS: FilterOption[] = [ +const STATUS_FILTER_OPTIONS = [ { value: 'all', label: 'All' }, { value: 'healthy', label: 'Healthy' }, { value: 'warning', label: 'Warning' }, { value: 'critical', label: 'Critical' }, -]; +] satisfies Array<{ value: CephStatusFilter; label: string }>; function classify(resource: Resource): CephStatusFilter { const raw = (resource.ceph?.healthStatus ?? resource.status ?? '').toUpperCase(); @@ -64,7 +72,11 @@ function indicatorFor(category: CephStatusFilter): { } { switch (category) { 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 'warning': return { variant: 'warning', label: 'Warning', tone: 'text-amber-600 dark:text-amber-300' }; case 'critical': @@ -76,9 +88,7 @@ function indicatorFor(category: CephStatusFilter): { function summarizeServices(services: ResourceCephServiceMeta[] | undefined): string { if (!services || services.length === 0) return '—'; - return services - .map((svc) => `${svc.type}:${svc.running}/${svc.total}`) - .join(' · '); + return services.map((svc) => `${svc.type}:${svc.running}/${svc.total}`).join(' · '); } function poolsLabel(resource: Resource): JSX.Element { @@ -102,7 +112,11 @@ function osdLabel(resource: Resource): JSX.Element { const inService = meta.numOsdsIn ?? 0; const allUp = up === total && inService === total; return ( - + {up}/{total} up · {inService} in @@ -127,8 +141,7 @@ function capacityLabel(resource: Resource): JSX.Element { if (typeof total === 'number' && total > 0) { return ( - {pct.toFixed(1)}% - of {formatBytes(total)} + {pct.toFixed(1)}% of {formatBytes(total)} ); } @@ -154,8 +167,22 @@ export const ProxmoxCephTable: Component<{ const [search, setSearch] = createSignal(''); const [status, setStatus] = createSignal('all'); const [selectedId, setSelectedId] = createSignal(null); - const toggleSelected = (id: string) => + // Single-cluster pages have an almost empty surface unless the user + // expands the only row, so auto-open it on first load. Once the user + // interacts (toggle or close), we stop overriding their choice. + const [userPicked, setUserPicked] = createSignal(false); + createEffect(() => { + if (userPicked()) return; + setSelectedId(props.resources.length === 1 ? props.resources[0].id : null); + }); + const toggleSelected = (id: string) => { + setUserPicked(true); setSelectedId((current) => (current === id ? null : id)); + }; + const closeSelected = () => { + setUserPicked(true); + setSelectedId(null); + }; const filtered = createMemo(() => { const term = search().trim().toLowerCase(); @@ -195,21 +222,17 @@ export const ProxmoxCephTable: Component<{ } >
-
-
- -
- - - {total()} clusters}> - {visible()} of {total()} clusters - - -
+ 0} @@ -223,23 +246,33 @@ export const ProxmoxCephTable: Component<{ } > - - - - - Cluster - Health - FSID - Quorum - OSDs - PGs - Pools - Capacity - Services - Detail + +
+ + + Cluster + Health + FSID + + Quorum + + + OSDs + + + PGs + + + Pools + + + Capacity + + Services + Detail - + {(cluster) => { const ind = indicatorFor(classify(cluster)); @@ -255,7 +288,7 @@ export const ProxmoxCephTable: Component<{ onClick={() => toggleSelected(cluster.id)} aria-expanded={isOpen()} > - +
{name} @@ -270,7 +303,7 @@ export const ProxmoxCephTable: Component<{
- +
- + {fsid} - + {quorumLabel(cluster.ceph)} - {osdLabel(cluster)} - + + {osdLabel(cluster)} + + 0} fallback={—} @@ -303,14 +346,24 @@ export const ProxmoxCephTable: Component<{ {cluster.ceph?.numPGs} - {poolsLabel(cluster)} - + + {poolsLabel(cluster)} + + {capacityLabel(cluster)} - + {summarizeServices(cluster.ceph?.services)} - + {healthMessageCell(cluster)} @@ -320,13 +373,10 @@ export const ProxmoxCephTable: Component<{ colspan={10} class="p-0 border-b border-border bg-surface-alt" > -
event.stopPropagation()} - > +
event.stopPropagation()}> setSelectedId(null)} + onClose={closeSelected} />
@@ -338,7 +388,7 @@ export const ProxmoxCephTable: Component<{
-
+