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 ContainerIcon from 'lucide-solid/icons/container';
import { Show, createMemo } from 'solid-js'; import { Show, createMemo } from 'solid-js';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import { import {
PlatformErrorState, PlatformErrorState,
PlatformSectionTabs,
PlatformTableEmptyState, PlatformTableEmptyState,
} from '@/features/platformPage/sharedPlatformPage'; } from '@/features/platformPage/sharedPlatformPage';
import { DockerHostsTable } from './DockerHostsTable'; import { DockerHostsTable } from './DockerHostsTable';
import { DockerServicesTable } from './DockerServicesTable'; import { DockerServicesTable } from './DockerServicesTable';
import { import {
DOCKER_TAB_SPECS,
buildDockerPageModel, buildDockerPageModel,
buildDockerContainerDefaultHiddenColumnIds, buildDockerContainerDefaultHiddenColumnIds,
buildVisibleDockerTabSpecs,
type DockerPageTabId,
} from './dockerPageModel'; } from './dockerPageModel';
const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container,docker-service'; const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container,docker-service';
@@ -27,12 +22,10 @@ const DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES = {
context: 'Host', context: 'Host',
disk: 'Writable layer', disk: 'Writable layer',
} as const; } 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" />; const dockerIcon = () => <ContainerIcon class="h-6 w-6 text-slate-400" />;
export function DockerPageSurface() { export function DockerPageSurface() {
const location = useLocation();
const { resources, loading, error, refetch } = useUnifiedResources({ const { resources, loading, error, refetch } = useUnifiedResources({
query: DOCKER_RESOURCE_QUERY, query: DOCKER_RESOURCE_QUERY,
cacheKey: 'docker-workspace', cacheKey: 'docker-workspace',
@@ -42,24 +35,9 @@ export function DockerPageSurface() {
const dockerWorkloadDefaultHiddenColumns = createMemo(() => const dockerWorkloadDefaultHiddenColumns = createMemo(() =>
buildDockerContainerDefaultHiddenColumnIds(model().containers), 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 ( return (
<div data-testid="docker-page" class="space-y-3"> <div data-testid="docker-page" class="space-y-3">
<PlatformSectionTabs
tabs={visibleTabs()}
active={activeTab()}
ariaLabel="Docker sections"
/>
<Show <Show
when={!loading() || model().resources.length > 0} when={!loading() || model().resources.length > 0}
fallback={ fallback={
@@ -90,44 +68,14 @@ export function DockerPageSurface() {
/> />
} }
> >
<Show when={activeTab() === 'overview'}> <div class="space-y-4">
<div class="space-y-4"> <DockerHostsTable
<DockerHostsTable resources={model().hosts}
resources={model().hosts} emptyIcon={dockerIcon()}
emptyIcon={dockerIcon()} emptyTitle="No Docker hosts"
emptyTitle="No Docker hosts" emptyDescription="Container hosts appear here once a Pulse agent registers them."
emptyDescription="Container hosts appear here once a Pulse agent registers them." showToolbar={false}
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'}>
<WorkloadsSurface <WorkloadsSurface
vms={[]} vms={[]}
containers={[]} containers={[]}
@@ -143,16 +91,18 @@ export function DockerPageSurface() {
columnVisibilityStorageScope={DOCKER_WORKLOAD_COLUMN_SCOPE} columnVisibilityStorageScope={DOCKER_WORKLOAD_COLUMN_SCOPE}
additionalDefaultHiddenColumnIds={dockerWorkloadDefaultHiddenColumns()} additionalDefaultHiddenColumnIds={dockerWorkloadDefaultHiddenColumns()}
columnLabelOverrides={DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES} columnLabelOverrides={DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES}
compactGroupHeaders
/> />
</Show> <Show when={model().services.length > 0}>
<Show when={activeTab() === 'services'}> <DockerServicesTable
<DockerServicesTable resources={model().services}
resources={model().services} emptyIcon={dockerIcon()}
emptyIcon={dockerIcon()} emptyTitle="No Swarm services"
emptyTitle="No Swarm services" emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them." showToolbar={false}
/> />
</Show> </Show>
</div>
</Show> </Show>
</Show> </Show>
</Show> </Show>
@@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource'; import type { Resource } from '@/types/resource';
import { import {
DOCKER_TAB_SPECS,
buildDockerContainerDefaultHiddenColumnIds, buildDockerContainerDefaultHiddenColumnIds,
buildDockerPageModel, buildDockerPageModel,
buildVisibleDockerTabSpecs,
hasDockerSwarmEvidence, hasDockerSwarmEvidence,
} from '../dockerPageModel'; } from '../dockerPageModel';
@@ -20,14 +18,6 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
}); });
describe('dockerPageModel', () => { 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', () => { it('buckets Docker hosts, containers, and Swarm services from canonical resources', () => {
const model = buildDockerPageModel([ const model = buildDockerPageModel([
makeResource({ id: 'docker-host-1', type: 'agent' }), makeResource({ id: 'docker-host-1', type: 'agent' }),
@@ -64,32 +54,6 @@ describe('dockerPageModel', () => {
expect(model.resources).toEqual([]); 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', () => { it('hides Docker container I/O columns by default when the snapshot has no I/O telemetry', () => {
expect( expect(
buildDockerContainerDefaultHiddenColumnIds([ buildDockerContainerDefaultHiddenColumnIds([
@@ -1,20 +1,6 @@
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import type { Resource, ResourceType } from '@/types/resource'; 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_HOST_TYPES = new Set<ResourceType>(['agent', 'docker-host']);
const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']); const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']);
const DOCKER_SERVICE_TYPES = new Set<ResourceType>(['docker-service']); 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[] = [ 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', id: 'kubernetes',
rootPath: '/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 ({ test('every platform sub-tab exposes v5-style operator controls', async ({
page, page,
}, testInfo) => { }, testInfo) => {
@@ -178,8 +184,6 @@ test.describe('Platform pages shell', () => {
// own canonical FilterBar via `showFilterToolbar`. // own canonical FilterBar via `showFilterToolbar`.
const cases: ReadonlyArray<{ path: string; testId: string }> = [ const cases: ReadonlyArray<{ path: string; testId: string }> = [
{ path: '/docker/overview', testId: 'docker-page' }, { 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/overview', testId: 'kubernetes-page' },
{ path: '/kubernetes/nodes', testId: 'kubernetes-page' }, { path: '/kubernetes/nodes', testId: 'kubernetes-page' },
{ path: '/kubernetes/pods', testId: 'kubernetes-page' }, { path: '/kubernetes/pods', testId: 'kubernetes-page' },