feat: node-scoped Networking operator page (#1603)

* feat: add node-scoped Networking operator page

Adds a Networking view with overview, topology, inventory, and findings.

Shared aggregate reads back the page; Resources keeps prune and redirects here.

Includes fail-closed network delete guards, operator docs, and /nodes/:slug/networking routing.

* fix: rename unused variable n to _n to satisfy no-unused-vars lint

* fix: keep top bar search clickable when nav grows

* feat: complete Compose-first Networking Phase 2 operator assistant

* fix: move networking action visibility helper out of component module

* feat(networking): complete Compose-first Networking operator page

Finish the node-scoped Networking page (Overview, Networks, Topology,
Findings) with design-system parity and correct finding semantics.

- Rebuild detail sheets on SystemSheet/SheetSection; align the tab band,
  masthead, and mobile tone with Fleet and Security.
- Encode the host-mode and exposure severity matrix; fix collision counts
  so intentional shared externals are not flagged; add one typed drift
  predicate shared by inventory, topology, badges, and overview counts.
- Preserve per-container attachments and IPs on topology node clicks;
  drawer-only click with an explicit logs action; ownership and boolean
  filters; bound large graphs before layout.
- Aggregate cached Compose Doctor findings into the Findings tab with
  honest source labels, structural merge and dedupe, staleness
  reconciliation, and a shared exposure-context helper both engines use.
- Networks tab: privacy-safe service search, precise ownership counts,
  schema v3 with version-2 adapters on every endpoint, pre-confirm delete
  reasons, and the shared sortable table with an internal scroll region.
- Interop: Fleet node-card networking signal with pending-intent
  navigation, stack-to-node backlink, and Dossier/Drift deep links.
- Enrich sanitized inspect with an allowlisted connected-container list;
  fetch topology once and filter client-side.
- Docs and tests across every new finding kind, adapter, and flow.

* fix(networking): correct drift count, exposure fail-soft, and inspect crash paths

Address code-review findings on the Networking page implementation:
- Fix the Overview drift count to use the shared drift-kind predicate instead
  of a hardcoded list that omitted external-network-missing.
- Gate Compose Doctor's unclassified-exposure and reverse-proxy-undocumented
  rules on exposure-context availability, so a DB read failure no longer
  fabricates findings (mirrors the live engine's existing fail-soft behavior).
- Guard the per-stack exposure-intent read in topology aggregation so a
  transient DB failure degrades to unknown intent instead of failing the
  whole response.
- Harden the network detail drawer against a partial inspect payload from an
  older remote node, and log the real error instead of a bare catch.
- Remove now-duplicated severity-rank and drift-kind helpers in favor of the
  shared modules; drop dead backend-only exports; widen the frontend schema
  version type to a plain number instead of casting past a literal type.
- Add coverage for the delete-guard precedence, the full host-mode severity
  matrix, the schema-2 compatibility adapter, and the sanitized connected-
  container allowlist; tighten two tests that were not exercising the
  behavior they claimed to.

* fix: add missing onOpenNodeNetworking prop to FleetView experimental test

The added required prop on FleetViewProps broke the merge-build when the
test file (on main but not on this branch) was compiled against the
updated FleetView interface.
This commit is contained in:
Anso
2026-07-14 20:18:10 -04:00
committed by GitHub
parent 8096799e49
commit 8980910153
80 changed files with 5686 additions and 667 deletions
+1
View File
@@ -21,6 +21,7 @@ export const SENCHO_OPEN_STACK_EVENT = 'sencho-open-stack';
export interface SenchoOpenStackDetail {
nodeId: number;
stackName: string;
destination?: 'stack' | 'editor' | 'anatomy-networking' | 'doctor' | 'dossier' | 'drift';
}
/** Tabs of the top-level Security view. Used by the nav state and by
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
adaptNetworkingOverview, buildExternalNetworkSnippet, filterNetworkRows, getNetworkingPosture,
} from './networking';
import type { NetworkingFinding, NetworkingFindingKind, NetworkingNetworkRow, NodeNetworkingOverview } from '@/types/networking';
const rows: NetworkingNetworkRow[] = [
{
id: 'managed', name: 'app-net', driver: 'bridge', scope: 'local', isSystem: false, ingress: false,
composeProject: 'app', stack: 'app', connectedCount: 2, isSencho: false, ownership: 'sencho-managed',
declaredByStacks: ['app'], declaredExternalByStacks: ['peer'], isExternalDependency: true,
sharedStackCount: 2, exposureSummary: null, findingIds: ['drift'], serviceNames: ['web'],
},
{
id: 'system', name: 'bridge', driver: 'bridge', scope: 'local', isSystem: true, ingress: false,
composeProject: null, stack: null, connectedCount: 0, isSencho: false, ownership: 'system',
declaredByStacks: [], declaredExternalByStacks: [], isExternalDependency: false,
sharedStackCount: 0, exposureSummary: null, findingIds: [], serviceNames: [],
},
];
const findingKindById = new Map<string, NetworkingFindingKind>([['drift', 'network-missing']]);
describe('networking view helpers', () => {
it('filters ownership, dependencies, and findings independently', () => {
expect(filterNetworkRows(rows, 'managed', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'external', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'system', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'shared', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'drift', '', findingKindById)).toHaveLength(1);
});
it('drift filter requires a drift-kind finding, not just any finding', () => {
const nonDriftKindById = new Map<string, NetworkingFindingKind>([['drift', 'shared-network']]);
expect(filterNetworkRows(rows, 'drift', '', nonDriftKindById)).toHaveLength(0);
});
it('does not infer contained posture from a schema-less response', () => {
expect(getNetworkingPosture([], true, true).label).toBe('Partial networking data');
});
it('adapts schema-less responses without synthesizing findings', () => {
const adapted = adaptNetworkingOverview({ networks: rows });
expect(adapted.isLegacy).toBe(true);
expect(adapted.findings).toEqual([]);
expect(adapted.runtimeAvailable).toBe(false);
});
it('adapts a schema-2 response: derives ownership counts and fills new-field defaults', () => {
// A schema-2 remote sends partial rows/findings (no serviceNames, no
// sources/doctorFindings); the envelope type models the schema-3 ideal, so
// the partial wire shape is cast to mirror what actually arrives.
const adapted = adaptNetworkingOverview({
schemaVersion: 2,
runtimeAvailable: true,
overview: { networkCount: 2 } as NodeNetworkingOverview,
networks: [
{ id: 'a', name: 'a', ownership: 'sencho-managed', isExternalDependency: false },
{ id: 'b', name: 'b', ownership: 'unmanaged', isExternalDependency: true },
] as unknown as NetworkingNetworkRow[],
findings: [{ id: 'f1', kind: 'network-missing', severity: 'high', title: 't', message: 'm' }] as unknown as NetworkingFinding[],
});
expect(adapted.isLegacy).toBe(false);
expect(adapted.runtimeAvailable).toBe(true);
// The schema-2 envelope omits ownership counts, so they are derived from the rows.
expect(adapted.overview?.senchoManagedNetworkCount).toBe(1);
expect(adapted.overview?.unmanagedNetworkCount).toBe(1);
expect(adapted.overview?.externalDependencyNetworkCount).toBe(1);
// Fields new in schema 3 get safe defaults rather than undefined.
expect(adapted.networks[0].serviceNames).toEqual([]);
expect(adapted.findings[0].sources).toEqual(['live']);
expect(adapted.findings[0].doctorFindings).toEqual([]);
});
it('builds an external network snippet only for safe names', () => {
expect(buildExternalNetworkSnippet('shared-net')).toBe(
'networks:\n shared-net:\n external: true',
);
expect(buildExternalNetworkSnippet('../unsafe')).toBeNull();
});
});
+204
View File
@@ -0,0 +1,204 @@
import {
isNetworkDriftFindingKind,
type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOverviewEnvelope,
type NetworkingRecommendedAction, type NodeNetworkingOverview,
} from '@/types/networking';
export type NetworkFilter = 'all' | 'managed' | 'external' | 'system' | 'shared' | 'exposed' | 'drift';
export function isNetworkingActionVisible(
action: NetworkingRecommendedAction,
isAdmin: boolean,
canEditStack: (stack: string) => boolean,
): boolean {
if (action.kind === 'create-network') return isAdmin;
if (action.kind === 'set-exposure-intent') return canEditStack(action.stack);
return true;
}
// findingIds on a row are opaque hash IDs; drift classification needs each
// finding's kind, so callers supply a lookup built from the full findings list.
// Shared by the Networks-table drift filter and its count so they never diverge.
export function rowHasDriftFinding(
row: NetworkingNetworkRow,
findingKindById: Map<string, NetworkingFindingKind>,
): boolean {
return row.findingIds.some((id) => {
const kind = findingKindById.get(id);
return kind !== undefined && isNetworkDriftFindingKind(kind);
});
}
function matchesNetworkFilter(
row: NetworkingNetworkRow,
filter: NetworkFilter,
findingKindById: Map<string, NetworkingFindingKind>,
): boolean {
switch (filter) {
case 'managed':
return row.ownership === 'sencho-managed';
case 'external':
return row.isExternalDependency;
case 'system':
return row.ownership === 'system';
case 'shared':
return row.sharedStackCount >= 2;
case 'exposed':
return Boolean(row.exposureSummary?.broadExposureCount);
case 'drift':
return rowHasDriftFinding(row, findingKindById);
case 'all':
default:
return true;
}
}
export function filterNetworkRows(
rows: NetworkingNetworkRow[],
filter: NetworkFilter,
search: string,
findingKindById: Map<string, NetworkingFindingKind> = new Map(),
): NetworkingNetworkRow[] {
const query = search.trim().toLowerCase();
return rows.filter((row) => {
if (!matchesNetworkFilter(row, filter, findingKindById)) return false;
if (!query) return true;
return [
row.name,
row.stack,
row.composeProject,
row.driver,
...row.declaredByStacks,
...row.declaredExternalByStacks,
...row.serviceNames,
].filter(Boolean).some((value) => value!.toLowerCase().includes(query));
});
}
export function getNetworkingPosture(
findings: NetworkingFinding[],
runtimeAvailable: boolean,
isLegacy: boolean,
): { label: string; tone: 'live' | 'warning' | 'critical' | 'neutral' } {
if (isLegacy) return { label: 'Partial networking data', tone: 'neutral' };
if (!runtimeAvailable) return { label: 'Runtime unavailable', tone: 'warning' };
if (findings.some((finding) => finding.severity === 'critical' || finding.severity === 'high')) {
return { label: 'Action needed', tone: 'critical' };
}
if (findings.some((finding) => finding.severity === 'medium')) {
return { label: 'Review', tone: 'warning' };
}
return { label: 'Contained', tone: 'live' };
}
export function canUseNetworkName(name: string): boolean {
return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name);
}
export function buildExternalNetworkSnippet(name: string): string | null {
if (!canUseNetworkName(name)) return null;
return `networks:\n ${name}:\n external: true`;
}
/** Normalizes a network row from a schema-2 remote (no serviceNames) so the UI
* never crashes on a missing array. */
function adaptNetworkRow(row: Partial<NetworkingNetworkRow>): NetworkingNetworkRow {
return {
id: row.id ?? '',
name: row.name ?? '',
driver: row.driver ?? 'bridge',
scope: row.scope ?? 'local',
isSystem: row.isSystem === true,
ingress: row.ingress === true,
enableIPv6: row.enableIPv6,
composeProject: row.composeProject ?? null,
stack: row.stack ?? null,
connectedCount: row.connectedCount ?? 0,
isSencho: row.isSencho === true,
ownership: row.ownership ?? 'unmanaged',
declaredByStacks: row.declaredByStacks ?? [],
declaredExternalByStacks: row.declaredExternalByStacks ?? [],
isExternalDependency: row.isExternalDependency === true,
sharedStackCount: row.sharedStackCount ?? 0,
exposureSummary: row.exposureSummary ?? null,
findingIds: row.findingIds ?? [],
serviceNames: row.serviceNames ?? [],
};
}
/** Normalizes a finding from a schema-2 remote (no sources/doctorFindings). */
function adaptFinding(f: Partial<NetworkingFinding>): NetworkingFinding {
return {
id: f.id ?? '',
kind: (f.kind ?? 'runtime-unavailable') as NetworkingFinding['kind'],
severity: f.severity ?? 'info',
title: f.title ?? '',
message: f.message ?? '',
stack: f.stack,
network: f.network,
service: f.service,
evidence: f.evidence ?? [],
recommendedActions: f.recommendedActions ?? [],
sources: f.sources ?? ['live'],
doctorFindings: f.doctorFindings ?? [],
};
}
/** Derives ownership counts from network rows when the overview envelope itself
* omits them (schema 2), instead of showing incorrect zeroes. */
function deriveOwnershipCounts(networks: NetworkingNetworkRow[]): {
senchoManagedNetworkCount: number;
composeManagedNetworkCount: number;
unmanagedNetworkCount: number;
externalDependencyNetworkCount: number;
} {
return {
senchoManagedNetworkCount: networks.filter((n) => n.ownership === 'sencho-managed').length,
composeManagedNetworkCount: networks.filter((n) => n.ownership === 'compose-managed').length,
unmanagedNetworkCount: networks.filter((n) => n.ownership === 'unmanaged').length,
externalDependencyNetworkCount: networks.filter((n) => n.isExternalDependency).length,
};
}
export function adaptNetworkingOverview(body: Partial<NetworkingOverviewEnvelope>): {
isLegacy: boolean;
runtimeAvailable: boolean;
overview: NodeNetworkingOverview | null;
networks: NetworkingNetworkRow[];
findings: NetworkingFinding[];
recentActivity: NetworkingOverviewEnvelope['recentActivity'];
} {
const schemaVersion = body.schemaVersion;
// Schema 1 / absent: fully legacy, no usable shape at all.
const isLegacy = schemaVersion === undefined || schemaVersion < 2;
if (isLegacy) {
return {
isLegacy: true,
runtimeAvailable: false,
overview: null,
networks: [],
findings: [],
recentActivity: [],
};
}
const networks = (body.networks ?? []).map(adaptNetworkRow);
const findings = (body.findings ?? []).map(adaptFinding);
const isSchema2 = schemaVersion === 2;
const overview = body.overview
? {
...body.overview,
...(isSchema2 ? deriveOwnershipCounts(networks) : {}),
}
: null;
return {
isLegacy: false,
runtimeAvailable: body.runtimeAvailable === true,
overview,
networks,
findings,
recentActivity: body.recentActivity ?? [],
};
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { findingSourceLabel, groupFindings, rankFindings } from './networkingSeverity';
import type { NetworkingFinding } from '@/types/networking';
function finding(overrides: Partial<NetworkingFinding> = {}): NetworkingFinding {
return {
id: overrides.id ?? Math.random().toString(36),
kind: 'network-mode-host',
severity: 'medium',
title: 't',
message: 'm',
evidence: [],
recommendedActions: [],
sources: ['live'],
doctorFindings: [],
...overrides,
};
}
describe('groupFindings', () => {
it('groups critical and high into needs-action, medium into review, info into informational', () => {
const groups = groupFindings([
finding({ id: 'a', severity: 'critical' }),
finding({ id: 'b', severity: 'high' }),
finding({ id: 'c', severity: 'medium' }),
finding({ id: 'd', severity: 'info' }),
]);
expect(groups['needs-action'].map((f) => f.id)).toEqual(['a', 'b']);
expect(groups['review-recommended'].map((f) => f.id)).toEqual(['c']);
expect(groups.informational.map((f) => f.id)).toEqual(['d']);
});
});
describe('rankFindings', () => {
it('ranks live ahead of doctor-only at equal severity', () => {
const live = finding({ id: 'live', severity: 'high', sources: ['live'] });
const doctorOnly = finding({ id: 'doctor', severity: 'high', sources: ['doctor'] });
const ranked = rankFindings([doctorOnly, live]);
expect(ranked.map((f) => f.id)).toEqual(['live', 'doctor']);
});
it('ranks strictly by severity first', () => {
const low = finding({ id: 'low', severity: 'info' });
const high = finding({ id: 'high', severity: 'critical' });
expect(rankFindings([low, high]).map((f) => f.id)).toEqual(['high', 'low']);
});
});
describe('findingSourceLabel', () => {
it('labels a merged card as Live plus Doctor', () => {
const merged = finding({ sources: ['live', 'doctor'], doctorFindings: [{ ruleId: 'r', ranAt: new Date().toISOString(), title: 't', message: 'm', severity: 'high' }] });
expect(findingSourceLabel(merged)).toBe('Live · also found by Doctor');
});
it('labels a Doctor-only card with its last-run timestamp', () => {
const ranAt = new Date('2026-01-01T00:00:00Z').toISOString();
const doctorOnly = finding({ sources: ['doctor'], doctorFindings: [{ ruleId: 'r', ranAt, title: 't', message: 'm', severity: 'high' }] });
expect(findingSourceLabel(doctorOnly)).toContain('Last Doctor run');
});
it('returns null for a live-only finding', () => {
expect(findingSourceLabel(finding({ sources: ['live'] }))).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { NetworkingFinding, NetworkingFindingSeverity } from '@/types/networking';
export const NETWORKING_SEVERITY_RANK: Record<NetworkingFindingSeverity, number> = {
info: 0,
medium: 1,
high: 2,
critical: 3,
};
/** Text color per severity, shared by the Overview attention list and the Findings tab. */
export const SEVERITY_TEXT_CLASS: Record<NetworkingFindingSeverity, string> = {
info: 'text-stat-subtitle',
medium: 'text-warning',
high: 'text-destructive',
critical: 'text-destructive',
};
/** Severity descending; at equal severity a live-sourced finding ranks ahead of a
* Doctor-only finding, mirroring the backend ranking helper. */
export function compareFindingsForRanking(a: NetworkingFinding, b: NetworkingFinding): number {
const rankDiff = NETWORKING_SEVERITY_RANK[b.severity] - NETWORKING_SEVERITY_RANK[a.severity];
if (rankDiff !== 0) return rankDiff;
const aIsLive = a.sources.includes('live');
const bIsLive = b.sources.includes('live');
if (aIsLive !== bIsLive) return aIsLive ? -1 : 1;
return 0;
}
export function rankFindings(findings: NetworkingFinding[]): NetworkingFinding[] {
return [...findings].sort(compareFindingsForRanking);
}
export type NetworkingFindingGroup = 'needs-action' | 'review-recommended' | 'informational';
export const FINDING_GROUP_LABELS: Record<NetworkingFindingGroup, string> = {
'needs-action': 'Needs action',
'review-recommended': 'Review recommended',
informational: 'Informational',
};
export function groupForSeverity(severity: NetworkingFindingSeverity): NetworkingFindingGroup {
if (severity === 'critical' || severity === 'high') return 'needs-action';
if (severity === 'medium') return 'review-recommended';
return 'informational';
}
export function groupFindings(findings: NetworkingFinding[]): Record<NetworkingFindingGroup, NetworkingFinding[]> {
const ranked = rankFindings(findings);
const groups: Record<NetworkingFindingGroup, NetworkingFinding[]> = {
'needs-action': [],
'review-recommended': [],
informational: [],
};
for (const f of ranked) groups[groupForSeverity(f.severity)].push(f);
return groups;
}
/** Label for a finding's source provenance, so cached Doctor findings never read as current runtime facts. */
export function findingSourceLabel(finding: Pick<NetworkingFinding, 'sources' | 'doctorFindings'>): string | null {
const isLive = finding.sources.includes('live');
const isDoctor = finding.sources.includes('doctor');
if (isLive && isDoctor) return 'Live · also found by Doctor';
if (isDoctor && !isLive) {
const ranAt = finding.doctorFindings[0]?.ranAt;
return ranAt ? `Last Doctor run · ${new Date(ranAt).toLocaleString()}` : 'Last Doctor run';
}
return null;
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { filterTopologyNetworks, normalizeTopologyResponse } from './networkingTopology';
import type { NetworkingTopologyNetwork } from '@/types/networking';
const missingNetwork: NetworkingTopologyNetwork = {
id: 'missing:shared-net', name: 'shared-net', driver: 'unknown', scope: 'local', stack: null,
isSystem: false, ingress: false, ownership: 'unmanaged', declaredByStacks: [],
declaredExternalByStacks: ['app'], isExternalDependency: true, runtimeState: 'missing',
findingIds: ['network-missing'], containers: [],
};
describe('networking topology adapters', () => {
it('unwraps unavailable envelopes without generating missing nodes', () => {
expect(normalizeTopologyResponse({ schemaVersion: 2, runtimeAvailable: false, networks: [missingNetwork] })).toEqual({
networks: [], runtimeAvailable: false,
});
});
it('filters synthetic missing external nodes', () => {
expect(filterTopologyNetworks([missingNetwork], {
stack: '', network: '', ownership: 'all', exposedOnly: false, driftOnly: false, missingExternalOnly: true, sharedOnly: false,
})).toEqual([missingNetwork]);
});
});
+153
View File
@@ -0,0 +1,153 @@
import type { NetworkingTopologyContainer, NetworkingTopologyEnvelope, NetworkingTopologyNetwork } from '@/types/networking';
export type TopologyOwnershipFilter = 'all' | 'managed' | 'external' | 'system';
export interface NetworkingTopologyFilters {
stack: string;
network: string;
ownership: TopologyOwnershipFilter;
exposedOnly: boolean;
driftOnly: boolean;
missingExternalOnly: boolean;
sharedOnly: boolean;
}
export const DEFAULT_TOPOLOGY_FILTERS: NetworkingTopologyFilters = {
stack: '',
network: '',
ownership: 'all',
exposedOnly: false,
driftOnly: false,
missingExternalOnly: false,
sharedOnly: false,
};
/** Large-topology strategy: edges stop animating above this count,
* and the graph refuses to render (falling back to the Networks table) above
* the combined node+edge cap. The cap check runs on FILTERED, already-deduped
* counts so narrowing a filter can bring an over-cap graph back under it. */
export const TOPOLOGY_ANIMATION_EDGE_LIMIT = 80;
export const TOPOLOGY_RENDER_CAP = 350;
/** Cheap pre-layout size count. Mirrors the container cross-network dedupe that
* `layoutGraph`/`aggregateContainers` perform, so a container attached to two
* networks counts as one node (not two) and contributes one edge per network. */
export function countTopologyGraphSize(networks: NetworkingTopologyNetwork[]): {
nodeCount: number;
edgeCount: number;
} {
const containerIds = new Set<string>();
let edgeCount = 0;
for (const network of networks) {
for (const container of network.containers) {
containerIds.add(container.id);
edgeCount += 1;
}
}
return { nodeCount: networks.length + containerIds.size, edgeCount };
}
export function normalizeTopologyResponse(payload: unknown): {
networks: NetworkingTopologyNetwork[];
runtimeAvailable: boolean;
} {
if (Array.isArray(payload)) {
return { networks: payload as NetworkingTopologyNetwork[], runtimeAvailable: true };
}
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid topology response');
}
const envelope = payload as Partial<NetworkingTopologyEnvelope>;
if (envelope.runtimeAvailable === false) {
return { networks: [], runtimeAvailable: false };
}
if (!Array.isArray(envelope.networks)) {
throw new Error('Invalid topology response');
}
// A schema-2 remote's containers lack `hostMode`; default it so exposedOnly
// filtering degrades to published-ports-only instead of crashing.
const networks = envelope.networks.map((network) => ({
...network,
containers: network.containers.map((container) => ({
...container,
hostMode: container.hostMode === true,
})),
}));
return { networks, runtimeAvailable: true };
}
function containerMatchesStackQuery(
container: NetworkingTopologyContainer,
stackQuery: string,
): boolean {
return Boolean(
container.stack?.toLowerCase().includes(stackQuery)
|| container.service?.toLowerCase().includes(stackQuery),
);
}
function matchesOwnership(network: NetworkingTopologyNetwork, ownership: TopologyOwnershipFilter): boolean {
switch (ownership) {
case 'managed': return network.ownership === 'sencho-managed';
case 'external': return network.isExternalDependency;
case 'system': return network.ownership === 'system';
case 'all':
default: return true;
}
}
export function filterTopologyNetworks(
networks: NetworkingTopologyNetwork[],
filters: NetworkingTopologyFilters,
): NetworkingTopologyNetwork[] {
const networkQuery = filters.network.trim().toLowerCase();
const stackQuery = filters.stack.trim().toLowerCase();
return networks.flatMap((network) => {
const isMissing = network.runtimeState === 'missing' || network.id.startsWith('missing:');
const containers = stackQuery
? network.containers.filter((container) => containerMatchesStackQuery(container, stackQuery))
: network.containers;
if (networkQuery && !network.name.toLowerCase().includes(networkQuery)) return [];
if (
stackQuery
&& !network.declaredByStacks.some((stack) => stack.toLowerCase().includes(stackQuery))
&& containers.length === 0
) {
return [];
}
if (!matchesOwnership(network, filters.ownership)) return [];
if (
filters.exposedOnly
&& !network.containers.some((container) => container.publishedPorts.length > 0 || container.hostMode)
) {
return [];
}
if (
filters.driftOnly
&& !network.findingIds.length
&& !network.containers.some((container) => container.findingIds.length || container.driftFlags.length)
) {
return [];
}
if (filters.missingExternalOnly && !isMissing) return [];
if (filters.sharedOnly) {
// declaredExternalByStacks is a SUBSET of declaredByStacks (an external
// declaration is still a declaration), so summing both double-counts.
// Shared means 2+ distinct declaring stacks.
const uniqueStacks = new Set(network.declaredByStacks);
if (uniqueStacks.size < 2) return [];
}
return [{ ...network, containers }];
});
}
export function isMissingTopologyNetwork(network: NetworkingTopologyNetwork): boolean {
return network.runtimeState === 'missing' || network.id.startsWith('missing:');
}
export function formatTopologyPort(port: NetworkingTopologyContainer['publishedPorts'][number]): string {
return `${port.hostIp ?? '0.0.0.0'}:${port.published ?? port.target}/${port.protocol}`;
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { countTopologyGraphSize, TOPOLOGY_ANIMATION_EDGE_LIMIT, TOPOLOGY_RENDER_CAP } from './networkingTopology';
import type { NetworkingTopologyContainer, NetworkingTopologyNetwork } from '@/types/networking';
function container(id: string): NetworkingTopologyContainer {
return {
id, name: id, ip: '10.0.0.1', state: 'running', image: 'nginx', stack: 'app', service: 'web',
composeAliases: [], publishedPorts: [], exposureIntent: null, findingIds: [], driftFlags: [], hostMode: false,
};
}
function network(id: string, containers: NetworkingTopologyContainer[]): NetworkingTopologyNetwork {
return {
id, name: id, driver: 'bridge', scope: 'local', stack: null, isSystem: false, ingress: false,
ownership: 'sencho-managed', declaredByStacks: [], declaredExternalByStacks: [],
isExternalDependency: false, findingIds: [], containers,
};
}
describe('countTopologyGraphSize', () => {
it('counts a container attached to two networks as ONE node and TWO edges', () => {
const shared = container('shared');
const size = countTopologyGraphSize([network('net-a', [shared]), network('net-b', [shared])]);
// 2 networks + 1 deduped container = 3 nodes; 2 edges (one per attachment).
expect(size.nodeCount).toBe(3);
expect(size.edgeCount).toBe(2);
});
it('lands just under the render cap at the boundary and just over one container later', () => {
// One network with N containers is (1 + N) nodes and N edges: total 1 + 2N.
const belowN = Math.floor((TOPOLOGY_RENDER_CAP - 1) / 2); // largest N with 1 + 2N <= cap
const below = countTopologyGraphSize([network('g', Array.from({ length: belowN }, (_, i) => container(`c${i}`)))]);
expect(below.nodeCount + below.edgeCount).toBeLessThanOrEqual(TOPOLOGY_RENDER_CAP);
const above = countTopologyGraphSize([network('g', Array.from({ length: belowN + 1 }, (_, i) => container(`c${i}`)))]);
expect(above.nodeCount + above.edgeCount).toBeGreaterThan(TOPOLOGY_RENDER_CAP);
});
it('crosses the animation edge limit exactly one edge past the threshold', () => {
const atLimit = countTopologyGraphSize([network('g', Array.from({ length: TOPOLOGY_ANIMATION_EDGE_LIMIT }, (_, i) => container(`c${i}`)))]);
expect(atLimit.edgeCount).toBe(TOPOLOGY_ANIMATION_EDGE_LIMIT);
const over = countTopologyGraphSize([network('g', Array.from({ length: TOPOLOGY_ANIMATION_EDGE_LIMIT + 1 }, (_, i) => container(`c${i}`)))]);
expect(over.edgeCount).toBeGreaterThan(TOPOLOGY_ANIMATION_EDGE_LIMIT);
});
});
+1
View File
@@ -15,6 +15,7 @@ export type ActiveView =
| 'editor'
| 'host-console'
| 'resources'
| 'networking'
| 'templates'
| 'global-observability'
| 'fleet'
@@ -157,4 +157,12 @@ describe('senchoRoute', () => {
expect(parsed.view).toBe('editor');
expect(parsed.editorTab).toBeNull();
});
it('round-trips the networking view', () => {
const path = buildPath({ ...base, activeView: 'networking' });
expect(path).toBe('/nodes/local/networking');
const parsed = parsePath(path, '');
expect(parsed.view).toBe('networking');
expect(parsed.nodeSlug).toBe('local');
});
});
+1
View File
@@ -8,6 +8,7 @@ const VIEW_SEGMENTS = {
editor: 'stacks',
'host-console': 'host-console',
resources: 'resources',
networking: 'networking',
templates: 'templates',
'global-observability': 'logs',
fleet: 'fleet',