Collapse Docker page to single unified surface

The "Hosts" tab already rendered the hosts table, the containers
WorkloadsSurface, and the Swarm services table together, so the
separate Containers and Swarm services sub-tabs were strict subset
views of overview. Other platform pages (Proxmox, TrueNAS,
Kubernetes) use sub-tabs for genuinely different data per tab; the
Docker shape was the odd one out.

Drop PlatformSectionTabs from DockerPageSurface and render the
unified hosts + containers + services stack unconditionally. Remove
the now-dead DOCKER_TAB_SPECS / DockerPageTabId / DockerTabSpec /
buildVisibleDockerTabSpecs exports from dockerPageModel. The
DockerPage no longer interprets the URL segment, so legacy
/docker/overview, /docker/containers, /docker/services bookmarks
continue to resolve via the existing /docker/* wildcard route in
App.tsx.
This commit is contained in:
rcourtman
2026-05-19 09:52:10 +01:00
parent 90482607da
commit 1f66a2f93f
4 changed files with 33 additions and 141 deletions
@@ -1,21 +1,16 @@
import { useLocation } from '@solidjs/router';
import ContainerIcon from 'lucide-solid/icons/container';
import { Show, createMemo } from 'solid-js';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
PlatformErrorState,
PlatformSectionTabs,
PlatformTableEmptyState,
} from '@/features/platformPage/sharedPlatformPage';
import { DockerHostsTable } from './DockerHostsTable';
import { DockerServicesTable } from './DockerServicesTable';
import {
DOCKER_TAB_SPECS,
buildDockerPageModel,
buildDockerContainerDefaultHiddenColumnIds,
buildVisibleDockerTabSpecs,
type DockerPageTabId,
} from './dockerPageModel';
const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container,docker-service';
@@ -27,12 +22,10 @@ const DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES = {
context: 'Host',
disk: 'Writable layer',
} as const;
const VALID_TABS = new Set<DockerPageTabId>(DOCKER_TAB_SPECS.map((tab) => tab.id));
const dockerIcon = () => <ContainerIcon class="h-6 w-6 text-slate-400" />;
export function DockerPageSurface() {
const location = useLocation();
const { resources, loading, error, refetch } = useUnifiedResources({
query: DOCKER_RESOURCE_QUERY,
cacheKey: 'docker-workspace',
@@ -42,24 +35,9 @@ export function DockerPageSurface() {
const dockerWorkloadDefaultHiddenColumns = createMemo(() =>
buildDockerContainerDefaultHiddenColumnIds(model().containers),
);
const visibleTabs = createMemo(() => buildVisibleDockerTabSpecs(model()));
const visibleTabIds = createMemo(
() => new Set<DockerPageTabId>(visibleTabs().map((tab) => tab.id)),
);
const activeTab = createMemo<DockerPageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1] as DockerPageTabId | undefined;
if (!segment || !VALID_TABS.has(segment)) return 'overview';
return visibleTabIds().has(segment) ? segment : 'overview';
});
return (
<div data-testid="docker-page" class="space-y-3">
<PlatformSectionTabs
tabs={visibleTabs()}
active={activeTab()}
ariaLabel="Docker sections"
/>
<Show
when={!loading() || model().resources.length > 0}
fallback={
@@ -90,44 +68,14 @@ export function DockerPageSurface() {
/>
}
>
<Show when={activeTab() === 'overview'}>
<div class="space-y-4">
<DockerHostsTable
resources={model().hosts}
emptyIcon={dockerIcon()}
emptyTitle="No Docker hosts"
emptyDescription="Container hosts appear here once a Pulse agent registers them."
showToolbar={false}
/>
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
showFilterToolbar
suppressPlatformFilter
forcedPlatform={DOCKER_PLATFORM_FILTER}
forcedViewMode={DOCKER_WORKLOAD_FORCED_VIEW_MODE}
defaultSortKey={DOCKER_WORKLOAD_DEFAULT_SORT_KEY}
columnVisibilityStorageScope={DOCKER_WORKLOAD_COLUMN_SCOPE}
additionalDefaultHiddenColumnIds={dockerWorkloadDefaultHiddenColumns()}
columnLabelOverrides={DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES}
compactGroupHeaders
/>
<Show when={model().services.length > 0}>
<DockerServicesTable
resources={model().services}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
showToolbar={false}
/>
</Show>
</div>
</Show>
<Show when={activeTab() === 'containers'}>
<div class="space-y-4">
<DockerHostsTable
resources={model().hosts}
emptyIcon={dockerIcon()}
emptyTitle="No Docker hosts"
emptyDescription="Container hosts appear here once a Pulse agent registers them."
showToolbar={false}
/>
<WorkloadsSurface
vms={[]}
containers={[]}
@@ -143,16 +91,18 @@ export function DockerPageSurface() {
columnVisibilityStorageScope={DOCKER_WORKLOAD_COLUMN_SCOPE}
additionalDefaultHiddenColumnIds={dockerWorkloadDefaultHiddenColumns()}
columnLabelOverrides={DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES}
compactGroupHeaders
/>
</Show>
<Show when={activeTab() === 'services'}>
<DockerServicesTable
resources={model().services}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
/>
</Show>
<Show when={model().services.length > 0}>
<DockerServicesTable
resources={model().services}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
showToolbar={false}
/>
</Show>
</div>
</Show>
</Show>
</Show>
@@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
DOCKER_TAB_SPECS,
buildDockerContainerDefaultHiddenColumnIds,
buildDockerPageModel,
buildVisibleDockerTabSpecs,
hasDockerSwarmEvidence,
} from '../dockerPageModel';
@@ -20,14 +18,6 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
});
describe('dockerPageModel', () => {
it('declares the Docker section set with hosts, containers, and Swarm services', () => {
expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([
'overview',
'containers',
'services',
]);
});
it('buckets Docker hosts, containers, and Swarm services from canonical resources', () => {
const model = buildDockerPageModel([
makeResource({ id: 'docker-host-1', type: 'agent' }),
@@ -64,32 +54,6 @@ describe('dockerPageModel', () => {
expect(model.resources).toEqual([]);
});
it('shows Docker subtabs only when canonical resource evidence exists', () => {
expect(
buildVisibleDockerTabSpecs(
buildDockerPageModel([makeResource({ id: 'docker-host-1', type: 'agent' })]),
).map((tab) => tab.id),
).toEqual(['overview']);
expect(
buildVisibleDockerTabSpecs(
buildDockerPageModel([
makeResource({ id: 'docker-host-1', type: 'agent' }),
makeResource({ id: 'ctr-1', type: 'app-container' }),
]),
).map((tab) => tab.id),
).toEqual(['overview', 'containers']);
expect(
buildVisibleDockerTabSpecs(
buildDockerPageModel([
makeResource({ id: 'docker-host-1', type: 'agent' }),
makeResource({ id: 'svc-1', type: 'docker-service' }),
]),
).map((tab) => tab.id),
).toEqual(['overview', 'services']);
});
it('hides Docker container I/O columns by default when the snapshot has no I/O telemetry', () => {
expect(
buildDockerContainerDefaultHiddenColumnIds([
@@ -1,20 +1,6 @@
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import type { Resource, ResourceType } from '@/types/resource';
export type DockerPageTabId = 'overview' | 'containers' | 'services';
export type DockerTabSpec = {
id: DockerPageTabId;
label: string;
path: string;
};
export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [
{ id: 'overview', label: 'Hosts', path: '/docker/overview' },
{ id: 'containers', label: 'Containers', path: '/docker/containers' },
{ id: 'services', label: 'Swarm services', path: '/docker/services' },
] as const;
const DOCKER_HOST_TYPES = new Set<ResourceType>(['agent', 'docker-host']);
const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']);
const DOCKER_SERVICE_TYPES = new Set<ResourceType>(['docker-service']);
@@ -108,15 +94,3 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
};
}
export function buildVisibleDockerTabSpecs(model: DockerPageModel): DockerTabSpec[] {
const visible = new Set<DockerPageTabId>(['overview']);
if (model.containers.length > 0) {
visible.add('containers');
}
if (model.services.length > 0) {
visible.add('services');
}
return DOCKER_TAB_SPECS.filter((tab) => visible.has(tab.id));
}
@@ -51,14 +51,6 @@ type PlatformPageCase = {
};
const PLATFORM_PAGES: readonly PlatformPageCase[] = [
{
id: 'docker',
rootPath: '/docker',
testId: 'docker-page',
ariaLabel: 'Docker sections',
tabPaths: ['/docker/overview', '/docker/containers', '/docker/services'],
populatedTabPaths: ['/docker/overview', '/docker/containers', '/docker/services'],
},
{
id: 'kubernetes',
rootPath: '/kubernetes',
@@ -165,6 +157,20 @@ test.describe('Platform pages shell', () => {
});
}
test('docker page renders as a single unified surface without sub-tabs', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop shell smoke');
await stubEmptyResources(page);
await page.goto('/docker/overview', { waitUntil: 'domcontentloaded' });
const pageRoot = page.getByTestId('docker-page');
await expect(pageRoot).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('navigation', { name: 'Docker sections' })).toHaveCount(0);
});
test('every platform sub-tab exposes v5-style operator controls', async ({
page,
}, testInfo) => {
@@ -178,8 +184,6 @@ test.describe('Platform pages shell', () => {
// own canonical FilterBar via `showFilterToolbar`.
const cases: ReadonlyArray<{ path: string; testId: string }> = [
{ path: '/docker/overview', testId: 'docker-page' },
{ path: '/docker/containers', testId: 'docker-page' },
{ path: '/docker/services', testId: 'docker-page' },
{ path: '/kubernetes/overview', testId: 'kubernetes-page' },
{ path: '/kubernetes/nodes', testId: 'kubernetes-page' },
{ path: '/kubernetes/pods', testId: 'kubernetes-page' },