diff --git a/backend/src/__tests__/doctor-networking-findings.test.ts b/backend/src/__tests__/doctor-networking-findings.test.ts new file mode 100644 index 00000000..150fd019 --- /dev/null +++ b/backend/src/__tests__/doctor-networking-findings.test.ts @@ -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 & { 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 { + 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); + }); +}); diff --git a/backend/src/__tests__/networking-host-severity.test.ts b/backend/src/__tests__/networking-host-severity.test.ts new file mode 100644 index 00000000..4bc4b632 --- /dev/null +++ b/backend/src/__tests__/networking-host-severity.test.ts @@ -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'); + }); + } +}); diff --git a/backend/src/__tests__/networking-operator.test.ts b/backend/src/__tests__/networking-operator.test.ts new file mode 100644 index 00000000..78279b28 --- /dev/null +++ b/backend/src/__tests__/networking-operator.test.ts @@ -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 }); + }); +}); diff --git a/backend/src/__tests__/networking-severity.test.ts b/backend/src/__tests__/networking-severity.test.ts new file mode 100644 index 00000000..624c6772 --- /dev/null +++ b/backend/src/__tests__/networking-severity.test.ts @@ -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 = { 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> { + 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); + }); +}); diff --git a/backend/src/__tests__/preflight-rules.test.ts b/backend/src/__tests__/preflight-rules.test.ts index d55cf204..aaab42cd 100644 --- a/backend/src/__tests__/preflight-rules.test.ts +++ b/backend/src/__tests__/preflight-rules.test.ts @@ -32,6 +32,7 @@ function ctx(over: Partial = {}): 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); diff --git a/backend/src/__tests__/sanitize-network-inspect.test.ts b/backend/src/__tests__/sanitize-network-inspect.test.ts new file mode 100644 index 00000000..316f5934 --- /dev/null +++ b/backend/src/__tests__/sanitize-network-inspect.test.ts @@ -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([]); + }); +}); diff --git a/backend/src/__tests__/system-maintenance-self-protect.test.ts b/backend/src/__tests__/system-maintenance-self-protect.test.ts index afcec6bc..760d46f2 100644 --- a/backend/src/__tests__/system-maintenance-self-protect.test.ts +++ b/backend/src/__tests__/system-maintenance-self-protect.test.ts @@ -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); return fake; diff --git a/backend/src/routes/networking.ts b/backend/src/routes/networking.ts index 11d5f59b..fa6817d5 100644 --- a/backend/src/routes/networking.ts +++ b/backend/src/routes/networking.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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' }); + } +}); diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index f4385560..38dba944 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -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); diff --git a/backend/src/services/ComposeDoctorService.ts b/backend/src/services/ComposeDoctorService.ts index 7331bad9..b662ff80 100644 --- a/backend/src/services/ComposeDoctorService.ts +++ b/backend/src/services/ComposeDoctorService.ts @@ -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; accessUrlPorts: Set; 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 = {}; - 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. */ diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 0875d49d..fba0988f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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 { 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, ?, ?, ?, ?)' diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 9ba73ad4..39ba0242 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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(); + 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, diff --git a/backend/src/services/network/composeNetworkInspector.ts b/backend/src/services/network/composeNetworkInspector.ts index 2853b9b8..b8f44799 100644 --- a/backend/src/services/network/composeNetworkInspector.ts +++ b/backend/src/services/network/composeNetworkInspector.ts @@ -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 { +/** Render the effective model and assemble facts, optionally reusing a snapshot. */ +export async function buildStackNetworkFacts( + nodeId: number, + stackName: string, + sharedSnapshot?: DependencySnapshot | null, +): Promise { 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); diff --git a/backend/src/services/network/composeRenderSemaphore.ts b/backend/src/services/network/composeRenderSemaphore.ts new file mode 100644 index 00000000..753ee9e4 --- /dev/null +++ b/backend/src/services/network/composeRenderSemaphore.ts @@ -0,0 +1,33 @@ +interface SemaphoreState { + active: number; + waiters: Array<() => void>; +} + +const MAX_ACTIVE_RENDERS = 4; +const states = new Map(); + +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(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(nodeId: number, work: () => Promise): Promise { + const release = await acquire(nodeId); + try { + return await work(); + } finally { + release(); + } +} diff --git a/backend/src/services/network/doctorNetworkingFindings.ts b/backend/src/services/network/doctorNetworkingFindings.ts new file mode 100644 index 00000000..759e222a --- /dev/null +++ b/backend/src/services/network/doctorNetworkingFindings.ts @@ -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 = { + '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 = { + 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([ + '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(); + 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(); + 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( + (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]; +} diff --git a/backend/src/services/network/exposureContext.ts b/backend/src/services/network/exposureContext.ts new file mode 100644 index 00000000..311596bc --- /dev/null +++ b/backend/src/services/network/exposureContext.ts @@ -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; + accessUrlPorts: Set; + 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 = {}; + 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 }; + } +} diff --git a/backend/src/services/network/networkDeleteGuards.ts b/backend/src/services/network/networkDeleteGuards.ts new file mode 100644 index 00000000..a77b49a6 --- /dev/null +++ b/backend/src/services/network/networkDeleteGuards.ts @@ -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(); + 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 }; +} diff --git a/backend/src/services/network/networkingAggregate.ts b/backend/src/services/network/networkingAggregate.ts new file mode 100644 index 00000000..4db42780 --- /dev/null +++ b/backend/src/services/network/networkingAggregate.ts @@ -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 { + 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>[], + findings: ReturnType, + 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 }; + } +} diff --git a/backend/src/services/network/networkingEnvelope.ts b/backend/src/services/network/networkingEnvelope.ts new file mode 100644 index 00000000..a5c595f1 --- /dev/null +++ b/backend/src/services/network/networkingEnvelope.ts @@ -0,0 +1,23 @@ +import { NETWORKING_SCHEMA_VERSION, type NetworkingEnvelope } from './networkingTypes'; + +export function okEnvelope( + 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, + }; +} diff --git a/backend/src/services/network/networkingFindings.ts b/backend/src/services/network/networkingFindings.ts new file mode 100644 index 00000000..3c4831e7 --- /dev/null +++ b/backend/src/services/network/networkingFindings.ts @@ -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; + +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): 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>(); + 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>(); + 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>(); + for (const container of snapshot.containers) { + if (!container.stack) continue; + for (const attached of container.networks) { + const stacks = stacksByNetwork.get(attached.name) ?? new Set(); + 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(); + 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; +} diff --git a/backend/src/services/network/networkingInventory.ts b/backend/src/services/network/networkingInventory.ts new file mode 100644 index 00000000..cba1d723 --- /dev/null +++ b/backend/src/services/network/networkingInventory.ts @@ -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 { + const byNetwork = new Map(); + 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>(); + const serviceNamesByNetwork = new Map>(); + for (const container of snapshot.containers) { + for (const attached of container.networks) { + const set = stacksByNetwork.get(attached.name) ?? new Set(); + if (container.stack) set.add(container.stack); + stacksByNetwork.set(attached.name, set); + + if (container.service) { + const services = serviceNamesByNetwork.get(attached.name) ?? new Set(); + 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(); + 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, + }; +} diff --git a/backend/src/services/network/networkingSeverity.ts b/backend/src/services/network/networkingSeverity.ts new file mode 100644 index 00000000..101db11b --- /dev/null +++ b/backend/src/services/network/networkingSeverity.ts @@ -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 = { + 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); +} diff --git a/backend/src/services/network/networkingTopology.ts b/backend/src/services/network/networkingTopology.ts new file mode 100644 index 00000000..e624c4b6 --- /dev/null +++ b/backend/src/services/network/networkingTopology.ts @@ -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> { + const byRuntime = new Map>(); + 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(); + 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>>, + 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()] 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>, +): 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; +} diff --git a/backend/src/services/network/networkingTypes.ts b/backend/src/services/network/networkingTypes.ts new file mode 100644 index 00000000..40826342 --- /dev/null +++ b/backend/src/services/network/networkingTypes.ts @@ -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; +} diff --git a/backend/src/services/network/sanitizeNetworkInspect.ts b/backend/src/services/network/sanitizeNetworkInspect.ts new file mode 100644 index 00000000..644e259b --- /dev/null +++ b/backend/src/services/network/sanitizeNetworkInspect.ts @@ -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; + IPAM?: { + Config?: Array<{ Subnet?: string; Gateway?: string }>; + }; + Containers?: Record; +} + +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); +} diff --git a/backend/src/services/preflight/rules.ts b/backend/src/services/preflight/rules.ts index f7b6cd49..de0c4148 100644 --- a/backend/src/services/preflight/rules.ts +++ b/backend/src/services/preflight/rules.ts @@ -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[] = []; diff --git a/backend/src/services/preflight/types.ts b/backend/src/services/preflight/types.ts index cb50591c..2f2a2fc3 100644 --- a/backend/src/services/preflight/types.ts +++ b/backend/src/services/preflight/types.ts @@ -125,6 +125,10 @@ export interface PreflightContext { accessUrlPorts: Set; /** 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; } diff --git a/backend/src/utils/mapWithConcurrency.ts b/backend/src/utils/mapWithConcurrency.ts new file mode 100644 index 00000000..d6929f1b --- /dev/null +++ b/backend/src/utils/mapWithConcurrency.ts @@ -0,0 +1,22 @@ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (!Number.isInteger(limit) || limit < 1) { + throw new Error('Concurrency limit must be a positive integer'); + } + + const results = new Array(items.length); + let nextIndex = 0; + const worker = async (): Promise => { + 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; +} diff --git a/docs/docs.json b/docs/docs.json index fdfaaa41..64928a48 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -102,6 +102,7 @@ "features/editor", "features/stack-file-explorer", "features/resources", + "features/networking", "features/dashboard", "features/app-store", "features/global-search", diff --git a/docs/features/compose-doctor.mdx b/docs/features/compose-doctor.mdx index 4bed6aff..493b265d 100644 --- a/docs/features/compose-doctor.mdx +++ b/docs/features/compose-doctor.mdx @@ -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. diff --git a/docs/features/compose-networking.mdx b/docs/features/compose-networking.mdx index df6ee6ba..bc24e7be 100644 --- a/docs/features/compose-networking.mdx +++ b/docs/features/compose-networking.mdx @@ -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 @@ -180,7 +182,7 @@ See [Compose Doctor](/features/compose-doctor) for the complete check set and se /> -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. diff --git a/docs/features/deep-links.mdx b/docs/features/deep-links.mdx index 5b7cd94e..4af0a2bd 100644 --- a/docs/features/deep-links.mdx +++ b/docs/features/deep-links.mdx @@ -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//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 diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx index 8269165d..7ed0739a 100644 --- a/docs/features/global-search.mdx +++ b/docs/features/global-search.mdx @@ -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. - 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. + 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. ## 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 | diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 652d5839..e1850a83 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -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 diff --git a/docs/features/networking.mdx b/docs/features/networking.mdx new file mode 100644 index 00000000..ffe6351d --- /dev/null +++ b/docs/features/networking.mdx @@ -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 + + + + 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. + + + 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. + + + 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. + + + 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. + + + 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. + + + 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. + + diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 35f5d313..db207aac 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -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 diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 8510d145..0ef140c8 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -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. 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. -### Networks - - - Networks tab in List view with filter chips, view-mode toggle, Create Network button, and the network table - - -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. - - - Create network dialog with Name, Driver, Subnet, Gateway fields, and Internal and Attachable toggles - - -| 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. - - - Network inspect sheet for arr-net showing the Overview, IPAM configuration, and Connected containers sections - - -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. - - - Network topology graph with arr-net at top and the connected containers fanned out below it, mini-map and zoom controls visible - - -- **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. - - - Topology with Show system networks toggled on; arr-net plus the bridge, none, and host system networks all visible at the top - - ### Unmanaged diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 9679191b..db8ae710 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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(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(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).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) => ( setActiveView(selectedFile ? 'editor' : 'dashboard')} onFleetNavigateToNode={handleFleetNavigateToNode} + onOpenNodeNetworking={handleOpenNodeNetworking} filterNodeId={filterNodeId} onClearScheduledOpsFilter={() => setFilterNodeId(null)} schedulePrefill={schedulePrefill} @@ -1039,6 +1088,12 @@ export default function EditorLayout() { ); + case 'networking': + return ( + + + + ); case 'global-observability': // Hub-only, like the desktop content path (ViewRouter); no capability gate. return ( diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 50461fab..b1b62ab9 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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; } @@ -679,6 +680,7 @@ export function EditorView(props: EditorViewProps) { applying={loadingAction === 'update'} canEdit={can('stack:edit', 'stack', stackName)} notifications={notifications} + requestedTab={props.requestedAnatomyTab} /> )} diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 0c436ed4..b5deaf1f 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -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 ; } + if (activeView === 'networking') { + return ( + + + + ); + } 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({ { 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'); diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 6d27e818..4d6e1888 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -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 }, ); diff --git a/frontend/src/components/EditorLayout/mobile-treatments.test.ts b/frontend/src/components/EditorLayout/mobile-treatments.test.ts index 4075d9e6..4139c3f7 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.test.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.test.ts @@ -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'], ); }); }); diff --git a/frontend/src/components/EditorLayout/mobile-treatments.ts b/frontend/src/components/EditorLayout/mobile-treatments.ts index 4846e668..95c58b9d 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.ts @@ -22,6 +22,7 @@ export const MOBILE_TREATMENTS: Record = { settings: 'bespoke', editor: 'detail', resources: 'bespoke', + networking: 'bespoke', security: 'bespoke', templates: 'bespoke', 'global-observability': 'bespoke', diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 0c8b66a2..8fafa571 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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} diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index c88c8d6e..ed864119 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -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; 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(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); @@ -255,6 +259,17 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u Cordoned )} + {onOpenNetworking && networkingSignal && (networkingSignal.exposed || networkingSignal.unknown || networkingSignal.drift) && ( + { event.stopPropagation(); onOpenNetworking(node.id); }} + title="Open this node's Networking page" + > + Networking · + {networkingSignal.drift ? ' drift' : networkingSignal.exposed ? ' exposed' : ' unknown exposure'} + + )} diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index d4c9bce1..46465953 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -29,6 +29,8 @@ interface OverviewTabProps { fleetStackLabelMap: Record>; updateStatusMap: Map; onNavigateToNode: (nodeId: number, stackName: string) => void; + onOpenNodeNetworking: (nodeId: number) => void; + networkingByNode: Map; 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} diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index dbd35e0d..94ce2185 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(screen.queryByText(/Networking/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx index 825b2e66..b9448b09 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx @@ -35,6 +35,8 @@ function props(overrides: Partial> = {} fleetStackLabelMap: {}, updateStatusMap: new Map(), onNavigateToNode: vi.fn(), + onOpenNodeNetworking: vi.fn(), + networkingByNode: new Map(), updatingNodeId: null, topologyMode: 'hub' as const, onTopologyModeChange: vi.fn(), diff --git a/frontend/src/components/FleetView/hooks/useFleetOverview.ts b/frontend/src/components/FleetView/hooks/useFleetOverview.ts index 24d982ac..6f454215 100644 --- a/frontend/src/components/FleetView/hooks/useFleetOverview.ts +++ b/frontend/src/components/FleetView/hooks/useFleetOverview.ts @@ -255,5 +255,6 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee distinctNodeLabels: distinctLabels, activeFilterCount, clearFilters, + networkingByNode, }; } diff --git a/frontend/src/components/NetworkTopologyView.tsx b/frontend/src/components/NetworkTopologyView.tsx index dd0e637d..39f92d7f 100644 --- a/frontend/src/components/NetworkTopologyView.tsx +++ b/frontend/src/components/NetworkTopologyView.tsx @@ -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 { + const containerMap = new Map(); + 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; - 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 }) {
@@ -81,15 +105,24 @@ function ContainerNodeComponent({ data }: { data: ContainerNodeData }) { {data.label}
{data.stack && ( - {data.stack} + { + event.stopPropagation(); + data.onStackClick?.(data.stack!); + }} + > + {data.stack} + )} - {data.networks.length > 0 && ( + {data.attachments.length > 0 && (
- {data.networks.map(netName => ( -
- {netName} + {data.attachments.map(({ network, ip }) => ( +
+ {network} - {data.ipAddresses[netName]?.replace(/\/\d+$/, '') || ''} + {ip?.replace(/\/\d+$/, '') || ''}
))} @@ -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 (
@@ -118,6 +152,11 @@ function NetworkNodeComponent({ data }: { data: { label: string; driver: string; {data.label}
{data.driver} + {(data.exposed || data.drift || data.missing) && ( +
+ {data.missing ? 'missing external' : data.drift ? 'drift' : 'exposed'} +
+ )}
); @@ -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; - 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(); 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([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); 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([]); + 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; 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 ( +
+ +

This graph is too large to render.

+

Narrow the filters to bring it under {TOPOLOGY_RENDER_CAP} nodes and edges, or use the Networks table instead.

+
+ ); + } + if (nodes.length === 0) { return (

- {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.'}

- {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.'}

@@ -306,11 +431,23 @@ export default function NetworkTopologyView({ onContainerClick }: NetworkTopolog return (
-
- - +
+ {showSystemToggle && ( + <> + + + + )} +
+ {TOPOLOGY_LEGEND.map((entry) => ( + + + {entry.label} + + ))} +
- - Search networks - - - )} - n.managedStatus === 'managed').length, - unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, - }} - /> - - ) : ( -
- ) : ( -
- - - - - ID - - - - Status - Actions - - - {isLoading ? : ( - - {networkSort.sorted.length === 0 ? ( - No networks found. - ) : networkSort.sorted.map((net, i) => ( - - {net.Id.substring(0, 12)} - {net.Name} - {net.Driver} - {net.Scope} - -
- window.dispatchEvent( - new CustomEvent(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }), - ) : undefined} - /> - {net.isSencho && } -
-
- -
- - {isAdmin && ( - - - - - - - - {net.isSencho && Protected · running Sencho instance} - - - )} -
-
-
- ))} -
- )} -
-
-
- )} - - {/* Unmanaged Containers */}
@@ -1714,13 +1482,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}

- {/* Create Network Modal */} - - {/* Image Details Sheet */} setBrowseVolume(null)} /> - - {/* Network detail sheet */} - setInspectNetwork(null)} - /> - setInspectScanId(null)} diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 7e6577d9..6dbc502e 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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 (
- +
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 4404012b..bafa76ce 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -59,7 +59,14 @@ export function TopBar({ {/* NAV ZONE: Navigation (hidden on mobile) */} -