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
@@ -0,0 +1,232 @@
/**
* Doctor networking findings adapter: reads ONLY cached Compose Doctor reports
* (never triggers a fresh preflight run), merges overlapping findings into the
* matching live card, and surfaces Doctor-only rules as standalone findings.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ComposeDoctorService } from '../services/ComposeDoctorService';
import { applyDoctorNetworkingFindings } from '../services/network/doctorNetworkingFindings';
import type { NetworkingFinding } from '../services/network/networkingTypes';
import type { StackNetworkFacts } from '../services/network/types';
import type { PreflightReport } from '../services/preflight/types';
function stubReport(overrides: Partial<PreflightReport> & { findings: PreflightReport['findings'] }): PreflightReport {
return {
stack: 'stack1',
ranAt: Date.now(),
ranBy: 'admin',
renderable: true,
renderError: null,
status: 'high',
highestSeverity: 'high',
sourceHash: 'h', renderedHash: 'h',
activeStatus: 'high', activeHighestSeverity: 'high', activeCount: overrides.findings.length, acknowledgedCount: 0,
...overrides,
};
}
function stubFacts(overrides: Partial<StackNetworkFacts> = {}): StackNetworkFacts {
return {
stack: 'stack1',
renderable: true,
renderError: null,
runtime: 'available',
networks: [],
services: [{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] }],
drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] },
...overrides,
};
}
function liveHostFinding(stack: string, service: string): NetworkingFinding {
return {
id: 'live-host-1',
kind: 'network-mode-host',
severity: 'medium',
title: 'Host network mode',
message: `Service "${service}" uses network_mode: host.`,
stack,
service,
evidence: [],
recommendedActions: [{ kind: 'open-stack-networking', label: 'Open stack networking', stack }],
sources: ['live'],
doctorFindings: [],
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe('applyDoctorNetworkingFindings', () => {
it('never calls runPreflight; reads only getLatest', () => {
const getLatest = vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({ stack: 'stack1', findings: [] })),
} as unknown as ComposeDoctorService);
applyDoctorNetworkingFindings([], { nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null });
expect(getLatest).toHaveBeenCalled();
const instance = getLatest.mock.results[0].value as { getLatest: unknown; runPreflight?: unknown };
expect(instance.runPreflight).toBeUndefined();
});
it('merges a Doctor host-mode finding into the matching live card instead of duplicating it', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({
stack: 'stack1',
findings: [{
ruleId: 'network-mode-host', severity: 'high', title: 'Host mode', message: 'uses host networking',
service: 'web', sourcePath: 'services.web.network_mode',
}],
})),
} as unknown as ComposeDoctorService);
const live = [liveHostFinding('stack1', 'web')];
const result = applyDoctorNetworkingFindings(live, {
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
});
expect(result).toHaveLength(1);
expect(result[0].sources).toEqual(['live', 'doctor']);
expect(result[0].doctorFindings).toHaveLength(1);
expect(result[0].severity).toBe('medium'); // canonical severity stays the live one
expect(result[0].doctorFindings[0].severity).toBe('high'); // Doctor's own severity is preserved
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-doctor')).toBe(true);
});
it('surfaces a Doctor-only rule (no live counterpart) as a standalone finding', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({
stack: 'stack1',
findings: [{
ruleId: 'sensitive-service-broad-exposure', severity: 'high',
title: 'Sensitive service broadly exposed', message: 'db is broadly exposed', service: 'db',
}],
})),
} as unknown as ComposeDoctorService);
const result = applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['stack1'],
stackFacts: [stubFacts({ services: [{ name: 'db', networks: [], publishedPorts: [], extraHosts: [] }] })],
snapshot: null,
});
expect(result).toHaveLength(1);
expect(result[0].sources).toEqual(['doctor']);
expect(result[0].kind).toBe('sensitive-service-broad-exposure');
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-doctor')).toBe(true);
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-networking')).toBe(true);
});
it('collapses two occurrences on the SAME service into one merged card (one-to-many)', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({
stack: 'stack1',
findings: [
{ ruleId: 'port-conflict-internal', severity: 'warning', title: 'Port conflict', message: 'port 80 conflicts', service: 'web' },
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 443 conflicts', service: 'web' },
],
})),
} as unknown as ComposeDoctorService);
const result = applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
});
expect(result).toHaveLength(1);
expect(result[0].doctorFindings).toHaveLength(2);
// Mixed severities: the merged card takes the worst (blocker -> critical).
expect(result[0].severity).toBe('critical');
});
it('gives distinct services distinct cards, never collapsing them together', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({
stack: 'stack1',
findings: [
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 80 conflicts', service: 'web' },
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 90 conflicts', service: 'api' },
],
})),
} as unknown as ComposeDoctorService);
const result = applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['stack1'],
stackFacts: [stubFacts({ services: [
{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] },
{ name: 'api', networks: [], publishedPorts: [], extraHosts: [] },
] })],
snapshot: null,
});
expect(result).toHaveLength(2);
expect(new Set(result.map((f) => f.id)).size).toBe(2);
});
it('excludes acknowledged findings', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({
stack: 'stack1',
findings: [{
ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm',
service: 'db', acknowledged: true,
}],
})),
} as unknown as ComposeDoctorService);
const result = applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
});
expect(result).toHaveLength(0);
});
it('discards a stale finding when the referenced service no longer exists', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({
stack: 'stack1',
findings: [{
ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm', service: 'removed-service',
}],
})),
} as unknown as ComposeDoctorService);
const result = applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
});
expect(result).toHaveLength(0);
});
it('is fail-soft: a getLatest() failure for one stack does not throw and other stacks still contribute', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockImplementation((_nodeId: number, stack: string) => {
if (stack === 'broken') throw new Error('db unavailable');
return stubReport({
stack,
findings: [{ ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm', service: 'web' }],
});
}),
} as unknown as ComposeDoctorService);
expect(() => applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['broken', 'stack1'], stackFacts: [stubFacts({ stack: 'stack1' })], snapshot: null,
})).not.toThrow();
const result = applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['broken', 'stack1'], stackFacts: [stubFacts({ stack: 'stack1' })], snapshot: null,
});
expect(result).toHaveLength(1);
expect(result[0].stack).toBe('stack1');
});
it('never-run stacks are absent (no "never ran" nagging finding)', () => {
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
getLatest: vi.fn().mockReturnValue(stubReport({ stack: 'stack1', status: 'never-run', findings: [] })),
} as unknown as ComposeDoctorService);
const result = applyDoctorNetworkingFindings([], {
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
});
expect(result).toHaveLength(0);
});
});
@@ -0,0 +1,28 @@
/**
* Complete host-mode severity matrix as a pure-function unit test, so a
* refactor that inverts one intent's severity is caught without the full route.
*/
import { describe, it, expect } from 'vitest';
import { hostModeSeverity } from '../services/network/networkingFindings';
import type { ExposureIntent } from '../services/network/types';
describe('hostModeSeverity matrix', () => {
// Contradiction rows: high regardless of whether the Dossier documents access.
const contradiction: (ExposureIntent | null)[] = ['internal', 'same-node', 'unknown', null];
for (const intent of contradiction) {
it(`${intent ?? 'unset'} is high with and without documentation`, () => {
expect(hostModeSeverity(intent, false)).toBe('high');
expect(hostModeSeverity(intent, true)).toBe('high');
});
}
// Deliberate-exposure rows: medium undocumented, downgraded to info once a
// Dossier access URL documents the exposure.
const deliberate: ExposureIntent[] = ['lan', 'public', 'reverse-proxy', 'temporary'];
for (const intent of deliberate) {
it(`${intent} is medium undocumented and info once documented`, () => {
expect(hostModeSeverity(intent, false)).toBe('medium');
expect(hostModeSeverity(intent, true)).toBe('info');
});
}
});
@@ -0,0 +1,253 @@
/**
* Node networking operator routes: auth boundaries, aggregate reads, delete guards.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
import { DatabaseService } from '../services/DatabaseService';
import { evaluateNetworkDeleteGuard } from '../services/network/networkDeleteGuards';
import SelfIdentityService from '../services/SelfIdentityService';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
const STACK = 'netop';
function token(username: string, role: string, tokenVersion = 1): string {
const user = DatabaseService.getInstance().getUserByUsername(username);
return `Bearer ${jwt.sign(
{ username, role, tokenVersion: user?.token_version ?? tokenVersion },
TEST_JWT_SECRET,
{ expiresIn: '5m' },
)}`;
}
const NET_ID = 'abcdefabcdef';
function stubAggregate() {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({
containers: [],
networks: [
{ id: NET_ID, name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null },
],
volumes: [],
}),
inspectNetwork: vi.fn().mockResolvedValue({
Id: NET_ID,
Name: 'orphan_net',
Driver: 'bridge',
Scope: 'local',
Labels: { 'com.example.key': 'secret-value' },
Containers: {},
}),
removeNetwork: vi.fn().mockResolvedValue(undefined),
} as unknown as DockerController);
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({
rendered: JSON.stringify({
name: STACK,
services: { web: { image: 'nginx:latest', networks: { backend: null } } },
networks: { backend: { name: `${STACK}_backend` } },
volumes: {},
}),
stderr: '',
code: 0,
timedOut: false,
}),
} as unknown as ComposeService);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = token(TEST_USERNAME, 'admin');
});
afterAll(() => cleanupTestDb(tmpDir));
describe('networking operator routes', () => {
let stackDir: string;
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
stubAggregate();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('keeps GET /api/networking/summary auth-only for viewers', async () => {
const db = DatabaseService.getInstance();
db.addUser({ username: 'net-viewer', password_hash: 'x', role: 'viewer' });
const viewerHeader = token('net-viewer', 'viewer');
const res = await request(app).get('/api/networking/summary').set('Authorization', viewerHeader);
expect(res.status).toBe(200);
});
it('requires authentication on new overview route and allows stack:read roles', async () => {
expect((await request(app).get('/api/networking/overview')).status).toBe(401);
const db = DatabaseService.getInstance();
db.addUser({ username: 'net-viewer2', password_hash: 'x', role: 'viewer' });
const viewerRes = await request(app).get('/api/networking/overview').set('Authorization', token('net-viewer2', 'viewer'));
expect(viewerRes.status).toBe(200);
const ok = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(ok.status).toBe(200);
expect(ok.body.schemaVersion).toBe(3);
expect(ok.body.runtimeAvailable).toBe(true);
expect(ok.body.overview).toBeDefined();
expect(Array.isArray(ok.body.networks)).toBe(true);
expect(Array.isArray(ok.body.findings)).toBe(true);
});
it('sanitized network inspect returns label keys only', async () => {
const res = await request(app).get(`/api/networking/networks/${NET_ID}`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.schemaVersion).toBe(3);
expect(res.body.network.labelKeys).toEqual(['com.example.key']);
expect(JSON.stringify(res.body)).not.toContain('secret-value');
});
it('returns a degraded schema envelope when Docker networking is unavailable', async () => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('runtime unavailable')),
} as unknown as DockerController);
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
schemaVersion: 3,
runtimeAvailable: false,
networks: [],
});
expect(res.body.findings).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'runtime-unavailable', severity: 'info' }),
]));
expect(res.body.findings.every((finding: { severity: string }) => !['warning', 'error'].includes(finding.severity))).toBe(true);
});
it('blocks admin delete when network is attached', async () => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({
containers: [{ id: 'c1', name: 'web', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'nginx', networks: [{ name: 'orphan_net', id: NET_ID, ip: '' }], volumes: [], ports: [] }],
networks: [{ id: NET_ID, name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
volumes: [],
}),
removeNetwork: vi.fn(),
} as unknown as DockerController);
const removeSpy = vi.spyOn(DockerController.getInstance(1), 'removeNetwork');
const res = await request(app)
.post('/api/system/networks/delete')
.set('Authorization', authHeader)
.send({ id: NET_ID });
expect(res.status).toBe(409);
expect(res.body.code).toBe('attached');
expect(removeSpy).not.toHaveBeenCalled();
});
});
describe('classifySnapshotNetworks', () => {
it('preserves composeProject on external ownership rows', () => {
const snapshot = {
containers: [],
networks: [{
id: 'ext1',
name: 'external_shared',
driver: 'bridge',
scope: 'local',
isSystem: false,
composeProject: 'other-project',
stack: null,
}],
volumes: [],
};
const rows = DockerController.classifySnapshotNetworks(snapshot, ['localstack']);
expect(rows[0].composeProject).toBe('other-project');
expect(rows[0].stack).toBeNull();
expect(rows[0].ownership).toBe('compose-managed');
});
});
describe('evaluateNetworkDeleteGuard', () => {
it('fails closed with stack-declaration-unknown when stacks are unrenderable', () => {
const snapshot = {
containers: [],
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
volumes: [],
};
const guard = evaluateNetworkDeleteGuard('n1', snapshot, [
{ stack: STACK, renderable: false, renderError: 'x', runtime: 'available', networks: [], services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } },
]);
expect(guard.blocked).toBe(true);
expect(guard.code).toBe('stack-declaration-unknown');
});
it('blocks a system network ahead of every other reason', () => {
const snapshot = {
containers: [], volumes: [],
networks: [{ id: 'sys', name: 'bridge', driver: 'bridge', scope: 'local', isSystem: true, composeProject: null, stack: null }],
};
expect(evaluateNetworkDeleteGuard('sys', snapshot, []).code).toBe('system-network');
});
it('blocks a Sencho-owned network', () => {
const spy = vi.spyOn(SelfIdentityService.getInstance(), 'isOwnNetwork').mockReturnValue(true);
try {
const snapshot = {
containers: [], volumes: [],
networks: [{ id: 'own', name: 'sencho_mesh', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
};
expect(evaluateNetworkDeleteGuard('own', snapshot, []).code).toBe('sencho-owned');
} finally {
spy.mockRestore();
}
});
it('blocks a network that still has an attached container', () => {
const snapshot = {
volumes: [],
containers: [{ id: 'c1', name: 'web', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'img', networks: [{ name: 'app_net', id: 'n1', ip: '' }], volumes: [], ports: [] }],
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
};
expect(evaluateNetworkDeleteGuard('n1', snapshot, []).code).toBe('attached');
});
it('blocks a network a renderable stack declares', () => {
const snapshot = {
containers: [], volumes: [],
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
};
const guard = evaluateNetworkDeleteGuard('n1', snapshot, [
{ stack: STACK, renderable: true, renderError: null, runtime: 'available',
networks: [{ key: 'app_net', name: 'app_net', external: false, internal: false, createdByStack: true }],
services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } },
]);
expect(guard.code).toBe('stack-declared');
});
it('allows deleting an unattached, undeclared network', () => {
const snapshot = {
containers: [], volumes: [],
networks: [{ id: 'n1', name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
};
expect(evaluateNetworkDeleteGuard('n1', snapshot, [])).toEqual({ blocked: false });
});
it('does not block when the network is absent from the snapshot', () => {
expect(evaluateNetworkDeleteGuard('ghost', { containers: [], networks: [], volumes: [] }, [])).toEqual({ blocked: false });
});
});
@@ -0,0 +1,190 @@
/**
* Severity matrix and collision correctness for the live Networking findings
* engine: host-mode severity across exposure intents (with/without a
* documented Dossier access URL), and the network-name-collision fix that
* must not flag intentional shared-external-network declarations.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
import { DatabaseService } from '../services/DatabaseService';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
function renderedModel(stack: string, opts: { networkMode?: string; hostIp?: string } = {}) {
const service: Record<string, unknown> = { image: 'nginx:latest' };
if (opts.networkMode) {
service.network_mode = opts.networkMode;
} else {
service.ports = [{ published: '8080', target: '80', host_ip: opts.hostIp ?? '' }];
}
return {
rendered: JSON.stringify({
name: stack,
services: { web: service },
networks: {},
volumes: {},
}),
stderr: '',
code: 0,
timedOut: false,
};
}
function stubEmptySnapshot() {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
function writeStack(stack: string) {
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
return dir;
}
async function findingsFor(stack: string): Promise<Array<{ kind: string; severity: string; stack?: string }>> {
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(res.status).toBe(200);
return (res.body.findings as Array<{ kind: string; severity: string; stack?: string }>)
.filter((f) => f.stack === stack);
}
describe('networking host-mode severity matrix', () => {
const STACK = 'sevhost';
let stackDir: string;
beforeEach(() => {
stackDir = writeStack(STACK);
stubEmptySnapshot();
});
afterEach(() => {
vi.restoreAllMocks();
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
DatabaseService.getInstance().deleteStackDossier(1, STACK);
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('unset intent is high severity for network_mode: host', async () => {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
} as unknown as ComposeService);
const findings = await findingsFor(STACK);
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
expect(hostFinding?.severity).toBe('high');
});
it('internal intent is high severity for network_mode: host regardless of documentation', async () => {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
} as unknown as ComposeService);
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'internal', null);
DatabaseService.getInstance().upsertStackDossier(1, STACK, {
purpose: '', owner: '', access_urls: 'http://localhost:8080', static_ip: '', vlan: '',
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '',
recovery_notes: '', custom_notes: '',
});
const findings = await findingsFor(STACK);
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
expect(hostFinding?.severity).toBe('high');
});
it('lan intent without documented access is medium severity', async () => {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
} as unknown as ComposeService);
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'lan', null);
const findings = await findingsFor(STACK);
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
expect(hostFinding?.severity).toBe('medium');
});
it('lan intent with a documented Dossier access URL downgrades to info', async () => {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
} as unknown as ComposeService);
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'lan', null);
DatabaseService.getInstance().upsertStackDossier(1, STACK, {
purpose: '', owner: '', access_urls: 'http://localhost:8080', static_ip: '', vlan: '',
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '',
recovery_notes: '', custom_notes: '',
});
const findings = await findingsFor(STACK);
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
expect(hostFinding?.severity).toBe('info');
});
});
describe('networking collision correctness', () => {
const STACK_A = 'colla';
const STACK_B = 'collb';
let dirA: string;
let dirB: string;
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(dirA, { recursive: true, force: true });
fs.rmSync(dirB, { recursive: true, force: true });
});
it('two stacks declaring the SAME external network are not a name collision', async () => {
dirA = writeStack(STACK_A);
dirB = writeStack(STACK_B);
stubEmptySnapshot();
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockImplementation((stack: string) => Promise.resolve({
rendered: JSON.stringify({
name: stack,
services: { web: { image: 'nginx:latest' } },
networks: { edge: { name: 'edge-net', external: true } },
volumes: {},
}),
stderr: '', code: 0, timedOut: false,
})),
} as unknown as ComposeService);
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(res.status).toBe(200);
const findings = res.body.findings as Array<{ kind: string; network?: string }>;
expect(findings.some((f) => f.kind === 'network-name-collision' && f.network === 'edge-net')).toBe(false);
});
it('two stacks declaring a non-external network with the same forced literal name IS a collision', async () => {
dirA = writeStack(STACK_A);
dirB = writeStack(STACK_B);
stubEmptySnapshot();
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockImplementation((stack: string) => Promise.resolve({
rendered: JSON.stringify({
name: stack,
services: { web: { image: 'nginx:latest' } },
networks: { app: { name: 'shared-literal' } },
volumes: {},
}),
stderr: '', code: 0, timedOut: false,
})),
} as unknown as ComposeService);
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(res.status).toBe(200);
const findings = res.body.findings as Array<{ kind: string; network?: string }>;
expect(findings.some((f) => f.kind === 'network-name-collision' && f.network === 'shared-literal')).toBe(true);
});
});
@@ -32,6 +32,7 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
existingContainers: [], nodeStateAvailable: true, bindChecks: [],
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false,
exposureAvailable: true,
isSelfStack: false, ...over,
};
}
@@ -354,6 +355,15 @@ describe('exposure-intent rules', () => {
it('does not warn unclassified when no port is published', () => {
expect(ids(runRules(ctx({ model: model([svc()]), stackIntent: null })), 'exposure-unclassified')).toHaveLength(0);
});
it('does not fabricate intent findings when the exposure context is unavailable', () => {
// A DB read failure leaves every intent null and no access URLs; the
// interpretation rules must stay silent rather than read that as unclassified
// or undocumented.
const rp = model([svc({ name: 'web', labelKeys: ['traefik.enable'], ports: [{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
const f = runRules(ctx({ model: rp, exposureAvailable: false }));
expect(ids(f, 'exposure-unclassified')).toHaveLength(0);
expect(ids(f, 'reverse-proxy-undocumented')).toHaveLength(0);
});
it('flags a published port absent from the documented access URLs', () => {
const f = runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([443]) }));
expect(ids(f, 'exposure-port-vs-dossier')).toHaveLength(1);
@@ -0,0 +1,43 @@
/**
* The connected-container section of the sanitized inspect DTO must expose only
* the allowlisted fields and must never leak label values, MAC addresses, or
* endpoint IDs from the raw Docker inspect payload.
*/
import { describe, it, expect } from 'vitest';
import { sanitizeNetworkInspect } from '../services/network/sanitizeNetworkInspect';
import type { DependencySnapshot } from '../services/DockerController';
describe('sanitizeNetworkInspect connected containers', () => {
it('exposes only name/service/stack/ipv4 (CIDR stripped) and no raw label values or MAC/endpoint data', () => {
const snapshot: DependencySnapshot = {
networks: [{ id: 'net1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'app', stack: 'app' }],
volumes: [],
containers: [{
id: 'c1', name: 'app-web-1', service: 'web', composeProject: 'app', stack: 'app', state: 'running', image: 'nginx',
networks: [{ name: 'app_net', id: 'net1', ip: '172.20.0.5/16' }], volumes: [], ports: [],
}],
};
const raw = {
Id: 'net1', Name: 'app_net', Driver: 'bridge', Scope: 'local',
Labels: { 'com.docker.compose.project': 'app', 'secret.token': 'do-not-leak' },
Containers: { c1: { Name: 'app-web-1', MacAddress: '02:42:ac:14:00:05', EndpointID: 'endpoint-xyz', IPv4Address: '172.20.0.5/16' } },
};
const result = sanitizeNetworkInspect(raw, snapshot.networks[0], snapshot);
expect(result.connectedContainers).toEqual([
{ name: 'app-web-1', service: 'web', stack: 'app', ipv4: '172.20.0.5' },
]);
// Label keys are exposed, values never are.
expect(result.labelKeys).toContain('secret.token');
const serialized = JSON.stringify(result);
expect(serialized).not.toContain('do-not-leak');
expect(serialized).not.toContain('02:42:ac:14:00:05');
expect(serialized).not.toContain('endpoint-xyz');
});
it('yields an empty connected list when no snapshot is provided', () => {
const result = sanitizeNetworkInspect({ Id: 'net1', Name: 'app_net' }, undefined, undefined);
expect(result.connectedContainers).toEqual([]);
});
});
@@ -51,6 +51,13 @@ function stubDockerControllerNoops() {
removeNetwork: vi.fn().mockResolvedValue(undefined),
removeVolume: vi.fn().mockResolvedValue(undefined),
removeContainers: vi.fn().mockResolvedValue([]),
getDependencySnapshot: vi.fn().mockResolvedValue({
containers: [],
networks: [
{ id: OTHER_NETWORK, name: 'other_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null },
],
volumes: [],
}),
};
vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType<typeof DockerController.getInstance>);
return fake;
+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);
+16 -20
View File
@@ -9,7 +9,7 @@ import { DatabaseService } from './DatabaseService';
import { computeStackHashes } from './DriftLedgerService';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
import { parseAccessUrlPorts } from './network/normalize';
import { getExposureContext } from './network/exposureContext';
import type { ExposureIntent } from './network/types';
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
import type {
@@ -266,7 +266,7 @@ export class ComposeDoctorService {
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls, exposureAvailable } = this.exposureState(nodeId, stackName);
const selfStack = await isSelfStack(stackName);
return {
@@ -290,6 +290,7 @@ export class ComposeDoctorService {
serviceIntents,
accessUrlPorts,
hasAccessUrls,
exposureAvailable,
isSelfStack: selfStack,
};
}
@@ -297,6 +298,8 @@ export class ComposeDoctorService {
/**
* The user's stored exposure intent (resolved into stack-level + per-service)
* and the dossier's documented access-URL ports, for the exposure rules.
* Delegates to the shared exposure-context helper (also used by the live
* Networking findings engine) so both engines can never diverge on severity.
* Fail-soft: a read error defaults to unset/empty so the rules simply do not
* fire rather than the whole preflight failing.
*/
@@ -305,26 +308,19 @@ export class ComposeDoctorService {
serviceIntents: Record<string, ExposureIntent>;
accessUrlPorts: Set<number>;
hasAccessUrls: boolean;
exposureAvailable: boolean;
} {
try {
const db = DatabaseService.getInstance();
const rows = db.getStackExposureIntents(nodeId, stackName);
const stackIntent = rows.find(r => r.service === '')?.intent ?? null;
const serviceIntents: Record<string, ExposureIntent> = {};
for (const r of rows) if (r.service !== '') serviceIntents[r.service] = r.intent;
const accessUrls = db.getStackDossier(nodeId, stackName)?.access_urls ?? '';
return {
stackIntent,
serviceIntents,
accessUrlPorts: parseAccessUrlPorts(accessUrls),
hasAccessUrls: accessUrls.trim().length > 0,
};
} catch (error) {
console.warn('[ComposeDoctor] Exposure state unavailable for %s; exposure rules skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false };
const context = getExposureContext(nodeId, stackName);
if (!context.available) {
return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, exposureAvailable: false };
}
return {
stackIntent: context.stackIntent,
serviceIntents: context.serviceIntents,
accessUrlPorts: context.accessUrlPorts,
hasAccessUrls: context.hasAccessUrls,
exposureAvailable: true,
};
}
/** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */
+16
View File
@@ -3113,6 +3113,22 @@ export class DatabaseService {
return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
}
public getNodeStackActivity(nodeId: number, opts: { limit: number }): NotificationHistory[] {
const categories = [
'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', 'stack_restarted',
'image_update_applied', 'update_started', 'health_gate_passed', 'health_gate_failed',
];
const placeholders = categories.map(() => '?').join(', ');
const sql = `
SELECT * FROM notification_history
WHERE node_id = ? AND category IN (${placeholders})
ORDER BY timestamp DESC, id DESC
LIMIT ?
`;
return (this.db.prepare(sql).all(nodeId, ...categories, opts.limit) as unknown[])
.map(row => this.mapNotificationRow(row as any));
}
public addNotificationHistory(nodeId: number, notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
const stmt = this.db.prepare(
'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category, actor_username) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)'
+52 -1
View File
@@ -21,6 +21,7 @@ import {
type PruneScope,
type PruneTarget,
} from './prunePlan';
import type { NetworkingNetworkBase } from './network/networkingTypes';
import { isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
@@ -220,6 +221,8 @@ export interface DependencyNetwork {
driver: string;
scope: string;
isSystem: boolean;
ingress?: boolean;
enableIPv6?: boolean;
/** Raw com.docker.compose.project label (may not map to a known stack). */
composeProject: string | null;
/** Resolved Sencho stack this network belongs to, or null. */
@@ -1773,12 +1776,15 @@ class DockerController {
const networks: DependencyNetwork[] = networksRaw.map((net: any) => {
const project = net.Labels?.['com.docker.compose.project'] ?? null;
const ingress = net.Ingress === true;
return {
id: net.Id,
name: net.Name,
driver: net.Driver ?? 'bridge',
scope: net.Scope ?? 'local',
isSystem: DockerController.SYSTEM_NETWORKS.has(net.Name),
isSystem: DockerController.SYSTEM_NETWORKS.has(net.Name) || ingress,
ingress,
...(typeof net.EnableIPv6 === 'boolean' ? { enableIPv6: net.EnableIPv6 } : {}),
composeProject: project,
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
};
@@ -1797,6 +1803,51 @@ class DockerController {
return { containers, networks, volumes };
}
/**
* Classifies snapshot networks into base inventory rows (phase A). Uses only
* the provided snapshot; no additional Docker API calls.
*/
public static classifySnapshotNetworks(
snapshot: DependencySnapshot,
_knownStackNames: string[],
): NetworkingNetworkBase[] {
const selfIdentity = SelfIdentityService.getInstance();
const connectedByNetwork = new Map<string, number>();
for (const c of snapshot.containers) {
for (const attached of c.networks) {
const key = attached.id || attached.name;
connectedByNetwork.set(key, (connectedByNetwork.get(key) ?? 0) + 1);
if (attached.name && attached.name !== key) {
connectedByNetwork.set(attached.name, (connectedByNetwork.get(attached.name) ?? 0) + 1);
}
}
}
return snapshot.networks.map(net => ({
id: net.id,
name: net.name,
driver: net.driver,
scope: net.scope,
isSystem: net.isSystem,
ingress: net.ingress === true,
enableIPv6: net.enableIPv6,
composeProject: net.composeProject,
stack: net.stack,
connectedCount: connectedByNetwork.get(net.id) ?? connectedByNetwork.get(net.name) ?? 0,
isSencho: selfIdentity.isOwnNetwork(net.id) || selfIdentity.isOwnNetwork(net.name),
ownership: net.isSystem || net.ingress === true
? 'system'
: net.stack
? 'sencho-managed'
: net.composeProject
? 'compose-managed'
: 'unmanaged',
declaredByStacks: [],
declaredExternalByStacks: [],
isExternalDependency: false,
}));
}
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
private static resolveProjectLabel(
project: string | undefined,
@@ -77,8 +77,12 @@ export function assembleStackNetworkFacts(
return { stack: stackName, renderable: true, renderError: null, runtime, networks, services, drift };
}
/** Render the effective model and snapshot the node, then assemble the facts. */
export async function buildStackNetworkFacts(nodeId: number, stackName: string): Promise<StackNetworkFacts> {
/** Render the effective model and assemble facts, optionally reusing a snapshot. */
export async function buildStackNetworkFacts(
nodeId: number,
stackName: string,
sharedSnapshot?: DependencySnapshot | null,
): Promise<StackNetworkFacts> {
const fsSvc = FileSystemService.getInstance(nodeId);
let model: EffectiveModel | null = null;
@@ -89,34 +93,33 @@ export async function buildStackNetworkFacts(nodeId: number, stackName: string):
try {
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
} catch (parseErr) {
// JSON.parse errors carry no file content, so the message is safe to log.
console.warn('[NetworkInspector] Effective model parse failed for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
renderError = 'Sencho could not parse the rendered Compose model.';
}
} else {
// Raw stderr can echo file content/secrets and is never surfaced; only the
// names of any missing required variables, otherwise a generic nudge.
const missing = parseMissingRequiredVars(result.stderr);
renderError = missing.length
? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
: 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.';
}
} catch (err) {
// Spawn failure (docker unavailable). Redact defensively.
renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|| 'Sencho could not run docker compose on this node.';
}
// A null snapshot means the runtime is unavailable (drift is then left empty),
// never confused with a real empty snapshot.
let snapshot: DependencySnapshot | null = null;
try {
const knownStacks = await fsSvc.getStacks();
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
} catch (error) {
console.warn('[NetworkInspector] Node snapshot unavailable for %s; runtime facts skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
let snapshot: DependencySnapshot | null;
if (sharedSnapshot !== undefined) {
snapshot = sharedSnapshot;
} else {
try {
const knownStacks = await fsSvc.getStacks();
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
} catch (error) {
console.warn('[NetworkInspector] Node snapshot unavailable for %s; runtime facts skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
snapshot = null;
}
}
return assembleStackNetworkFacts(stackName, model, renderError, snapshot);
@@ -0,0 +1,33 @@
interface SemaphoreState {
active: number;
waiters: Array<() => void>;
}
const MAX_ACTIVE_RENDERS = 4;
const states = new Map<number, SemaphoreState>();
async function acquire(nodeId: number): Promise<() => void> {
const state = states.get(nodeId) ?? { active: 0, waiters: [] };
states.set(nodeId, state);
if (state.active >= MAX_ACTIVE_RENDERS) {
await new Promise<void>(resolve => state.waiters.push(resolve));
}
state.active += 1;
return () => {
state.active -= 1;
const next = state.waiters.shift();
if (next) next();
if (state.active === 0 && state.waiters.length === 0) states.delete(nodeId);
};
}
export async function withComposeRenderSlot<T>(nodeId: number, work: () => Promise<T>): Promise<T> {
const release = await acquire(nodeId);
try {
return await work();
} finally {
release();
}
}
@@ -0,0 +1,274 @@
/**
* Aggregates Compose Doctor's networking-relevant rules (cached runs ONLY, via
* ComposeDoctorService.getLatest, never a fresh node-wide runPreflight/render)
* into the unified Networking findings list. Doctor findings are either
* structurally merged into a matching live finding (dedupe key match) or
* surfaced as a standalone Doctor-only card. Cached data is never presented as
* fresher than it is: Doctor-only cards carry their `ranAt` and are revalidated
* against currently loaded facts where a structural check is cheap; otherwise
* they are retained as cached rather than silently dropped or fabricated as
* live.
*/
import { createHash } from 'crypto';
import { ComposeDoctorService } from '../ComposeDoctorService';
import type { PreflightFinding } from '../preflight/types';
import type { DependencySnapshot } from '../DockerController';
import type { StackNetworkFacts } from './types';
import { getErrorMessage } from '../../utils/errors';
import { sanitizeForLog } from '../../utils/safeLog';
import { NETWORKING_SEVERITY_RANK } from './networkingSeverity';
import type {
DoctorFindingMetadata,
NetworkingFinding,
NetworkingFindingKind,
NetworkingFindingSeverity,
NetworkingRecommendedAction,
} from './networkingTypes';
/** The 11 Compose Doctor rules with a networking-relevant interpretation. */
export const DOCTOR_NETWORKING_RULE_KIND: Record<string, NetworkingFindingKind> = {
'port-conflict-node': 'port-conflict-node',
'port-conflict-internal': 'port-conflict-internal',
'external-network-missing': 'external-network-missing',
'port-exposed-all-interfaces': 'exposure-all-interfaces',
'network-mode-host': 'network-mode-host',
'exposure-internal-published': 'exposure-intent-mismatch',
'sensitive-service-broad-exposure': 'sensitive-service-broad-exposure',
'exposure-unclassified': 'exposure-unclassified',
'exposure-port-vs-dossier': 'exposure-port-vs-dossier',
'reverse-proxy-undocumented': 'reverse-proxy-undocumented',
'new-network': 'new-network',
};
export const DOCTOR_NETWORKING_RULE_IDS = Object.keys(DOCTOR_NETWORKING_RULE_KIND);
const DOCTOR_SEVERITY_MAP: Record<string, NetworkingFindingSeverity> = {
blocker: 'critical',
high: 'high',
warning: 'medium',
info: 'info',
};
// Kinds the live engine can also produce; a dedupe-key match merges the Doctor
// occurrence into the live card instead of creating a Doctor-only duplicate.
const LIVE_OVERLAP_KINDS = new Set<NetworkingFindingKind>([
'external-network-missing',
'network-mode-host',
'exposure-all-interfaces',
'exposure-intent-mismatch',
'exposure-unclassified',
'network-missing', // new-network reconciles into this when the stack is running
]);
function resolveNetworkName(sourcePath: string | undefined, facts: StackNetworkFacts | undefined): string | undefined {
if (!sourcePath || !facts) return undefined;
const key = sourcePath.replace(/^networks\./, '');
return facts.networks.find((n) => n.key === key)?.name ?? key;
}
/** Structural dedupe key (never derived from title/message). Returns null when
* the finding has no live counterpart to merge into (a Doctor-only kind, or an
* interpretable kind missing the structural fields needed to key it). */
function liveDedupeKey(
kind: NetworkingFindingKind,
stack: string,
f: PreflightFinding,
facts: StackNetworkFacts | undefined,
): { key: string; mergeKind: NetworkingFindingKind } | null {
switch (kind) {
case 'external-network-missing': {
const network = resolveNetworkName(f.sourcePath, facts);
return network ? { key: `${kind}\0${stack}\0${network}`, mergeKind: kind } : null;
}
case 'network-mode-host':
case 'exposure-all-interfaces':
case 'exposure-intent-mismatch':
case 'exposure-unclassified':
return f.service ? { key: `${kind}\0${stack}\0${f.service}`, mergeKind: kind } : null;
case 'new-network': {
const network = resolveNetworkName(f.sourcePath, facts);
// Reconciles against the live "declared network missing at runtime" finding.
return network ? { key: `network-missing\0${stack}\0${network}`, mergeKind: 'network-missing' } : null;
}
default:
return null;
}
}
function toMetadata(ruleId: string, ranAt: number | null, f: PreflightFinding): DoctorFindingMetadata {
return {
ruleId,
ranAt: ranAt ? new Date(ranAt).toISOString() : new Date(0).toISOString(),
title: f.title,
message: f.message,
service: f.service,
sourcePath: f.sourcePath,
remediation: f.remediation,
severity: DOCTOR_SEVERITY_MAP[f.severity] ?? 'info',
};
}
/** Rule-specific recommended actions for a Doctor-only finding card. Each rule
* keeps its Doctor link and adds the destination most useful for that rule. */
function doctorOnlyActions(kind: NetworkingFindingKind, stack: string): NetworkingRecommendedAction[] {
const doctorAction: NetworkingRecommendedAction = { kind: 'open-stack-doctor', label: 'Open Doctor', stack };
switch (kind) {
case 'port-conflict-node':
case 'port-conflict-internal':
return [doctorAction, { kind: 'open-stack-editor', label: 'Open stack editor', stack }];
case 'exposure-port-vs-dossier':
case 'reverse-proxy-undocumented':
return [doctorAction, { kind: 'open-stack-dossier', label: 'Open Dossier', stack }];
default:
return [doctorAction, { kind: 'open-stack-networking', label: 'Open stack networking', stack }];
}
}
/** Revalidates a Doctor occurrence against already-loaded facts, never an extra
* render/snapshot call. Returns false when current data conclusively proves
* the finding resolved (it should be discarded), true when it should be kept
* (either confirmed current, or simply not cheaply checkable so kept cached). */
function isStillPlausible(
kind: NetworkingFindingKind,
f: PreflightFinding,
facts: StackNetworkFacts | undefined,
snapshot: DependencySnapshot | null,
): boolean {
if (!facts?.renderable) return true; // can't check; keep cached
switch (kind) {
case 'network-mode-host':
case 'exposure-all-interfaces':
case 'exposure-intent-mismatch':
case 'exposure-unclassified':
case 'sensitive-service-broad-exposure':
case 'port-conflict-node':
case 'port-conflict-internal': {
// These are all service-scoped: if the service no longer exists in the
// current effective model, the finding is stale.
if (!f.service) return true;
return facts.services.some((svc) => svc.name === f.service);
}
case 'external-network-missing':
case 'new-network': {
const network = resolveNetworkName(f.sourcePath, facts);
if (!network || !snapshot) return true;
return !snapshot.networks.some((n) => n.name === network);
}
default:
return true;
}
}
interface DoctorAggregationOptions {
nodeId: number;
stackNames: string[];
stackFacts: StackNetworkFacts[];
snapshot: DependencySnapshot | null;
}
/** Merges cached Doctor findings into the live findings list. Returns the full
* unified list (live findings enriched in place, plus new Doctor-only cards
* appended). Fail-soft per stack: a getLatest() failure is logged and skipped,
* never fails the whole aggregate. */
export function applyDoctorNetworkingFindings(
live: NetworkingFinding[],
options: DoctorAggregationOptions,
): NetworkingFinding[] {
const { nodeId, stackNames, stackFacts, snapshot } = options;
const factsByStack = new Map(stackFacts.map((f) => [f.stack, f]));
// Index live findings by their own structural key for O(1) merge lookup.
const liveByKey = new Map<string, NetworkingFinding>();
for (const f of live) {
if (!LIVE_OVERLAP_KINDS.has(f.kind)) continue;
const keyParts = f.service
? [f.kind, f.stack ?? '', f.service]
: f.network
? [f.kind, f.stack ?? '', f.network]
: null;
if (keyParts) liveByKey.set(keyParts.join('\0'), f);
}
// Doctor-only groups: same (mergeKind, stack, service-or-network) collapse
// into ONE card carrying every matched occurrence; distinct
// services/networks/stacks always get distinct cards.
const doctorOnlyGroups = new Map<string, { kind: NetworkingFindingKind; stack: string; service?: string; network?: string; entries: DoctorFindingMetadata[] }>();
let ordinal = 0;
for (const stack of stackNames) {
let report;
try {
report = ComposeDoctorService.getInstance().getLatest(nodeId, stack);
} catch (error) {
console.warn('[Networking] Doctor lookup failed for stack %s; skipped:',
sanitizeForLog(stack), sanitizeForLog(getErrorMessage(error, 'unknown')));
continue;
}
if (report.status === 'never-run' || report.findings.length === 0) continue;
const facts = factsByStack.get(stack);
for (const f of report.findings) {
if (f.acknowledged === true) continue;
const kind = DOCTOR_NETWORKING_RULE_KIND[f.ruleId];
if (!kind) continue;
if (!isStillPlausible(kind, f, facts, snapshot)) continue;
const dedupe = liveDedupeKey(kind, stack, f, facts);
const metadata = toMetadata(f.ruleId, report.ranAt, f);
if (dedupe) {
const liveMatch = liveByKey.get(dedupe.key);
if (liveMatch) {
if (!liveMatch.sources.includes('doctor')) liveMatch.sources.push('doctor');
liveMatch.doctorFindings.push(metadata);
if (!liveMatch.recommendedActions.some((a) => a.kind === 'open-stack-doctor')) {
liveMatch.recommendedActions.push({ kind: 'open-stack-doctor', label: 'Open Doctor', stack });
}
continue;
}
// new-network with no live network-missing counterpart: the network is
// either present (already filtered by isStillPlausible) or the stack is
// stopped/not-yet-deployed, so show the Doctor informational read
// instead of a misleading live drift interpretation. Falls through to
// the Doctor-only group below, keyed on the reconciled mergeKind so a
// network-missing counterpart appearing later still collapses.
}
const groupKey = dedupe
? dedupe.key
: f.service
? `${kind}\0${stack}\0${f.service}`
: `${kind}\0${stack}\0ordinal-${ordinal++}`;
const group = doctorOnlyGroups.get(groupKey) ?? { kind, stack, service: f.service, entries: [] };
group.entries.push(metadata);
doctorOnlyGroups.set(groupKey, group);
}
}
const doctorOnlyFindings: NetworkingFinding[] = [];
for (const group of doctorOnlyGroups.values()) {
const worstSeverity = group.entries.reduce<NetworkingFindingSeverity>(
(worst, entry) => (NETWORKING_SEVERITY_RANK[entry.severity] > NETWORKING_SEVERITY_RANK[worst] ? entry.severity : worst),
'info',
);
const idPayload = [group.kind, group.stack, group.service ?? '', group.entries.map((e) => e.ruleId).join(',')].join('\0');
doctorOnlyFindings.push({
id: createHash('sha256').update(idPayload).digest('hex').slice(0, 16),
kind: group.kind,
severity: worstSeverity,
title: group.entries[0].title,
message: group.entries[0].message,
stack: group.stack,
service: group.service,
evidence: [
{ label: 'Stack', value: group.stack },
...(group.service ? [{ label: 'Service', value: group.service }] : []),
],
recommendedActions: doctorOnlyActions(group.kind, group.stack),
sources: ['doctor'],
doctorFindings: group.entries,
});
}
return [...live, ...doctorOnlyFindings];
}
@@ -0,0 +1,45 @@
/**
* Shared exposure-context reader consumed by BOTH ComposeDoctorService (preflight
* exposure rules) and the live networking findings engine, so their severities can
* never diverge. Fail-soft via a discriminated result: a DB read failure is distinct
* from a genuinely unset intent, so callers must not treat `available: false` as
* "no intent" (that would fabricate false unclassified/mismatch findings).
*/
import { DatabaseService } from '../DatabaseService';
import { parseAccessUrlPorts } from './normalize';
import type { ExposureIntent } from './types';
import { getErrorMessage } from '../../utils/errors';
import { sanitizeForLog } from '../../utils/safeLog';
export type ExposureContext =
| { available: false }
| {
available: true;
stackIntent: ExposureIntent | null;
serviceIntents: Record<string, ExposureIntent>;
accessUrlPorts: Set<number>;
hasAccessUrls: boolean;
};
export function getExposureContext(nodeId: number, stackName: string): ExposureContext {
try {
const db = DatabaseService.getInstance();
const rows = db.getStackExposureIntents(nodeId, stackName);
const stackIntent = rows.find((r) => r.service === '')?.intent ?? null;
const serviceIntents: Record<string, ExposureIntent> = {};
for (const r of rows) if (r.service !== '') serviceIntents[r.service] = r.intent;
const accessUrls = db.getStackDossier(nodeId, stackName)?.access_urls ?? '';
return {
available: true,
stackIntent,
serviceIntents,
accessUrlPorts: parseAccessUrlPorts(accessUrls),
hasAccessUrls: accessUrls.trim().length > 0,
};
} catch (error) {
console.warn('[Networking] Exposure context unavailable for %s; exposure interpretation skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
return { available: false };
}
}
@@ -0,0 +1,71 @@
/**
* Fail-closed guards for admin network delete on /api/system/networks/delete.
*/
import SelfIdentityService from '../SelfIdentityService';
import type { DependencySnapshot } from '../DockerController';
import type { StackNetworkFacts } from './types';
import type { NetworkingNetworkBase } from './networkingTypes';
export type NetworkDeleteBlockCode =
| 'system-network'
| 'sencho-owned'
| 'attached'
| 'stack-declared'
| 'stack-declaration-unknown';
export interface NetworkDeleteGuardResult {
blocked: boolean;
code?: NetworkDeleteBlockCode;
error?: string;
}
export function evaluateNetworkDeleteGuard(
networkId: string,
snapshot: DependencySnapshot,
stackFacts: StackNetworkFacts[],
baseRow?: NetworkingNetworkBase,
): NetworkDeleteGuardResult {
const net = baseRow
?? snapshot.networks.find(n => n.id === networkId || n.name === networkId);
if (!net) {
return { blocked: false };
}
if (net.isSystem) {
return { blocked: true, code: 'system-network', error: 'System networks cannot be deleted.' };
}
const self = SelfIdentityService.getInstance();
if (self.isOwnNetwork(net.id) || self.isOwnNetwork(net.name)) {
return { blocked: true, code: 'sencho-owned', error: 'Sencho-managed networks cannot be deleted.' };
}
const connected = snapshot.containers.some(c =>
c.networks.some(a => a.id === net.id || a.name === net.name),
);
if (connected) {
return { blocked: true, code: 'attached', error: 'Detach all containers before deleting this network.' };
}
const hasUnrenderable = stackFacts.some(f => !f.renderable);
const declaredNames = new Set<string>();
for (const facts of stackFacts) {
if (!facts.renderable) continue;
for (const n of facts.networks) declaredNames.add(n.name);
}
if (declaredNames.has(net.name)) {
return { blocked: true, code: 'stack-declared', error: 'This network is declared by a Compose stack.' };
}
if (hasUnrenderable && (net.composeProject || net.stack)) {
return {
blocked: true,
code: 'stack-declaration-unknown',
error: 'Cannot verify stack declarations while one or more stacks failed to render.',
};
}
return { blocked: false };
}
@@ -0,0 +1,159 @@
/**
* Shared node networking aggregate: one Docker snapshot per request, effective-model
* stack facts, findings, enriched inventory, and optional topology.
*/
import DockerController from '../DockerController';
import { FileSystemService } from '../FileSystemService';
import { DatabaseService } from '../DatabaseService';
import { buildStackNetworkFacts } from './composeNetworkInspector';
import { buildNodeNetworkingFindings } from './networkingFindings';
import { applyDoctorNetworkingFindings } from './doctorNetworkingFindings';
import { enrichNetworkRows } from './networkingInventory';
import { buildNetworkingTopology } from './networkingTopology';
import { rankFindings } from './networkingSeverity';
import { isHostNetwork, isLoopback } from './normalize';
import type { ExposureIntent } from './types';
import type { DependencySnapshot } from '../DockerController';
import type { NodeNetworkingAggregate, NodeNetworkingOverview } from './networkingTypes';
import { getErrorMessage } from '../../utils/errors';
import { sanitizeForLog } from '../../utils/safeLog';
import { mapWithConcurrency } from '../../utils/mapWithConcurrency';
import { withComposeRenderSlot } from './composeRenderSemaphore';
export async function buildNodeNetworkingAggregate(
nodeId: number,
options: { includeTopology?: boolean; includeSystem?: boolean },
): Promise<NodeNetworkingAggregate> {
const fsSvc = FileSystemService.getInstance(nodeId);
const stacks = await fsSvc.getStacks();
let snapshot: DependencySnapshot | null = null;
try {
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
} catch (error) {
console.warn('[Networking] Snapshot failed on node %d:', nodeId, sanitizeForLog(getErrorMessage(error, 'unknown')));
}
const baseRows = snapshot ? DockerController.classifySnapshotNetworks(snapshot, stacks) : [];
const stackFacts = await mapWithConcurrency(stacks, 4, stack => withComposeRenderSlot(
nodeId,
() => buildStackNetworkFacts(nodeId, stack, snapshot),
));
// Pipeline order matters: live findings, then cached Doctor adaptation
// and merge, BEFORE inventory enrichment / overview / topology all consume
// the same unified list, so counts and findingIds never disagree.
const liveFindings = buildNodeNetworkingFindings(nodeId, snapshot, stackFacts, baseRows);
const findings = rankFindings(applyDoctorNetworkingFindings(liveFindings, {
nodeId, stackNames: stacks, stackFacts, snapshot,
}));
const networks = snapshot ? enrichNetworkRows(nodeId, baseRows, stackFacts, snapshot, findings) : [];
const overview = buildOverview(nodeId, stacks, snapshot, stackFacts, findings, networks);
const aggregate: NodeNetworkingAggregate = {
overview,
networks,
findings,
stackFacts,
runtimeAvailable: snapshot !== null,
recentActivity: DatabaseService.getInstance().getNodeStackActivity(nodeId, { limit: 20 }),
};
if (options.includeTopology) {
aggregate.topology = buildNetworkingTopology(
nodeId,
snapshot,
stackFacts,
findings,
options.includeSystem === true,
);
}
return aggregate;
}
function buildOverview(
nodeId: number,
stacks: string[],
snapshot: DependencySnapshot | null,
stackFacts: Awaited<ReturnType<typeof buildStackNetworkFacts>>[],
findings: ReturnType<typeof buildNodeNetworkingFindings>,
networks: import('./networkingTypes').NetworkingNetworkRow[],
): NodeNetworkingOverview {
const db = DatabaseService.getInstance();
const renderFailedStacks = stackFacts.filter(f => !f.renderable).map(f => f.stack);
let exposedStackCount = 0;
let unknownExposureStackCount = 0;
let missingExternalCount = 0;
for (const facts of stackFacts) {
if (!facts.renderable) continue;
const intents = db.getStackExposureIntents(nodeId, facts.stack);
const stackIntent = intents.find((intent) => intent.service === '')?.intent ?? null;
const byService = new Map(
intents.filter((intent) => intent.service !== '').map((intent) => [intent.service, intent.intent]),
);
const publishes = (service: (typeof facts.services)[number]): boolean =>
service.publishedPorts.length > 0 || isHostNetwork(service.networkMode);
if (facts.services.some((service) =>
isHostNetwork(service.networkMode) || service.publishedPorts.some((port) => !isLoopback(port.hostIp)),
)) {
exposedStackCount += 1;
}
const hasUnclassifiedPublish = facts.services.some((service) => {
if (!publishes(service)) return false;
const intent: ExposureIntent | null = byService.get(service.name) ?? stackIntent;
return intent === null || intent === 'unknown';
});
if (hasUnclassifiedPublish) unknownExposureStackCount += 1;
missingExternalCount += facts.networks.filter((network) =>
network.external && facts.drift.missingFromRuntime.includes(network.name),
).length;
}
const connectedContainerCount = snapshot
? snapshot.containers.filter((container) => container.networks.length > 0).length
: null;
return {
runtimeAvailable: snapshot !== null,
networkCount: snapshot?.networks.length ?? null,
stackCount: stacks.length,
connectedContainerCount,
systemNetworkCount: snapshot?.networks.filter(n => n.isSystem).length ?? null,
senchoManagedNetworkCount: snapshot ? networks.filter((r) => r.ownership === 'sencho-managed').length : null,
composeManagedNetworkCount: snapshot ? networks.filter((r) => r.ownership === 'compose-managed').length : null,
unmanagedNetworkCount: snapshot ? networks.filter((r) => r.ownership === 'unmanaged').length : null,
externalDependencyNetworkCount: snapshot ? networks.filter((r) => r.isExternalDependency).length : null,
exposedStackCount,
unknownExposureStackCount,
missingExternalCount,
// Alias/service DNS collisions plus genuine (non-intentional-sharing) name
// collisions all count toward the overview number.
networkCollisionCount: findings.filter(f =>
f.kind === 'network-name-collision' || f.kind === 'alias-collision' || f.kind === 'service-name-collision',
).length,
findingCount: findings.length,
renderFailedStacks,
};
}
export async function loadNetworkingSnapshot(nodeId: number): Promise<{
stacks: string[];
snapshot: DependencySnapshot | null;
}> {
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
try {
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
return { stacks, snapshot };
} catch (error) {
console.error('[Networking] Snapshot failed on node %d:', nodeId, sanitizeForLog(getErrorMessage(error, 'unknown')));
return { stacks, snapshot: null };
}
}
@@ -0,0 +1,23 @@
import { NETWORKING_SCHEMA_VERSION, type NetworkingEnvelope } from './networkingTypes';
export function okEnvelope<T extends object>(
runtimeAvailable: boolean,
fields: T,
): NetworkingEnvelope & T {
return {
schemaVersion: NETWORKING_SCHEMA_VERSION,
runtimeAvailable,
generatedAt: new Date().toISOString(),
...fields,
};
}
export function runtimeUnavailableEnvelope() {
return {
schemaVersion: NETWORKING_SCHEMA_VERSION,
runtimeAvailable: false,
generatedAt: new Date().toISOString(),
error: 'Docker networking runtime is unavailable',
code: 'runtime-unavailable' as const,
};
}
@@ -0,0 +1,371 @@
/**
* Node-scoped networking findings derived from effective-model stack facts,
* exposure intents, and the shared dependency snapshot.
*/
import { createHash } from 'crypto';
import type { DependencySnapshot } from '../DockerController';
import type { ExposureIntent, StackNetworkFacts } from './types';
import { isHostNetwork } from './normalize';
import { getExposureContext, type ExposureContext } from './exposureContext';
import type { NetworkingFinding, NetworkingFindingKind, NetworkingNetworkBase, NetworkingRecommendedAction } from './networkingTypes';
type FindingExtra = Pick<NetworkingFinding, 'stack' | 'network' | 'service'>;
function finding(
kind: NetworkingFindingKind,
severity: NetworkingFinding['severity'],
title: string,
message: string,
extra: FindingExtra = {},
recommendedActions: NetworkingRecommendedAction[] = [],
): NetworkingFinding {
const idPayload = [kind, message, extra.stack ?? '', extra.network ?? '', extra.service ?? ''].join('\0');
return {
id: createHash('sha256').update(idPayload).digest('hex').slice(0, 16),
kind,
severity,
title,
message,
...extra,
evidence: [
...(extra.stack ? [{ label: 'Stack', value: extra.stack }] : []),
...(extra.service ? [{ label: 'Service', value: extra.service }] : []),
...(extra.network ? [{ label: 'Network', value: extra.network }] : []),
],
recommendedActions,
sources: ['live'],
doctorFindings: [],
};
}
function stackActions(stack: string): NetworkingRecommendedAction[] {
return [{ kind: 'open-stack-networking', label: 'Open stack networking', stack }];
}
function effectiveIntent(service: string, stackIntent: ExposureIntent | null, byService: Record<string, ExposureIntent>): ExposureIntent | null {
return byService[service] ?? stackIntent ?? null;
}
/** Host-mode severity matrix, parameterized by documented Dossier access.
* Internal/same-node are a contradiction regardless of documentation: high. Unset
* and unknown are unclassified exposure under host networking (no network
* isolation): high.
* Everything else (lan/public/reverse-proxy/temporary) is medium undocumented,
* info when a Dossier access URL documents the exposure. */
export function hostModeSeverity(intent: ExposureIntent | null, hasAccessUrls: boolean): NetworkingFinding['severity'] {
if (intent === 'internal' || intent === 'same-node') return 'high';
if (intent === null || intent === 'unknown') return 'high';
return hasAccessUrls ? 'info' : 'medium';
}
function addExposureFindings(
out: NetworkingFinding[],
facts: StackNetworkFacts,
context: ExposureContext,
): void {
const stackIntent = context.available ? context.stackIntent : null;
const byService = context.available ? context.serviceIntents : {};
const hasAccessUrls = context.available && context.hasAccessUrls;
for (const service of facts.services) {
const hostMode = isHostNetwork(service.networkMode);
const publishes = hostMode || service.publishedPorts.length > 0;
if (!publishes) continue;
const intent = effectiveIntent(service.name, stackIntent, byService);
const intentAction: NetworkingRecommendedAction = {
kind: 'set-exposure-intent', label: 'Set exposure intent', stack: facts.stack, service: service.name,
};
if (hostMode) {
// Structural fact; stays visible even when exposure context is unavailable,
// at the neutral fallback severity (no intent/Dossier interpretation).
out.push(finding(
'network-mode-host',
context.available ? hostModeSeverity(intent, hasAccessUrls) : 'medium',
'Host network mode',
`Service "${service.name}" in stack "${facts.stack}" uses network_mode: host.`,
{ stack: facts.stack, service: service.name },
[intentAction, ...stackActions(facts.stack)],
));
continue;
}
const broad = service.publishedPorts.some(port => port.allInterfaces);
if (!context.available) {
// Structural all-interface bind fact stays visible at fallback severity;
// intent-dependent interpretation (unclassified/mismatch) is skipped.
if (broad) {
out.push(finding(
'exposure-all-interfaces',
'medium',
'Port bound on all interfaces',
`Service "${service.name}" in stack "${facts.stack}" publishes ports on all interfaces.`,
{ stack: facts.stack, service: service.name },
[intentAction],
));
}
continue;
}
if (intent === null || intent === 'unknown') {
out.push(finding(
'exposure-unclassified',
'info',
'Publishing without exposure intent',
`Stack "${facts.stack}" publishes ports from "${service.name}" without a classified exposure intent.`,
{ stack: facts.stack, service: service.name },
[intentAction],
));
}
if ((intent === 'internal' || intent === 'same-node') && service.publishedPorts.some(port => !port.loopbackOnly)) {
out.push(finding(
'exposure-intent-mismatch',
'high',
intent === 'internal' ? 'Internal intent with host publish' : 'Same-node intent with host publish',
`Service "${service.name}" is classified ${intent} but publishes ports to the host.`,
{ stack: facts.stack, service: service.name },
[intentAction],
));
} else if (intent === 'temporary' && broad) {
out.push(finding(
'exposure-intent-mismatch',
'medium',
'Temporary exposure is broadly bound',
`Service "${service.name}" is marked temporary and publishes on all interfaces.`,
{ stack: facts.stack, service: service.name },
[intentAction],
));
} else if (broad && intent !== 'public' && intent !== 'reverse-proxy') {
out.push(finding(
'exposure-all-interfaces',
'medium',
'Port bound on all interfaces',
`Service "${service.name}" in stack "${facts.stack}" publishes ports on all interfaces.`,
{ stack: facts.stack, service: service.name },
[intentAction],
));
}
}
}
function addCrossStackDnsFindings(out: NetworkingFinding[], stackFacts: StackNetworkFacts[], baseNetworks: NetworkingNetworkBase[]): void {
const runtimeNetworks = new Map(baseNetworks.map(network => [network.name, network]));
const entriesByNetwork = new Map<string, Array<{ stack: string; service: string; names: string[] }>>();
for (const facts of stackFacts.filter(facts => facts.renderable)) {
for (const service of facts.services) {
for (const membership of service.networks) {
const declared = facts.networks.find(network => network.key === membership.key);
const networkName = declared?.name ?? membership.key;
const network = runtimeNetworks.get(networkName);
if (network?.isSystem || network?.ingress) continue;
const shared = declared?.external === true || (network?.connectedCount ?? 0) > 1;
if (!shared) continue;
const entries = entriesByNetwork.get(networkName) ?? [];
entries.push({ stack: facts.stack, service: service.name, names: [service.name, ...membership.aliases] });
entriesByNetwork.set(networkName, entries);
}
}
}
for (const [networkName, entries] of entriesByNetwork) {
const names = new Map<string, Array<{ stack: string; service: string; isAlias: boolean }>>();
for (const entry of entries) {
entry.names.forEach((name, index) => {
const values = names.get(name) ?? [];
values.push({ stack: entry.stack, service: entry.service, isAlias: index > 0 });
names.set(name, values);
});
}
for (const [name, owners] of names) {
const distinct = new Set(owners.map(owner => `${owner.stack}\0${owner.service}`));
if (distinct.size < 2) continue;
const allServices = owners.every(owner => !owner.isAlias);
out.push(finding(
allServices ? 'service-name-collision' : 'alias-collision',
allServices ? 'medium' : 'high',
allServices ? 'Duplicate service name on shared network' : 'Duplicate DNS name on shared network',
`Name "${name}" resolves to multiple services on shared network "${networkName}".`,
{ network: networkName },
[{ kind: 'filter-topology', label: 'View network topology', networkName }],
));
}
}
}
function addComposeDriftFindings(
out: NetworkingFinding[],
facts: StackNetworkFacts,
baseNetworks: NetworkingNetworkBase[],
): void {
const networkIds = new Map(baseNetworks.map((network) => [network.name, network.id]));
for (const network of facts.networks) {
if (!network.external && network.createdByStack && facts.drift.missingFromRuntime.includes(network.name)) {
out.push(finding(
'network-missing',
'high',
'Declared network missing',
`Stack "${facts.stack}" declares network "${network.name}" but it does not exist in the runtime.`,
{ stack: facts.stack, network: network.name },
[...stackActions(facts.stack), { kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: network.name }],
));
}
if (!network.external && network.createdByStack && facts.drift.declaredButUnused.includes(network.key)) {
out.push(finding(
'declared-network-unused',
'medium',
'Declared network unused',
`Stack "${facts.stack}" declares network "${network.name}" but no running service is attached.`,
{ stack: facts.stack, network: network.name },
stackActions(facts.stack),
));
}
}
for (const attachment of facts.drift.runtimeOnlyAttachments) {
out.push(finding(
'network-undeclared',
'high',
'Undeclared network attachment',
`Container "${attachment.container}" (${attachment.service ?? 'unknown service'}) is attached to undeclared network "${attachment.network}".`,
{ stack: facts.stack, network: attachment.network, service: attachment.service ?? undefined },
[...stackActions(facts.stack), ...(networkIds.has(attachment.network) ? [{ kind: 'inspect-network', label: 'Inspect network', networkId: networkIds.get(attachment.network)! } satisfies NetworkingRecommendedAction] : [])],
));
}
for (const attachment of facts.drift.foreignNetworkAttachments) {
out.push(finding(
'foreign-network-attachment',
'high',
'Foreign network attachment',
`Container "${attachment.container}" in stack "${facts.stack}" is attached to network "${attachment.network}" owned elsewhere.`,
{ stack: facts.stack, network: attachment.network },
[...stackActions(facts.stack), ...(networkIds.has(attachment.network) ? [{ kind: 'inspect-network', label: 'Inspect network', networkId: networkIds.get(attachment.network)! } satisfies NetworkingRecommendedAction] : [])],
));
}
}
function addSharedNetworkFindings(
out: NetworkingFinding[],
snapshot: DependencySnapshot,
stackFacts: StackNetworkFacts[],
): void {
const stacksByNetwork = new Map<string, Set<string>>();
for (const container of snapshot.containers) {
if (!container.stack) continue;
for (const attached of container.networks) {
const stacks = stacksByNetwork.get(attached.name) ?? new Set<string>();
stacks.add(container.stack);
stacksByNetwork.set(attached.name, stacks);
}
}
for (const [networkName, stacks] of stacksByNetwork) {
if (stacks.size < 2) continue;
out.push(finding(
'shared-network',
'info',
'Shared network',
`Network "${networkName}" connects containers from ${stacks.size} stacks: ${[...stacks].sort().join(', ')}.`,
{ network: networkName },
[{ kind: 'filter-topology', label: 'View network topology', networkName }],
));
}
// Only genuinely unsafe name collisions are worth a warning: two or more stacks
// declaring a NON-external network that happens to resolve to the same literal
// name (a forced `name:` override colliding by accident). Two or more stacks
// that all declare the SAME network as `external: true` is the ordinary
// shared-external-network pattern, already covered above as an info-level
// "shared network" finding, not a collision.
const declaredNames = new Map<string, { stack: string; external: boolean }[]>();
for (const facts of stackFacts) {
if (!facts.renderable) continue;
for (const network of facts.networks) {
const entries = declaredNames.get(network.name) ?? [];
entries.push({ stack: facts.stack, external: network.external === true });
declaredNames.set(network.name, entries);
}
}
for (const [networkName, entries] of declaredNames) {
const uniqueStacks = [...new Set(entries.map((e) => e.stack))];
if (uniqueStacks.length < 2) continue;
const allExternal = entries.every((e) => e.external);
if (allExternal) continue;
out.push(finding(
'network-name-collision',
'medium',
'Network name collision',
`Multiple stacks resolve to the same network name "${networkName}": ${uniqueStacks.join(', ')}.`,
{ network: networkName },
[{ kind: 'filter-topology', label: 'View network topology', networkName }],
));
}
}
function largeFlatSeverity(connectedCount: number): NetworkingFinding['severity'] {
if (connectedCount >= 1000) return 'high';
if (connectedCount >= 500) return 'medium';
return 'info';
}
export function buildNodeNetworkingFindings(
nodeId: number,
snapshot: DependencySnapshot | null,
stackFacts: StackNetworkFacts[],
baseNetworks: NetworkingNetworkBase[],
): NetworkingFinding[] {
const out: NetworkingFinding[] = [];
if (!snapshot) {
out.push(finding(
'runtime-unavailable', 'info', 'Runtime networking unavailable',
'Sencho could not read Docker networking state on this node.',
{},
[
{ kind: 'refresh', label: 'Refresh runtime data' },
{ kind: 'open-docs', label: 'Open troubleshooting guide', docsPath: '/operations/troubleshooting' },
],
));
}
for (const facts of stackFacts) {
if (!facts.renderable) continue;
const context = getExposureContext(nodeId, facts.stack);
addExposureFindings(out, facts, context);
if (!snapshot) continue;
addComposeDriftFindings(out, facts, baseNetworks);
for (const network of facts.networks) {
if (network.external && facts.drift.missingFromRuntime.includes(network.name)) {
const isRunning = snapshot.containers.some(container => container.stack === facts.stack && ['running', 'restarting'].includes(container.state));
out.push(finding(
'external-network-missing', isRunning ? 'critical' : 'high', 'External network not found',
`Stack "${facts.stack}" requires the external network "${network.name}", which is not present on this node.`,
{ stack: facts.stack, network: network.name },
[
{ kind: 'create-network', label: 'Create network', networkName: network.name, requiresAdmin: true },
{ kind: 'copy-compose-snippet', label: 'Copy Compose snippet', snippetKind: 'external-network', networkName: network.name },
{ kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: network.name },
{ kind: 'open-stack-editor', label: 'Open stack editor', stack: facts.stack },
],
));
}
}
}
if (!snapshot) return out;
addSharedNetworkFindings(out, snapshot, stackFacts);
addCrossStackDnsFindings(out, stackFacts, baseNetworks);
for (const network of baseNetworks) {
if (network.isSystem || network.ingress || ['host', 'none'].includes(network.driver)) continue;
if (network.connectedCount >= 100) {
out.push(finding(
'large-flat-network', largeFlatSeverity(network.connectedCount), 'Large flat network',
`Network "${network.name}" has ${network.connectedCount} connected endpoints.`,
{ network: network.name },
[{ kind: 'filter-topology', label: 'View network topology', networkName: network.name }],
));
}
if (['overlay', 'macvlan', 'ipvlan'].includes(network.driver) || network.enableIPv6) {
out.push(finding(
'advanced-driver-caveat', 'info', 'Advanced network configuration',
`Network "${network.name}" uses ${network.enableIPv6 ? 'IPv6 or ' : ''}${network.driver} networking.`,
{ network: network.name },
[{ kind: 'inspect-network', label: 'Inspect network', networkId: network.id }],
));
}
}
return out;
}
@@ -0,0 +1,128 @@
/**
* Phase B enrichment for network inventory rows.
*/
import type { DependencySnapshot } from '../DockerController';
import { DatabaseService } from '../DatabaseService';
import type { StackNetworkFacts } from './types';
import { isHostNetwork } from './normalize';
import type {
NetworkingExposureSummary,
NetworkingFinding,
NetworkingNetworkBase,
NetworkingNetworkRow,
} from './networkingTypes';
/** Stacks whose effective model declares a network (optionally external-only). */
export function stacksDeclaringNetwork(
stackFacts: StackNetworkFacts[],
networkName: string,
externalOnly = false,
): string[] {
return stackFacts
.filter((facts) =>
facts.renderable
&& facts.networks.some((network) =>
network.name === networkName && (!externalOnly || network.external),
),
)
.map((facts) => facts.stack);
}
export function indexFindingIdsByNetwork(findings: NetworkingFinding[]): Map<string, string[]> {
const byNetwork = new Map<string, string[]>();
for (const finding of findings) {
if (!finding.network) continue;
const list = byNetwork.get(finding.network) ?? [];
list.push(finding.id);
byNetwork.set(finding.network, list);
}
return byNetwork;
}
export function enrichNetworkRows(
nodeId: number,
baseRows: NetworkingNetworkBase[],
stackFacts: StackNetworkFacts[],
snapshot: DependencySnapshot,
findings: NetworkingFinding[],
): NetworkingNetworkRow[] {
const stacksByNetwork = new Map<string, Set<string>>();
const serviceNamesByNetwork = new Map<string, Set<string>>();
for (const container of snapshot.containers) {
for (const attached of container.networks) {
const set = stacksByNetwork.get(attached.name) ?? new Set<string>();
if (container.stack) set.add(container.stack);
stacksByNetwork.set(attached.name, set);
if (container.service) {
const services = serviceNamesByNetwork.get(attached.name) ?? new Set<string>();
services.add(container.service);
serviceNamesByNetwork.set(attached.name, services);
}
}
}
const findingsByNetwork = indexFindingIdsByNetwork(findings);
return baseRows.map((row) => {
const declaredByStacks = stacksDeclaringNetwork(stackFacts, row.name);
const declaredExternalByStacks = stacksDeclaringNetwork(stackFacts, row.name, true);
return {
...row,
declaredByStacks,
declaredExternalByStacks,
isExternalDependency: declaredExternalByStacks.length > 0,
sharedStackCount: stacksByNetwork.get(row.name)?.size ?? 0,
exposureSummary: buildExposureSummary(nodeId, row.name, stackFacts, snapshot),
findingIds: findingsByNetwork.get(row.name) ?? [],
serviceNames: [...(serviceNamesByNetwork.get(row.name) ?? [])].sort(),
};
});
}
function buildExposureSummary(
nodeId: number,
networkName: string,
stackFacts: StackNetworkFacts[],
snapshot: DependencySnapshot,
): NetworkingExposureSummary | null {
const stacksOnNetwork = new Set<string>();
for (const c of snapshot.containers) {
if (c.networks.some(n => n.name === networkName) && c.stack) stacksOnNetwork.add(c.stack);
}
if (stacksOnNetwork.size === 0) return null;
let broadExposureCount = 0;
let unclassifiedStackCount = 0;
const db = DatabaseService.getInstance();
for (const stack of stacksOnNetwork) {
const facts = stackFacts.find(f => f.stack === stack);
if (!facts?.renderable) continue;
const intents = db.getStackExposureIntents(nodeId, stack);
const stackIntent = intents.find(i => i.service === '')?.intent ?? null;
const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent]));
let stackBroad = false;
let stackUnclassified = false;
for (const svc of facts.services) {
const attached = svc.networks.some(n => {
const net = facts.networks.find(nn => nn.key === n.key);
return (net?.name ?? n.key) === networkName;
});
if (!attached) continue;
const publishes = svc.publishedPorts.length > 0 || isHostNetwork(svc.networkMode);
if (svc.publishedPorts.some(p => p.allInterfaces) || isHostNetwork(svc.networkMode)) stackBroad = true;
const intent = byService.get(svc.name) ?? stackIntent;
if (publishes && (intent === null || intent === 'unknown')) stackUnclassified = true;
}
if (stackBroad) broadExposureCount += 1;
if (stackUnclassified) unclassifiedStackCount += 1;
}
return {
publishingStackCount: stacksOnNetwork.size,
broadExposureCount,
unclassifiedStackCount,
};
}
@@ -0,0 +1,27 @@
/**
* Shared severity ranking, reused by the Overview top-N attention list and the
* Findings tab grouping, so the two views can never disagree on ordering.
*/
import type { NetworkingFinding, NetworkingFindingSeverity } from './networkingTypes';
export const NETWORKING_SEVERITY_RANK: Record<NetworkingFindingSeverity, number> = {
info: 0,
medium: 1,
high: 2,
critical: 3,
};
/** Severity descending; at equal severity, a live-sourced finding ranks ahead of a
* Doctor-only finding (cached data should not outrank current runtime truth). */
export function compareFindingsForRanking(a: NetworkingFinding, b: NetworkingFinding): number {
const rankDiff = NETWORKING_SEVERITY_RANK[b.severity] - NETWORKING_SEVERITY_RANK[a.severity];
if (rankDiff !== 0) return rankDiff;
const aIsLive = a.sources.includes('live');
const bIsLive = b.sources.includes('live');
if (aIsLive !== bIsLive) return aIsLive ? -1 : 1;
return 0;
}
export function rankFindings(findings: NetworkingFinding[]): NetworkingFinding[] {
return [...findings].sort(compareFindingsForRanking);
}
@@ -0,0 +1,202 @@
/**
* Compose-aware topology built from the aggregate snapshot and stack facts.
* No additional Docker API calls.
*/
import type { DependencySnapshot } from '../DockerController';
import { DatabaseService } from '../DatabaseService';
import type { ExposureIntent, StackNetworkFacts } from './types';
import { isHostNetwork } from './normalize';
import { getErrorMessage } from '../../utils/errors';
import { sanitizeForLog } from '../../utils/safeLog';
import type {
NetworkingFinding,
NetworkingOwnership,
NetworkingTopology,
NetworkingTopologyContainer,
NetworkingTopologyNetwork,
} from './networkingTypes';
import { indexFindingIdsByNetwork, stacksDeclaringNetwork } from './networkingInventory';
const RUNNING_STATES = new Set(['running', 'restarting']);
function aliasMapForStack(facts: StackNetworkFacts): Map<string, Map<string, string[]>> {
const byRuntime = new Map<string, Map<string, string[]>>();
for (const service of facts.services) {
for (const membership of service.networks) {
const network = facts.networks.find((item) => item.key === membership.key);
const runtimeName = network?.name ?? membership.key;
const bucket = byRuntime.get(runtimeName) ?? new Map<string, string[]>();
for (const alias of membership.aliases) {
const list = bucket.get(alias) ?? [];
list.push(service.name);
bucket.set(alias, list);
}
byRuntime.set(runtimeName, bucket);
}
}
return byRuntime;
}
function ownershipForNetwork(net: DependencySnapshot['networks'][number]): NetworkingOwnership {
if (net.isSystem || net.ingress) return 'system';
if (net.stack) return 'sencho-managed';
if (net.composeProject) return 'compose-managed';
return 'unmanaged';
}
function aliasesForService(
aliasesByStack: Map<string, Map<string, Map<string, string[]>>>,
stack: string | null,
service: string | null,
networkName: string,
): string[] {
if (!stack || !service) return [];
const networkAliases = aliasesByStack.get(stack)?.get(networkName);
if (!networkAliases) return [];
return [...networkAliases.entries()]
.filter(([, services]) => services.includes(service))
.map(([alias]) => alias);
}
export function buildNetworkingTopology(
nodeId: number,
snapshot: DependencySnapshot | null,
stackFacts: StackNetworkFacts[],
findings: NetworkingFinding[],
includeSystem: boolean,
): NetworkingTopology {
if (!snapshot) return { networks: [], includeSystem };
const findingsByNetwork = indexFindingIdsByNetwork(findings);
const aliasesByStack = new Map(stackFacts.map((facts) => [facts.stack, aliasMapForStack(facts)]));
const intentsByStack = new Map(stackFacts.map((facts) => {
// A per-stack exposure-intent read failure must not 500 the whole topology;
// degrade to no intents for that stack (the badge simply reads "unknown").
try {
const intents = DatabaseService.getInstance().getStackExposureIntents(nodeId, facts.stack);
return [facts.stack, new Map(intents.map((intent) => [intent.service, intent.intent]))] as const;
} catch (error) {
console.warn('[Networking] Exposure intents unavailable for %s in topology:',
sanitizeForLog(facts.stack), sanitizeForLog(getErrorMessage(error, 'unknown')));
return [facts.stack, new Map<string, ExposureIntent>()] as const;
}
}));
const networks: NetworkingTopologyNetwork[] = [];
for (const net of snapshot.networks) {
if (net.isSystem && !includeSystem) continue;
const containers: NetworkingTopologyContainer[] = [];
for (const container of snapshot.containers) {
if (!RUNNING_STATES.has(container.state)) continue;
for (const attached of container.networks) {
if (attached.name !== net.name && attached.id !== net.id) continue;
containers.push({
id: container.id,
name: container.name,
ip: attached.ip,
state: container.state,
image: container.image,
stack: container.stack,
service: container.service,
composeAliases: aliasesForService(aliasesByStack, container.stack, container.service, net.name),
publishedPorts: findService(container.stack, container.service, stackFacts)?.publishedPorts ?? [],
exposureIntent: container.stack && container.service
? getExposureIntent(container.stack, container.service, intentsByStack)
: null,
findingIds: findingsByNetwork.get(net.name) ?? [],
driftFlags: getDriftFlags(container.stack, container.service, net.name, stackFacts),
hostMode: isHostNetwork(findService(container.stack, container.service, stackFacts)?.networkMode),
});
}
}
const declaredExternalByStacks = stacksDeclaringNetwork(stackFacts, net.name, true);
networks.push({
id: net.id,
name: net.name,
driver: net.driver,
scope: net.scope,
stack: net.stack,
isSystem: net.isSystem,
ingress: net.ingress === true,
enableIPv6: net.enableIPv6,
ownership: ownershipForNetwork(net),
declaredByStacks: stacksDeclaringNetwork(stackFacts, net.name),
declaredExternalByStacks,
isExternalDependency: declaredExternalByStacks.length > 0,
runtimeState: 'present',
findingIds: findingsByNetwork.get(net.name) ?? [],
containers,
});
}
const presentNames = new Set(snapshot.networks.map((network) => network.name));
for (const facts of stackFacts) {
if (!facts.renderable) continue;
for (const declared of facts.networks) {
if (!declared.external || presentNames.has(declared.name)) continue;
const existing = networks.find((network) => network.name === declared.name);
if (existing) {
if (!existing.declaredExternalByStacks.includes(facts.stack)) {
existing.declaredExternalByStacks.push(facts.stack);
existing.isExternalDependency = true;
}
continue;
}
networks.push({
id: `missing:${encodeURIComponent(declared.name)}`,
name: declared.name,
driver: 'unknown',
scope: 'unknown',
stack: null,
isSystem: false,
ingress: false,
ownership: 'unmanaged',
declaredByStacks: [facts.stack],
declaredExternalByStacks: [facts.stack],
isExternalDependency: true,
runtimeState: 'missing',
findingIds: findingsByNetwork.get(declared.name) ?? [],
containers: [],
});
}
}
return { networks, includeSystem };
}
function findService(stack: string | null, service: string | null, facts: StackNetworkFacts[]) {
return facts.find((item) => item.stack === stack)?.services.find((candidate) => candidate.name === service);
}
function getExposureIntent(
stack: string,
service: string,
intentsByStack: Map<string, Map<string, ExposureIntent>>,
): ExposureIntent | null {
const intents = intentsByStack.get(stack);
return intents?.get(service) ?? intents?.get('') ?? null;
}
function getDriftFlags(
stack: string | null,
service: string | null,
network: string,
facts: StackNetworkFacts[],
): string[] {
if (!stack) return [];
const current = facts.find((item) => item.stack === stack);
if (!current) return [];
const flags: string[] = [];
if (current.drift.runtimeOnlyAttachments.some((item) => item.network === network && item.service === service)) {
flags.push('undeclared-attachment');
}
if (current.drift.foreignNetworkAttachments.some((item) => item.network === network)) {
flags.push('foreign-attachment');
}
if (current.drift.missingFromRuntime.includes(network)) {
flags.push('missing-runtime-network');
}
return flags;
}
@@ -0,0 +1,224 @@
/**
* DTOs for the node-scoped Networking operator page. These payloads never carry
* raw Docker label values or inspect secrets; label keys only on detail views.
*/
import type { StackNetworkFacts } from './types';
export const NETWORKING_SCHEMA_VERSION = 3;
export type NetworkingOwnership = 'system' | 'sencho-managed' | 'compose-managed' | 'unmanaged';
/** Phase A row: identity and ownership from the dependency snapshot only. */
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 NetworkingExposureSummary {
publishingStackCount: number;
broadExposureCount: number;
unclassifiedStackCount: number;
}
/** Phase B row: inventory enrichment after stack facts and findings exist. */
export interface NetworkingNetworkRow extends NetworkingNetworkBase {
sharedStackCount: number;
exposureSummary: NetworkingExposureSummary | null;
findingIds: string[];
/** Compose service names attached to this network, from the dependency snapshot only (never labels). */
serviceNames: string[];
}
export const NETWORKING_FINDING_KINDS = [
'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',
// Doctor-only rules (cached, aggregated via doctorNetworkingFindings.ts)
'port-conflict-node',
'port-conflict-internal',
'sensitive-service-broad-exposure',
'exposure-port-vs-dossier',
'reverse-proxy-undocumented',
'new-network',
] as const;
export type NetworkingFindingKind = typeof NETWORKING_FINDING_KINDS[number];
export type NetworkingFindingSeverity = 'critical' | 'high' | 'medium' | 'info';
export type NetworkingFindingSource = 'live' | 'doctor';
/** One retained Doctor occurrence merged into (or standing in for) a unified finding.
* Never derived from message text; ruleId + structural fields only. */
export interface DoctorFindingMetadata {
ruleId: string;
ranAt: string;
title: string;
message: string;
service?: string;
sourcePath?: string;
remediation?: string;
/** Doctor's own severity translation, kept even when the merged card's canonical
* severity (live) differs, so provenance is never lost. */
severity: NetworkingFindingSeverity;
}
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 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[];
/** Which engine(s) detected this finding. A card merged from both engines keeps
* live severity/title/message as canonical and carries Doctor context in doctorFindings. */
sources: NetworkingFindingSource[];
/** Every retained Doctor occurrence that structurally matches this card (one-to-many:
* e.g. two broad-bind ports on one service both attach here). Empty for live-only findings. */
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 NetworkingTopologyContainer {
id: string;
name: string;
ip: string;
state: string;
image: string;
stack: string | null;
service: string | null;
composeAliases: string[];
publishedPorts: import('./types').NetworkFactPort[];
exposureIntent: import('./types').ExposureIntent | null;
findingIds: string[];
driftFlags: string[];
hostMode: boolean;
}
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 NodeNetworkingAggregate {
overview: NodeNetworkingOverview;
networks: NetworkingNetworkRow[];
findings: NetworkingFinding[];
topology?: NetworkingTopology;
stackFacts: StackNetworkFacts[];
runtimeAvailable: boolean;
recentActivity: import('../DatabaseService').NotificationHistory[];
}
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[];
/** Strict allowlist join against the DependencySnapshot; never labels, MAC addresses,
* endpoint IDs, or raw Docker options. */
connectedContainers: SanitizedNetworkInspectContainer[];
}
export interface NetworkingEnvelope {
schemaVersion: typeof NETWORKING_SCHEMA_VERSION;
runtimeAvailable: boolean;
generatedAt: string;
}
@@ -0,0 +1,76 @@
/**
* Privacy-safe network inspect DTO for the Networking page. Returns structural
* fields and label keys only; never label values or IPAM secrets.
*/
import type { DependencyNetwork, DependencySnapshot } from '../DockerController';
import type { SanitizedNetworkInspect } from './networkingTypes';
interface RawNetworkInspect {
Id?: string;
Name?: string;
Driver?: string;
Scope?: string;
Internal?: boolean;
Attachable?: boolean;
Ingress?: boolean;
EnableIPv6?: boolean;
Labels?: Record<string, string>;
IPAM?: {
Config?: Array<{ Subnet?: string; Gateway?: string }>;
};
Containers?: Record<string, unknown>;
}
export function sanitizeNetworkInspect(
raw: RawNetworkInspect,
snapshotNet: DependencyNetwork | undefined,
snapshot?: DependencySnapshot,
): SanitizedNetworkInspect {
const subnets: string[] = [];
const gateways: string[] = [];
for (const cfg of raw.IPAM?.Config ?? []) {
if (typeof cfg.Subnet === 'string' && cfg.Subnet) subnets.push(cfg.Subnet);
if (typeof cfg.Gateway === 'string' && cfg.Gateway) gateways.push(cfg.Gateway);
}
const networkId = raw.Id ?? snapshotNet?.id ?? '';
const networkName = raw.Name ?? snapshotNet?.name ?? '';
const connectedContainers = snapshot
? snapshot.containers
.filter((c) => c.networks.some((n) => n.id === networkId || n.name === networkName))
.map((c) => {
const attachment = c.networks.find((n) => n.id === networkId || n.name === networkName);
return {
name: c.name,
service: c.service,
stack: c.stack,
ipv4: attachment?.ip ? attachment.ip.replace(/\/\d+$/, '') : null,
};
})
: [];
return {
id: networkId,
name: networkName,
driver: raw.Driver ?? snapshotNet?.driver ?? 'bridge',
scope: raw.Scope ?? snapshotNet?.scope ?? 'local',
internal: raw.Internal === true,
attachable: raw.Attachable === true,
ingress: raw.Ingress === true,
enableIPv6: raw.EnableIPv6 === true,
stack: snapshotNet?.stack ?? null,
composeProject: snapshotNet?.composeProject ?? null,
connectedCount: raw.Containers ? Object.keys(raw.Containers).length : 0,
labelKeys: Object.keys(raw.Labels ?? {}).sort(),
subnets,
gateways,
connectedContainers,
};
}
export function findSnapshotNetwork(
snapshot: DependencySnapshot,
idOrName: string,
): DependencyNetwork | undefined {
return snapshot.networks.find(n => n.id === idOrName || n.name === idOrName);
}
+5
View File
@@ -666,6 +666,8 @@ const exposureUnclassified: PreflightRule = {
id: 'exposure-unclassified',
run(ctx) {
if (!ctx.model) return [];
// A read failure leaves every intent null; do not read that as "unclassified".
if (!ctx.exposureAvailable) return [];
const publishing = ctx.model.services.filter(s => s.ports.length > 0);
if (publishing.length === 0) return [];
// Fire only when a publishing service is still effectively unclassified: a
@@ -712,6 +714,9 @@ const reverseProxyUndocumented: PreflightRule = {
id: 'reverse-proxy-undocumented',
run(ctx) {
if (!ctx.model) return [];
// A read failure hides both the intent and any documented access URLs; do not
// interpret that absence as an undocumented reverse proxy.
if (!ctx.exposureAvailable) return [];
// Already documented or intentionally reverse-proxied at the stack level.
if (ctx.hasAccessUrls || ctx.stackIntent === 'reverse-proxy') return [];
const findings: PreflightFinding[] = [];
+4
View File
@@ -125,6 +125,10 @@ export interface PreflightContext {
accessUrlPorts: Set<number>;
/** Whether the dossier records any access URL (gates the port-vs-documented rule). */
hasAccessUrls: boolean;
/** False when the exposure context could not be read (a DB failure), distinct
* from a genuinely unset intent. Gates the intent-interpretation rules so a
* transient read failure does not fabricate unclassified/undocumented findings. */
exposureAvailable: boolean;
/** True when this stack is the running Sencho instance on the node. */
isSelfStack: boolean;
}
+22
View File
@@ -0,0 +1,22 @@
export async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new Error('Concurrency limit must be a positive integer');
}
const results = new Array<R>(items.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await fn(items[index], index);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
+1
View File
@@ -102,6 +102,7 @@
"features/editor",
"features/stack-file-explorer",
"features/resources",
"features/networking",
"features/dashboard",
"features/app-store",
"features/global-search",
+4
View File
@@ -165,6 +165,10 @@ To resolve exposure-related findings:
Once an intent and access URLs are set, Sencho can detect when the configuration contradicts the documented intent, making future runs more precise.
## Networking-relevant findings on the Networking page
The networking-relevant rules on this page (host mode, exposure intent, port conflicts, and the exposure-intent checks above) also surface on the node-wide [Networking](/features/networking) page's Findings tab, using the result of your last run here. A finding both engines detect is merged into one card; a finding only these Doctor rules catch appears there labeled with the run's timestamp. Acknowledging a finding here also excludes it from the Networking page.
## Node-state checks and graceful degradation
Six of the 30 rules require live Docker state to run: five are in the Node state category (external networks and volumes, new-resource notices, and container_name collision) and one is "Host port is already in use" in Port conflicts. All six are skipped when the Docker daemon is unreachable.
+4 -2
View File
@@ -171,6 +171,8 @@ When Compose Doctor is available on the active node, a prompt at the bottom of t
See [Compose Doctor](/features/compose-doctor) for the complete check set and severity levels.
A **View node networking** link near the bottom of the tab opens the node-wide [Networking](/features/networking) page, for when you want to see this stack's networks in the context of every other stack sharing them.
## Creating networks
<Frame>
@@ -180,7 +182,7 @@ See [Compose Doctor](/features/compose-doctor) for the complete check set and se
/>
</Frame>
Admins can create a new Docker network directly from the Networking tab using the **create network** button in the panel header. This is the same action available from the Resources Hub Networks tab.
Admins can create a new Docker network from the stack **Networking** tab or from the node [Networking](/features/networking) operator page using the **create network** button in the panel header.
| Field | Required | Description |
|-------|----------|-------------|
@@ -199,7 +201,7 @@ After the network is created, the Networking tab refreshes automatically. The ne
## Limitations
- **No modify or delete.** Sencho can create networks (via the create network dialog) but never modifies or deletes existing networks from this tab. Cleanup is handled by `docker compose down` or the Resources Hub prune actions.
- **No modify or delete from the stack tab.** Sencho can create networks (via the create network dialog on the stack tab or the operator Networking page) but never modifies or deletes existing networks from the stack tab. Cleanup is handled by `docker compose down`, the operator [Networking](/features/networking) page for inventory, or Resources prune actions.
- **Structural fields only.** Environment variable values and label values are never returned. Secrets interpolated into structural fields (network `name:`, ports, `extra_hosts`) are resolved and do appear.
- **Runtime drift requires Docker reachability.** When the node is not reachable, the tab shows the declared model but cannot compare against the live state.
- **Capability-gated tab.** The Networking tab only appears when the active node reports that it supports Compose Networking. Older nodes hide the tab until they are updated.
+2 -1
View File
@@ -17,6 +17,7 @@ Sencho encodes the active node, the view you are on, and the deep state that vie
| Compose editor | `/nodes/local/stacks/radarr/compose` | Radarr's compose.yaml Monaco editor |
| Env tab + file | `/nodes/local/stacks/radarr/env?env=.env.prod` | Radarr's env tab with a specific env file selected |
| Resources | `/nodes/local/resources` | Resources for the active node |
| Networking | `/nodes/local/networking` | Networking operator page for the active node |
| Security tab | `/nodes/local/security/images` | Security view on the Images tab |
| Settings section | `/nodes/local/settings/nodes` | Settings on the Nodes section |
| Fleet tab | `/nodes/local/fleet/snapshots` | Fleet on the Snapshots tab (desktop) |
@@ -81,7 +82,7 @@ On a phone, `/nodes/local/stacks/<stack>/files` opens the compose editor instead
Some in-app state is intentionally not encoded:
- **Stack Anatomy sub-tabs** (Anatomy, Activity, Dossier, Drift, Environment, and the rest) stay in memory only. Refresh returns you to the default Anatomy tab for that stack.
- **Stack Anatomy sub-tabs** (Anatomy, Activity, Dossier, Drift, Environment, and the rest) stay in memory only. Refresh returns you to the default Anatomy tab for that stack. A [Networking](/features/networking) finding's action can open a stack directly on its Doctor, Dossier, or Drift tab; this is in-app navigation, not a separate URL, so the same refresh behavior applies.
## Tips
+2 -2
View File
@@ -6,7 +6,7 @@ description: Jump to any page, node, or stack from anywhere in the app with a si
The **global search palette** lets you move around Sencho without reaching for the mouse. It covers the destinations the top bar exposes for your tier and role, every configured node, and every stack on every online node in your fleet.
<Frame>
<img src="/images/global-search/palette-pages.png" alt="Sencho global search palette open with no query, the Pages group listing Home, Fleet, Resources, App Store, Logs, and Auto-Update each with a leading icon." />
<img src="/images/global-search/palette-pages.png" alt="Sencho global search palette open with no query, the Pages group listing Home, Fleet, Resources, Networking, App Store, Logs, and Auto-Update each with a leading icon." />
</Frame>
## Opening the palette
@@ -25,7 +25,7 @@ The palette groups results into three sections.
| Group | What it contains | What happens when you pick one |
|-------|------------------|--------------------------------|
| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, App Store, and Logs always appear; Update and Schedules appear for admins; Console appears for admins when that limited-availability surface is present; Audit appears on Admiral for roles with the audit permission (the full Audit view, statistics, export, anomalies, and extended retention). Community keeps a rolling 14-day recent-activity audit API window without the Audit view in navigation. | Navigates to that page |
| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, Networking, App Store, and Logs always appear; Update and Schedules appear for admins; Console appears for admins when that limited-availability surface is present; Audit appears on Admiral for roles with the audit permission (the full Audit view, statistics, export, anomalies, and extended retention). Community keeps a rolling 14-day recent-activity audit API window without the Audit view in navigation. | Navigates to that page |
| **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page |
| **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor |
+1 -1
View File
@@ -154,7 +154,7 @@ Top-level views that manage fleet-wide state are scoped to the local hub and are
| **Logs** | Aggregates logs across the fleet. |
| **Auto-Update** | Compares image versions across all nodes in your fleet. |
Node-level views (Home, Resources, App Store, Console, the editor) work as expected against whichever node you have selected.
Node-level views (Home, Resources, Networking, App Store, Console, the editor) work as expected against whichever node you have selected.
## The Nodes table
+79
View File
@@ -0,0 +1,79 @@
---
title: Networking
description: Node-scoped Compose network inventory, topology, inspect, and findings on a dedicated operator page.
---
The **Networking** page is a first-class operator view for the active node. It answers how Docker networks on that node relate to your Sencho stacks: what exists, what is connected, where Compose intent and runtime disagree, and which issues deserve attention next.
This page is distinct from the stack editor **Networking** tab, which stays stack-scoped and models one deployment at a time. Use the operator page for node-wide inventory and hygiene; use the stack tab when you are editing or validating a single stack's compose model. See [Compose Networking](/features/compose-networking) for the stack tab.
Networking follows whichever node is selected in the top bar. Remote nodes load through the same distributed API proxy as other node views.
## Overview
The overview masthead summarizes the node at a glance and derives its posture from findings: action needed, review, contained, partial networking data, or runtime unavailable. Stat cards break down network counts by ownership (Sencho-managed, external dependency, system) alongside exposed stacks, unknown exposure, missing externals, and name collisions; each links to the relevant inventory or findings view.
The **operator attention** list ranks the top findings needing review, with a one-click primary action per row and a link to the full Findings tab. Three Compose-first sections round out the page: external network dependencies, networks shared across stacks, and services publishing without a classified exposure intent. The activity band below shows recent node-wide stack work, linked back to the owning stack.
Use **Refresh** to re-fetch live Docker state and Compose facts in one pass.
## Topology
The **Topology** tab reuses Sencho's interactive network graph: networks on top, containers below, animated edges, pan and zoom, and a mini-map.
- **Network nodes** show the network name and driver, color-coded by managed, external, or system status.
- **Container nodes** show the container name, state, stack badge, image, and IPv4 on each attachment.
- **Click-to-logs** on a running container opens its log stream.
- **Show system networks** toggles `bridge`, `host`, and `none` into the graph when you need to debug default-bridge attachments.
Data comes from `GET /api/networking/topology` on the active node. Missing external dependencies appear as a distinct graph node when Docker is available. These synthetic nodes cannot be inspected or deleted, but link back to the declaring stack.
## Networks
The **Networks** tab distinguishes runtime ownership from Compose dependency intent. Ownership identifies system, Sencho-managed, Compose-managed, or unmanaged networks. An external dependency is a separate signal: a network can be owned by a stack and still be declared as external by another stack. Filter chips cover managed, external dependencies, system, shared, exposed, and drift signals.
Click the eye icon on a row to open the detail drawer. Inspect is sanitized: structural fields and label **keys** only, never secret values interpolated into names or options.
### Create network
Admins can open **Create network** from the page header. The dialog matches the fields documented in [Compose Networking → Creating networks](/features/compose-networking#creating-networks). Creating a network here does not attach it to a stack; add it to the Compose file with `external: true` and redeploy when you are ready to use it.
## Findings
The **Findings** tab lists Compose-first networking issues Sencho derives from the effective model plus one live Docker snapshot per refresh, grouped into **Needs action**, **Review recommended**, and **Informational**. Examples include missing external networks, duplicate network names across stacks, host-network exposure mismatches, and stacks whose model could not be rendered.
Findings also fold in the networking-relevant results from your last Compose Doctor run on each stack. A finding both engines detect shows **Live · also found by Doctor**; a finding only Doctor caught (Doctor checks a few things the live engine does not, such as host port conflicts) shows **Last Doctor run** with the timestamp of that run. Doctor's contribution is always the cached result of the last run on that stack, not a fresh check, so run Doctor again on a stack if you want its findings current. An active acknowledgement on a Doctor finding excludes it here too.
Each finding includes a severity, evidence, and the actions that are appropriate for the current operator. Depending on permissions and the finding, this can open the affected stack, its Networking, Doctor, Dossier, or Drift tab, filter topology, inspect a network, create a missing network, copy a safe Compose snippet, or refresh the data.
## Resources and cleanup
Day-to-day network cleanup stays on [Resources](/features/resources): **Quick Clean → Prune Dead Networks** removes unused networks that are safe to prune under the scope you choose. The Resources page also links here when you need the full inventory or topology view.
## Deep links and search
- URL: `/nodes/local/networking` for the default local node (remote nodes use their stable slug).
- Global search (`Ctrl+K` / `Cmd+K`) lists **Networking** under **Pages** alongside Home, Resources, and the other top-level views.
## Troubleshooting
<AccordionGroup>
<Accordion title="The Networking page says it is not available on this node">
The node must expose the Compose Networking capability. Older remote agents that have not been updated show a lock card instead of the operator page. Update the node to the current Sencho version and try again.
</Accordion>
<Accordion title="I expected network list or topology under Resources">
Network inventory, topology, inspect, and findings moved to this page. Resources still handles images, volumes, unmanaged containers, reclaim, and **Prune Dead Networks**. Pick **Networking** from the top navigation to find them.
</Accordion>
<Accordion title="Findings mention a stack that failed to render">
Sencho compares live Docker state against each stack's effective Compose model. When `docker compose config` fails for a stack, that stack is listed under render failures on the overview and related findings may be incomplete until the Compose file is fixed.
</Accordion>
<Accordion title="The overview says runtime unavailable">
Sencho could not reach Docker on this node. Compose-model findings remain visible, while runtime-only inventory, drift, missing external checks, and inspect are unavailable until Docker responds again.
</Accordion>
<Accordion title="The overview says partial networking data">
The selected remote node supports the earlier networking response format. Inventory fields may be available, but Sencho does not infer a healthy posture or show enriched finding actions. Update that node to use the complete operator view.
</Accordion>
<Accordion title="A network is marked as an external dependency">
This means one or more Compose stacks expect the network to exist before deployment. It does not imply that the network is unmanaged. Review the ownership and declaring stacks together before changing or deleting it.
</Accordion>
</AccordionGroup>
+5 -1
View File
@@ -29,7 +29,11 @@ Browse, preview, and download files inside a stack's directory from the dashboar
### Resources hub
View and manage Docker images, volumes, networks, and unmanaged containers. Resources are classified as Managed, External, or Unused / Reclaimable, so cleanup decisions are visible before you prune. [Learn more →](/features/resources)
View and manage Docker images, volumes, and unmanaged containers. Resources are classified as Managed, External, or Unused / Reclaimable, so cleanup decisions are visible before you prune. Network inventory and topology live on the Networking page; Resources keeps Quick Clean prune for dead networks. [Learn more →](/features/resources)
### Networking
Browse node-wide Docker network inventory, topology, inspect, and Compose-first findings on a dedicated operator page. Distinct from the stack editor Networking tab, which stays scoped to one deployment. [Learn more →](/features/networking)
### Dashboard
+5 -74
View File
@@ -1,9 +1,9 @@
---
title: Resources Hub
description: Browse, filter, scan, and clean up Docker images, volumes, networks, and unmanaged containers from a single view.
description: Browse, filter, scan, and clean up Docker images, volumes, and unmanaged containers from a single view.
---
The **Resources** tab shows everything Docker is storing on your host, broken down by ownership, with one-click cleanup, on-demand vulnerability scanning, and a topology view of how your containers connect.
The **Resources** tab shows everything Docker is storing on your host, broken down by ownership, with one-click cleanup and on-demand vulnerability scanning.
<Frame>
<img src="/images/resources/resources-reclaim.png" alt="Resources Hub with the You can reclaim banner, Docker Disk Footprint treemap, Quick Clean panel, and the Images tab populated below" />
@@ -56,7 +56,9 @@ A confirmation dialog appears before any destructive prune. Sencho first builds
## Resource tabs
Below the hero, four tabs partition your inventory: **images**, **volumes**, **networks**, and **Unmanaged**. The Unmanaged tab shows a count badge whenever orphan containers are detected.
Below the hero, three tabs partition your inventory: **images**, **volumes**, and **Unmanaged**. The Unmanaged tab shows a count badge whenever orphan containers are detected.
Network inventory, topology, inspect, and findings live on the dedicated [Networking](/features/networking) page. **Quick Clean** still includes **Prune Dead Networks**.
A **Scan history** button sits on the right of the tab strip when image vulnerability scanning is configured for the node. It takes you to the [Security page](/features/security) History tab so you can review past results without launching a new scan. See [Vulnerability scanning](/features/vulnerability-scanning) for the full workflow.
@@ -126,77 +128,6 @@ Click the folder icon on any volume row (admin-only) to open a read-only browser
Volumes commonly contain database files, password hashes, certificates, and other secrets. Treat the volume browser as a high-power admin tool.
</Warning>
### Networks
<Frame>
<img src="/images/networks/networks-list.png" alt="Networks tab in List view with filter chips, view-mode toggle, Create Network button, and the network table" />
</Frame>
Lists all Docker networks with ID, name, driver, scope (`local`, `global`, `swarm`), and managed status. System networks (`bridge`, `host`, `none`) are shown but cannot be deleted.
**Filter chips:** `All`, `Managed`, `External`, each with its count.
A **List | Topology** view-mode toggle sits on the right of the toolbar. **List** is the default tabular view; **Topology** is a graph view of how containers connect to networks (covered below).
#### Create Network
In **List** view, the **+ Create Network** button (admin-only) opens the creation dialog.
<Frame>
<img src="/images/networks/create-network.png" alt="Create network dialog with Name, Driver, Subnet, Gateway fields, and Internal and Attachable toggles" />
</Frame>
| Field | Required | Description |
|-------|----------|-------------|
| **Name** | Yes | Alphanumeric, hyphens, underscores, and dots |
| **Driver** | No | `bridge` (default), `overlay`, `macvlan`, `host`, or `none` |
| **Subnet** | No | CIDR notation, for example `172.20.0.0/16` |
| **Gateway** | No | Gateway IP, for example `172.20.0.1` |
| **Internal** | No | Isolates the network from external access |
| **Attachable** | No | Allows containers to be manually attached |
The footer shows a **DRIVER** chip that mirrors the selected driver, and a **Create network** button that stays disabled until the form is valid.
#### Inspect network
Click the eye icon on any network row to open a detail sheet.
<Frame>
<img src="/images/networks/network-inspect.png" alt="Network inspect sheet for arr-net showing the Overview, IPAM configuration, and Connected containers sections" />
</Frame>
The sheet is grouped into sections:
- **Overview** lists the ID, driver, scope, creation date, and the internal/attachable flags.
- **IPAM configuration** shows the subnet, gateway, and IP range for each configured address pool.
- **Options** lists driver-specific options applied to the network (rendered only when present).
- **Connected** shows every container attached to the network, with its name, IPv4 address, and MAC address. The section header counts the containers.
- **Labels** lists all Docker labels on the network (rendered only when present).
#### Network topology
Switch to the **Topology** view to see an interactive graph of your Docker networks and the containers attached to them. The graph uses an automatic hierarchical layout (networks on top, containers below) that scales cleanly regardless of how many networks and containers you have.
<Frame>
<img src="/images/networks/network-topology.png" alt="Network topology graph with arr-net at top and the connected containers fanned out below it, mini-map and zoom controls visible" />
</Frame>
- **Network nodes** are dashed-border cards showing the network name and driver, color-coded by status (managed, external, system).
- **Container nodes** are cards showing the container name, a state indicator, the stack badge, the image name, and the IPv4 address per network.
- **Edges** are animated, color-coded connections between networks and their containers.
- **Click-to-logs.** Click any running container node to open its log viewer directly.
- **Refresh.** Use the refresh button in the toolbar to re-fetch the topology on demand.
- Pan, zoom, and drag nodes to explore the graph.
- A **mini-map** in the bottom-right gives an overview of the full graph. Zoom and fit-view controls sit on the bottom-left.
##### Show system networks
By default, system networks (`bridge`, `host`, `none`) are hidden so the graph stays focused on user networks. Toggle **Show system networks** to include them, which is useful for debugging containers attached to the default bridge.
<Frame>
<img src="/images/networks/network-topology-toggle.png" alt="Topology with Show system networks toggled on; arr-net plus the bridge, none, and host system networks all visible at the top" />
</Frame>
### Unmanaged
<Frame>
+57 -2
View File
@@ -72,6 +72,7 @@ const AutoUpdateReadinessView = lazy(() => import('./AutoUpdateReadinessView'));
const AppStoreView = lazy(() => import('./AppStoreView').then(m => ({ default: m.AppStoreView })));
const AuditLogView = lazy(() => import('./AuditLogView').then(m => ({ default: m.AuditLogView })));
const ResourcesView = lazy(() => import('./ResourcesView'));
const NetworkingView = lazy(() => import('./networking/NetworkingView').then(m => ({ default: m.NetworkingView })));
const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })));
export default function EditorLayout() {
@@ -312,6 +313,12 @@ export default function EditorLayout() {
pendingStackLoadRef,
pendingLogsRef,
} = stackActions;
// Pending-intent target for a cross-node "open this node's Networking page"
// request (e.g. a Fleet networking signal). Mirrors pendingStackLoadRef:
// setActiveNode first, then the node-settled effect below navigates once
// activeNode actually reflects the target, so Networking never briefly
// mounts and fetches against the previous node.
const pendingNetworkingNodeRef = useRef<number | null>(null);
const panelStartedAt = usePanelSessionStartedAt(panelState);
@@ -342,6 +349,7 @@ export default function EditorLayout() {
// Optimistically flip to the detail surface the instant a row is tapped,
// before loadFile's fetch resolves selectedFile; cleared once it settles.
const [pendingDetailStack, setPendingDetailStack] = useState<string | null>(null);
const [pendingAnatomyTab, setPendingAnatomyTab] = useState<'networking' | 'doctor' | 'dossier' | 'drift' | undefined>();
const [fleetUpdatesIntent, setFleetUpdatesIntent] = useState<{ tab: 'nodes' | 'changelog' } | null>(null);
const handleFleetUpdatesIntentConsumed = useCallback(() => setFleetUpdatesIntent(null), []);
@@ -411,6 +419,14 @@ export default function EditorLayout() {
}
}, [pendingDetailStack, detailReady, isFileLoading, stacksLoadStatus, urlHydratingStack, routeDetailError]);
useEffect(() => {
if (pendingAnatomyTab && selectedFile && !isFileLoading) {
const timer = window.setTimeout(() => setPendingAnatomyTab(undefined), 0);
return () => window.clearTimeout(timer);
}
return undefined;
}, [isFileLoading, pendingAnatomyTab, selectedFile]);
// A phone shows one surface at a time, so every mobile navigation tears down
// the current detail and switches surfaces, guarding a dirty editor first.
// `then` runs the destination-specific work (navigate to a view, open
@@ -446,9 +462,20 @@ export default function EditorLayout() {
// is already active, else stash it and switch nodes (the node-switch effect
// loads the pending stack once the registry settles). Mobile shows the
// optimistic detail surface immediately.
const handleFleetNavigateToNode = (nodeId: number, stackName: string) => {
const handleFleetNavigateToNode = (
nodeId: number,
stackName: string,
destination: SenchoOpenStackDetail['destination'] = 'stack',
) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
setPendingAnatomyTab(
destination === 'anatomy-networking' ? 'networking'
: destination === 'doctor' ? 'doctor'
: destination === 'dossier' ? 'dossier'
: destination === 'drift' ? 'drift'
: undefined,
);
if (isMobile) setPendingDetailStack(stackName);
if (activeNode?.id === nodeId) {
void stackActions.loadFile(stackName);
@@ -467,12 +494,28 @@ export default function EditorLayout() {
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<SenchoOpenStackDetail>).detail;
if (detail) openStackFromEventRef.current(detail.nodeId, detail.stackName);
if (detail) openStackFromEventRef.current(detail.nodeId, detail.stackName, detail.destination);
};
window.addEventListener(SENCHO_OPEN_STACK_EVENT, handler);
return () => window.removeEventListener(SENCHO_OPEN_STACK_EVENT, handler);
}, []);
// Open a node's Networking page from a Fleet card's networking signal.
// Pending-intent gated (see pendingNetworkingNodeRef above): if the node is
// already active, navigate immediately; otherwise switch nodes first and let
// the node-settled effect complete the navigation once activeNode reflects
// the switch.
const handleOpenNodeNetworking = (nodeId: number) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
if (activeNode?.id === nodeId) {
setActiveView('networking');
return;
}
pendingNetworkingNodeRef.current = nodeId;
setActiveNode(node);
};
// "Inspect" a node from the mobile Fleet screen: switch to it and land on its
// stack list.
const handleInspectNode = (nodeId: number) => {
@@ -536,6 +579,7 @@ export default function EditorLayout() {
const renderEditor = (headerActions?: ReactNode) => (
<EditorView
headerActions={headerActions}
requestedAnatomyTab={pendingAnatomyTab}
stackName={stackName}
isDarkMode={isDarkMode}
containers={containers}
@@ -659,6 +703,8 @@ export default function EditorLayout() {
const pendingStack = pendingStackLoadRef.current;
pendingStackLoadRef.current = null;
const pendingNetworkingNodeId = pendingNetworkingNodeRef.current;
pendingNetworkingNodeRef.current = null;
stackActions.resetEditorState();
// Stack filenames can repeat across nodes; drop the previous node's failure
@@ -667,6 +713,8 @@ export default function EditorLayout() {
if (pendingStack) {
void stackActions.loadFile(pendingStack);
} else if (pendingNetworkingNodeId === activeNode.id) {
setActiveView('networking');
} else if (isRealSwitch) {
setActiveView('dashboard');
}
@@ -892,6 +940,7 @@ export default function EditorLayout() {
}}
onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')}
onFleetNavigateToNode={handleFleetNavigateToNode}
onOpenNodeNetworking={handleOpenNodeNetworking}
filterNodeId={filterNodeId}
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
schedulePrefill={schedulePrefill}
@@ -1039,6 +1088,12 @@ export default function EditorLayout() {
<ResourcesView headerActions={mobileMastheadActions} />
</Suspense>
);
case 'networking':
return (
<Suspense fallback={lazyFallback}>
<NetworkingView headerActions={mobileMastheadActions} />
</Suspense>
);
case 'global-observability':
// Hub-only, like the desktop content path (ViewRouter); no capability gate.
return (
@@ -216,6 +216,7 @@ export interface EditorViewProps {
// Mobile-only: notifications + more-menu cluster for the detail header right
// slot (the global TopBar is dropped on the full-screen detail surface).
headerActions?: React.ReactNode;
requestedAnatomyTab?: 'networking' | 'doctor' | 'dossier' | 'drift';
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
@@ -679,6 +680,7 @@ export function EditorView(props: EditorViewProps) {
applying={loadingAction === 'update'}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
requestedTab={props.requestedAnatomyTab}
/>
)}
</div>
@@ -45,7 +45,10 @@ const AuditLogView = lazy(() =>
const ScheduledOperationsView = lazy(() => import('../ScheduledOperationsView'));
const AutoUpdateReadinessView = lazy(() => import('../AutoUpdateReadinessView'));
const SecurityView = lazy(() =>
import('../SecurityView').then(m => ({ default: m.SecurityView })),
import('../SecurityView').then(m => ({ default: m.SecurityView })),
);
const NetworkingView = lazy(() =>
import('../networking/NetworkingView').then(m => ({ default: m.NetworkingView })),
);
// Sized for the main workspace area (flex-1 with p-6 padding). Visible
@@ -81,6 +84,7 @@ export interface ViewRouterProps {
onTemplateDeploySuccess: (stackName: string) => void;
onHostConsoleClose: () => void;
onFleetNavigateToNode: (nodeId: number, stackName: string) => void;
onOpenNodeNetworking: (nodeId: number) => void;
filterNodeId: number | null;
onClearScheduledOpsFilter: () => void;
schedulePrefill: ScheduleTaskPrefill | null;
@@ -116,6 +120,7 @@ export function ViewRouter({
onTemplateDeploySuccess,
onHostConsoleClose,
onFleetNavigateToNode,
onOpenNodeNetworking,
filterNodeId,
onClearScheduledOpsFilter,
schedulePrefill,
@@ -157,6 +162,13 @@ export function ViewRouter({
if (activeView === 'resources') {
return <ResourcesView />;
}
if (activeView === 'networking') {
return (
<LazyView>
<NetworkingView />
</LazyView>
);
}
if (activeView === 'security') {
// Node-scoped (not hub-only): scan/scanner data follows the active node
// like Resources. The page itself is Community; per-tab gates handle
@@ -211,6 +223,7 @@ export function ViewRouter({
<LazyView>
<FleetView
onNavigateToNode={onFleetNavigateToNode}
onOpenNodeNetworking={onOpenNodeNetworking}
onOpenSettingsSection={onOpenSettingsSection}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
fleetUpdatesIntent={fleetUpdatesIntent}
@@ -228,6 +228,9 @@ describe('useViewNavigationState', () => {
expect(values).toContain('fleet');
expect(values).toContain('resources');
expect(values).toContain('templates');
// Networking is a base nav item; the global search palette's Pages group
// derives from this list, so it needs no separate registration.
expect(values).toContain('networking');
// Logs is an admin-only operator view; a non-admin must not see the entry.
expect(values).not.toContain('global-observability');
expect(values).not.toContain('auto-updates');
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Terminal, CloudDownload, Home, HardDrive, ScrollText,
Activity, Radar, RefreshCw, Clock, ShieldCheck,
Activity, Radar, RefreshCw, Clock, ShieldCheck, Network,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
@@ -133,6 +133,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
}
items.push(
{ value: 'resources', label: 'Resources', icon: HardDrive },
{ value: 'networking', label: 'Networking', icon: Network },
{ value: 'security', label: 'Security', icon: ShieldCheck },
{ value: 'templates', label: 'App Store', icon: CloudDownload },
);
@@ -32,7 +32,7 @@ describe('mobile treatments', () => {
// components/mobile/, or a masthead-led mobile branch of the desktop view as
// Security does).
expect([...BESPOKE_MOBILE_VIEWS].sort()).toEqual(
['audit-log', 'auto-updates', 'dashboard', 'fleet', 'global-observability', 'resources', 'scheduled-ops', 'security', 'settings', 'templates'],
['audit-log', 'auto-updates', 'dashboard', 'fleet', 'global-observability', 'networking', 'resources', 'scheduled-ops', 'security', 'settings', 'templates'],
);
});
});
@@ -22,6 +22,7 @@ export const MOBILE_TREATMENTS: Record<ActiveView, MobileTreatment> = {
settings: 'bespoke',
editor: 'detail',
resources: 'bespoke',
networking: 'bespoke',
security: 'bespoke',
templates: 'bespoke',
'global-observability': 'bespoke',
+5
View File
@@ -40,6 +40,8 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
/** Switches to a node and opens its Networking page (Fleet networking signal). */
onOpenNodeNetworking: (nodeId: number) => void;
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
onOpenSettingsSection?: (section: SectionId) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
@@ -52,6 +54,7 @@ interface FleetViewProps {
export function FleetView({
onNavigateToNode,
onOpenNodeNetworking,
onOpenSettingsSection,
onOpenMuteRulesWithPrefill,
fleetUpdatesIntent,
@@ -275,6 +278,8 @@ export function FleetView({
fleetStackLabelMap={overview.fleetStackLabelMap}
updateStatusMap={overview.updateStatusMap}
onNavigateToNode={onNavigateToNode}
onOpenNodeNetworking={onOpenNodeNetworking}
networkingByNode={overview.networkingByNode}
onUpdate={updateStatus.triggerNodeUpdate}
updatingNodeId={updateStatus.updatingNodeId}
onRetryUpdate={updateStatus.retryNodeUpdate}
+16 -1
View File
@@ -37,6 +37,10 @@ import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
export interface NodeCardProps {
node: FleetNode;
onNavigate: (nodeId: number, stackName: string) => void;
/** Switches to this node and opens its Networking page. */
onOpenNetworking?: (nodeId: number) => void;
/** Networking posture signal from computeNodeNetworkingSummary, if loaded. */
networkingSignal?: { exposed: boolean; unknown: boolean; drift: boolean };
labelMap?: Record<string, StackLabel[]>;
updateStatus?: NodeUpdateStatus;
onUpdate?: (nodeId: number) => void;
@@ -64,7 +68,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
// --- Main Export ---
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false);
@@ -255,6 +259,17 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
<Ban className="w-2.5 h-2.5 mr-0.5" /> Cordoned
</Badge>
)}
{onOpenNetworking && networkingSignal && (networkingSignal.exposed || networkingSignal.unknown || networkingSignal.drift) && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0 cursor-pointer bg-warning/10 text-warning border-warning/30 hover:bg-warning/20"
onClick={(event) => { event.stopPropagation(); onOpenNetworking(node.id); }}
title="Open this node's Networking page"
>
Networking ·
{networkingSignal.drift ? ' drift' : networkingSignal.exposed ? ' exposed' : ' unknown exposure'}
</Badge>
)}
</div>
</div>
</div>
@@ -29,6 +29,8 @@ interface OverviewTabProps {
fleetStackLabelMap: Record<number, Record<string, StackLabel[]>>;
updateStatusMap: Map<number, NodeUpdateStatus>;
onNavigateToNode: (nodeId: number, stackName: string) => void;
onOpenNodeNetworking: (nodeId: number) => void;
networkingByNode: Map<number, { exposed: boolean; unknown: boolean; drift: boolean }>;
onUpdate?: (nodeId: number) => void;
updatingNodeId: number | null;
onRetryUpdate?: (nodeId: number) => void;
@@ -65,6 +67,8 @@ export function OverviewTab({
fleetStackLabelMap,
updateStatusMap,
onNavigateToNode,
onOpenNodeNetworking,
networkingByNode,
onUpdate,
updatingNodeId,
onRetryUpdate,
@@ -143,6 +147,8 @@ export function OverviewTab({
key={node.id}
node={node}
onNavigate={onNavigateToNode}
onOpenNetworking={onOpenNodeNetworking}
networkingSignal={networkingByNode.get(node.id)}
labelMap={fleetStackLabelMap[node.id] ?? {}}
updateStatus={updateStatusMap.get(node.id)}
onUpdate={onUpdate}
@@ -128,4 +128,31 @@ describe('NodeCard', () => {
expect(screen.getByText('Pinned')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
});
it('shows the networking signal badge and switches to the node on click', async () => {
const onOpenNetworking = vi.fn();
const user = userEvent.setup();
render(
<NodeCard
{...baseProps(onlineNode())}
onOpenNetworking={onOpenNetworking}
networkingSignal={{ exposed: false, unknown: false, drift: true }}
/>,
);
const badge = screen.getByText(/Networking/);
expect(badge).toBeInTheDocument();
await user.click(badge);
expect(onOpenNetworking).toHaveBeenCalledWith(2);
});
it('hides the networking signal badge when there is nothing to flag', () => {
render(
<NodeCard
{...baseProps(onlineNode())}
onOpenNetworking={vi.fn()}
networkingSignal={{ exposed: false, unknown: false, drift: false }}
/>,
);
expect(screen.queryByText(/Networking/)).not.toBeInTheDocument();
});
});
@@ -35,6 +35,8 @@ function props(overrides: Partial<React.ComponentProps<typeof OverviewTab>> = {}
fleetStackLabelMap: {},
updateStatusMap: new Map(),
onNavigateToNode: vi.fn(),
onOpenNodeNetworking: vi.fn(),
networkingByNode: new Map(),
updatingNodeId: null,
topologyMode: 'hub' as const,
onTopologyModeChange: vi.fn(),
@@ -255,5 +255,6 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
distinctNodeLabels: distinctLabels,
activeFilterCount,
clearFilters,
networkingByNode,
};
}
+224 -87
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
ReactFlow,
Background,
@@ -22,24 +22,31 @@ import { TogglePill } from '@/components/ui/toggle-pill';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
DEFAULT_TOPOLOGY_FILTERS, filterTopologyNetworks, isMissingTopologyNetwork, normalizeTopologyResponse,
TOPOLOGY_ANIMATION_EDGE_LIMIT, TOPOLOGY_RENDER_CAP, countTopologyGraphSize,
type NetworkingTopologyFilters,
} from '@/lib/networkingTopology';
import type {
NetworkingTopologyContainerDetail, NetworkingTopologyNetwork,
} from '@/types/networking';
// ── Types ─────────────────────────────────────────────────────────────────────
interface TopologyContainer {
id: string;
type TopologyNetwork = NetworkingTopologyNetwork;
interface ContainerAggregate {
name: string;
ip: string;
attachments: { network: string; ip: string }[];
state: string;
image: string;
stack: string | null;
}
interface TopologyNetwork {
Id: string;
Name: string;
Driver: string;
managedStatus: 'managed' | 'unmanaged' | 'system';
containers: TopologyContainer[];
service: string | null;
composeAliases: string[];
publishedPorts: NetworkingTopologyContainerDetail['publishedPorts'];
exposureIntent: NetworkingTopologyContainerDetail['exposureIntent'];
findingIds: string[];
driftFlags: string[];
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -54,16 +61,34 @@ function stateColor(state: string): string {
}
}
/** Aggregates containers across networks the same way for both the cheap
* pre-layout size counter and the real dagre layout, so the render cap is
* evaluated against the same node/edge counts the graph will actually use. */
function aggregateContainers(networksList: TopologyNetwork[]): Map<string, ContainerAggregate> {
const containerMap = new Map<string, ContainerAggregate>();
for (const net of networksList) {
for (const c of net.containers) {
if (!containerMap.has(c.id)) {
containerMap.set(c.id, {
name: c.name, attachments: [],
state: c.state, image: c.image, stack: c.stack, service: c.service,
composeAliases: c.composeAliases, publishedPorts: c.publishedPorts,
exposureIntent: c.exposureIntent,
findingIds: c.findingIds, driftFlags: c.driftFlags,
});
}
containerMap.get(c.id)!.attachments.push({ network: net.name, ip: c.ip });
}
}
return containerMap;
}
// ── Custom Nodes ──────────────────────────────────────────────────────────────
interface ContainerNodeData {
interface ContainerNodeData extends ContainerAggregate {
label: string;
containerId: string;
networks: string[];
ipAddresses: Record<string, string>;
state: string;
image: string;
stack: string | null;
onStackClick?: (stack: string) => void;
}
function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
@@ -71,7 +96,6 @@ function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
<div
className={cn(
'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 min-w-[160px]',
(!data.state || data.state === 'running') && 'cursor-pointer hover:border-t-card-border-hover',
)}
>
<Handle type="target" position={Position.Top} className="!bg-muted-foreground !w-2 !h-2" />
@@ -81,15 +105,24 @@ function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
<span className="text-xs font-medium truncate max-w-[140px]">{data.label}</span>
</div>
{data.stack && (
<Badge variant="outline" className="text-[9px] h-4 px-1 font-mono mb-1">{data.stack}</Badge>
<Badge
variant="outline"
className="text-[9px] h-4 px-1 font-mono mb-1 cursor-pointer hover:bg-muted"
onClick={(event) => {
event.stopPropagation();
data.onStackClick?.(data.stack!);
}}
>
{data.stack}
</Badge>
)}
{data.networks.length > 0 && (
{data.attachments.length > 0 && (
<div className="space-y-0.5">
{data.networks.map(netName => (
<div key={netName} className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{netName}</span>
{data.attachments.map(({ network, ip }) => (
<div key={network} className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{network}</span>
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">
{data.ipAddresses[netName]?.replace(/\/\d+$/, '') || ''}
{ip?.replace(/\/\d+$/, '') || ''}
</span>
</div>
))}
@@ -103,13 +136,14 @@ function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
);
}
function NetworkNodeComponent({ data }: { data: { label: string; driver: string; status: string } }) {
const statusColor = data.status === 'managed' ? 'text-success' : data.status === 'system' ? 'text-muted-foreground' : 'text-warning';
function NetworkNodeComponent({ data }: { data: { label: string; driver: string; ownership: NetworkingTopologyNetwork['ownership']; missing: boolean; exposed: boolean; drift: boolean } }) {
const statusColor = data.ownership === 'sencho-managed' ? 'text-success' : data.ownership === 'system' ? 'text-muted-foreground' : data.missing ? 'text-destructive' : 'text-warning';
return (
<div className={cn(
'rounded-lg border-2 border-dashed px-4 py-2.5 min-w-[140px] text-center',
data.status === 'managed' ? 'border-success/30 bg-success/5' :
data.status === 'system' ? 'border-muted-foreground/20 bg-muted/20' :
data.missing ? 'border-destructive/40 bg-destructive/5 cursor-pointer' :
data.ownership === 'sencho-managed' ? 'border-success/30 bg-success/5 cursor-pointer' :
data.ownership === 'system' ? 'border-muted-foreground/20 bg-muted/20 cursor-pointer' :
'border-warning/30 bg-warning/5'
)}>
<Handle type="target" position={Position.Top} className="!bg-transparent !border-none !w-0 !h-0" />
@@ -118,6 +152,11 @@ function NetworkNodeComponent({ data }: { data: { label: string; driver: string;
<span className="text-xs font-medium">{data.label}</span>
</div>
<Badge variant="outline" className="text-[9px] h-4">{data.driver}</Badge>
{(data.exposed || data.drift || data.missing) && (
<div className="mt-1 font-mono text-[9px] uppercase text-stat-subtitle">
{data.missing ? 'missing external' : data.drift ? 'drift' : 'exposed'}
</div>
)}
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-none !w-0 !h-0" />
</div>
);
@@ -140,72 +179,62 @@ const EDGE_COLORS = [
'oklch(0.70 0.10 340)', // pink
];
// ── Layout Helper (dagre) ───────────────────────────────────────────────────
const TOPOLOGY_LEGEND: { label: string; swatchClass: string }[] = [
{ label: 'Sencho-managed', swatchClass: 'bg-success/60 border-success' },
{ label: 'External / unmanaged', swatchClass: 'bg-warning/60 border-warning' },
{ label: 'System', swatchClass: 'bg-muted-foreground/40 border-muted-foreground' },
{ label: 'Missing external', swatchClass: 'bg-destructive/60 border-destructive' },
];
// ── Layout Helper (dagre) ────────────────────────────────────────────────────
function layoutGraph(
networksList: TopologyNetwork[],
onStackClick?: (stack: string) => void,
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'TB', ranksep: 120, nodesep: 60 });
g.setDefaultEdgeLabel(() => ({}));
// Deduplicate containers across networks
const containerMap = new Map<string, {
name: string;
networks: string[];
ipAddresses: Record<string, string>;
state: string;
image: string;
stack: string | null;
}>();
for (const net of networksList) {
for (const c of net.containers) {
if (!containerMap.has(c.id)) {
containerMap.set(c.id, {
name: c.name, networks: [], ipAddresses: {},
state: c.state, image: c.image, stack: c.stack,
});
}
const entry = containerMap.get(c.id)!;
entry.networks.push(net.Name);
entry.ipAddresses[net.Name] = c.ip;
}
}
const containerMap = aggregateContainers(networksList);
// Add nodes to dagre graph
for (const net of networksList) {
g.setNode(`net-${net.Id}`, { width: 160, height: 60 });
g.setNode(`net-${net.id}`, { width: 160, height: 60 });
}
for (const [id] of containerMap) {
g.setNode(`ctr-${id}`, { width: 200, height: 100 });
}
// Add edges and collect for React Flow
const seenEdges = new Set<string>();
const edgeList: { netId: string; ctrId: string; color: string }[] = [];
networksList.forEach((net, ni) => {
const color = EDGE_COLORS[ni % EDGE_COLORS.length];
for (const c of net.containers) {
const edgeKey = `${net.Id}-${c.id}`;
const edgeKey = `${net.id}-${c.id}`;
if (!seenEdges.has(edgeKey)) {
seenEdges.add(edgeKey);
g.setEdge(`net-${net.Id}`, `ctr-${c.id}`);
edgeList.push({ netId: net.Id, ctrId: c.id, color });
g.setEdge(`net-${net.id}`, `ctr-${c.id}`);
edgeList.push({ netId: net.id, ctrId: c.id, color });
}
}
});
dagre.layout(g);
// Convert dagre positions (center-based) to React Flow positions (top-left)
const flowNodes: Node[] = [];
for (const net of networksList) {
const pos = g.node(`net-${net.Id}`);
const pos = g.node(`net-${net.id}`);
flowNodes.push({
id: `net-${net.Id}`,
id: `net-${net.id}`,
type: 'network',
position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 },
data: { label: net.Name, driver: net.Driver, status: net.managedStatus },
data: {
label: net.name, driver: net.driver, ownership: net.ownership,
missing: isMissingTopologyNetwork(net),
exposed: net.containers.some((container) => container.publishedPorts.length > 0),
drift: net.findingIds.length > 0 || net.containers.some((container) => container.findingIds.length > 0 || container.driftFlags.length > 0),
network: net,
},
draggable: true,
});
}
@@ -218,21 +247,28 @@ function layoutGraph(
data: {
label: ctr.name,
containerId: id,
networks: ctr.networks,
ipAddresses: ctr.ipAddresses,
attachments: ctr.attachments,
state: ctr.state,
image: ctr.image,
stack: ctr.stack,
service: ctr.service,
composeAliases: ctr.composeAliases,
publishedPorts: ctr.publishedPorts,
exposureIntent: ctr.exposureIntent,
findingIds: ctr.findingIds,
driftFlags: ctr.driftFlags,
onStackClick,
},
draggable: true,
});
}
const animated = edgeList.length <= TOPOLOGY_ANIMATION_EDGE_LIMIT;
const flowEdges: Edge[] = edgeList.map(({ netId, ctrId, color }) => ({
id: `edge-${netId}-${ctrId}`,
source: `net-${netId}`,
target: `ctr-${ctrId}`,
animated: true,
animated,
style: { stroke: color, strokeWidth: 1.5 },
}));
@@ -242,41 +278,112 @@ function layoutGraph(
// ── Main Component ────────────────────────────────────────────────────────────
interface NetworkTopologyViewProps {
onContainerClick?: (containerId: string, containerName: string) => void;
onContainerSelect?: (container: NetworkingTopologyContainerDetail) => void;
onNetworkClick?: (network: NetworkingTopologyNetwork) => void;
onStackClick?: (stack: string) => void;
/** API path for topology data. Defaults to the Resources maintenance route. */
endpoint?: string;
/** When false, hides the include-system toggle (caller controls scope). */
showSystemToggle?: boolean;
/** Controlled include-system value when showSystemToggle is false. */
includeSystem?: boolean;
filters?: NetworkingTopologyFilters;
}
export default function NetworkTopologyView({ onContainerClick }: NetworkTopologyViewProps) {
export default function NetworkTopologyView({
onContainerSelect,
onNetworkClick,
onStackClick,
endpoint = '/system/networks/topology',
showSystemToggle = true,
includeSystem: controlledIncludeSystem,
filters,
}: NetworkTopologyViewProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [loading, setLoading] = useState(true);
const [includeSystem, setIncludeSystem] = useState(false);
const onContainerClickRef = useRef(onContainerClick);
onContainerClickRef.current = onContainerClick;
const [internalIncludeSystem, setInternalIncludeSystem] = useState(false);
const includeSystem = controlledIncludeSystem ?? internalIncludeSystem;
const onContainerSelectRef = useRef(onContainerSelect);
onContainerSelectRef.current = onContainerSelect;
const onNetworkClickRef = useRef(onNetworkClick);
onNetworkClickRef.current = onNetworkClick;
const onStackClickRef = useRef(onStackClick);
onStackClickRef.current = onStackClick;
const [runtimeAvailable, setRuntimeAvailable] = useState(true);
const [loadError, setLoadError] = useState(false);
// Raw (unfiltered) networks from the last fetch; filters are applied
// client-side so toggling them never triggers a new request (Workstream J).
const [rawNetworks, setRawNetworks] = useState<TopologyNetwork[]>([]);
const [overCap, setOverCap] = useState(false);
const fetchTopology = useCallback(async () => {
setLoading(true);
setLoadError(false);
try {
const res = await apiFetch(`/system/networks/topology?includeSystem=${includeSystem}`);
const res = await apiFetch(`${endpoint}?includeSystem=${includeSystem}`);
if (!res.ok) throw new Error('Failed to fetch topology');
const inspected = await res.json();
const { nodes: layoutNodes, edges: layoutEdges } = layoutGraph(inspected);
setNodes(layoutNodes);
setEdges(layoutEdges);
const inspected: unknown = await res.json();
const topology = normalizeTopologyResponse(inspected);
setRuntimeAvailable(topology.runtimeAvailable);
setRawNetworks(topology.networks);
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
setLoadError(true);
setRawNetworks([]);
} finally {
setLoading(false);
}
}, [setNodes, setEdges, includeSystem]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [includeSystem, endpoint]);
useEffect(() => { fetchTopology(); }, [fetchTopology]);
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
if (node.type === 'container' && (!node.data.state || node.data.state === 'running')) {
onContainerClickRef.current?.(node.data.containerId as string, node.data.label as string);
// Filters, the cheap size count, the cap check, and layout all run client-side
// against the cached raw response (no refetch per filter/search keystroke).
const networksList = useMemo(() => filterTopologyNetworks(
rawNetworks.filter((network) => includeSystem || !network.isSystem),
filters ?? DEFAULT_TOPOLOGY_FILTERS,
), [rawNetworks, includeSystem, filters]);
useEffect(() => {
const size = countTopologyGraphSize(networksList);
if (size.nodeCount + size.edgeCount > TOPOLOGY_RENDER_CAP) {
setOverCap(true);
setNodes([]);
setEdges([]);
return;
}
setOverCap(false);
const { nodes: layoutNodes, edges: layoutEdges } = layoutGraph(networksList, onStackClickRef.current);
setNodes(layoutNodes);
setEdges(layoutEdges);
}, [networksList, setNodes, setEdges]);
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
if (node.type === 'network') {
onNetworkClickRef.current?.(node.data.network as NetworkingTopologyNetwork);
}
if (node.type === 'container') {
onContainerSelectRef.current?.({
id: node.data.containerId as string,
name: node.data.label as string,
attachments: node.data.attachments as { network: string; ip: string }[],
state: node.data.state as string,
image: node.data.image as string,
stack: node.data.stack as string | null,
service: node.data.service as string | null,
composeAliases: node.data.composeAliases as string[],
publishedPorts: node.data.publishedPorts as NetworkingTopologyContainerDetail['publishedPorts'],
exposureIntent: node.data.exposureIntent as NetworkingTopologyContainerDetail['exposureIntent'],
findingIds: node.data.findingIds as string[],
driftFlags: node.data.driftFlags as string[],
});
}
// Node click opens the drawer only; viewing logs is an explicit action
// inside the container drawer (previously this also auto-opened logs,
// which raced the drawer for the user's attention).
}, []);
if (loading) {
@@ -288,15 +395,33 @@ export default function NetworkTopologyView({ onContainerClick }: NetworkTopolog
);
}
if (overCap) {
return (
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground gap-3">
<Network className="w-8 h-8 opacity-40" strokeWidth={1.5} />
<p className="text-sm">This graph is too large to render.</p>
<p className="text-xs opacity-70">Narrow the filters to bring it under {TOPOLOGY_RENDER_CAP} nodes and edges, or use the Networks table instead.</p>
</div>
);
}
if (nodes.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground gap-3">
<Network className="w-8 h-8 opacity-40" strokeWidth={1.5} />
<p className="text-sm">
{includeSystem ? 'No networks found.' : 'No user-created networks found.'}
{loadError
? 'Could not load topology for this node.'
: !runtimeAvailable
? 'Docker runtime unavailable.'
: includeSystem
? 'No networks found.'
: 'No user-created networks found.'}
</p>
<p className="text-xs opacity-70">
{includeSystem
{!runtimeAvailable
? 'Topology is unavailable until Docker responds on this node.'
: includeSystem
? 'No Docker networks are available on this node.'
: 'Create a network or deploy stacks with custom networks to see the topology.'}
</p>
@@ -306,11 +431,23 @@ export default function NetworkTopologyView({ onContainerClick }: NetworkTopolog
return (
<div className="rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-card-border">
<TogglePill id="show-system" checked={includeSystem} onChange={setIncludeSystem} />
<Label htmlFor="show-system" className="text-xs cursor-pointer">
Show system networks
</Label>
<div className="flex flex-wrap items-center gap-3 px-3 py-2 border-b border-card-border">
{showSystemToggle && (
<>
<TogglePill id="show-system" checked={includeSystem} onChange={setInternalIncludeSystem} />
<Label htmlFor="show-system" className="text-xs cursor-pointer">
Show system networks
</Label>
</>
)}
<div className="flex flex-wrap items-center gap-2">
{TOPOLOGY_LEGEND.map((entry) => (
<span key={entry.label} className="flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
<span className={cn('h-2 w-2 rounded-full border', entry.swatchClass)} />
{entry.label}
</span>
))}
</div>
<Button
variant="ghost"
size="icon"
+1 -1
View File
@@ -30,7 +30,7 @@ interface NodeSchedulingSummary {
export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
export interface SenchoNavigateDetail {
view: 'scheduled-ops' | 'auto-updates' | 'security' | 'fleet';
view: 'scheduled-ops' | 'auto-updates' | 'security' | 'fleet' | 'networking' | 'resources';
nodeId?: number;
/** Target tab when navigating to the Security view. */
tab?: SecurityTab;
+5 -251
View File
@@ -14,34 +14,25 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen, Search } from 'lucide-react';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Eye, Loader2, History, FolderOpen, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { SeverityBadge } from '@/components/ui/SeverityBadge';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager';
import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events';
import type { ScanSummary } from '@/types/security';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { CapabilityGate } from './CapabilityGate';
import LazyBoundary from './LazyBoundary';
import { formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events';
import type { SenchoOpenStackDetail } from '@/lib/events';
import type { SenchoOpenLogsDetail } from '@/lib/events';
import { lazy, Suspense } from 'react';
import { ReclaimHero } from './resources/ReclaimHero';
import { FootprintTreemap } from './resources/FootprintTreemap';
import { ImageDetailsSheet } from './resources/ImageDetailsSheet';
import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet';
import { VolumeNameLabel } from './resources/VolumeNameLabel';
import { CreateNetworkDialog } from './resources/CreateNetworkDialog';
import { useTableSort } from '@/hooks/useTableSort';
import { SortableTableHead } from '@/components/ui/sortable-table';
import { NetworkDetailSheet, type NetworkInspectData } from './resources/NetworkDetailSheet';
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
// ── Interfaces ─────────────────────────────────────────────────────────────────
@@ -98,8 +89,6 @@ interface UnmanagedContainer {
Image: string;
}
// NetworkInspectData is re-exported from ./resources/NetworkDetailSheet
type ResourceFilter = 'all' | 'managed' | 'unmanaged';
type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes';
type PruneScope = 'managed' | 'all';
@@ -385,11 +374,6 @@ const VOLUME_COMPARATORS: Record<'name' | 'driver', (a: DockerVolume, b: DockerV
name: (a, b) => a.Name.localeCompare(b.Name),
driver: (a, b) => a.Driver.localeCompare(b.Driver),
};
const NETWORK_COMPARATORS: Record<'name' | 'driver' | 'scope', (a: DockerNetwork, b: DockerNetwork) => number> = {
name: (a, b) => a.Name.localeCompare(b.Name),
driver: (a, b) => a.Driver.localeCompare(b.Driver),
scope: (a, b) => a.Scope.localeCompare(b.Scope),
};
// ── Main Component ─────────────────────────────────────────────────────────────
@@ -400,10 +384,9 @@ interface ResourcesViewProps {
export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) {
const isMobile = useIsMobile();
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'networks' | 'unmanaged'>('images');
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images');
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list');
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
@@ -416,23 +399,18 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
// Filter state
const [imageFilter, setImageFilter] = useState<ResourceFilter>('all');
const [volumeFilter, setVolumeFilter] = useState<ResourceFilter>('all');
const [networkFilter, setNetworkFilter] = useState<ResourceFilter>('all');
// Search state
const [imageSearch, setImageSearch] = useState('');
const [volumeSearch, setVolumeSearch] = useState('');
const [networkSearch, setNetworkSearch] = useState('');
// Collapsible search: icon-only until clicked, stays open while query is active.
const [imageSearchExpanded, setImageSearchExpanded] = useState(false);
const [volumeSearchExpanded, setVolumeSearchExpanded] = useState(false);
const [networkSearchExpanded, setNetworkSearchExpanded] = useState(false);
const imageSearchRef = useRef<HTMLInputElement>(null);
const volumeSearchRef = useRef<HTMLInputElement>(null);
const networkSearchRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (imageSearchExpanded) imageSearchRef.current?.focus(); }, [imageSearchExpanded]);
useEffect(() => { if (volumeSearchExpanded) volumeSearchRef.current?.focus(); }, [volumeSearchExpanded]);
useEffect(() => { if (networkSearchExpanded) networkSearchRef.current?.focus(); }, [networkSearchExpanded]);
// Modal states
const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null);
@@ -448,10 +426,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const [reclaimHeroEnabled, setReclaimHeroEnabled] = useState(true);
const [heroDismissedBytes, setHeroDismissedBytes] = useState<number | null>(null);
// Network create/inspect state
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
const [inspectNetwork, setInspectNetwork] = useState<NetworkInspectData | null>(null);
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
// Classified image selection is node-bound so a node switch cannot leave
// the previous node's usedByStacks visible beside a new node's inspect.
const [inspectImage, setInspectImage] = useState<(DockerImage & { nodeId: string | number }) | null>(null);
@@ -774,22 +748,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
}
};
const handleInspectNetwork = async (id: string) => {
setInspectLoadingId(id);
try {
const res = await apiFetch(`/system/networks/${id}`);
if (!res.ok) throw new Error('Failed to inspect network');
const data = await res.json();
setInspectNetwork(data);
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
} finally {
setInspectLoadingId(null);
}
};
// Derived filtered lists
const filteredImages = images.filter(img =>
(imageFilter === 'managed' ? img.managedStatus === 'managed' :
imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true) &&
@@ -800,16 +758,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true) &&
(volumeSearch === '' || vol.Name.toLowerCase().includes(volumeSearch.toLowerCase()))
);
const filteredNetworks = networks.filter(net =>
(networkFilter === 'managed' ? net.managedStatus === 'managed' :
networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true) &&
(networkSearch === '' || net.Name.toLowerCase().includes(networkSearch.toLowerCase()))
);
// Sortable tables (standard sort behavior across the resource tables).
const imageSort = useTableSort(filteredImages, IMAGE_COMPARATORS, 'repo');
const volumeSort = useTableSort(filteredVolumes, VOLUME_COMPARATORS, 'name');
const networkSort = useTableSort(filteredNetworks, NETWORK_COMPARATORS, 'name');
const handleFootprintFilter = (filter: ResourceFilter) => {
setImageFilter(filter);
@@ -991,7 +942,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
tabs={[
{ value: 'images', label: 'Images', count: images.length },
{ value: 'volumes', label: 'Volumes', count: volumes.length },
{ value: 'networks', label: 'Networks', count: networks.length },
{ value: 'unmanaged', label: 'Unmanaged', count: totalOrphansCount },
]}
/>
@@ -999,12 +949,12 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<div className="flex items-center gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
{(['images', 'volumes', 'networks'] as const).map(tab => (
{(['images', 'volumes'] as const).map(tab => (
<TabsHighlightItem key={tab} value={tab}>
<TabsTrigger value={tab}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">
{tab === 'images' ? images.length : tab === 'volumes' ? volumes.length : networks.length}
{tab === 'images' ? images.length : volumes.length}
</span>
</TabsTrigger>
</TabsHighlightItem>
@@ -1331,188 +1281,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</div>
</TabsContent>
{/* Networks */}
<TabsContent value="networks" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<div className="flex flex-wrap items-center gap-2 mb-4">
{networkViewMode === 'list' ? (
<>
{networkSearch !== '' || networkSearchExpanded ? (
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={networkSearchRef}
placeholder="Search networks..."
value={networkSearch}
onChange={(e) => setNetworkSearch(e.target.value)}
onBlur={() => { if (networkSearch === '') setNetworkSearchExpanded(false); }}
className="pl-9 h-9"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setNetworkSearchExpanded(true)} aria-label="Search networks">
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search networks</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle
value={networkFilter}
onChange={setNetworkFilter}
counts={{
all: networks.length,
managed: networks.filter(n => n.managedStatus === 'managed').length,
unmanaged: networks.filter(n => n.managedStatus !== 'managed').length,
}}
/>
</>
) : (
<span aria-hidden="true" />
)}
<div className="flex-1" />
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
<button
onClick={() => setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
</button>
<button
onClick={() => setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
</button>
</div>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setShowCreateNetwork(true)}
>
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
Create Network
</Button>
)}
</div>
</div>
{networkViewMode === 'topology' ? (
<div className="p-4">
<CapabilityGate capability="network-topology" featureName="Network Topology">
<LazyBoundary>
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView
key={activeNode?.id}
onContainerClick={(id, name) => {
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
detail: { containerId: id, containerName: name },
}));
}}
/>
</Suspense>
</LazyBoundary>
</CapabilityGate>
</div>
) : (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[62vh]">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[120px] text-[11px]">ID</TableHead>
<SortableTableHead label="Name" columnKey="name" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<SortableTableHead label="Driver" columnKey="driver" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<SortableTableHead label="Scope" columnKey="scope" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<TableHead className="text-[11px]">Status</TableHead>
<TableHead className="text-right text-[11px]">Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading ? <TableSkeleton cols={6} /> : (
<TableBody>
{networkSort.sorted.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center py-8 text-muted-foreground text-sm">No networks found.</TableCell></TableRow>
) : networkSort.sorted.map((net, i) => (
<TableRow
key={net.Id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
>
<TableCell className="font-mono text-xs text-muted-foreground">{net.Id.substring(0, 12)}</TableCell>
<TableCell className="font-medium max-w-[200px] truncate">{net.Name}</TableCell>
<TableCell className="text-xs">{net.Driver}</TableCell>
<TableCell><Badge variant="outline" className="text-[10px] h-5">{net.Scope}</Badge></TableCell>
<TableCell>
<div className="flex items-center gap-1.5 flex-wrap">
<ManagedBadge
status={net.managedStatus}
managedBy={net.managedBy}
onOpenStack={activeNode ? (stack) => window.dispatchEvent(
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }),
) : undefined}
/>
{net.isSencho && <SenchoBadge />}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-foreground transition-colors"
disabled={inspectLoadingId !== null}
onClick={() => handleInspectNetwork(net.Id)}
>
{inspectLoadingId === net.Id ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : <Eye className="w-3.5 h-3.5" strokeWidth={1.5} />}
</Button>
{isAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={net.isSencho ? 'cursor-not-allowed' : undefined}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
disabled={net.managedStatus === 'system' || net.isSencho}
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</span>
</TooltipTrigger>
{net.isSencho && <TooltipContent>Protected · running Sencho instance</TooltipContent>}
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
</ScrollArea>
</div>
)}
</TabsContent>
{/* Unmanaged Containers */}
<TabsContent value="unmanaged" className="m-0 border-0 p-0 h-full flex flex-col animate-in fade-in-0 duration-200">
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
@@ -1714,13 +1482,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</p>
</ConfirmModal>
{/* Create Network Modal */}
<CreateNetworkDialog
open={showCreateNetwork}
onOpenChange={setShowCreateNetwork}
onCreated={fetchAllData}
/>
{/* Image Details Sheet */}
<ImageDetailsSheet
image={inspectImage && activeNode && inspectImage.nodeId === activeNode.id ? inspectImage : null}
@@ -1733,13 +1494,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
{/* Volume Browser Sheet */}
<VolumeBrowserSheet volumeName={browseVolume} onClose={() => setBrowseVolume(null)} />
{/* Network detail sheet */}
<NetworkDetailSheet
network={inspectNetwork}
onClose={() => setInspectNetwork(null)}
/>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
+13 -1
View File
@@ -34,6 +34,7 @@ interface StackAnatomyPanelProps {
canEdit: boolean;
applying?: boolean;
notifications?: NotificationItem[];
requestedTab?: 'networking' | 'doctor' | 'dossier' | 'drift';
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
@@ -99,6 +100,7 @@ export default function StackAnatomyPanel({
canEdit,
applying = false,
notifications,
requestedTab,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
@@ -138,6 +140,16 @@ export default function StackAnatomyPanel({
attemptedAt?: number;
errorMessage?: string | null;
} | null>(null);
const [activeTab, setActiveTab] = useState('anatomy');
useEffect(() => {
if (requestedTab === 'networking' && networkingEnabled) setActiveTab('networking');
if (requestedTab === 'doctor' && doctorEnabled) setActiveTab('doctor');
// Dossier and Drift are unconditional base tabs (no capability gate), unlike
// Doctor/Networking which require a node capability.
if (requestedTab === 'dossier') setActiveTab('dossier');
if (requestedTab === 'drift') setActiveTab('drift');
}, [doctorEnabled, networkingEnabled, requestedTab]);
const { dismissed: scanBannerDismissed, dismiss: dismissScanBanner } =
useScanBannerDismiss(stackName, activeNode?.id, scanStatus);
@@ -397,7 +409,7 @@ export default function StackAnatomyPanel({
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<Tabs defaultValue="anatomy" className="flex flex-col h-full min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
<ScrollableTabRow surface="card" wrapperClassName="min-w-0 flex-1">
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
+15 -3
View File
@@ -59,7 +59,14 @@ export function TopBar({
{/* NAV ZONE: Navigation (hidden on mobile) */}
<TooltipProvider delayDuration={300} disableHoverableContent>
<nav aria-label="Primary" className="hidden md:flex self-stretch items-stretch">
<nav
aria-label="Primary"
className={cn(
'hidden md:flex self-stretch items-stretch',
showLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
!showLabels && centered && 'shrink-0',
)}
>
{navItems.map(({ value, label, icon: Icon }) => {
const isActive = activeView === value;
const button = (
@@ -68,7 +75,7 @@ export function TopBar({
aria-label={label}
aria-current={isActive ? 'page' : undefined}
className={cn(
'relative inline-flex h-full items-center gap-2 px-4',
'relative inline-flex h-full shrink-0 items-center gap-2 px-4',
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
@@ -99,7 +106,12 @@ export function TopBar({
</TooltipProvider>
{/* RIGHT ZONE: Utilities + identity pin */}
<div className="flex flex-1 min-w-0 items-center justify-end gap-2">
<div
className={cn(
'flex items-center justify-end gap-2',
centered ? 'flex-1 min-w-0' : showLabels ? 'relative z-10 shrink-0' : 'flex-1 min-w-0',
)}
>
{search}
{themeSwitch}
{notifications}
@@ -106,14 +106,14 @@ describe('FleetView experimental discovery', () => {
});
it('shows Routing and Secrets when experimental discovery is on for paid admin', () => {
render(<FleetView onNavigateToNode={vi.fn()} />);
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy();
expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy();
});
it('hides Routing and Secrets when experimental discovery is off', () => {
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
render(<FleetView onNavigateToNode={vi.fn()} />);
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull();
expect(screen.queryByRole('tab', { name: /secrets/i })).toBeNull();
expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy();
@@ -127,6 +127,7 @@ describe('FleetView experimental discovery', () => {
const { rerender } = render(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -138,6 +139,7 @@ describe('FleetView experimental discovery', () => {
rerender(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -152,6 +154,7 @@ describe('FleetView experimental discovery', () => {
const { rerender } = render(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -162,6 +165,7 @@ describe('FleetView experimental discovery', () => {
rerender(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -60,7 +60,6 @@ vi.mock('../resources/ReclaimHero', () => ({
vi.mock('../resources/FootprintTreemap', () => ({ FootprintTreemap: () => null }));
vi.mock('../resources/ImageDetailsSheet', () => ({ ImageDetailsSheet: () => null }));
vi.mock('../resources/VolumeBrowserSheet', () => ({ VolumeBrowserSheet: () => null }));
vi.mock('../resources/NetworkDetailSheet', () => ({ NetworkDetailSheet: () => null }));
vi.mock('../NetworkTopologyView', () => ({ default: () => null }));
vi.mock('../CapabilityGate', () => ({ CapabilityGate: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
vi.mock('../LazyBoundary', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Badge } from '@/components/ui/badge';
import { Container } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { NetworkingEnvelope, SanitizedNetworkInspect } from '@/types/networking';
export function NetworkDetailDrawer({
networkId,
onClose,
}: {
networkId: string | null;
onClose: () => void;
}) {
const [detail, setDetail] = useState<SanitizedNetworkInspect | null>(null);
const [loading, setLoading] = useState(false);
const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
useEffect(() => {
if (!networkId) {
setDetail(null);
setRuntimeUnavailable(false);
return;
}
let cancelled = false;
setDetail(null);
setRuntimeUnavailable(false);
const run = async () => {
setLoading(true);
try {
const res = await apiFetch(`/networking/networks/${encodeURIComponent(networkId)}`);
if (res.status === 503) {
if (!cancelled) setRuntimeUnavailable(true);
return;
}
if (!res.ok) throw new Error('inspect failed');
const body = await res.json() as NetworkingEnvelope & { network: Partial<SanitizedNetworkInspect> };
if (!cancelled) {
// An older remote may omit the newer array fields; backfill every array
// the drawer renders so a partial shape cannot crash the component.
const n = body.network;
setDetail({
...n,
labelKeys: n.labelKeys ?? [],
subnets: n.subnets ?? [],
gateways: n.gateways ?? [],
connectedContainers: n.connectedContainers ?? [],
} as SanitizedNetworkInspect);
}
} catch (error) {
if (!cancelled) {
console.error('[Networking] Failed to inspect network:', error);
toast.error('Failed to load network details.');
}
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [networkId]);
// connectedContainers is always an array after backfill; fall back to the count
// field so an older remote (empty array, populated count) still shows the total.
const containerCount = detail ? (detail.connectedContainers.length || detail.connectedCount) : 0;
const meta = detail
? `${detail.driver} · ${detail.scope}${detail.subnets[0] ? ` · ${detail.subnets[0]}` : ''} · ${containerCount} container${containerCount === 1 ? '' : 's'}`
: '';
return (
<SystemSheet
open={networkId !== null}
onOpenChange={(open) => { if (!open) onClose(); }}
crumb={['Networking', 'Networks', detail?.name ?? '—']}
name={detail?.name ?? 'Network'}
meta={meta}
size="md"
>
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
{runtimeUnavailable && (
<p className="text-sm text-warning">
Docker runtime is unavailable, so this network cannot be inspected.
</p>
)}
{detail && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground">Driver</span>
<span className="mt-0.5 block text-xs">
<Badge variant="outline" className="h-5 text-[10px]">{detail.driver}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Scope</span>
<span className="mt-0.5 block text-xs">
<Badge variant="outline" className="h-5 text-[10px]">{detail.scope}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Internal</span>
<p className="mt-0.5 text-xs">{detail.internal ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Attachable</span>
<p className="mt-0.5 text-xs">{detail.attachable ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Stack</span>
<p className="mt-0.5 text-xs">{detail.stack ?? 'none'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Compose project</span>
<p className="mt-0.5 text-xs">{detail.composeProject ?? 'none'}</p>
</div>
<div className="col-span-2">
<span className="text-xs text-muted-foreground">Label keys</span>
<p className="mt-0.5 font-mono text-xs">{detail.labelKeys.length ? detail.labelKeys.join(', ') : 'none'}</p>
</div>
</div>
</SheetSection>
{(detail.subnets.length > 0 || detail.gateways.length > 0) && (
<SheetSection title="IPAM configuration">
<div className="divide-y divide-card-border/40">
{detail.subnets.map((subnet, i) => (
<div key={subnet} className="flex items-center justify-between gap-2 py-2">
<span className="text-xs text-muted-foreground">Subnet</span>
<span className="font-mono text-xs tabular-nums">{subnet}</span>
{detail.gateways[i] && (
<>
<span className="text-xs text-muted-foreground">Gateway</span>
<span className="font-mono text-xs tabular-nums">{detail.gateways[i]}</span>
</>
)}
</div>
))}
</div>
</SheetSection>
)}
<SheetSection title="Connected" meta={`${containerCount} container${containerCount === 1 ? '' : 's'}`}>
{containerCount === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">No containers connected to this network.</p>
) : (
<div className="divide-y divide-card-border/40">
{detail.connectedContainers.map((c) => (
<div key={c.name} className="space-y-1 py-2">
<div className="flex items-center gap-2">
<Container className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
<span className="truncate text-sm font-medium">{c.name}</span>
{c.stack && <Badge variant="outline" className="h-4 px-1 font-mono text-[9px]">{c.stack}</Badge>}
</div>
<div className="grid grid-cols-2 gap-2 pl-5">
<div>
<span className="text-[10px] text-muted-foreground">Service</span>
<p className="font-mono text-xs">{c.service ?? 'none'}</p>
</div>
<div>
<span className="text-[10px] text-muted-foreground">IPv4</span>
<p className="font-mono text-xs tabular-nums">{c.ipv4 ?? 'N/A'}</p>
</div>
</div>
</div>
))}
</div>
)}
</SheetSection>
</>
)}
</SystemSheet>
);
}
@@ -0,0 +1,336 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { SortableTableHead } from '@/components/ui/sortable-table';
import { useTableSort } from '@/hooks/useTableSort';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Eye, Search, Trash2, ExternalLink, GitBranch } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { filterNetworkRows, rowHasDriftFinding, type NetworkFilter } from '@/lib/networking';
import { type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOwnership } from '@/types/networking';
export type { NetworkingNetworkRow } from '@/types/networking';
const OWNERSHIP_BADGE_CLASS: Record<NetworkingOwnership, string> = {
'sencho-managed': 'border-success/40 text-success',
'compose-managed': 'border-brand/40 text-brand',
unmanaged: 'border-warning/40 text-warning',
system: 'border-muted-foreground/30 text-muted-foreground',
};
const OWNERSHIP_LABEL: Record<NetworkingOwnership, string> = {
'sencho-managed': 'Sencho-managed',
'compose-managed': 'Compose-managed',
unmanaged: 'Unmanaged',
system: 'System',
};
function OwnershipBadge({ ownership }: { ownership: NetworkingOwnership }) {
return (
<Badge variant="outline" className={cn('h-5 text-[10px] font-mono', OWNERSHIP_BADGE_CLASS[ownership])}>
{OWNERSHIP_LABEL[ownership]}
</Badge>
);
}
const COLUMN_COUNT = 9;
function NetworkTableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<TableBody>
{Array.from({ length: rows }).map((_, r) => (
<TableRow key={r} className="animate-in fade-in-0" style={{ animationDelay: `${r * 40}ms` }}>
{Array.from({ length: COLUMN_COUNT }).map((_, c) => (
<TableCell key={c}>
<Skeleton className={cn('h-4', c === 0 ? 'w-32' : c === 3 ? 'w-24' : 'w-10')} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
);
}
/** A network is unsafe to delete without a pre-confirm explanation when it has
* connected containers or is declared by a stack's Compose file; the backend
* 409-guards these, but the UI should explain BEFORE the confirm dialog rather
* than let a generic confirmation surprise the user with a rejection. */
function deleteBlockReason(row: NetworkingNetworkRow): string | null {
if (row.connectedCount > 0) {
return `Connected to ${row.connectedCount} container${row.connectedCount === 1 ? '' : 's'}; disconnect them first.`;
}
if (row.declaredByStacks.length > 0) {
return `Declared by ${row.declaredByStacks.join(', ')}; remove the declaration first.`;
}
return null;
}
const FILTER_OPTIONS: { key: NetworkFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'managed', label: 'Managed' },
{ key: 'external', label: 'External dep.' },
{ key: 'system', label: 'System' },
{ key: 'shared', label: 'Shared' },
{ key: 'exposed', label: 'Exposed' },
{ key: 'drift', label: 'Drift' },
];
function FilterToggle({
value,
onChange,
counts,
}: {
value: NetworkFilter;
onChange: (v: NetworkFilter) => void;
counts: Record<NetworkFilter, number>;
}) {
return (
<div className="flex items-center gap-1">
{FILTER_OPTIONS.map(({ key, label }) => (
<Button
key={key}
type="button"
variant={value === key ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5 gap-1.5"
onClick={() => onChange(key)}
>
{label}
<span className="font-mono tabular-nums text-[10px] opacity-70">{counts[key]}</span>
</Button>
))}
</div>
);
}
function countNetworkFilters(rows: NetworkingNetworkRow[], findingKindById: Map<string, NetworkingFindingKind>): Record<NetworkFilter, number> {
const counts: Record<NetworkFilter, number> = {
all: rows.length,
managed: 0,
external: 0,
system: 0,
shared: 0,
exposed: 0,
drift: 0,
};
for (const row of rows) {
if (row.ownership === 'sencho-managed') counts.managed += 1;
if (row.isExternalDependency) counts.external += 1;
if (row.ownership === 'system') counts.system += 1;
if (row.sharedStackCount > 1) counts.shared += 1;
if (row.exposureSummary?.broadExposureCount) counts.exposed += 1;
if (rowHasDriftFinding(row, findingKindById)) counts.drift += 1;
}
return counts;
}
// Stable comparator map (module scope so useTableSort does not re-sort every
// render), mirroring the Resources image/volume table sort standard.
type NetworkSortKey = 'name' | 'driver' | 'scope' | 'ownership' | 'shared' | 'exposure' | 'findings' | 'connected';
const NETWORK_COMPARATORS: Record<NetworkSortKey, (a: NetworkingNetworkRow, b: NetworkingNetworkRow) => number> = {
name: (a, b) => a.name.localeCompare(b.name),
driver: (a, b) => a.driver.localeCompare(b.driver),
scope: (a, b) => a.scope.localeCompare(b.scope),
ownership: (a, b) => a.ownership.localeCompare(b.ownership),
shared: (a, b) => a.sharedStackCount - b.sharedStackCount,
exposure: (a, b) => (a.exposureSummary?.broadExposureCount ?? 0) - (b.exposureSummary?.broadExposureCount ?? 0),
findings: (a, b) => a.findingIds.length - b.findingIds.length,
connected: (a, b) => a.connectedCount - b.connectedCount,
};
export function NetworkInventoryTable({
rows,
findings,
loading,
isAdmin,
onInspect,
onDelete,
onOpenStack,
onFilterTopology,
}: {
rows: NetworkingNetworkRow[];
findings: NetworkingFinding[];
loading: boolean;
isAdmin: boolean;
onInspect: (id: string) => void;
onDelete: (id: string, name: string) => void;
onOpenStack: (stack: string) => void;
onFilterTopology: (name: string) => void;
}) {
const [filter, setFilter] = useState<NetworkFilter>('all');
const [search, setSearch] = useState('');
const [searchExpanded, setSearchExpanded] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (searchExpanded) searchRef.current?.focus(); }, [searchExpanded]);
const findingKindById = useMemo(
() => new Map(findings.map((f) => [f.id, f.kind])),
[findings],
);
const counts = useMemo(() => countNetworkFilters(rows, findingKindById), [rows, findingKindById]);
const filtered = useMemo(
() => filterNetworkRows(rows, filter, search, findingKindById),
[rows, filter, search, findingKindById],
);
const { sorted, sortKey, sortDir, toggleSort } = useTableSort(filtered, NETWORK_COMPARATORS, 'name');
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 mb-4">
{search !== '' || searchExpanded ? (
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchRef}
placeholder="Search network, stack, service, or driver..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => { if (search === '') setSearchExpanded(false); }}
className="pl-9 h-9"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setSearchExpanded(true)} aria-label="Search networks">
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search networks</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle value={filter} onChange={setFilter} counts={counts} />
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<div className="max-h-[62vh] overflow-auto">
<TooltipProvider>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<SortableTableHead label="Name" columnKey="name" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Driver" columnKey="driver" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Scope" columnKey="scope" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Ownership" columnKey="ownership" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Shared" columnKey="shared" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Exposure" columnKey="exposure" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Findings" columnKey="findings" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Connected" columnKey="connected" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-right text-[11px]" />
<TableHead className="w-40 text-right text-[11px]">Actions</TableHead>
</TableRow>
</TableHeader>
{loading ? <NetworkTableSkeleton /> : (
<TableBody>
{sorted.length === 0 ? (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="text-center py-8 text-muted-foreground text-sm">
No networks match this filter.
</TableCell>
</TableRow>
) : sorted.map((row, i) => (
<TableRow
key={row.id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
>
<TableCell className="font-mono text-xs">{row.name}</TableCell>
<TableCell className="text-xs text-stat-subtitle">{row.driver}</TableCell>
<TableCell className="text-xs text-stat-subtitle">{row.scope}</TableCell>
<TableCell><OwnershipBadge ownership={row.ownership} /></TableCell>
<TableCell className="text-xs tabular-nums">{row.sharedStackCount || '—'}</TableCell>
<TableCell className="text-xs tabular-nums">
{row.exposureSummary?.broadExposureCount ?? '—'}
</TableCell>
<TableCell className="text-xs tabular-nums">{row.findingIds.length || '—'}</TableCell>
<TableCell className="text-right tabular-nums text-xs">{row.connectedCount}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{row.stack && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onOpenStack(row.stack!)}
aria-label={`Open ${row.stack}`}
>
<ExternalLink className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Open stack</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onInspect(row.id)}
aria-label={`Inspect ${row.name}`}
>
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>View details</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onFilterTopology(row.name)}
aria-label={`Show ${row.name} in topology`}
>
<GitBranch className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Show in topology</TooltipContent>
</Tooltip>
{isAdmin && (() => {
const blockReason = deleteBlockReason(row);
const protectedReason = row.isSencho
? 'Protected · running Sencho instance'
: row.isSystem
? 'System network'
: blockReason;
const disabled = row.isSystem || row.isSencho || blockReason !== null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={disabled ? 'cursor-not-allowed' : undefined}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30"
disabled={disabled}
onClick={() => onDelete(row.id, row.name)}
aria-label={`Delete ${row.name}`}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{protectedReason ?? 'Delete network'}</TooltipContent>
</Tooltip>
);
})()}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
</TooltipProvider>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { useAuth } from '@/context/AuthContext';
import { isNetworkingActionVisible } from '@/lib/networking';
import {
FINDING_GROUP_LABELS, findingSourceLabel, groupFindings, SEVERITY_TEXT_CLASS, type NetworkingFindingGroup,
} from '@/lib/networkingSeverity';
import type { NetworkingFinding, NetworkingRecommendedAction } from '@/types/networking';
const GROUP_ORDER: NetworkingFindingGroup[] = ['needs-action', 'review-recommended', 'informational'];
const GROUP_CLASS: Record<NetworkingFindingGroup, string> = {
'needs-action': 'text-destructive',
'review-recommended': 'text-warning',
informational: 'text-stat-subtitle',
};
export function NetworkingFindingsList({
findings,
loading,
canEdit,
isAdmin,
onAction,
disabled = false,
}: {
findings: NetworkingFinding[];
loading: boolean;
canEdit: ReturnType<typeof useAuth>['can'];
isAdmin: boolean;
onAction: (action: NetworkingRecommendedAction) => void | Promise<void>;
disabled?: boolean;
}) {
if (loading) return <p className="text-sm text-muted-foreground">Loading findings</p>;
if (findings.length === 0) {
return <p className="text-sm text-muted-foreground">No networking issues detected.</p>;
}
const groups = groupFindings(findings);
return (
<div className="space-y-6">
{GROUP_ORDER.map((group) => {
const items = groups[group];
if (!items.length) return null;
return (
<section key={group}>
<p className={`mb-2 font-mono text-[10px] uppercase tracking-[0.18em] ${GROUP_CLASS[group]}`}>
{FINDING_GROUP_LABELS[group]} · {items.length}
</p>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<div className="max-h-[62vh] overflow-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-20 text-[11px]">Severity</TableHead>
<TableHead className="text-[11px]">Finding</TableHead>
<TableHead className="w-32 text-[11px]">Stack</TableHead>
<TableHead className="w-32 text-[11px]">Service</TableHead>
<TableHead className="w-32 text-[11px]">Network</TableHead>
<TableHead className="w-40 text-[11px]">Source</TableHead>
{!disabled && <TableHead className="w-44 text-right text-[11px]">Action</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.map((finding, i) => {
const sourceLabel = findingSourceLabel(finding);
const primary = finding.recommendedActions.find((action) =>
isNetworkingActionVisible(action, isAdmin, (stack) => canEdit('stack:edit', 'stack', stack)),
);
return (
<TableRow
key={finding.id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
>
<TableCell>
<span className={`font-mono text-[10px] uppercase tracking-wide ${SEVERITY_TEXT_CLASS[finding.severity]}`}>
{finding.severity}
</span>
</TableCell>
<TableCell>
<p className="text-sm font-medium text-stat-value">{finding.title}</p>
<p className="text-xs text-stat-subtitle">{finding.message}</p>
</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.stack ?? ''}</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.service ?? ''}</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.network ?? ''}</TableCell>
<TableCell className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle/80">
{sourceLabel ?? ''}
</TableCell>
{!disabled && (
<TableCell className="text-right">
{primary && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => void onAction(primary)}>
{primary.label}
</Button>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</section>
);
})}
</div>
);
}
@@ -0,0 +1,235 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { ScrollText, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import NetworkTopologyView from '@/components/NetworkTopologyView';
import { CapabilityGate } from '@/components/CapabilityGate';
import { SENCHO_OPEN_LOGS_EVENT, type SenchoOpenLogsDetail } from '@/lib/events';
import {
DEFAULT_TOPOLOGY_FILTERS,
formatTopologyPort,
isMissingTopologyNetwork,
type NetworkingTopologyFilters,
type TopologyOwnershipFilter,
} from '@/lib/networkingTopology';
import type { NetworkingTopologyContainerDetail, NetworkingTopologyNetwork } from '@/types/networking';
import { NetworkDetailDrawer } from './NetworkDetailDrawer';
const BOOLEAN_FILTERS: { key: keyof Pick<NetworkingTopologyFilters, 'exposedOnly' | 'driftOnly' | 'missingExternalOnly' | 'sharedOnly'>; label: string }[] = [
{ key: 'exposedOnly', label: 'Exposed' },
{ key: 'driftOnly', label: 'Drift' },
{ key: 'missingExternalOnly', label: 'Missing external' },
{ key: 'sharedOnly', label: 'Shared' },
];
const OWNERSHIP_OPTIONS: { key: TopologyOwnershipFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'managed', label: 'Sencho-managed' },
{ key: 'external', label: 'External' },
{ key: 'system', label: 'System' },
];
// Chip toggle mirroring the Fleet Map filter strip so the two topology-style
// views share one filter affordance.
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors',
active
? 'border-brand/60 bg-brand/15 text-brand'
: 'border-card-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
)}
>
{children}
</button>
);
}
export function NetworkingTopologyPanel({
reloadKey,
pendingNetworkFilter,
onPendingNetworkFilterApplied,
onOpenStack,
}: {
reloadKey: number;
pendingNetworkFilter?: string;
onPendingNetworkFilterApplied: () => void;
onOpenStack: (stack: string) => void;
}) {
const [includeSystem, setIncludeSystem] = useState(false);
const [filters, setFilters] = useState<NetworkingTopologyFilters>(DEFAULT_TOPOLOGY_FILTERS);
const [selectedNetwork, setSelectedNetwork] = useState<NetworkingTopologyNetwork | null>(null);
const [selectedContainer, setSelectedContainer] = useState<NetworkingTopologyContainerDetail | null>(null);
useEffect(() => {
if (!pendingNetworkFilter) return;
setFilters((value) => ({ ...value, network: pendingNetworkFilter }));
onPendingNetworkFilterApplied();
}, [pendingNetworkFilter, onPendingNetworkFilterApplied]);
const missingSelected = selectedNetwork !== null && isMissingTopologyNetwork(selectedNetwork);
const viewLogs = () => {
if (!selectedContainer) return;
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
detail: { containerId: selectedContainer.id, containerName: selectedContainer.name },
}));
};
return (
<CapabilityGate capability="network-topology" featureName="Network Topology">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
className="h-8 w-56 pl-8 text-sm"
placeholder="Filter stack or service…"
value={filters.stack}
onChange={(event) => setFilters((value) => ({ ...value, stack: event.target.value }))}
/>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
className="h-8 w-48 pl-8 text-sm"
placeholder="Filter network…"
value={filters.network}
onChange={(event) => setFilters((value) => ({ ...value, network: event.target.value }))}
/>
</div>
<SegmentedControl
value={filters.ownership}
onChange={(key) => setFilters((value) => ({ ...value, ownership: key }))}
ariaLabel="Network ownership"
options={OWNERSHIP_OPTIONS.map(({ key, label }) => ({ value: key, label }))}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={includeSystem} onClick={() => setIncludeSystem(!includeSystem)}>
Include system
</FilterChip>
{BOOLEAN_FILTERS.map(({ key, label }) => (
<FilterChip key={key} active={filters[key]} onClick={() => setFilters((value) => ({ ...value, [key]: !value[key] }))}>
{label}
</FilterChip>
))}
</div>
<NetworkTopologyView
key={`${reloadKey}-${includeSystem}`}
endpoint="/networking/topology"
showSystemToggle={false}
includeSystem={includeSystem}
filters={filters}
onNetworkClick={setSelectedNetwork}
onContainerSelect={setSelectedContainer}
onStackClick={onOpenStack}
/>
<NetworkDetailDrawer
networkId={selectedNetwork && !isMissingTopologyNetwork(selectedNetwork) ? selectedNetwork.id : null}
onClose={() => setSelectedNetwork(null)}
/>
<SystemSheet
open={missingSelected}
onOpenChange={(open) => { if (!open) setSelectedNetwork(null); }}
crumb={['Networking', 'Topology', selectedNetwork?.name ?? 'Missing external network']}
name={selectedNetwork?.name ?? 'Missing external network'}
meta="External dependency · not present in Docker"
size="md"
>
{selectedNetwork && (
<SheetSection title="Missing external network">
<p className="text-sm text-warning">
This external dependency is declared by Compose but is not present in Docker.
</p>
<div className="mt-3 space-y-2 text-sm">
<div>
<span className="text-xs text-stat-subtitle">Declaring stacks</span>
<p className="mt-0.5">{selectedNetwork.declaredExternalByStacks.join(', ') || 'none'}</p>
</div>
<div>
<span className="text-xs text-stat-subtitle">Finding IDs</span>
<p className="mt-0.5 font-mono text-xs">{selectedNetwork.findingIds.join(', ') || 'none'}</p>
</div>
</div>
</SheetSection>
)}
</SystemSheet>
<SystemSheet
open={selectedContainer !== null}
onOpenChange={(open) => { if (!open) setSelectedContainer(null); }}
crumb={['Networking', 'Topology', selectedContainer?.name ?? 'Container']}
name={selectedContainer?.name ?? 'Container'}
meta={selectedContainer ? `${selectedContainer.state} · ${selectedContainer.image}` : ''}
size="md"
primaryAction={selectedContainer && selectedContainer.state === 'running' ? {
label: 'View logs',
icon: ScrollText,
onClick: viewLogs,
} : undefined}
>
{selectedContainer && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground">Stack</span>
<p className="mt-0.5 text-xs">
{selectedContainer.stack ? (
<button
type="button"
className="font-mono text-brand hover:underline"
onClick={() => onOpenStack(selectedContainer.stack!)}
>
{selectedContainer.stack}
</button>
) : 'none'}
</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Service</span>
<p className="mt-0.5 text-xs">{selectedContainer.service ?? 'none'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Exposure intent</span>
<p className="mt-0.5 text-xs">{selectedContainer.exposureIntent ?? 'unknown'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Aliases</span>
<p className="mt-0.5 text-xs">{selectedContainer.composeAliases.join(', ') || 'none'}</p>
</div>
</div>
</SheetSection>
<SheetSection title="Network attachments">
<div className="divide-y divide-card-border/40">
{selectedContainer.attachments.map((attachment) => (
<div key={attachment.network} className="flex items-center justify-between py-1.5">
<Badge variant="outline" className="h-5 font-mono text-[10px]">{attachment.network}</Badge>
<span className="font-mono text-xs tabular-nums">{attachment.ip?.replace(/\/\d+$/, '') || 'N/A'}</span>
</div>
))}
</div>
</SheetSection>
<SheetSection title="Published ports">
<p className="text-xs">{selectedContainer.publishedPorts.map(formatTopologyPort).join(', ') || 'none'}</p>
</SheetSection>
<SheetSection title="Findings and drift">
<div className="space-y-1 text-xs">
<p><span className="text-stat-subtitle">Findings</span> {selectedContainer.findingIds.join(', ') || 'none'}</p>
<p><span className="text-stat-subtitle">Drift</span> {selectedContainer.driftFlags.join(', ') || 'none'}</p>
</div>
</SheetSection>
</>
)}
</SystemSheet>
</div>
</CapabilityGate>
);
}
@@ -0,0 +1,603 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import {
LayoutDashboard, Network, GitBranch, AlertTriangle, RefreshCw, Plus, Unplug,
} from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { PageMasthead, type MastheadMetadataItem } from '@/components/ui/PageMasthead';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { LockCard } from '@/components/ui/LockCard';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { Masthead, MobileSubTabs, type Tone } from '@/components/mobile/mobile-ui';
import { springs } from '@/lib/motion';
import { CreateNetworkDialog } from '@/components/resources/CreateNetworkDialog';
import { ConfirmModal } from '@/components/ui/modal';
import { NetworkDetailDrawer } from './NetworkDetailDrawer';
import { NetworkInventoryTable } from './NetworkInventoryTable';
import { NetworkingFindingsList } from './NetworkingFindingsList';
import { NetworkingTopologyPanel } from './NetworkingTopologyPanel';
import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events';
import {
adaptNetworkingOverview, buildExternalNetworkSnippet, canUseNetworkName, getNetworkingPosture,
isNetworkingActionVisible,
} from '@/lib/networking';
import { rankFindings, SEVERITY_TEXT_CLASS } from '@/lib/networkingSeverity';
import { isNetworkDriftFindingKind } from '@/types/networking';
import type {
NetworkingFinding, NetworkingOverviewEnvelope, NetworkingRecommendedAction,
NetworkingNetworkRow, NodeNetworkingOverview,
} from '@/types/networking';
export type NetworkingTab = 'overview' | 'topology' | 'networks' | 'findings';
interface NetworkingViewProps {
headerActions?: ReactNode;
}
const TABS: { value: NetworkingTab; label: string; icon: typeof LayoutDashboard }[] = [
{ value: 'overview', label: 'Overview', icon: LayoutDashboard },
{ value: 'networks', label: 'Networks', icon: Network },
{ value: 'topology', label: 'Topology', icon: GitBranch },
{ value: 'findings', label: 'Findings', icon: AlertTriangle },
];
const POSTURE_TONE: Record<ReturnType<typeof getNetworkingPosture>['tone'], 'error' | 'warn' | 'idle' | 'live'> = {
critical: 'error',
warning: 'warn',
neutral: 'idle',
live: 'live',
};
// Maps the shared posture tone onto the mobile masthead's dot tone, state-word
// color, and pulse, mirroring SecurityView's MOBILE_MASTHEAD_TONE table.
type StateWordClass = 'text-destructive' | 'text-warning' | 'text-stat-value' | 'text-stat-title';
const MOBILE_MASTHEAD_TONE: Record<'error' | 'warn' | 'idle' | 'live', { dot: Tone; word: StateWordClass; pulse: boolean }> = {
error: { dot: 'destructive', word: 'text-destructive', pulse: true },
warn: { dot: 'warning', word: 'text-warning', pulse: true },
live: { dot: 'brand', word: 'text-stat-value', pulse: false },
idle: { dot: 'warning', word: 'text-stat-title', pulse: false },
};
function destinationForAction(kind: NetworkingRecommendedAction['kind']): SenchoOpenStackDetail['destination'] {
switch (kind) {
case 'open-stack-networking':
case 'set-exposure-intent':
return 'anatomy-networking';
case 'open-stack-doctor':
return 'doctor';
case 'open-stack-editor':
return 'editor';
case 'open-stack-dossier':
return 'dossier';
case 'open-stack-drift':
return 'drift';
default:
return 'stack';
}
}
export function NetworkingView({ headerActions }: NetworkingViewProps) {
const { isAdmin, can } = useAuth();
const { activeNode } = useNodes();
const isMobile = useIsMobile();
const nodeId = activeNode?.id;
const [tab, setTab] = useState<NetworkingTab>('overview');
const [loading, setLoading] = useState(true);
const [unsupported, setUnsupported] = useState(false);
const [overview, setOverview] = useState<NodeNetworkingOverview | null>(null);
const [networks, setNetworks] = useState<NetworkingNetworkRow[]>([]);
const [findings, setFindings] = useState<NetworkingFinding[]>([]);
const [recentActivity, setRecentActivity] = useState<NetworkingOverviewEnvelope['recentActivity']>([]);
const [runtimeAvailable, setRuntimeAvailable] = useState(true);
const [isLegacy, setIsLegacy] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
const [initialNetworkName, setInitialNetworkName] = useState<string | undefined>();
const [selectedNetworkId, setSelectedNetworkId] = useState<string | null>(null);
const [pendingTopologyFilter, setPendingTopologyFilter] = useState<string | undefined>();
const [confirmDeleteNetwork, setConfirmDeleteNetwork] = useState<{ id: string; name: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteNetwork = useCallback(async () => {
if (!confirmDeleteNetwork) return;
setIsDeleting(true);
const loadingId = toast.loading('Deleting network...');
try {
const res = await apiFetch('/system/networks/delete', {
method: 'POST',
body: JSON.stringify({ id: confirmDeleteNetwork.id }),
});
const data = await res.json().catch(() => null) as { error?: string } | null;
if (!res.ok) {
throw new Error(data?.error || 'Failed to delete network');
}
toast.success('Network deleted');
setReloadKey(k => k + 1);
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || 'Failed to delete network'));
} finally {
toast.dismiss(loadingId);
setIsDeleting(false);
setConfirmDeleteNetwork(null);
}
}, [confirmDeleteNetwork]);
useEffect(() => {
const controller = new AbortController();
let stale = false;
setLoading(true);
setUnsupported(false);
setOverview(null);
setNetworks([]);
setFindings([]);
setRecentActivity([]);
setIsLegacy(false);
const load = async () => {
try {
const response = await apiFetch('/networking/overview', { nodeId, signal: controller.signal });
if (response.status === 404) {
if (!stale) setUnsupported(true);
return;
}
if (!response.ok) throw new Error('Failed to load networking data.');
const body = await response.json() as Partial<NetworkingOverviewEnvelope>;
if (stale) return;
const adapted = adaptNetworkingOverview(body);
setIsLegacy(adapted.isLegacy);
setRuntimeAvailable(adapted.runtimeAvailable);
setOverview(adapted.overview);
setNetworks(adapted.networks);
setFindings(adapted.findings);
setRecentActivity(adapted.recentActivity);
} catch (error) {
if (!stale && !(error instanceof DOMException && error.name === 'AbortError')) {
console.error('[Networking] Failed to load overview:', error);
toast.error('Failed to load networking data.');
}
} finally {
if (!stale) setLoading(false);
}
};
void load();
return () => {
stale = true;
controller.abort();
};
}, [nodeId, reloadKey]);
const openStack = useCallback((stackName: string, destination: SenchoOpenStackDetail['destination'] = 'stack') => {
if (nodeId === undefined) return;
window.dispatchEvent(new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, {
detail: { nodeId, stackName, destination },
}));
}, [nodeId]);
const dispatchAction = useCallback(async (action: NetworkingRecommendedAction) => {
switch (action.kind) {
case 'open-stack':
case 'open-stack-networking':
case 'open-stack-doctor':
case 'open-stack-editor':
case 'open-stack-dossier':
case 'open-stack-drift':
case 'set-exposure-intent':
openStack(action.stack, destinationForAction(action.kind));
return;
case 'create-network':
if (!isAdmin) {
toast.error('Creating networks requires admin access.');
return;
}
setInitialNetworkName(action.networkName);
setShowCreateNetwork(true);
return;
case 'inspect-network':
setSelectedNetworkId(action.networkId);
return;
case 'filter-topology':
setPendingTopologyFilter(action.networkName);
setTab('topology');
return;
case 'refresh':
setReloadKey((value) => value + 1);
return;
case 'open-docs':
if (action.docsPath.startsWith('/features/') || action.docsPath.startsWith('/operations/')) {
window.open(action.docsPath, '_blank', 'noopener,noreferrer');
}
return;
case 'copy-compose-snippet':
case 'copy-docker-command': {
if (!canUseNetworkName(action.networkName)) {
toast.error('Network name is not safe to copy.');
return;
}
const text = action.kind === 'copy-compose-snippet'
? `networks:\n ${action.networkName}:\n external: true`
: `docker network create ${action.networkName}`;
try {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy to clipboard.');
}
return;
}
default:
return;
}
}, [isAdmin, openStack]);
if (unsupported) {
return (
<LockCard
icon={Unplug}
title="Networking is not available on this node"
body="Upgrade the node to use the Networking page."
/>
);
}
const posture = getNetworkingPosture(findings, runtimeAvailable, isLegacy);
const mobileTone = MOBILE_MASTHEAD_TONE[POSTURE_TONE[posture.tone]];
const needsActionCount = findings.filter((f) => f.severity === 'critical' || f.severity === 'high').length;
const reviewCount = findings.filter((f) => f.severity === 'medium').length;
const driftCount = findings.filter((f) => isNetworkDriftFindingKind(f.kind)).length;
const mastheadMetadata: MastheadMetadataItem[] | undefined = overview ? [
{ label: 'NEEDS ACTION', value: String(needsActionCount), tone: needsActionCount > 0 ? 'error' : 'value' },
{ label: 'REVIEW', value: String(reviewCount), tone: reviewCount > 0 ? 'warn' : 'value' },
{ label: 'DRIFT', value: overview.networkCount === null ? '—' : String(driftCount) },
] : undefined;
const masthead = isMobile ? (
<Masthead
kicker="networking"
state={posture.label}
meta={activeNode?.name}
right={headerActions}
stateTone={mobileTone.dot}
stateClassName={mobileTone.word}
live={mobileTone.pulse}
/>
) : (
<PageMasthead
kicker="networking"
state={posture.label}
tone={POSTURE_TONE[posture.tone]}
subtitle={isLegacy ? 'Update this node to unlock findings and attention.' : 'Compose-first network inventory, topology, and findings for this node.'}
metadata={mastheadMetadata}
size="hero"
className="rounded-lg"
/>
);
const externalDependencies = networks.filter((n) => n.isExternalDependency);
const sharedNetworks = networks.filter((n) => n.sharedStackCount >= 2);
const unclassifiedExposureNetworks = networks.filter((n) => (n.exposureSummary?.unclassifiedStackCount ?? 0) > 0);
const topFindings = rankFindings(findings).slice(0, 5);
const overviewCards = overview ? (
<div className="space-y-4">
{!runtimeAvailable && !isLegacy && (
<Card className="border-warning/40 bg-warning/5">
<CardContent className="p-3 text-sm text-warning">Docker runtime is unavailable. Compose-model signals remain available.</CardContent>
</Card>
)}
<div className="grid overflow-hidden rounded-lg border border-card-border bg-card shadow-card-bevel sm:grid-cols-2 xl:grid-cols-4">
{[
{ label: 'Networks', value: overview.networkCount ?? '—', tab: 'networks' as const },
{ label: 'Managed', value: overview.senchoManagedNetworkCount ?? '—', tab: 'networks' as const },
{ label: 'External deps.', value: overview.externalDependencyNetworkCount ?? '—', tab: 'networks' as const },
{ label: 'System', value: overview.systemNetworkCount ?? '—', tab: 'networks' as const },
{ label: 'Exposed stacks', value: overview.exposedStackCount, tab: 'networks' as const },
{ label: 'Unknown exposure', value: overview.unknownExposureStackCount, tab: 'findings' as const },
{ label: 'Missing externals', value: overview.missingExternalCount, tab: 'findings' as const },
{ label: 'Collisions', value: overview.networkCollisionCount, tab: 'findings' as const },
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => setTab(item.tab)}
className="border-r border-b border-card-border p-4 text-left hover:bg-muted/20 sm:[&:nth-child(2n)]:border-r-0 xl:[&:nth-child(4n)]:border-r-0 xl:[&:nth-child(n+5)]:border-b-0"
>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">{item.label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-stat-value">{item.value}</p>
</button>
))}
</div>
<Card>
<CardContent className="p-4">
<div className="mb-2 flex items-center justify-between">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Operator attention</p>
{findings.length > 5 && (
<button type="button" className="text-xs text-brand hover:underline" onClick={() => setTab('findings')}>
View all findings
</button>
)}
</div>
{isLegacy ? (
<p className="text-sm text-stat-value">This node provides a partial networking response. Update it to review enriched findings.</p>
) : topFindings.length === 0 ? (
<p className="text-sm text-stat-value">No networking issues detected.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Severity</TableHead>
<TableHead>Finding</TableHead>
<TableHead>Stack</TableHead>
<TableHead className="w-44">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topFindings.map((finding) => {
const primary = finding.recommendedActions.find((action) =>
isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack)),
);
return (
<TableRow key={finding.id}>
<TableCell className="w-20">
<span className={`font-mono text-[10px] uppercase tracking-wide ${SEVERITY_TEXT_CLASS[finding.severity]}`}>
{finding.severity}
</span>
</TableCell>
<TableCell className="text-sm text-stat-value">{finding.title}</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.stack ?? finding.network ?? ''}</TableCell>
<TableCell className="w-44">
{primary && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => void dispatchAction(primary)}>
{primary.label}
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{!isLegacy && (
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">External network dependencies</p>
{externalDependencies.length === 0 ? (
<p className="mt-2 text-sm text-stat-subtitle">No stack depends on an external network.</p>
) : (
<ul className="mt-2 space-y-1 text-sm">
{externalDependencies.slice(0, 5).map((n) => (
<li key={n.id} className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs">{n.name}</span>
<span className="text-xs text-stat-subtitle">{n.declaredExternalByStacks.join(', ')}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Shared networks</p>
{sharedNetworks.length === 0 ? (
<p className="mt-2 text-sm text-stat-subtitle">No network is shared across stacks.</p>
) : (
<ul className="mt-2 space-y-1 text-sm">
{sharedNetworks.slice(0, 5).map((n) => (
<li key={n.id} className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs">{n.name}</span>
<span className="text-xs text-stat-subtitle">{n.sharedStackCount} stacks</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Exposure without intent</p>
{unclassifiedExposureNetworks.length === 0 ? (
<p className="mt-2 text-sm text-stat-subtitle">Every publishing service has a classified intent.</p>
) : (
<ul className="mt-2 space-y-1 text-sm">
{unclassifiedExposureNetworks.slice(0, 5).map((n) => (
<li key={n.id} className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs">{n.name}</span>
<span className="text-xs text-stat-subtitle">{n.exposureSummary?.unclassifiedStackCount ?? 0}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)}
{recentActivity.length > 0 && (
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Recent activity</p>
<ul className="mt-2 space-y-1 text-sm text-stat-subtitle">
{recentActivity.slice(0, 5).map((activity) => (
<li key={activity.id}>
{activity.stack_name ? (
<button type="button" className="hover:underline" onClick={() => openStack(activity.stack_name!)}>
{activity.message}
</button>
) : activity.message}
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
) : null;
const tabControls = (
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
{TABS.map(t => (
<TabsHighlightItem key={t.value} value={t.value}>
<TabsTrigger value={t.value}>
<t.icon className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
{t.label}
</TabsTrigger>
</TabsHighlightItem>
))}
</TabsHighlight>
</TabsList>
);
return (
<div className="h-full overflow-auto p-4 md:p-6">
{masthead}
<Tabs value={tab} onValueChange={(v) => setTab(v as NetworkingTab)}>
{isMobile ? (
<MobileSubTabs
ariaLabel="Networking sections"
active={tab}
onSelect={(v) => setTab(v as NetworkingTab)}
tabs={TABS.map(t => ({ value: t.value, label: t.label }))}
/>
) : (
<div className="flex items-center justify-between gap-3 mb-4 mt-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
{tabControls}
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setReloadKey(k => k + 1)}
disabled={loading}
className="h-9 w-9 p-0"
aria-label="Refresh"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAdmin && (
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateNetwork(true)}>
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
Create network
</Button>
)}
</div>
</div>
)}
<TabsContent value="overview" className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading overview</p> : overviewCards}
{overview && overview.renderFailedStacks.length > 0 && (
<p className="mt-3 text-sm text-warning">
{overview.renderFailedStacks.length} stack(s) could not render for full findings.
</p>
)}
</TabsContent>
<TabsContent value="networks" className="mt-4">
<NetworkInventoryTable
rows={networks}
findings={findings}
loading={loading}
isAdmin={isAdmin}
onInspect={(id) => setSelectedNetworkId(id)}
onDelete={(id, name) => setConfirmDeleteNetwork({ id, name })}
onOpenStack={openStack}
onFilterTopology={(networkName) => {
setPendingTopologyFilter(networkName);
setTab('topology');
}}
/>
</TabsContent>
<TabsContent value="topology" className="mt-4">
{isLegacy ? (
<Card>
<CardContent className="p-4 text-sm text-stat-subtitle">
Topology enrichment requires the complete networking response. Update this node to view it.
</CardContent>
</Card>
) : (
<NetworkingTopologyPanel
reloadKey={reloadKey}
pendingNetworkFilter={pendingTopologyFilter}
onPendingNetworkFilterApplied={() => setPendingTopologyFilter(undefined)}
onOpenStack={openStack}
/>
)}
</TabsContent>
<TabsContent value="findings" className="mt-4">
<NetworkingFindingsList findings={findings} loading={loading} canEdit={can} isAdmin={isAdmin} onAction={dispatchAction} disabled={isLegacy} />
</TabsContent>
</Tabs>
<CreateNetworkDialog
open={showCreateNetwork}
onOpenChange={setShowCreateNetwork}
initialName={initialNetworkName}
onCreated={({ name }) => {
if (initialNetworkName) {
const snippet = buildExternalNetworkSnippet(name);
if (snippet) {
toast.success(`Network "${name}" created`, {
action: {
label: 'Copy Compose YAML',
onClick: () => {
void navigator.clipboard.writeText(snippet)
.then(() => toast.success('Compose YAML copied'))
.catch(() => toast.error('Failed to copy Compose YAML.'));
},
},
});
}
}
setInitialNetworkName(undefined);
setReloadKey(k => k + 1);
}}
/>
<NetworkDetailDrawer
networkId={selectedNetworkId}
onClose={() => setSelectedNetworkId(null)}
/>
<ConfirmModal
open={confirmDeleteNetwork !== null}
onOpenChange={(open) => { if (!open) setConfirmDeleteNetwork(null); }}
variant="destructive"
kicker="NETWORKING · DELETE · IRREVERSIBLE"
title="Delete network"
confirmLabel={isDeleting ? 'Deleting...' : 'Delete'}
confirming={isDeleting}
onConfirm={handleDeleteNetwork}
>
<p className="text-sm text-stat-subtitle">
Permanently deletes{' '}
<span className="font-mono font-medium text-stat-value">
{confirmDeleteNetwork?.name ?? confirmDeleteNetwork?.id.substring(0, 12)}
</span>.
</p>
</ConfirmModal>
</div>
);
}
@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NetworkInventoryTable } from '../NetworkInventoryTable';
import type { NetworkingNetworkRow } from '@/types/networking';
function row(overrides: Partial<NetworkingNetworkRow> = {}): NetworkingNetworkRow {
return {
id: overrides.id ?? Math.random().toString(36),
name: 'a-net', driver: 'bridge', scope: 'local', isSystem: false, ingress: false,
composeProject: 'app', stack: 'app', connectedCount: 0, isSencho: false, ownership: 'compose-managed',
declaredByStacks: [], declaredExternalByStacks: [], isExternalDependency: false,
sharedStackCount: 0, exposureSummary: null, findingIds: [], serviceNames: [],
...overrides,
};
}
describe('NetworkInventoryTable', () => {
it('sorts rows when a column header is clicked', async () => {
const rows = [row({ id: '1', name: 'zeta' }), row({ id: '2', name: 'alpha' })];
const user = userEvent.setup();
render(
<NetworkInventoryTable
rows={rows}
findings={[]}
loading={false}
isAdmin={false}
onInspect={vi.fn()}
onDelete={vi.fn()}
onOpenStack={vi.fn()}
onFilterTopology={vi.fn()}
/>,
);
// Default sort is name ascending, independent of input row order.
const cellsBefore = screen.getAllByRole('row').slice(1).map((r) => within(r).getAllByRole('cell')[0].textContent);
expect(cellsBefore).toEqual(['alpha', 'zeta']);
// Clicking the active column's header reverses the direction.
await user.click(screen.getByRole('button', { name: /Name/ }));
const cellsAfter = screen.getAllByRole('row').slice(1).map((r) => within(r).getAllByRole('cell')[0].textContent);
expect(cellsAfter).toEqual(['zeta', 'alpha']);
});
it('orders row actions as Open, View, Topology, Delete with tooltips', () => {
const rowWithStack = row({ stack: 'app' });
render(
<NetworkInventoryTable
rows={[rowWithStack]}
findings={[]}
loading={false}
isAdmin
onInspect={vi.fn()}
onDelete={vi.fn()}
onOpenStack={vi.fn()}
onFilterTopology={vi.fn()}
/>,
);
const actionButtons = screen.getAllByRole('button').filter((b) =>
['Open app', 'Inspect a-net', 'Show a-net in topology', 'Delete a-net'].includes(b.getAttribute('aria-label') ?? ''),
);
expect(actionButtons.map((b) => b.getAttribute('aria-label'))).toEqual([
'Open app', 'Inspect a-net', 'Show a-net in topology', 'Delete a-net',
]);
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { NetworkingFindingsList } from '../NetworkingFindingsList';
import type { NetworkingFinding } from '@/types/networking';
function finding(overrides: Partial<NetworkingFinding> = {}): NetworkingFinding {
return {
id: overrides.id ?? Math.random().toString(36),
kind: 'network-mode-host',
severity: 'medium',
title: 'Some finding',
message: 'message',
evidence: [],
recommendedActions: [],
sources: ['live'],
doctorFindings: [],
...overrides,
};
}
const canEdit = () => true;
describe('NetworkingFindingsList', () => {
it('shows a calm empty state when there are no findings', () => {
render(<NetworkingFindingsList findings={[]} loading={false} canEdit={canEdit} isAdmin onAction={vi.fn()} />);
expect(screen.getByText('No networking issues detected.')).toBeInTheDocument();
});
it('groups findings into Needs action, Review recommended, and Informational', () => {
render(
<NetworkingFindingsList
findings={[
finding({ id: 'a', severity: 'critical', title: 'Critical issue' }),
finding({ id: 'b', severity: 'medium', title: 'Medium issue' }),
finding({ id: 'c', severity: 'info', title: 'Info issue' }),
]}
loading={false}
canEdit={canEdit}
isAdmin
onAction={vi.fn()}
/>,
);
expect(screen.getByText(/Needs action/)).toBeInTheDocument();
expect(screen.getByText(/Review recommended/)).toBeInTheDocument();
expect(screen.getByText(/Informational/)).toBeInTheDocument();
});
it('shows the merged source label for a card found by both engines', () => {
render(
<NetworkingFindingsList
findings={[finding({
sources: ['live', 'doctor'],
doctorFindings: [{ ruleId: 'sensitive-service-broad-exposure', ranAt: new Date().toISOString(), title: 't', message: 'm', severity: 'high' }],
})]}
loading={false}
canEdit={canEdit}
isAdmin
onAction={vi.fn()}
/>,
);
expect(screen.getByText('Live · also found by Doctor')).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Combobox } from '@/components/ui/combobox';
@@ -27,8 +27,8 @@ const EMPTY_FORM: CreateNetworkForm = {
interface CreateNetworkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a network is created so the caller can refresh its view. */
onCreated?: () => void | Promise<void>;
initialName?: string;
onCreated?: (network: { name: string }) => void | Promise<void>;
}
/**
@@ -36,10 +36,14 @@ interface CreateNetworkDialogProps {
* `/system/networks`, so it can be reused from the Resources Networks tab and
* the stack-detail Networking tab without sharing parent state.
*/
export function CreateNetworkDialog({ open, onOpenChange, onCreated }: CreateNetworkDialogProps) {
export function CreateNetworkDialog({ open, onOpenChange, initialName, onCreated }: CreateNetworkDialogProps) {
const [form, setForm] = useState<CreateNetworkForm>(EMPTY_FORM);
const [creating, setCreating] = useState(false);
useEffect(() => {
if (open) setForm((current) => ({ ...current, name: initialName ?? current.name }));
}, [initialName, open]);
const handleCreate = async () => {
setCreating(true);
try {
@@ -61,7 +65,7 @@ export function CreateNetworkDialog({ open, onOpenChange, onCreated }: CreateNet
toast.success(`Network "${form.name}" created`);
onOpenChange(false);
setForm(EMPTY_FORM);
await onCreated?.();
await onCreated?.({ name: form.name });
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
@@ -1,193 +0,0 @@
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
import { toast } from '@/components/ui/toast-store';
import { copyToClipboard } from '@/lib/clipboard';
import { Container, Copy } from 'lucide-react';
export interface NetworkInspectData {
Id: string;
Name: string;
Created: string;
Scope: string;
Driver: string;
Internal: boolean;
Attachable: boolean;
Labels: Record<string, string>;
IPAM: {
Driver: string;
Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>;
};
Containers: Record<string, {
Name: string;
EndpointID: string;
MacAddress: string;
IPv4Address: string;
IPv6Address: string;
}>;
Options: Record<string, string>;
}
interface NetworkDetailSheetProps {
network: NetworkInspectData | null;
onClose: () => void;
}
export function NetworkDetailSheet({ network, onClose }: NetworkDetailSheetProps) {
const containerCount = network ? Object.keys(network.Containers || {}).length : 0;
const subnet = network?.IPAM?.Config?.[0]?.Subnet;
const meta = network
? `${network.Driver} · ${network.Scope}${subnet ? ` · ${subnet}` : ''} · ${containerCount} container${containerCount === 1 ? '' : 's'}`
: '';
const footerContext = network?.Created
? `Created ${new Date(network.Created).toLocaleString()}`
: undefined;
return (
<SystemSheet
open={!!network}
onOpenChange={(open) => { if (!open) onClose(); }}
crumb={['Resources', 'Networks', network?.Name ?? '—']}
name={network?.Name ?? 'Network'}
meta={meta}
footerContext={footerContext}
size="md"
>
{network && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground">ID</span>
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
{network.Id.substring(0, 12)}
<button
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={async () => {
try { await copyToClipboard(network.Id); toast.success('ID copied'); }
catch { toast.error('Copy failed.'); }
}}
aria-label="Copy network ID"
>
<Copy className="w-3 h-3" strokeWidth={1.5} />
</button>
</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Driver</span>
<span className="text-xs mt-0.5 block">
<Badge variant="outline" className="text-[10px] h-5">{network.Driver}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Scope</span>
<span className="text-xs mt-0.5 block">
<Badge variant="outline" className="text-[10px] h-5">{network.Scope}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Created</span>
<p className="text-xs mt-0.5">{new Date(network.Created).toLocaleString()}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Internal</span>
<p className="text-xs mt-0.5">{network.Internal ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Attachable</span>
<p className="text-xs mt-0.5">{network.Attachable ? 'Yes' : 'No'}</p>
</div>
</div>
</SheetSection>
{network.IPAM?.Config?.length > 0 && (
<SheetSection title="IPAM configuration">
<div className="divide-y divide-card-border/40">
{network.IPAM.Config.map((cfg, i) => (
<div key={i} className="py-2 space-y-1.5">
{cfg.Subnet && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Subnet</span>
<span className="font-mono text-xs tabular-nums">{cfg.Subnet}</span>
</div>
)}
{cfg.Gateway && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Gateway</span>
<span className="font-mono text-xs tabular-nums">{cfg.Gateway}</span>
</div>
)}
{cfg.IPRange && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">IP Range</span>
<span className="font-mono text-xs tabular-nums">{cfg.IPRange}</span>
</div>
)}
</div>
))}
</div>
</SheetSection>
)}
{network.Options && Object.keys(network.Options).length > 0 && (
<SheetSection title="Options">
<Table>
<TableBody>
{Object.entries(network.Options).map(([key, val]) => (
<TableRow key={key} className="hover:bg-muted/30">
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SheetSection>
)}
<SheetSection title={`Connected · ${containerCount} container${containerCount === 1 ? '' : 's'}`}>
{containerCount === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">No containers connected to this network.</p>
) : (
<div className="divide-y divide-card-border/40">
{Object.entries(network.Containers).map(([id, c]) => (
<div key={id} className="py-2 space-y-1.5">
<div className="flex items-center gap-2">
<Container className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
<span className="text-sm font-medium truncate">{c.Name}</span>
</div>
<div className="grid grid-cols-2 gap-2 pl-5">
<div>
<span className="text-[10px] text-muted-foreground">IPv4</span>
<p className="font-mono text-xs tabular-nums">{c.IPv4Address || 'N/A'}</p>
</div>
<div>
<span className="text-[10px] text-muted-foreground">MAC</span>
<p className="font-mono text-xs tabular-nums">{c.MacAddress || 'N/A'}</p>
</div>
</div>
</div>
))}
</div>
)}
</SheetSection>
{network.Labels && Object.keys(network.Labels).length > 0 && (
<SheetSection title="Labels">
<Table>
<TableBody>
{Object.entries(network.Labels).map(([key, val]) => (
<TableRow key={key} className="hover:bg-muted/30">
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SheetSection>
)}
</>
)}
</SystemSheet>
);
}
@@ -152,4 +152,17 @@ describe('StackNetworkingPanel', () => {
expect(after).toBeGreaterThan(before);
});
});
it('dispatches the node-navigate event to open the Networking page', async () => {
mockApi(facts());
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
await screen.findByText('web_backend');
const handler = vi.fn();
window.addEventListener('sencho-navigate', handler);
fireEvent.click(screen.getByText(/view node networking/i));
expect(handler).toHaveBeenCalledTimes(1);
const detail = (handler.mock.calls[0][0] as CustomEvent).detail;
expect(detail).toEqual({ view: 'networking' });
window.removeEventListener('sencho-navigate', handler);
});
});
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { CreateNetworkDialog } from '@/components/resources/CreateNetworkDialog';
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager';
// Mirrors the backend networking payload shapes (the frontend never imports
// backend). IntentEntry intentionally keeps only the fields this panel reads.
@@ -336,6 +337,17 @@ export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled
<ArrowRight className="h-3 w-3" strokeWidth={1.5} /> deploy and security findings are in the Doctor tab
</div>
)}
<button
type="button"
onClick={() => {
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'networking' },
}));
}}
className="flex items-center gap-1 font-mono text-[10px] text-stat-subtitle hover:text-stat-value transition-colors"
>
<ArrowRight className="h-3 w-3" strokeWidth={1.5} /> view node networking
</button>
</div>
);
}
+1
View File
@@ -21,6 +21,7 @@ export const SENCHO_OPEN_STACK_EVENT = 'sencho-open-stack';
export interface SenchoOpenStackDetail {
nodeId: number;
stackName: string;
destination?: 'stack' | 'editor' | 'anatomy-networking' | 'doctor' | 'dossier' | 'drift';
}
/** Tabs of the top-level Security view. Used by the nav state and by
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
adaptNetworkingOverview, buildExternalNetworkSnippet, filterNetworkRows, getNetworkingPosture,
} from './networking';
import type { NetworkingFinding, NetworkingFindingKind, NetworkingNetworkRow, NodeNetworkingOverview } from '@/types/networking';
const rows: NetworkingNetworkRow[] = [
{
id: 'managed', name: 'app-net', driver: 'bridge', scope: 'local', isSystem: false, ingress: false,
composeProject: 'app', stack: 'app', connectedCount: 2, isSencho: false, ownership: 'sencho-managed',
declaredByStacks: ['app'], declaredExternalByStacks: ['peer'], isExternalDependency: true,
sharedStackCount: 2, exposureSummary: null, findingIds: ['drift'], serviceNames: ['web'],
},
{
id: 'system', name: 'bridge', driver: 'bridge', scope: 'local', isSystem: true, ingress: false,
composeProject: null, stack: null, connectedCount: 0, isSencho: false, ownership: 'system',
declaredByStacks: [], declaredExternalByStacks: [], isExternalDependency: false,
sharedStackCount: 0, exposureSummary: null, findingIds: [], serviceNames: [],
},
];
const findingKindById = new Map<string, NetworkingFindingKind>([['drift', 'network-missing']]);
describe('networking view helpers', () => {
it('filters ownership, dependencies, and findings independently', () => {
expect(filterNetworkRows(rows, 'managed', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'external', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'system', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'shared', '')).toHaveLength(1);
expect(filterNetworkRows(rows, 'drift', '', findingKindById)).toHaveLength(1);
});
it('drift filter requires a drift-kind finding, not just any finding', () => {
const nonDriftKindById = new Map<string, NetworkingFindingKind>([['drift', 'shared-network']]);
expect(filterNetworkRows(rows, 'drift', '', nonDriftKindById)).toHaveLength(0);
});
it('does not infer contained posture from a schema-less response', () => {
expect(getNetworkingPosture([], true, true).label).toBe('Partial networking data');
});
it('adapts schema-less responses without synthesizing findings', () => {
const adapted = adaptNetworkingOverview({ networks: rows });
expect(adapted.isLegacy).toBe(true);
expect(adapted.findings).toEqual([]);
expect(adapted.runtimeAvailable).toBe(false);
});
it('adapts a schema-2 response: derives ownership counts and fills new-field defaults', () => {
// A schema-2 remote sends partial rows/findings (no serviceNames, no
// sources/doctorFindings); the envelope type models the schema-3 ideal, so
// the partial wire shape is cast to mirror what actually arrives.
const adapted = adaptNetworkingOverview({
schemaVersion: 2,
runtimeAvailable: true,
overview: { networkCount: 2 } as NodeNetworkingOverview,
networks: [
{ id: 'a', name: 'a', ownership: 'sencho-managed', isExternalDependency: false },
{ id: 'b', name: 'b', ownership: 'unmanaged', isExternalDependency: true },
] as unknown as NetworkingNetworkRow[],
findings: [{ id: 'f1', kind: 'network-missing', severity: 'high', title: 't', message: 'm' }] as unknown as NetworkingFinding[],
});
expect(adapted.isLegacy).toBe(false);
expect(adapted.runtimeAvailable).toBe(true);
// The schema-2 envelope omits ownership counts, so they are derived from the rows.
expect(adapted.overview?.senchoManagedNetworkCount).toBe(1);
expect(adapted.overview?.unmanagedNetworkCount).toBe(1);
expect(adapted.overview?.externalDependencyNetworkCount).toBe(1);
// Fields new in schema 3 get safe defaults rather than undefined.
expect(adapted.networks[0].serviceNames).toEqual([]);
expect(adapted.findings[0].sources).toEqual(['live']);
expect(adapted.findings[0].doctorFindings).toEqual([]);
});
it('builds an external network snippet only for safe names', () => {
expect(buildExternalNetworkSnippet('shared-net')).toBe(
'networks:\n shared-net:\n external: true',
);
expect(buildExternalNetworkSnippet('../unsafe')).toBeNull();
});
});
+204
View File
@@ -0,0 +1,204 @@
import {
isNetworkDriftFindingKind,
type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOverviewEnvelope,
type NetworkingRecommendedAction, type NodeNetworkingOverview,
} from '@/types/networking';
export type NetworkFilter = 'all' | 'managed' | 'external' | 'system' | 'shared' | 'exposed' | 'drift';
export function isNetworkingActionVisible(
action: NetworkingRecommendedAction,
isAdmin: boolean,
canEditStack: (stack: string) => boolean,
): boolean {
if (action.kind === 'create-network') return isAdmin;
if (action.kind === 'set-exposure-intent') return canEditStack(action.stack);
return true;
}
// findingIds on a row are opaque hash IDs; drift classification needs each
// finding's kind, so callers supply a lookup built from the full findings list.
// Shared by the Networks-table drift filter and its count so they never diverge.
export function rowHasDriftFinding(
row: NetworkingNetworkRow,
findingKindById: Map<string, NetworkingFindingKind>,
): boolean {
return row.findingIds.some((id) => {
const kind = findingKindById.get(id);
return kind !== undefined && isNetworkDriftFindingKind(kind);
});
}
function matchesNetworkFilter(
row: NetworkingNetworkRow,
filter: NetworkFilter,
findingKindById: Map<string, NetworkingFindingKind>,
): boolean {
switch (filter) {
case 'managed':
return row.ownership === 'sencho-managed';
case 'external':
return row.isExternalDependency;
case 'system':
return row.ownership === 'system';
case 'shared':
return row.sharedStackCount >= 2;
case 'exposed':
return Boolean(row.exposureSummary?.broadExposureCount);
case 'drift':
return rowHasDriftFinding(row, findingKindById);
case 'all':
default:
return true;
}
}
export function filterNetworkRows(
rows: NetworkingNetworkRow[],
filter: NetworkFilter,
search: string,
findingKindById: Map<string, NetworkingFindingKind> = new Map(),
): NetworkingNetworkRow[] {
const query = search.trim().toLowerCase();
return rows.filter((row) => {
if (!matchesNetworkFilter(row, filter, findingKindById)) return false;
if (!query) return true;
return [
row.name,
row.stack,
row.composeProject,
row.driver,
...row.declaredByStacks,
...row.declaredExternalByStacks,
...row.serviceNames,
].filter(Boolean).some((value) => value!.toLowerCase().includes(query));
});
}
export function getNetworkingPosture(
findings: NetworkingFinding[],
runtimeAvailable: boolean,
isLegacy: boolean,
): { label: string; tone: 'live' | 'warning' | 'critical' | 'neutral' } {
if (isLegacy) return { label: 'Partial networking data', tone: 'neutral' };
if (!runtimeAvailable) return { label: 'Runtime unavailable', tone: 'warning' };
if (findings.some((finding) => finding.severity === 'critical' || finding.severity === 'high')) {
return { label: 'Action needed', tone: 'critical' };
}
if (findings.some((finding) => finding.severity === 'medium')) {
return { label: 'Review', tone: 'warning' };
}
return { label: 'Contained', tone: 'live' };
}
export function canUseNetworkName(name: string): boolean {
return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name);
}
export function buildExternalNetworkSnippet(name: string): string | null {
if (!canUseNetworkName(name)) return null;
return `networks:\n ${name}:\n external: true`;
}
/** Normalizes a network row from a schema-2 remote (no serviceNames) so the UI
* never crashes on a missing array. */
function adaptNetworkRow(row: Partial<NetworkingNetworkRow>): NetworkingNetworkRow {
return {
id: row.id ?? '',
name: row.name ?? '',
driver: row.driver ?? 'bridge',
scope: row.scope ?? 'local',
isSystem: row.isSystem === true,
ingress: row.ingress === true,
enableIPv6: row.enableIPv6,
composeProject: row.composeProject ?? null,
stack: row.stack ?? null,
connectedCount: row.connectedCount ?? 0,
isSencho: row.isSencho === true,
ownership: row.ownership ?? 'unmanaged',
declaredByStacks: row.declaredByStacks ?? [],
declaredExternalByStacks: row.declaredExternalByStacks ?? [],
isExternalDependency: row.isExternalDependency === true,
sharedStackCount: row.sharedStackCount ?? 0,
exposureSummary: row.exposureSummary ?? null,
findingIds: row.findingIds ?? [],
serviceNames: row.serviceNames ?? [],
};
}
/** Normalizes a finding from a schema-2 remote (no sources/doctorFindings). */
function adaptFinding(f: Partial<NetworkingFinding>): NetworkingFinding {
return {
id: f.id ?? '',
kind: (f.kind ?? 'runtime-unavailable') as NetworkingFinding['kind'],
severity: f.severity ?? 'info',
title: f.title ?? '',
message: f.message ?? '',
stack: f.stack,
network: f.network,
service: f.service,
evidence: f.evidence ?? [],
recommendedActions: f.recommendedActions ?? [],
sources: f.sources ?? ['live'],
doctorFindings: f.doctorFindings ?? [],
};
}
/** Derives ownership counts from network rows when the overview envelope itself
* omits them (schema 2), instead of showing incorrect zeroes. */
function deriveOwnershipCounts(networks: NetworkingNetworkRow[]): {
senchoManagedNetworkCount: number;
composeManagedNetworkCount: number;
unmanagedNetworkCount: number;
externalDependencyNetworkCount: number;
} {
return {
senchoManagedNetworkCount: networks.filter((n) => n.ownership === 'sencho-managed').length,
composeManagedNetworkCount: networks.filter((n) => n.ownership === 'compose-managed').length,
unmanagedNetworkCount: networks.filter((n) => n.ownership === 'unmanaged').length,
externalDependencyNetworkCount: networks.filter((n) => n.isExternalDependency).length,
};
}
export function adaptNetworkingOverview(body: Partial<NetworkingOverviewEnvelope>): {
isLegacy: boolean;
runtimeAvailable: boolean;
overview: NodeNetworkingOverview | null;
networks: NetworkingNetworkRow[];
findings: NetworkingFinding[];
recentActivity: NetworkingOverviewEnvelope['recentActivity'];
} {
const schemaVersion = body.schemaVersion;
// Schema 1 / absent: fully legacy, no usable shape at all.
const isLegacy = schemaVersion === undefined || schemaVersion < 2;
if (isLegacy) {
return {
isLegacy: true,
runtimeAvailable: false,
overview: null,
networks: [],
findings: [],
recentActivity: [],
};
}
const networks = (body.networks ?? []).map(adaptNetworkRow);
const findings = (body.findings ?? []).map(adaptFinding);
const isSchema2 = schemaVersion === 2;
const overview = body.overview
? {
...body.overview,
...(isSchema2 ? deriveOwnershipCounts(networks) : {}),
}
: null;
return {
isLegacy: false,
runtimeAvailable: body.runtimeAvailable === true,
overview,
networks,
findings,
recentActivity: body.recentActivity ?? [],
};
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { findingSourceLabel, groupFindings, rankFindings } from './networkingSeverity';
import type { NetworkingFinding } from '@/types/networking';
function finding(overrides: Partial<NetworkingFinding> = {}): NetworkingFinding {
return {
id: overrides.id ?? Math.random().toString(36),
kind: 'network-mode-host',
severity: 'medium',
title: 't',
message: 'm',
evidence: [],
recommendedActions: [],
sources: ['live'],
doctorFindings: [],
...overrides,
};
}
describe('groupFindings', () => {
it('groups critical and high into needs-action, medium into review, info into informational', () => {
const groups = groupFindings([
finding({ id: 'a', severity: 'critical' }),
finding({ id: 'b', severity: 'high' }),
finding({ id: 'c', severity: 'medium' }),
finding({ id: 'd', severity: 'info' }),
]);
expect(groups['needs-action'].map((f) => f.id)).toEqual(['a', 'b']);
expect(groups['review-recommended'].map((f) => f.id)).toEqual(['c']);
expect(groups.informational.map((f) => f.id)).toEqual(['d']);
});
});
describe('rankFindings', () => {
it('ranks live ahead of doctor-only at equal severity', () => {
const live = finding({ id: 'live', severity: 'high', sources: ['live'] });
const doctorOnly = finding({ id: 'doctor', severity: 'high', sources: ['doctor'] });
const ranked = rankFindings([doctorOnly, live]);
expect(ranked.map((f) => f.id)).toEqual(['live', 'doctor']);
});
it('ranks strictly by severity first', () => {
const low = finding({ id: 'low', severity: 'info' });
const high = finding({ id: 'high', severity: 'critical' });
expect(rankFindings([low, high]).map((f) => f.id)).toEqual(['high', 'low']);
});
});
describe('findingSourceLabel', () => {
it('labels a merged card as Live plus Doctor', () => {
const merged = finding({ sources: ['live', 'doctor'], doctorFindings: [{ ruleId: 'r', ranAt: new Date().toISOString(), title: 't', message: 'm', severity: 'high' }] });
expect(findingSourceLabel(merged)).toBe('Live · also found by Doctor');
});
it('labels a Doctor-only card with its last-run timestamp', () => {
const ranAt = new Date('2026-01-01T00:00:00Z').toISOString();
const doctorOnly = finding({ sources: ['doctor'], doctorFindings: [{ ruleId: 'r', ranAt, title: 't', message: 'm', severity: 'high' }] });
expect(findingSourceLabel(doctorOnly)).toContain('Last Doctor run');
});
it('returns null for a live-only finding', () => {
expect(findingSourceLabel(finding({ sources: ['live'] }))).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { NetworkingFinding, NetworkingFindingSeverity } from '@/types/networking';
export const NETWORKING_SEVERITY_RANK: Record<NetworkingFindingSeverity, number> = {
info: 0,
medium: 1,
high: 2,
critical: 3,
};
/** Text color per severity, shared by the Overview attention list and the Findings tab. */
export const SEVERITY_TEXT_CLASS: Record<NetworkingFindingSeverity, string> = {
info: 'text-stat-subtitle',
medium: 'text-warning',
high: 'text-destructive',
critical: 'text-destructive',
};
/** Severity descending; at equal severity a live-sourced finding ranks ahead of a
* Doctor-only finding, mirroring the backend ranking helper. */
export function compareFindingsForRanking(a: NetworkingFinding, b: NetworkingFinding): number {
const rankDiff = NETWORKING_SEVERITY_RANK[b.severity] - NETWORKING_SEVERITY_RANK[a.severity];
if (rankDiff !== 0) return rankDiff;
const aIsLive = a.sources.includes('live');
const bIsLive = b.sources.includes('live');
if (aIsLive !== bIsLive) return aIsLive ? -1 : 1;
return 0;
}
export function rankFindings(findings: NetworkingFinding[]): NetworkingFinding[] {
return [...findings].sort(compareFindingsForRanking);
}
export type NetworkingFindingGroup = 'needs-action' | 'review-recommended' | 'informational';
export const FINDING_GROUP_LABELS: Record<NetworkingFindingGroup, string> = {
'needs-action': 'Needs action',
'review-recommended': 'Review recommended',
informational: 'Informational',
};
export function groupForSeverity(severity: NetworkingFindingSeverity): NetworkingFindingGroup {
if (severity === 'critical' || severity === 'high') return 'needs-action';
if (severity === 'medium') return 'review-recommended';
return 'informational';
}
export function groupFindings(findings: NetworkingFinding[]): Record<NetworkingFindingGroup, NetworkingFinding[]> {
const ranked = rankFindings(findings);
const groups: Record<NetworkingFindingGroup, NetworkingFinding[]> = {
'needs-action': [],
'review-recommended': [],
informational: [],
};
for (const f of ranked) groups[groupForSeverity(f.severity)].push(f);
return groups;
}
/** Label for a finding's source provenance, so cached Doctor findings never read as current runtime facts. */
export function findingSourceLabel(finding: Pick<NetworkingFinding, 'sources' | 'doctorFindings'>): string | null {
const isLive = finding.sources.includes('live');
const isDoctor = finding.sources.includes('doctor');
if (isLive && isDoctor) return 'Live · also found by Doctor';
if (isDoctor && !isLive) {
const ranAt = finding.doctorFindings[0]?.ranAt;
return ranAt ? `Last Doctor run · ${new Date(ranAt).toLocaleString()}` : 'Last Doctor run';
}
return null;
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { filterTopologyNetworks, normalizeTopologyResponse } from './networkingTopology';
import type { NetworkingTopologyNetwork } from '@/types/networking';
const missingNetwork: NetworkingTopologyNetwork = {
id: 'missing:shared-net', name: 'shared-net', driver: 'unknown', scope: 'local', stack: null,
isSystem: false, ingress: false, ownership: 'unmanaged', declaredByStacks: [],
declaredExternalByStacks: ['app'], isExternalDependency: true, runtimeState: 'missing',
findingIds: ['network-missing'], containers: [],
};
describe('networking topology adapters', () => {
it('unwraps unavailable envelopes without generating missing nodes', () => {
expect(normalizeTopologyResponse({ schemaVersion: 2, runtimeAvailable: false, networks: [missingNetwork] })).toEqual({
networks: [], runtimeAvailable: false,
});
});
it('filters synthetic missing external nodes', () => {
expect(filterTopologyNetworks([missingNetwork], {
stack: '', network: '', ownership: 'all', exposedOnly: false, driftOnly: false, missingExternalOnly: true, sharedOnly: false,
})).toEqual([missingNetwork]);
});
});
+153
View File
@@ -0,0 +1,153 @@
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}`;
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { countTopologyGraphSize, TOPOLOGY_ANIMATION_EDGE_LIMIT, TOPOLOGY_RENDER_CAP } from './networkingTopology';
import type { NetworkingTopologyContainer, NetworkingTopologyNetwork } from '@/types/networking';
function container(id: string): NetworkingTopologyContainer {
return {
id, name: id, ip: '10.0.0.1', state: 'running', image: 'nginx', stack: 'app', service: 'web',
composeAliases: [], publishedPorts: [], exposureIntent: null, findingIds: [], driftFlags: [], hostMode: false,
};
}
function network(id: string, containers: NetworkingTopologyContainer[]): NetworkingTopologyNetwork {
return {
id, name: id, driver: 'bridge', scope: 'local', stack: null, isSystem: false, ingress: false,
ownership: 'sencho-managed', declaredByStacks: [], declaredExternalByStacks: [],
isExternalDependency: false, findingIds: [], containers,
};
}
describe('countTopologyGraphSize', () => {
it('counts a container attached to two networks as ONE node and TWO edges', () => {
const shared = container('shared');
const size = countTopologyGraphSize([network('net-a', [shared]), network('net-b', [shared])]);
// 2 networks + 1 deduped container = 3 nodes; 2 edges (one per attachment).
expect(size.nodeCount).toBe(3);
expect(size.edgeCount).toBe(2);
});
it('lands just under the render cap at the boundary and just over one container later', () => {
// One network with N containers is (1 + N) nodes and N edges: total 1 + 2N.
const belowN = Math.floor((TOPOLOGY_RENDER_CAP - 1) / 2); // largest N with 1 + 2N <= cap
const below = countTopologyGraphSize([network('g', Array.from({ length: belowN }, (_, i) => container(`c${i}`)))]);
expect(below.nodeCount + below.edgeCount).toBeLessThanOrEqual(TOPOLOGY_RENDER_CAP);
const above = countTopologyGraphSize([network('g', Array.from({ length: belowN + 1 }, (_, i) => container(`c${i}`)))]);
expect(above.nodeCount + above.edgeCount).toBeGreaterThan(TOPOLOGY_RENDER_CAP);
});
it('crosses the animation edge limit exactly one edge past the threshold', () => {
const atLimit = countTopologyGraphSize([network('g', Array.from({ length: TOPOLOGY_ANIMATION_EDGE_LIMIT }, (_, i) => container(`c${i}`)))]);
expect(atLimit.edgeCount).toBe(TOPOLOGY_ANIMATION_EDGE_LIMIT);
const over = countTopologyGraphSize([network('g', Array.from({ length: TOPOLOGY_ANIMATION_EDGE_LIMIT + 1 }, (_, i) => container(`c${i}`)))]);
expect(over.edgeCount).toBeGreaterThan(TOPOLOGY_ANIMATION_EDGE_LIMIT);
});
});
+1
View File
@@ -15,6 +15,7 @@ export type ActiveView =
| 'editor'
| 'host-console'
| 'resources'
| 'networking'
| 'templates'
| 'global-observability'
| 'fleet'
@@ -157,4 +157,12 @@ describe('senchoRoute', () => {
expect(parsed.view).toBe('editor');
expect(parsed.editorTab).toBeNull();
});
it('round-trips the networking view', () => {
const path = buildPath({ ...base, activeView: 'networking' });
expect(path).toBe('/nodes/local/networking');
const parsed = parsePath(path, '');
expect(parsed.view).toBe('networking');
expect(parsed.nodeSlug).toBe('local');
});
});
+1
View File
@@ -8,6 +8,7 @@ const VIEW_SEGMENTS = {
editor: 'stacks',
'host-console': 'host-console',
resources: 'resources',
networking: 'networking',
templates: 'templates',
'global-observability': 'logs',
fleet: 'fleet',
+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[];
}