Scale Docker network attachment details

This commit is contained in:
rcourtman
2026-06-03 11:32:41 +01:00
parent d19d27e915
commit 458ffd351b
4 changed files with 350 additions and 45 deletions
@@ -257,7 +257,10 @@ network id details belong in the inline row disclosure. Attached container
names, network addresses, image, health/state, and published ports are
feature-owned data, but search and disclosure behavior must remain inside the
shared table chrome rather than a card deck, nested card, or route-changing
object browser.
object browser. Dense networks must keep the inline disclosure bounded by
default and provide local attached-container search, status grouping, and
attention/running/other filters so large bridge or overlay networks remain
scan-friendly without hiding any container from drilldown.
1. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary.
The panel may own shell placement and local action layout, but
@@ -227,7 +227,11 @@ with the same edge visible on both resources so network workflow tables and
resource drawers can explain the relationship from either endpoint. The
Docker Networks table may keep a host-scoped legacy `docker.networks[]`
fallback only for older snapshots that have not yet published the relationship
edge; that fallback must not match containers across Docker hosts.
edge; that fallback must not match containers across Docker hosts. When a
single network has many attached containers, relationship consumers must keep
the attached container fields searchable and attention-filterable from the
network detail disclosure instead of forcing operators to inspect a full
container inventory table.
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `performance-and-scalability`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `performance-and-scalability`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
@@ -1,4 +1,6 @@
import { For, Show, createMemo, type Component } from 'solid-js';
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { FilterSegmentedControl } from '@/components/shared/FilterToolbar';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
@@ -46,6 +48,24 @@ type DockerNetworksTableProps = DockerNativeTableProps & {
relatedResources?: Resource[];
};
type AttachmentStatusFilter = 'all' | 'attention' | 'running' | 'other';
type AttachmentGroupKey = Exclude<AttachmentStatusFilter, 'all'>;
type AttachmentGroup = {
key: AttachmentGroupKey;
label: string;
description: string;
};
const ATTACHMENT_DETAIL_ROW_LIMIT = 24;
const ATTACHMENT_GROUPS: readonly AttachmentGroup[] = [
{ key: 'attention', label: 'Attention', description: 'Needs operator review' },
{ key: 'other', label: 'Other', description: 'Stopped, paused, or unknown' },
{ key: 'running', label: 'Running', description: 'No active issue reported' },
] as const;
const networkFlags = (resource: Resource): string =>
dockerJoinValues(
[
@@ -76,6 +96,9 @@ const networkSubnets = (resource: Resource): string =>
const attachmentCountLabel = (count: number): string =>
`${count} attached ${count === 1 ? 'container' : 'containers'}`;
const attachmentPlainCountLabel = (count: number): string =>
`${count} ${count === 1 ? 'container' : 'containers'}`;
const attachmentSummary = (rows: readonly DockerNetworkAttachmentRow[]): string => {
if (rows.length === 0) return 'No containers';
const labels = rows.slice(0, 3).map((row) => {
@@ -112,50 +135,216 @@ const networkDetailRows = (resource: Resource) => [
['Network ID', dockerTextValue(resource.docker?.networkId)],
];
const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[] }> = (props) => (
<section class="min-w-0">
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="text-xs font-semibold text-base-content">Attached containers</h3>
<span class="text-[11px] text-muted">{attachmentCountLabel(props.rows.length)}</span>
</div>
<Show
when={props.rows.length > 0}
fallback={
<div class="rounded border border-border bg-surface px-3 py-2 text-[11px] text-muted">
No attached Docker containers reported on this network.
</div>
}
>
<div class="space-y-1.5">
<For each={props.rows}>
{(row) => (
<div class="rounded border border-border bg-surface px-3 py-2">
<div class="grid gap-2 text-[11px] md:grid-cols-[minmax(0,1.2fr)_7rem_9rem_minmax(0,1fr)] md:items-center">
<div class="flex min-w-0 items-center gap-2">
<StatusDot size="sm" variant={row.status.variant} title={row.status.label} />
<span class="truncate font-semibold text-base-content" title={row.name}>
{row.name}
</span>
</div>
<span class="text-base-content">{row.status.label}</span>
<span class="font-mono text-base-content" title={row.address}>
{row.address}
</span>
<span class="truncate font-mono text-muted" title={row.ports}>
{row.ports}
</span>
</div>
<div class="mt-1 truncate text-[10px] text-muted" title={row.image}>
{row.image}
</div>
</div>
)}
</For>
</div>
</Show>
</section>
const attachmentGroupKey = (row: DockerNetworkAttachmentRow): AttachmentGroupKey => {
if (row.status.variant === 'danger' || row.status.variant === 'warning') return 'attention';
if (row.status.variant === 'success') return 'running';
return 'other';
};
const attachmentFilterMatches = (
row: DockerNetworkAttachmentRow,
filter: AttachmentStatusFilter,
): boolean => filter === 'all' || attachmentGroupKey(row) === filter;
const attachmentStatusCounts = (rows: readonly DockerNetworkAttachmentRow[]) => {
const counts: Record<AttachmentStatusFilter, number> = {
all: rows.length,
attention: 0,
running: 0,
other: 0,
};
for (const row of rows) {
counts[attachmentGroupKey(row)] += 1;
}
return counts;
};
const filterOptionLabel = (label: string, count: number): JSX.Element => (
<>
<span>{label}</span>
<span class="rounded bg-surface-alt px-1.5 py-px text-[10px] font-semibold text-muted">
{count}
</span>
</>
);
const AttachmentRowCard: Component<{ row: DockerNetworkAttachmentRow }> = (props) => (
<div class="rounded border border-border bg-surface px-3 py-2">
<div class="grid gap-2 text-[11px] md:grid-cols-[minmax(0,1.2fr)_7rem_9rem_minmax(0,1fr)] md:items-center">
<div class="flex min-w-0 items-center gap-2">
<StatusDot size="sm" variant={props.row.status.variant} title={props.row.status.label} />
<span class="truncate font-semibold text-base-content" title={props.row.name}>
{props.row.name}
</span>
</div>
<span class="text-base-content">{props.row.status.label}</span>
<span class="font-mono text-base-content" title={props.row.address}>
{props.row.address}
</span>
<span class="truncate font-mono text-muted" title={props.row.ports}>
{props.row.ports}
</span>
</div>
<div class="mt-1 truncate text-[10px] text-muted" title={props.row.image}>
{props.row.image}
</div>
</div>
);
const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[] }> = (props) => {
const [attachmentSearch, setAttachmentSearch] = createSignal('');
const [attachmentFilter, setAttachmentFilter] = createSignal<AttachmentStatusFilter>('all');
const [showAll, setShowAll] = createSignal(false);
const counts = createMemo(() => attachmentStatusCounts(props.rows));
const filterOptions = createMemo(() => [
{
value: 'all',
label: filterOptionLabel('All', counts().all),
ariaLabel: 'All',
title: 'All attached containers',
},
{
value: 'attention',
label: filterOptionLabel('Attention', counts().attention),
ariaLabel: 'Attention',
title: 'Containers that need review',
},
{
value: 'running',
label: filterOptionLabel('Running', counts().running),
ariaLabel: 'Running',
title: 'Running containers',
},
{
value: 'other',
label: filterOptionLabel('Other', counts().other),
ariaLabel: 'Other',
title: 'Stopped, paused, or unknown containers',
},
]);
const filteredRows = createMemo(() => {
const needle = attachmentSearch().trim().toLowerCase();
return props.rows.filter((row) => {
if (!attachmentFilterMatches(row, attachmentFilter())) return false;
if (!needle) return true;
return row.searchText.includes(needle);
});
});
const visibleRows = createMemo(() =>
showAll() ? filteredRows() : filteredRows().slice(0, ATTACHMENT_DETAIL_ROW_LIMIT),
);
const groupedRows = createMemo(() =>
ATTACHMENT_GROUPS.map((group) => ({
...group,
rows: visibleRows().filter((row) => attachmentGroupKey(row) === group.key),
})).filter((group) => group.rows.length > 0),
);
const hiddenRowCount = createMemo(() =>
Math.max(filteredRows().length - visibleRows().length, 0),
);
const activeFilterCount = createMemo(() => {
let count = 0;
if (attachmentSearch().trim()) count += 1;
if (attachmentFilter() !== 'all') count += 1;
return count;
});
const filteredSummary = createMemo(() => {
if (activeFilterCount() === 0) return attachmentCountLabel(props.rows.length);
return `${attachmentPlainCountLabel(filteredRows().length)} of ${attachmentPlainCountLabel(
props.rows.length,
)}`;
});
const setSearch = (value: string) => {
setAttachmentSearch(value);
setShowAll(false);
};
const setFilter = (value: string) => {
setAttachmentFilter(value as AttachmentStatusFilter);
setShowAll(false);
};
return (
<section class="min-w-0">
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="text-xs font-semibold text-base-content">Attached containers</h3>
<span class="text-[11px] text-muted">{filteredSummary()}</span>
</div>
<Show
when={props.rows.length > 0}
fallback={
<div class="rounded border border-border bg-surface px-3 py-2 text-[11px] text-muted">
No attached Docker containers reported on this network.
</div>
}
>
<div class="mb-3 grid gap-2 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-center">
<SearchInput
value={attachmentSearch}
onChange={setSearch}
placeholder="Search attached containers"
title="Search attached containers"
inputClass="py-1.5 text-xs"
clearOnFocusedEscape
/>
<FilterSegmentedControl
value={attachmentFilter()}
onChange={setFilter}
options={filterOptions()}
aria-label="Attached container status"
class="justify-start xl:justify-end"
/>
</div>
<Show
when={filteredRows().length > 0}
fallback={
<div class="rounded border border-border bg-surface px-3 py-2 text-[11px] text-muted">
No attached containers match current filters.
</div>
}
>
<div class="space-y-3">
<For each={groupedRows()}>
{(group) => (
<section class="space-y-1.5">
<div class="flex items-center justify-between gap-2 text-[11px]">
<div class="flex min-w-0 items-center gap-2">
<span class="font-semibold text-base-content">{group.label}</span>
<span class="truncate text-muted">{group.description}</span>
</div>
<span class="text-muted">{attachmentPlainCountLabel(group.rows.length)}</span>
</div>
<div class="space-y-1.5">
<For each={group.rows}>{(row) => <AttachmentRowCard row={row} />}</For>
</div>
</section>
)}
</For>
<Show when={hiddenRowCount() > 0}>
<button
type="button"
class="w-full rounded border border-border bg-surface px-3 py-2 text-xs font-medium text-base-content hover:bg-surface-hover"
onClick={() => setShowAll(true)}
>
Show all {attachmentPlainCountLabel(filteredRows().length)}
</button>
</Show>
<Show when={showAll() && filteredRows().length > ATTACHMENT_DETAIL_ROW_LIMIT}>
<button
type="button"
class="w-full rounded border border-border bg-surface px-3 py-2 text-xs font-medium text-base-content hover:bg-surface-hover"
onClick={() => setShowAll(false)}
>
Show first {ATTACHMENT_DETAIL_ROW_LIMIT}
</button>
</Show>
</div>
</Show>
</Show>
</section>
);
};
const NetworkConfigDetail: Component<{ resource: Resource }> = (props) => (
<section class="min-w-0">
<h3 class="mb-2 text-xs font-semibold text-base-content">Network details</h3>
@@ -386,6 +386,115 @@ describe('Docker native tables', () => {
expect(detail.getByText('nginx:latest')).toBeInTheDocument();
});
it('keeps dense Docker network attachment lists searchable and grouped', () => {
const network = makeResource({
id: 'network-dense',
type: 'docker-network',
name: 'frontend',
docker: {
hostname: 'edge-01',
hostSourceId: 'docker-host-1',
networkId: 'net-dense',
driver: 'bridge',
scope: 'local',
enableIpv4: true,
},
});
const makeAttachedContainer = (
index: number,
overrides: Partial<Resource['docker']> = {},
): Resource => {
const name =
index === 0
? 'api-unhealthy'
: index === 1
? 'api-restarting'
: index === 2
? 'worker-stopped'
: `worker-${String(index).padStart(2, '0')}`;
return makeResource({
id: `container-${index}`,
type: 'app-container',
name,
displayName: name,
status: 'running',
relationships: [
{
sourceId: `container-${index}`,
targetId: 'network-dense',
type: 'attached_to',
confidence: 1,
active: true,
discoverer: 'docker_adapter',
observedAt: '2026-06-03T09:00:00Z',
lastSeenAt: '2026-06-03T09:00:00Z',
},
],
docker: {
hostname: 'edge-01',
hostSourceId: 'docker-host-1',
image: `repo/${name}:latest`,
containerState: 'running',
networks: [{ name: 'frontend', ipv4: `10.88.0.${index + 10}` }],
ports: [{ ip: '0.0.0.0', publicPort: 8000 + index, privatePort: 80, protocol: 'tcp' }],
...overrides,
},
});
};
const containers = [
makeAttachedContainer(0, { health: 'unhealthy' }),
makeAttachedContainer(1, { containerState: 'restarting' }),
makeAttachedContainer(2, { containerState: 'exited', exitCode: 0 }),
...Array.from({ length: 27 }, (_, offset) => makeAttachedContainer(offset + 3)),
];
render(() => (
<DockerNetworksTable
resources={[network]}
relatedResources={[network, ...containers]}
emptyIcon={<span />}
emptyTitle="No networks"
emptyDescription="No networks"
/>
));
expect(
screen.getByText(
'30 attached containers · api-unhealthy 10.88.0.10, api-restarting 10.88.0.11, worker-stopped 10.88.0.12 +27',
),
).toBeInTheDocument();
fireEvent.click(document.querySelector('[data-docker-network-row="network-dense"]')!);
const detail = within(
document.querySelector('[data-docker-network-detail-row="network-dense"]')!,
);
expect(detail.getByPlaceholderText('Search attached containers')).toBeInTheDocument();
expect(detail.getByText('Show all 30 containers')).toBeInTheDocument();
expect(detail.getByText('api-unhealthy')).toBeInTheDocument();
expect(detail.getByText('api-restarting')).toBeInTheDocument();
expect(detail.queryByText('worker-29')).toBeNull();
fireEvent.click(detail.getByRole('button', { name: 'Show all 30 containers' }));
expect(detail.getByText('worker-29')).toBeInTheDocument();
expect(detail.getByText('Show first 24')).toBeInTheDocument();
fireEvent.click(detail.getByRole('button', { name: 'Attention' }));
expect(detail.getByText('2 containers of 30 containers')).toBeInTheDocument();
expect(detail.getByText('api-unhealthy')).toBeInTheDocument();
expect(detail.getByText('api-restarting')).toBeInTheDocument();
expect(detail.queryByText('worker-29')).toBeNull();
fireEvent.click(detail.getByRole('button', { name: 'All' }));
fireEvent.input(detail.getByPlaceholderText('Search attached containers'), {
target: { value: 'worker-29' },
});
expect(detail.getByText('1 container of 30 containers')).toBeInTheDocument();
expect(detail.getByText('worker-29')).toBeInTheDocument();
expect(detail.getByText('0.0.0.0:8029->80/tcp')).toBeInTheDocument();
});
it('renders Docker Swarm node API fields', () => {
render(() => (
<DockerSwarmNodesTable