diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 94878d574..e29c550ea 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 0b9f46c61..731120b62 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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. diff --git a/frontend-modern/src/features/docker/DockerNetworksTable.tsx b/frontend-modern/src/features/docker/DockerNetworksTable.tsx index 5a7243cb2..2688dcc2c 100644 --- a/frontend-modern/src/features/docker/DockerNetworksTable.tsx +++ b/frontend-modern/src/features/docker/DockerNetworksTable.tsx @@ -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; + +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) => ( -
-
-

Attached containers

- {attachmentCountLabel(props.rows.length)} -
- 0} - fallback={ -
- No attached Docker containers reported on this network. -
- } - > -
- - {(row) => ( -
-
-
- - - {row.name} - -
- {row.status.label} - - {row.address} - - - {row.ports} - -
-
- {row.image} -
-
- )} -
-
-
-
+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 = { + 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 => ( + <> + {label} + + {count} + + ); +const AttachmentRowCard: Component<{ row: DockerNetworkAttachmentRow }> = (props) => ( +
+
+
+ + + {props.row.name} + +
+ {props.row.status.label} + + {props.row.address} + + + {props.row.ports} + +
+
+ {props.row.image} +
+
+); + +const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[] }> = (props) => { + const [attachmentSearch, setAttachmentSearch] = createSignal(''); + const [attachmentFilter, setAttachmentFilter] = createSignal('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 ( +
+
+

Attached containers

+ {filteredSummary()} +
+ 0} + fallback={ +
+ No attached Docker containers reported on this network. +
+ } + > +
+ + +
+ + 0} + fallback={ +
+ No attached containers match current filters. +
+ } + > +
+ + {(group) => ( +
+
+
+ {group.label} + {group.description} +
+ {attachmentPlainCountLabel(group.rows.length)} +
+
+ {(row) => } +
+
+ )} +
+ 0}> + + + ATTACHMENT_DETAIL_ROW_LIMIT}> + + +
+
+
+
+ ); +}; + const NetworkConfigDetail: Component<{ resource: Resource }> = (props) => (

Network details

diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 27e9186d6..0b65ed2dd 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -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 => { + 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(() => ( + } + 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(() => (