fix(ui): refine compact platform tables

Contract-Neutral: Responsive table presentation and compact action-menu behavior only; no canonical API, data model, or product contract changes.
This commit is contained in:
rcourtman
2026-08-04 15:01:09 +01:00
parent 2c0899e208
commit 39e8fe8834
25 changed files with 410 additions and 168 deletions
+54 -80
View File
@@ -1,63 +1,49 @@
{
"version": 1,
"base_sha": "95745e77ca161831a4c5a90397b5723c2fc80e98",
"verified_at": "2026-08-04T11:37:59Z",
"base_sha": "2c0899e2086b4a2b9f501896dba5e5f894a41f66",
"verified_at": "2026-08-04T13:56:01Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
"frontend-modern/src/features/docker/DockerPageSurface.tsx",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx",
"frontend-modern/src/features/truenas/TrueNASPageSurface.tsx",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx": "ef811962a39ef0c8c70060241b585f9612cf162c53071524a2d78359c679ada4",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "cfcbda5651a54698af4575f32dda894580108ccfc08d6b87e7b7e8e6b8d4d5ae",
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "336410d2ffe6a72da7f2040156053f67c5503b2d37c0ac7230a7c28afc8a08f0",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "702d5b2813bca96ecbe632044d3f9dbeab5cb6fea6d634e4a4f9c194e3f10106",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "6179a35aa842b4600d16eb581bde4747a1971c9c9d71f2a18de885df2f474b64",
"frontend-modern/src/features/docker/DockerPageSurface.tsx": "e3b4aed8ca3650bdf8bd980c033decc3d6bb9ebfc428620b92bf03808d8dc700",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "54bb033261f4853f31e0b9767be2f9a31886fd1dd2c49c5413fec0f3a840a22d",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "35ec8051643c759d1b72058d04426e246b341401b8b96ca32147e404f444aee6",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx": "0d5d74c0055ba0365de433f90f2c7cf604b2998cdd59e5cd4fb11e8dde1208b7",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "35723e8090b81d94ec68c29c1baa86e8b1ecf4e903338323136929fb6cbc77b5",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "53421e71c4046f18dbefa4978dc6c74bf2c852e9d26a4f3ab8c9e003df236dc1",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "d1e17e02001cd1f88f6e98549115a8c37574a4288e61e5d3d708634d756d0c0d",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "6bf47f03195639b2900f16d45539e633a77709860f17150ef400966d161c9f6d",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "54a3c7a1127aa6d5e4822dd2c19ee9242789914a6ee1580bcbd6808fe482d265",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "4539a00569a64c06356c4b0a7decb608842e23c09259e5a12c0b1b40bcde1c5c",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "45a609aeea1ee0800a643b900a57d8d88af3be027e5a7f62c2708d1436b274a3",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "0c273f41bcd0fbe269d5ec3e876bb5ffaff97542a7fd6327cea2b7785913cde6",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "c06ed9b86054a5cef7f4b30d3054619dbb291442d6810fc8761637fc6db59773",
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx": "2ebcc5503c890bfea2f21eaed6c18e0ebb69625701f3884a7c17e62faaea504d",
"frontend-modern/src/features/truenas/TrueNASPageSurface.tsx": "46c506bbf9ca102e3018188e8b0e234d67ff188893d0ea16d95252508579c5ef",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "0b846223ff043bc2639f4d2e9fcc70438c02041165b739afebcdb9573c77ccbd",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx": "10a944844841d4188804aaf2a217067b4d0aae21357e7bd3264785f67b3d0308",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "45e71dc9be04a780c175f5c9ff757038f0d5c5df1c538605ec65fc8179cff84f",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx": "76e1acfc2f56dd6e94b142625056bdda2c4ed0c4ce6feec833773e62906dc80e",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx": "95e30af160d2e621acf0a1c09f43be788bb8d314c5b4d68790a3594a687e40c0",
"frontend-modern/src/index.css": "4498659bf01b186a9030fe96a58a3c9a0d3f5a901e374f6d9048faf6d2b02b62"
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx": "6626c80c0ff183ef69780362065ab59a627b827c8c8921dd8053a56afcb3fb96",
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "c34d1c973266dbbe4b0de64fc1a1a3af668728cbfd839346b555d1178ec11942",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "bcccb52e92fe2031e5b4e9b02146d0700c4dd8b8a68b495e23037a51f980c7c5",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "3aca178275b70399f32ee1c67168904294cd07551a66999e88f1225bd65271b4",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts": "f997acf6196c492c7aa4e923768545ac3ccb891947d53e37f682359fa7ddb6f3",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "41ae0bcd794d6c4fb21e45cd5fee424fb98c408af82befb4ec57a86213da188d",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "613780c1452feb8392d80d65ca585e474ebacc6919e69dec66c4d41f642fca22",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "007a0a5b37a1055018a6ef0eeae06be9efe5331f23d87f77aaf32f5315f755f3",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx": "d4fdcd6a7061b11b5a890b2e66dd93c44636a401b1afefed806d58b2336e5659",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx": "ef509df461f6d5e90911ebd0c0bd7a4d8c465cb43c6b4198a165d24011d5f30f",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx": "ba9797ebab585bf633d8c9a49762da7239b3dd6aa89de439bafd4471242d7265",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx": "f3cca1e796b77b92da9af1b8c66001d0b92fecfc199fc1e1f9e921d27978fee4",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "f3a8799fe8fd60f6cf34b10167ed06d573c825feeb514cf9fd3c3e11e0e128ca",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "70554e4eaf1434f5bfb88d710b17b638c7e838272244bd8472d79ba7e5e017c3",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "ad9ecf604b7060d4c96f531099e6e238ac3f47d9a2cd0ad5f1e5d82b64bb8293",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx": "bc68ee342610607a4951c5518c3c652bc54aafa471604b95537e90634a6dbddc",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "60b5ee0447c3818c0beb6fdda1e1e73df71e94c51c82855016bcedf4ef267102",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "29ad8f8168af4ed5a63559b25663f04b9d668c58c03ea6d312ebc8f824f85aa8",
"frontend-modern/src/index.css": "e2461a68feb4d21062faa1131c2dd0f9c3c1469b1bcac15e487cc771ac5b7503"
},
"routes": [
"/proxmox/overview",
@@ -96,44 +82,32 @@
"/standalone/availability"
],
"viewports": [
{
"width": 375,
"height": 900
},
{
"width": 768,
"height": 900
},
{
"width": 1024,
"height": 900
},
{
"width": 1440,
"height": 900
},
{
"width": 1920,
"height": 900
}
{ "width": 375, "height": 900 },
{ "width": 540, "height": 900 },
{ "width": 640, "height": 900 },
{ "width": 768, "height": 900 },
{ "width": 900, "height": 900 },
{ "width": 1024, "height": 900 },
{ "width": 1280, "height": 900 },
{ "width": 1440, "height": 900 },
{ "width": 1680, "height": 900 },
{ "width": 1920, "height": 900 }
],
"states": [
"All 34 primary platform routes loaded with the 213-resource mock inventory across Proxmox, Docker, Kubernetes, TrueNAS, VMware, and standalone machines",
"Every platform page root used the same wide data surface geometry at 1024px and 1920px so platform switching did not move content laterally",
"All primary tables at 375px and 1024px had scrollWidth equal to clientWidth with zero document overflow",
"Compact, intermediate, expanded, and full table layouts progressively exposed operator-priority columns from each table's measured container width",
"Proxmox overview compact tables showed Node, CPU, Mem, and Disk for hosts and Name, Up, CPU, Mem, and Disk for workloads",
"Proxmox storage pools and disks, replication, backup coverage and date views, Ceph, and mail tables retained the most useful operational fields at each width",
"Expanded Ceph cluster detail used independent responsive layouts for its narrower nested pool and service tables",
"Docker image and network, Kubernetes controller, autoscaling and event, TrueNAS system and topology, and VMware datastore and network tables restored supporting detail only when space allowed",
"The Docker Swarm mock empty state and all non-table platform views remained aligned and free of page overflow"
"All 34 primary platform routes loaded with the full mock inventory across Proxmox, Docker, Kubernetes, TrueNAS, VMware, and standalone machines",
"Every route was checked at ten viewport widths from 375px through 1920px for document overflow, table overflow, and clipped column headings",
"Compact tables preserved identity and immediate operating state before supporting metadata, while wider containers progressively restored secondary detail",
"Docker container columns responded to their measured table container, including the narrower assistant-open surface",
"Compact labels replaced long headings only where the full label would compete with operator-critical values",
"Nested Proxmox Ceph tables responded independently inside the expanded cluster drawer",
"All platform content roots retained aligned left edges and widths while switching between platforms"
],
"interactions": [
"Switched repeatedly between all six platform roots and confirmed identical content-shell left edges and widths",
"Navigated every primary platform route and switched Proxmox storage pools and disks plus backup coverage and date query views",
"Resized pages through 375px, 768px, 1024px, 1440px, and 1920px while inspecting visible headers, table bounds, and document bounds",
"Expanded Proxmox Ceph cluster detail and verified nested tables responded to their own containers instead of the browser viewport",
"Compared compact operator-priority columns with intermediate and full layouts across each platform's native tables",
"Visually reviewed representative screenshots for Proxmox overview, Docker images, Kubernetes workloads, TrueNAS overview, VMware overview and networks, including narrow and wide layouts"
"Navigated all 34 routes and exercised Proxmox storage and backup query variants",
"Resized every route through 375, 540, 640, 768, 900, 1024, 1280, 1440, 1680, and 1920px",
"Opened the Docker container compact lifecycle menu and confirmed Start, Stop, and Restart remain reachable within the viewport",
"Opened the assistant at 1280px and switched between Docker, Kubernetes, and VMware while checking container-relative layouts",
"Expanded Proxmox Ceph cluster detail and inspected its nested pool and service tables",
"Repeated strict zero-pixel clipping checks after the final Kubernetes autoscaling label adjustment"
]
}
@@ -1,5 +1,16 @@
import { For, Match, Switch, createSignal, type Component } from 'solid-js';
import {
For,
Match,
Show,
Switch,
createEffect,
createSignal,
onCleanup,
type Component,
} from 'solid-js';
import { Portal } from 'solid-js/web';
import Loader2Icon from 'lucide-solid/icons/loader-2';
import MoreHorizontalIcon from 'lucide-solid/icons/more-horizontal';
import PlayIcon from 'lucide-solid/icons/play';
import RotateCwIcon from 'lucide-solid/icons/rotate-cw';
import SquareIcon from 'lucide-solid/icons/square';
@@ -26,6 +37,7 @@ export type DockerContainerLifecycleControlsProps = {
resource: Resource;
class?: string;
surface?: DockerContainerLifecycleSurface;
collapsed?: boolean;
onActionSettled?: (context: DockerContainerLifecycleSettledContext) => void | Promise<void>;
};
@@ -67,6 +79,38 @@ export const DockerContainerLifecycleControls: Component<DockerContainerLifecycl
const [reviewAction, setReviewAction] = createSignal<DockerContainerLifecycleAction | null>(null);
const [reviewDetail, setReviewDetail] = createSignal<ActionDetailResponse | null>(null);
const [lastError, setLastError] = createSignal('');
const [menuOpen, setMenuOpen] = createSignal(false);
const [menuPosition, setMenuPosition] = createSignal({ left: 0, top: 0 });
let menuTriggerRef: HTMLButtonElement | undefined;
const updateMenuPosition = () => {
if (!menuTriggerRef || typeof window === 'undefined') return;
const rect = menuTriggerRef.getBoundingClientRect();
const menuWidth = 208;
const estimatedMenuHeight = 132;
setMenuPosition({
left: Math.max(8, Math.min(window.innerWidth - menuWidth - 8, rect.right - menuWidth)),
top: Math.max(8, Math.min(window.innerHeight - estimatedMenuHeight - 8, rect.bottom + 4)),
});
};
createEffect(() => {
if (!menuOpen() || typeof window === 'undefined') return;
updateMenuPosition();
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Element && target.closest('[data-docker-lifecycle-menu-root]')) return;
setMenuOpen(false);
};
window.addEventListener('resize', updateMenuPosition);
window.addEventListener('scroll', updateMenuPosition, true);
document.addEventListener('pointerdown', closeOnOutsidePointer);
onCleanup(() => {
window.removeEventListener('resize', updateMenuPosition);
window.removeEventListener('scroll', updateMenuPosition, true);
document.removeEventListener('pointerdown', closeOnOutsidePointer);
});
});
const prepareLifecycleReview = async (action: DockerContainerLifecycleAction) => {
const disabledReason = getDockerContainerLifecycleDisabledReason(props.resource, action);
@@ -114,40 +158,121 @@ export const DockerContainerLifecycleControls: Component<DockerContainerLifecycl
class={`inline-flex items-center justify-end gap-1 ${props.class ?? ''}`.trim()}
data-prevent-toggle
data-docker-container-actions-surface={props.surface ?? 'docker-page'}
data-docker-lifecycle-menu-root
>
<For each={DOCKER_CONTAINER_LIFECYCLE_ACTIONS}>
{(spec) => {
const disabled = () =>
Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) ||
(planningAction() !== null && planningAction() !== spec.action);
const Icon = iconForAction(spec.action);
return (
<button
type="button"
class={`${buttonBaseClass} ${planningAction() === spec.action ? runningButtonClass : disabled() ? disabledButtonClass : enabledButtonClass}`}
disabled={disabled()}
title={titleForAction(spec.action, spec.label)}
aria-label={titleForAction(spec.action, spec.label)}
data-docker-container-action={spec.action}
<Show
when={props.collapsed}
fallback={
<For each={DOCKER_CONTAINER_LIFECYCLE_ACTIONS}>
{(spec) => {
const disabled = () =>
Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) ||
(planningAction() !== null && planningAction() !== spec.action);
const Icon = iconForAction(spec.action);
return (
<button
type="button"
class={`${buttonBaseClass} ${planningAction() === spec.action ? runningButtonClass : disabled() ? disabledButtonClass : enabledButtonClass}`}
disabled={disabled()}
title={titleForAction(spec.action, spec.label)}
aria-label={titleForAction(spec.action, spec.label)}
data-docker-container-action={spec.action}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void prepareLifecycleReview(spec.action);
}}
>
<Switch>
<Match when={planningAction() === spec.action}>
<Loader2Icon class="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
</Match>
<Match when={true}>
<Icon class="h-3.5 w-3.5" aria-hidden="true" />
</Match>
</Switch>
</button>
);
}}
</For>
}
>
<button
ref={menuTriggerRef}
type="button"
class={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border ${enabledButtonClass}`}
title={`Container actions for ${dockerContainerLifecycleName(props.resource)}`}
aria-label={`Container actions for ${dockerContainerLifecycleName(props.resource)}`}
aria-haspopup="menu"
aria-expanded={menuOpen() ? 'true' : 'false'}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') setMenuOpen(false);
}}
onClick={(event) => {
event.stopPropagation();
updateMenuPosition();
setMenuOpen((open) => !open);
}}
>
<MoreHorizontalIcon class="h-4 w-4" aria-hidden="true" />
</button>
<Show when={menuOpen()}>
<Portal mount={document.body}>
<div
data-prevent-toggle
data-docker-lifecycle-menu-root
role="menu"
class="fixed z-[9999] w-52 rounded-md border border-border bg-surface p-1 text-left shadow-lg"
style={{ left: `${menuPosition().left}px`, top: `${menuPosition().top}px` }}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => {
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
void prepareLifecycleReview(spec.action);
if (event.key === 'Escape') setMenuOpen(false);
}}
>
<Switch>
<Match when={planningAction() === spec.action}>
<Loader2Icon class="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
</Match>
<Match when={true}>
<Icon class="h-3.5 w-3.5" aria-hidden="true" />
</Match>
</Switch>
</button>
);
}}
</For>
<For each={DOCKER_CONTAINER_LIFECYCLE_ACTIONS}>
{(spec) => {
const disabled = () =>
Boolean(
getDockerContainerLifecycleDisabledReason(props.resource, spec.action),
) ||
(planningAction() !== null && planningAction() !== spec.action);
const Icon = iconForAction(spec.action);
return (
<button
type="button"
role="menuitem"
class="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-xs font-medium text-base-content transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled()}
title={titleForAction(spec.action, spec.label)}
aria-label={titleForAction(spec.action, spec.label)}
data-docker-container-action={spec.action}
onClick={(event) => {
event.stopPropagation();
setMenuOpen(false);
void prepareLifecycleReview(spec.action);
}}
>
<Show
when={planningAction() === spec.action}
fallback={<Icon class="h-3.5 w-3.5" aria-hidden="true" />}
>
<Loader2Icon class="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
</Show>
<span>{spec.label}</span>
</button>
);
}}
</For>
</div>
</Portal>
</Show>
</Show>
<ActionReviewDialog
detail={reviewDetail()}
onClose={() => setReviewDetail(null)}
@@ -18,8 +18,12 @@ import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfa
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { getSimpleStatusIndicator } from '@/utils/status';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel';
import {
getWorkloadTableLayoutMode,
getWorkloadTableLayoutModeForContainer,
} from '@/components/Workloads/guestRowModel';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import { TableCell, TableRow } from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
@@ -29,6 +33,7 @@ import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformSortableTableHead,
PlatformResponsiveTableLabel,
PlatformTableMetricFallback,
PlatformTableEmptyState,
PlatformTableToolbar,
@@ -268,7 +273,13 @@ const DOCKER_CONTAINER_SEARCH_TIPS = {
export const DockerContainersTable: Component<DockerContainersTableProps> = (props) => {
const alertsActivation = useAlertsActivation();
const breakpoint = useBreakpoint();
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const observedWidth = useObservedElementWidth();
const layoutMode = createMemo(() => {
const measuredWidth = observedWidth.width();
return measuredWidth === null
? getWorkloadTableLayoutMode(breakpoint.width())
: getWorkloadTableLayoutModeForContainer(measuredWidth);
});
// Search and status live in the URL, like the host scope below, so the
// whole filter state is shareable and captured by saved views. That is what
// makes exclusions persistent: search `-name`, save it as the default view,
@@ -628,6 +639,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<DockerContainerLifecycleControls
resource={resource}
collapsed={layoutMode() !== 'wide'}
onActionSettled={props.onLifecycleActionSettled}
/>
</TableCell>
@@ -708,7 +720,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
/>
}
>
<div class="space-y-3">
<div ref={observedWidth.setElement} class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
@@ -778,9 +790,25 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
sort={sort}
sortKey={getDockerContainerSortKey(column.id)}
>
<Show when={column.id === 'memory'} fallback={<>{column.label}</>}>
<span class="md:hidden">Mem</span>
<span class="hidden md:inline">Memory</span>
<Show
when={column.id !== 'actions'}
fallback={<span class="sr-only">{column.label}</span>}
>
<Show
when={column.compactLabel}
fallback={
<Show when={column.id === 'memory'} fallback={<>{column.label}</>}>
<PlatformResponsiveTableLabel compact="Mem" full="Memory" />
</Show>
}
>
{(compactLabel) => (
<PlatformResponsiveTableLabel
compact={compactLabel()}
full={column.label}
/>
)}
</Show>
</Show>
</PlatformSortableTableHead>
)}
@@ -2,6 +2,7 @@ import { For, Show, createMemo, type Component } from 'solid-js';
import { TableCell, TableRow } from '@/components/shared/Table';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
@@ -171,7 +172,7 @@ export const DockerImagesTable: Component<
sortKey="update"
class="md:w-[16%]"
>
Update check
<PlatformResponsiveTableLabel compact="Update" full="Update check" />
</PlatformSortableTableHead>
</>
}
@@ -4,6 +4,7 @@ import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
type PlatformTableFilterOption,
PlatformTableEmptyState,
PlatformTableToolbar,
@@ -415,7 +416,7 @@ export const DockerNetworksTable: Component<DockerNetworksTableProps> = (props)
<>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Network</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Attached workloads
<PlatformResponsiveTableLabel compact="Attached" full="Attached workloads" />
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Attention</TableHead>
<Show when={showSubnet()}>
@@ -248,7 +248,7 @@ describe('Docker native tables', () => {
));
expect(screen.getByText('Container')).toBeInTheDocument();
expect(screen.getByText('Runtime')).toBeInTheDocument();
expect(screen.getByText('Engine')).toBeInTheDocument();
expect(screen.getByText('CPU')).toBeInTheDocument();
expect(screen.getByText('Memory')).toBeInTheDocument();
expect(screen.getByText('Restarts')).toBeInTheDocument();
@@ -281,7 +281,7 @@ describe('Docker native tables', () => {
);
});
it('hides the Runtime column when every container reports the same runtime', () => {
it('hides the Engine column when every container reports the same runtime', () => {
renderInRouter(() => (
<DockerContainersTable
resources={[
@@ -319,7 +319,7 @@ describe('Docker native tables', () => {
/>
));
expect(screen.queryByText('Runtime')).not.toBeInTheDocument();
expect(screen.queryByText('Engine')).not.toBeInTheDocument();
expect(screen.queryByText('docker 27.5.1')).not.toBeInTheDocument();
});
@@ -868,7 +868,12 @@ describe('Docker native tables', () => {
/>
));
const restartButton = screen.getByRole('button', {
fireEvent.click(
screen.getByRole('button', {
name: 'Container actions for edge-web',
}),
);
const restartButton = screen.getByRole('menuitem', {
name: 'Review restart for edge-web',
});
fireEvent.click(restartButton);
@@ -916,8 +921,13 @@ describe('Docker native tables', () => {
/>
));
expect(
fireEvent.click(
screen.getByRole('button', {
name: 'Container actions for edge-web',
}),
);
expect(
screen.getByRole('menuitem', {
name: 'Restart unavailable: Docker inventory is stale; refresh inventory before running lifecycle actions.',
}),
).toBeDisabled();
@@ -14,10 +14,10 @@ describe('dockerContainerTableModel', () => {
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates', 'actions']);
expect(getDockerContainerTableMinWidthClass()).toBe('min-w-full');
expect(getDockerContainerColumnWidthStyle('container', 'mobile', ids)).toEqual({
width: '28.0702%',
width: '26.3158%',
});
expect(getDockerContainerColumnWidthStyle('memory', 'mobile', ids)).toEqual({
width: '19.2982%',
width: '18.4211%',
});
});
@@ -22,6 +22,7 @@ export type DockerContainerTableColumnId =
export type DockerContainerTableColumn = {
id: DockerContainerTableColumnId;
label: string;
compactLabel?: string;
kind: PlatformTableColumnKind;
};
@@ -77,7 +78,7 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
const DOCKER_CONTAINER_COLUMNS: DockerContainerTableColumn[] = [
{ id: 'container', label: 'Container', kind: 'name' },
{ id: 'host', label: 'Host', kind: 'text' },
{ id: 'runtime', label: 'Runtime', kind: 'text' },
{ id: 'runtime', label: 'Engine', kind: 'text' },
{ id: 'image', label: 'Image', kind: 'text' },
{ id: 'state', label: 'State', kind: 'text' },
{ id: 'cpu', label: 'CPU', kind: 'metric-bar' },
@@ -86,7 +87,7 @@ const DOCKER_CONTAINER_COLUMNS: DockerContainerTableColumn[] = [
{ id: 'ports', label: 'Ports', kind: 'text' },
{ id: 'networks', label: 'Networks', kind: 'text' },
{ id: 'mounts', label: 'Mounts', kind: 'text' },
{ id: 'updates', label: 'Updates', kind: 'badge' },
{ id: 'updates', label: 'Updates', compactLabel: 'Update', kind: 'badge' },
{ id: 'actions', label: 'Actions', kind: 'badge' },
];
@@ -111,34 +112,34 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
Partial<Record<DockerContainerTableColumnId, number>>
> = {
mobile: {
container: 32,
container: 30,
state: 14,
cpu: 18,
memory: 22,
updates: 14,
memory: 21,
updates: 17,
actions: 14,
},
tablet: {
container: 28,
host: 16,
container: 27,
host: 15,
state: 10,
cpu: 15,
memory: 18,
restarts: 6,
updates: 7,
actions: 8,
memory: 19,
restarts: 9,
updates: 9,
actions: 6,
},
compact: {
container: 18,
host: 10,
runtime: 8,
image: 20,
image: 18,
state: 7,
cpu: 11,
memory: 12,
restarts: 7,
restarts: 9,
ports: 12,
updates: 8,
updates: 9,
actions: 8,
},
};
@@ -5,6 +5,7 @@ import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableNumberValue,
@@ -181,9 +182,9 @@ export const KubernetesAutoscalingTable: Component<{
kind="text"
sort={sort}
sortKey="target"
class="md:w-[18%]"
class="platform-table-mobile-w-30 md:w-[18%]"
>
Scale target
Target
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -196,17 +197,17 @@ export const KubernetesAutoscalingTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="current"
class="md:w-[9%]"
class="platform-table-mobile-w-15 md:w-[9%]"
>
Current
<PlatformResponsiveTableLabel compact="Now" full="Current" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="desired"
class="md:w-[9%]"
class="platform-table-mobile-w-15 md:w-[9%]"
>
Desired
<PlatformResponsiveTableLabel compact="Goal" full="Desired" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -5,6 +5,7 @@ import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
@@ -283,10 +284,10 @@ export const KubernetesConfigTable: Component<{
sortKey="lifecycle"
class="md:w-[16%]"
>
Lifecycle / trust
<PlatformResponsiveTableLabel compact="Status" full="Lifecycle / trust" />
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[14%]">
Data shape
<PlatformResponsiveTableLabel compact="Data" full="Data shape" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -5,6 +5,7 @@ import { getResourceTypeLabel } from '@/utils/resourceTypePresentation';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableNumberValue,
@@ -280,7 +281,12 @@ export const KubernetesControllersTable: Component<{
>
Controller
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} sortKey="kind" class="md:w-[9%]">
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="kind"
class="platform-table-mobile-w-25 md:w-[9%]"
>
Kind
</PlatformSortableTableHead>
<PlatformSortableTableHead
@@ -311,9 +317,9 @@ export const KubernetesControllersTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="ready"
class="md:w-[10%]"
class="platform-table-mobile-w-15 md:w-[10%]"
>
Ready/Done
<PlatformResponsiveTableLabel compact="Ready" full="Ready/Done" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
@@ -327,9 +333,9 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="exceptions"
class="md:w-[11%]"
class="platform-table-mobile-w-20 md:w-[11%]"
>
Exceptions
<PlatformResponsiveTableLabel compact="Issues" full="Exceptions" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -5,6 +5,7 @@ import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
@@ -227,7 +228,7 @@ export const KubernetesNetworkingTable: Component<{
sortKey="typeClass"
class="md:w-[12%]"
>
Type / class
<PlatformResponsiveTableLabel compact="Class" full="Type / class" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -5,6 +5,7 @@ import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
@@ -250,10 +251,10 @@ export const KubernetesPolicyTable: Component<{
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[15%]`}>
Policy shape
<PlatformResponsiveTableLabel compact="Policy" full="Policy shape" />
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
Spec / limits
<PlatformResponsiveTableLabel compact="Limits" full="Spec / limits" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
@@ -6,6 +6,7 @@ import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
@@ -185,7 +186,7 @@ export const KubernetesServicesTable: Component<{
sortKey="clusterIp"
class="md:w-[13%]"
>
Cluster IP
<PlatformResponsiveTableLabel compact="IP" full="Cluster IP" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -5,6 +5,7 @@ import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
@@ -297,7 +298,7 @@ export const KubernetesStorageTable: Component<{
sortKey="phase"
class="md:w-[14%]"
>
Binding / phase
<PlatformResponsiveTableLabel compact="State" full="Binding / phase" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -57,7 +57,7 @@ describe('KubernetesAutoscalingTable', () => {
));
expect(screen.getByText('Autoscaler')).toBeInTheDocument();
expect(screen.getByText('Scale target')).toBeInTheDocument();
expect(screen.getByText('Target')).toBeInTheDocument();
expect(screen.getByText('Bounds')).toBeInTheDocument();
expect(screen.getByText('Metrics')).toBeInTheDocument();
expect(screen.getByText('checkout-api-hpa')).toBeInTheDocument();
@@ -28,6 +28,7 @@ import {
getPlatformTableFiniteMetric,
getPlatformTableClass,
getPlatformTableContainerLayout,
getPlatformTableHeadClassForKind,
getPlatformResourceCountNoun,
getPlatformTableResponsiveMinWidthClass,
getPlatformTableWeightedColumnWidthStyle,
@@ -87,6 +88,13 @@ describe('getPlatformTableContainerLayout', () => {
});
});
describe('getPlatformTableHeadClassForKind', () => {
it('marks semantic identity columns for compact container prioritization', () => {
expect(getPlatformTableHeadClassForKind('name')).toContain('platform-table-name-column');
expect(getPlatformTableHeadClassForKind('text')).not.toContain('platform-table-name-column');
});
});
describe('normalizePlatformResourceStatusFilter', () => {
it('normalizes route-specific legacy status vocabulary', () => {
expect(normalizePlatformResourceStatusFilter('running')).toBe('online');
@@ -196,11 +196,22 @@ export const getPlatformTableCellClass = (align?: PlatformTableCellAlign): strin
// added once in columnAlignment.ts and propagated automatically). See
// PlatformTableColumnKind for the kind list and rationale.
export const getPlatformTableHeadClassForKind = (kind: PlatformTableColumnKind): string =>
getPlatformTableHeadClass(getPlatformColumnAlign(kind));
`${getPlatformTableHeadClass(getPlatformColumnAlign(kind))} ${
kind === 'name' ? 'platform-table-name-column' : ''
}`.trim();
export const getPlatformTableCellClassForKind = (kind: PlatformTableColumnKind): string =>
getPlatformTableCellClass(getPlatformColumnAlign(kind));
export function PlatformResponsiveTableLabel(props: { compact: string; full: string }) {
return (
<>
<span class="platform-table-label-compact">{props.compact}</span>
<span class="platform-table-label-full">{props.full}</span>
</>
);
}
// --- User-controlled column sorting ----------------------------------------
//
// Platform tables keep their built-in attention-first ordering until the user
@@ -19,6 +19,7 @@ import {
formatPlatformTablePercentValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
PlatformResponsiveTableLabel,
PlatformTableNumberValue,
} from '@/features/platformPage/sharedPlatformPage';
import type { StatusIndicatorVariant } from '@/utils/status';
@@ -238,7 +239,7 @@ export const ProxmoxCephClusterDrawer: Component<{
<TableHead class={getPlatformTableHeadClassForKind('name')}>Service</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Running
<PlatformResponsiveTableLabel compact="Up" full="Running" />
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Total
@@ -260,8 +260,11 @@ export const ProxmoxReplicationTable: Component<{
<TableHead class={getPlatformTableHeadClassForKind('text')}>Job</TableHead>
</Show>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Guest</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Source Target
<TableHead
class={getPlatformTableHeadClassForKind('text')}
title="Source → target"
>
Route
</TableHead>
<Show when={showOperational()}>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
@@ -13,6 +13,7 @@ import { asTrimmedString } from '@/utils/stringUtils';
import type { StatusIndicatorVariant } from '@/utils/status';
import {
PlatformErrorState,
PlatformResponsiveTableLabel,
PlatformSortableTableHead,
PlatformTableDateTimeValue,
PlatformTableEmptyState,
@@ -500,7 +501,7 @@ export const TrueNASProtectionTable: Component<{
sortKey="completed"
class="md:w-[12%]"
>
Completed
<PlatformResponsiveTableLabel compact="Finished" full="Completed" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
@@ -8,6 +8,7 @@ import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PlatformSortableTableHead,
PlatformResponsiveTableLabel,
PlatformTableEmptyState,
PlatformTableNumberValue,
PlatformTableTemperatureValue,
@@ -359,7 +360,7 @@ export const TrueNASStorageTopologyTable: Component<{
sortKey="usage"
class="md:w-[28%]"
>
Usage / Size
<PlatformResponsiveTableLabel compact="Usage" full="Usage / Size" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
@@ -157,8 +157,8 @@ describe('TrueNASStorageTopologyTable user sorting', () => {
it('sorts Usage / Size descending first with missing values last in each subtree', () => {
const { container } = renderTable();
fireEvent.click(headerFor('Usage / Size'));
expect(headerFor('Usage / Size')).toHaveAttribute('aria-sort', 'descending');
fireEvent.click(headerFor('Usage'));
expect(headerFor('Usage')).toHaveAttribute('aria-sort', 'descending');
// Pools order on their own usage (zulu 70 > alpha 50); alpha's datasets
// order 80 > 10 with the metric-less bravo sinking to the bottom of its
// sibling group.
@@ -209,7 +209,7 @@ export const VsphereHostsTable: Component<{
kind="name"
sort={sort}
sortKey="host"
class="md:w-[16%]"
class="md:w-[15%]"
>
Host
</PlatformSortableTableHead>
@@ -265,7 +265,7 @@ export const VsphereHostsTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="datastores"
class="hidden md:table-cell md:w-[7%]"
class="hidden md:table-cell md:w-[8%]"
>
Datastores
</PlatformSortableTableHead>
+65
View File
@@ -728,6 +728,71 @@ body,
white-space: nowrap;
}
/*
* Dense platform tables should spend their width on operational values, not
* oversized heading chrome. Keep headings readable without making every row
* taller, then let unusually long labels provide a container-aware compact
* form below the first table-column reveal breakpoint.
*/
.table-scroll-shell > .table-fixed th {
padding-right: 0.375rem;
padding-left: 0.375rem;
font-size: 11px;
letter-spacing: 0.025em;
}
.platform-table-label-compact {
display: inline;
}
.platform-table-label-full {
display: none;
}
@container (min-width: 34rem) {
.platform-table-label-compact {
display: none;
}
.platform-table-label-full {
display: inline;
}
}
@container (max-width: 33.999rem) {
.table-scroll-shell > .table-fixed th {
padding-right: 0.25rem;
padding-left: 0.25rem;
font-size: 10px;
letter-spacing: 0;
}
/*
* On a phone the expand control and health dot otherwise consume nearly the
* whole resource cell. Identity is the user's anchor, so semantic `name`
* columns receive the largest share before secondary values are divided.
*/
.table-scroll-shell > .table-fixed th.platform-table-name-column {
width: 40%;
}
.table-scroll-shell > .table-fixed th.platform-table-mobile-w-30 {
width: 30%;
}
.table-scroll-shell > .table-fixed th.platform-table-mobile-w-25 {
width: 25%;
}
.table-scroll-shell > .table-fixed th.platform-table-mobile-w-20 {
width: 20%;
}
.table-scroll-shell > .table-fixed th.platform-table-mobile-w-15 {
width: 15%;
}
}
/* Keep name column single line to avoid wrapping while giving it more space */
.table-fixed td:first-child {
white-space: nowrap;