mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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:
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user