mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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:
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user