Files
sencho/frontend/src/lib/networkingTopology.ts
T
Anso 8980910153 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.
2026-07-14 20:18:10 -04:00

154 lines
5.4 KiB
TypeScript

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}`;
}