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
+113
View File
@@ -1,7 +1,16 @@
import { Router, type Request, type Response } from 'express';
import { requirePermission } from '../middleware/permissions';
import { computeNodeNetworkingSummary } from '../services/network/networkingSummary';
import {
buildNodeNetworkingAggregate,
loadNetworkingSnapshot,
} from '../services/network/networkingAggregate';
import DockerController from '../services/DockerController';
import { findSnapshotNetwork, sanitizeNetworkInspect } from '../services/network/sanitizeNetworkInspect';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { isValidDockerResourceId } from '../utils/validation';
import { okEnvelope, runtimeUnavailableEnvelope } from '../services/network/networkingEnvelope';
export const networkingRouter = Router();
@@ -17,3 +26,107 @@ networkingRouter.get('/summary', async (req: Request, res: Response): Promise<vo
res.status(500).json({ error: 'Failed to build networking summary' });
}
});
networkingRouter.get('/overview', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
res.json(okEnvelope(aggregate.runtimeAvailable, {
overview: aggregate.overview,
networks: aggregate.networks,
findings: aggregate.findings,
recentActivity: aggregate.recentActivity,
}));
} catch (error) {
console.error('[Networking] Failed to build overview:', sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to build networking overview' });
}
});
networkingRouter.get('/networks', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
res.json(okEnvelope(aggregate.runtimeAvailable, { networks: aggregate.networks }));
} catch (error) {
console.error('[Networking] Failed to list networks:', sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to list networks' });
}
});
networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
const id = req.params.id as string;
if (!id || (!isValidDockerResourceId(id) && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id))) {
res.status(400).json({ error: 'Invalid network ID format' });
return;
}
try {
const { snapshot } = await loadNetworkingSnapshot(req.nodeId);
if (!snapshot) {
res.status(503).json(runtimeUnavailableEnvelope());
return;
}
const snapNet = findSnapshotNetwork(snapshot, id);
const raw = await DockerController.getInstance(req.nodeId).inspectNetwork(snapNet?.id ?? id);
res.json(okEnvelope(true, { network: sanitizeNetworkInspect(raw, snapNet, snapshot) }));
} catch (error: unknown) {
console.error('[Networking] Failed to inspect network:', sanitizeForLog(getErrorMessage(error, 'unknown')));
const statusCode = (error as { statusCode?: number }).statusCode;
const is404 = statusCode === 404
|| (error instanceof Error && error.message.includes('404'));
res.status(is404 ? 404 : 500).json({
error: is404 ? 'Network not found' : 'Failed to inspect network',
});
}
});
networkingRouter.get('/topology', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
const includeSystem = req.query.includeSystem === 'true';
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {
includeTopology: true,
includeSystem,
});
res.json(okEnvelope(aggregate.runtimeAvailable, {
networks: aggregate.topology?.networks ?? [],
includeSystem,
}));
} catch (error) {
console.error('[Networking] Failed to build topology:', sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to build networking topology' });
}
});
networkingRouter.get('/findings', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
res.json(okEnvelope(aggregate.runtimeAvailable, { findings: aggregate.findings }));
} catch (error) {
console.error('[Networking] Failed to list findings:', sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to list networking findings' });
}
});
networkingRouter.get('/findings/:id', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:read')) return;
const findingId = req.params.id as string;
if (!findingId) {
res.status(400).json({ error: 'Finding ID is required' });
return;
}
try {
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
const match = aggregate.findings.find(f => f.id === findingId);
if (!match) {
res.status(404).json({ error: 'Finding not found' });
return;
}
res.json(okEnvelope(aggregate.runtimeAvailable, { finding: match }));
} catch (error) {
console.error('[Networking] Failed to load finding:', sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to load networking finding' });
}
});
+18
View File
@@ -19,6 +19,9 @@ import { withTimeout, TimeoutError } from '../utils/withTimeout';
import { buildNodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { requirePermission } from '../middleware/permissions';
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
import { evaluateNetworkDeleteGuard } from '../services/network/networkDeleteGuards';
import { loadNetworkingSnapshot } from '../services/network/networkingAggregate';
// `docker system df` (the call backing estimateSystemReclaim) can take 30+
// seconds on Docker Desktop with many volumes; 8s matches the MonitorService
@@ -469,6 +472,21 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo
return res.status(400).json({ error: 'Invalid network ID format' });
}
if (rejectIfSelf('network', id, res)) return;
const { stacks, snapshot } = await loadNetworkingSnapshot(req.nodeId);
if (!snapshot) {
return res.status(503).json({ error: 'Docker networking runtime is unavailable' });
}
const stackFacts = await Promise.all(
stacks.map(stack => buildStackNetworkFacts(req.nodeId, stack, snapshot)),
);
const baseRow = DockerController.classifySnapshotNetworks(snapshot, stacks)
.find(n => n.id === id);
const guard = evaluateNetworkDeleteGuard(id, snapshot, stackFacts, baseRow);
if (guard.blocked) {
return res.status(409).json({ error: guard.error, code: guard.code });
}
console.log(`[Resources] Delete network: ${id.substring(0, 12)}`);
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.removeNetwork(id);