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
+249
View File
@@ -0,0 +1,249 @@
export type NetworkingOwnership = 'system' | 'sencho-managed' | 'compose-managed' | 'unmanaged';
export type NetworkingFindingSeverity = 'critical' | 'high' | 'medium' | 'info';
export type NetworkingFindingKind =
| 'external-network-missing'
| 'network-missing'
| 'network-undeclared'
| 'declared-network-unused'
| 'foreign-network-attachment'
| 'alias-collision'
| 'network-mode-host'
| 'exposure-unclassified'
| 'exposure-all-interfaces'
| 'shared-network'
| 'network-name-collision'
| 'service-name-collision'
| 'large-flat-network'
| 'advanced-driver-caveat'
| 'runtime-unavailable'
| 'exposure-intent-mismatch'
| 'port-conflict-node'
| 'port-conflict-internal'
| 'sensitive-service-broad-exposure'
| 'exposure-port-vs-dossier'
| 'reverse-proxy-undocumented'
| 'new-network';
/** The finding kinds that count as "drift", shared by the Networks-table drift
* filter/count and the Overview drift metric so the two never diverge. (Topology
* drift is keyed off each container's own driftFlags, not this list.) */
export const NETWORK_DRIFT_FINDING_KINDS: readonly NetworkingFindingKind[] = [
'network-undeclared',
'network-missing',
'declared-network-unused',
'foreign-network-attachment',
'external-network-missing',
];
export function isNetworkDriftFindingKind(kind: NetworkingFindingKind): boolean {
return (NETWORK_DRIFT_FINDING_KINDS as readonly string[]).includes(kind);
}
export interface NetworkingNetworkBase {
id: string;
name: string;
driver: string;
scope: string;
isSystem: boolean;
ingress: boolean;
enableIPv6?: boolean;
composeProject: string | null;
stack: string | null;
connectedCount: number;
isSencho: boolean;
ownership: NetworkingOwnership;
declaredByStacks: string[];
declaredExternalByStacks: string[];
isExternalDependency: boolean;
}
export interface NetworkingNetworkRow extends NetworkingNetworkBase {
sharedStackCount: number;
exposureSummary: {
publishingStackCount: number;
broadExposureCount: number;
unclassifiedStackCount: number;
} | null;
findingIds: string[];
serviceNames: string[];
}
export type NetworkingRecommendedAction =
| { kind: 'open-stack'; label: string; stack: string }
| { kind: 'open-stack-networking'; label: string; stack: string }
| { kind: 'open-stack-doctor'; label: string; stack: string }
| { kind: 'open-stack-editor'; label: string; stack: string }
| { kind: 'open-stack-dossier'; label: string; stack: string }
| { kind: 'open-stack-drift'; label: string; stack: string }
| { kind: 'set-exposure-intent'; label: string; stack: string; service?: string }
| { kind: 'create-network'; label: string; networkName: string; requiresAdmin: true }
| { kind: 'copy-compose-snippet'; label: string; snippetKind: 'external-network'; networkName: string }
| { kind: 'copy-docker-command'; label: string; commandKind: 'network-create'; networkName: string }
| { kind: 'filter-topology'; label: string; networkName?: string; stack?: string }
| { kind: 'inspect-network'; label: string; networkId: string }
| { kind: 'open-docs'; label: string; docsPath: string }
| { kind: 'refresh'; label: string };
export type NetworkingFindingSource = 'live' | 'doctor';
export interface DoctorFindingMetadata {
ruleId: string;
ranAt: string;
title: string;
message: string;
service?: string;
sourcePath?: string;
remediation?: string;
severity: NetworkingFindingSeverity;
}
export interface NetworkingFinding {
id: string;
kind: NetworkingFindingKind;
severity: NetworkingFindingSeverity;
title: string;
message: string;
stack?: string;
network?: string;
service?: string;
evidence: { label: string; value: string }[];
recommendedActions: NetworkingRecommendedAction[];
sources: NetworkingFindingSource[];
doctorFindings: DoctorFindingMetadata[];
}
export interface NodeNetworkingOverview {
runtimeAvailable: boolean;
networkCount: number | null;
stackCount: number;
connectedContainerCount: number | null;
systemNetworkCount: number | null;
senchoManagedNetworkCount: number | null;
composeManagedNetworkCount: number | null;
unmanagedNetworkCount: number | null;
externalDependencyNetworkCount: number | null;
exposedStackCount: number;
unknownExposureStackCount: number;
missingExternalCount: number;
networkCollisionCount: number;
findingCount: number;
renderFailedStacks: string[];
}
export interface NetworkFactPort {
hostIp: string | null;
published: string | null;
target: string;
protocol: string;
}
export interface NetworkingTopologyContainer {
id: string;
name: string;
ip: string;
state: string;
image: string;
stack: string | null;
service: string | null;
composeAliases: string[];
publishedPorts: NetworkFactPort[];
exposureIntent: 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown' | null;
findingIds: string[];
driftFlags: string[];
hostMode: boolean;
}
/** Client-computed detail model for the topology container drawer: aggregates a
* container's attachments across every network it belongs to (layoutGraph already
* dedupes containers cross-network; this preserves that full attachment list
* instead of collapsing it to a single `ip` string). */
export interface NetworkingTopologyContainerDetail {
id: string;
name: string;
stack: string | null;
service: string | null;
image: string;
state: string;
attachments: { network: string; ip: string }[];
composeAliases: string[];
publishedPorts: NetworkFactPort[];
exposureIntent: NetworkingTopologyContainer['exposureIntent'];
findingIds: string[];
driftFlags: string[];
}
export interface NetworkingTopologyNetwork {
id: string;
name: string;
driver: string;
scope: string;
stack: string | null;
isSystem: boolean;
ingress: boolean;
enableIPv6?: boolean;
ownership: NetworkingOwnership;
declaredByStacks: string[];
declaredExternalByStacks: string[];
isExternalDependency: boolean;
runtimeState?: 'present' | 'missing';
findingIds: string[];
containers: NetworkingTopologyContainer[];
}
export interface NetworkingTopology {
networks: NetworkingTopologyNetwork[];
includeSystem: boolean;
}
export interface NetworkingActivity {
id: number;
category: string;
message: string;
timestamp: string | number;
stack_name?: string | null;
}
export interface NetworkingEnvelope {
/** Wire value from the responding node; older remotes send 1 or 2, so the
* frontend treats it as an open number and adapts, never a fixed literal. */
schemaVersion: number;
runtimeAvailable: boolean;
generatedAt: string;
}
export interface NetworkingOverviewEnvelope extends NetworkingEnvelope {
overview: NodeNetworkingOverview;
networks: NetworkingNetworkRow[];
findings: NetworkingFinding[];
recentActivity: NetworkingActivity[];
}
export interface NetworkingTopologyEnvelope extends NetworkingEnvelope {
networks: NetworkingTopologyNetwork[];
}
export interface SanitizedNetworkInspectContainer {
name: string;
service: string | null;
stack: string | null;
ipv4: string | null;
}
export interface SanitizedNetworkInspect {
id: string;
name: string;
driver: string;
scope: string;
internal: boolean;
attachable: boolean;
ingress: boolean;
enableIPv6: boolean;
stack: string | null;
composeProject: string | null;
connectedCount: number;
labelKeys: string[];
subnets: string[];
gateways: string[];
connectedContainers: SanitizedNetworkInspectContainer[];
}