mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-29 05:09:10 +00:00
feat(fleet): add read-only dependency map tab (#1324)
* feat(fleet): add read-only dependency map tab Add a fleet-wide Dependencies tab to Fleet view that maps how stacks, services, networks, volumes, and ports relate, with flags for missing dependencies, port conflicts, orphaned resources, and cross-stack shared resources. Read-only; filterable by stack, node, and flag; collapsed by default with a list-view fallback at scale. The graph is derived at request time from Docker and compose metadata, so no new table or persisted state is introduced. A per-node graph endpoint feeds a hub aggregation endpoint that fans out across the fleet and degrades gracefully, surfacing unreachable or unparseable nodes inline while the rest of the map still renders. * fix(fleet): harden dependency map flag detection and remote merge Address review findings on the dependency map: - Port-conflict detection now does pairwise host-scope overlap, so an unrelated bind on the same port and protocol but a different specific host IP is no longer flagged, and the flag lands on the exact scoped port node. - A running service's depends_on target is only considered satisfied when it is actually running, so a crashed (exited) dependency is surfaced while a deliberately stopped stack stays quiet. - Declared external networks and volumes are reported missing when they do not exist on the host instead of being assumed present. - The hub deep-validates each remote node-graph payload before merging, so a reachable-but-malformed remote degrades to a single node error rather than failing the whole fleet map, and the validation failure is logged. - Searching or filtering on a network, volume, or port now also reveals the services that claim it and their stacks.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Unit tests for the dependency-map compose parser: depends_on / network /
|
||||
* named-volume / declared-port extraction, external + name: resolution, and
|
||||
* the fail-soft parseError paths.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
|
||||
|
||||
const svc = (name: string, body: string) => `services:\n ${name}:\n${body.split('\n').map((l) => l ? ' ' + l : l).join('\n')}\n`;
|
||||
|
||||
describe('parseComposeDependencies - services and depends_on', () => {
|
||||
it('extracts depends_on in list form', () => {
|
||||
const r = parseComposeDependencies(svc('web', 'image: nginx\ndepends_on:\n - db\n - cache'));
|
||||
expect(r.services[0].dependsOn).toEqual(['db', 'cache']);
|
||||
});
|
||||
|
||||
it('extracts depends_on in map form', () => {
|
||||
const r = parseComposeDependencies('services:\n web:\n depends_on:\n db:\n condition: service_healthy\n');
|
||||
expect(r.services[0].dependsOn).toEqual(['db']);
|
||||
});
|
||||
|
||||
it('extracts service networks in list and map form', () => {
|
||||
const list = parseComposeDependencies(svc('web', 'networks:\n - frontend\n - backend'));
|
||||
expect(list.services[0].networks).toEqual(['frontend', 'backend']);
|
||||
const map = parseComposeDependencies('services:\n web:\n networks:\n frontend:\n aliases: [w]\n');
|
||||
expect(map.services[0].networks).toEqual(['frontend']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseComposeDependencies - volumes', () => {
|
||||
it('keeps named volumes and drops binds and anonymous volumes', () => {
|
||||
const r = parseComposeDependencies(svc('web', 'volumes:\n - db_data:/var/lib\n - ./local:/app\n - /abs/path:/data\n - /anon-target'));
|
||||
expect(r.services[0].volumes).toEqual(['db_data']);
|
||||
});
|
||||
|
||||
it('keeps a long-form named volume and drops a long-form bind', () => {
|
||||
const r = parseComposeDependencies('services:\n web:\n volumes:\n - type: volume\n source: data\n target: /d\n - type: bind\n source: /host\n target: /b\n');
|
||||
expect(r.services[0].volumes).toEqual(['data']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseComposeDependencies - ports', () => {
|
||||
it('parses short-form host:container with default tcp', () => {
|
||||
const r = parseComposeDependencies(svc('web', 'ports:\n - "8080:80"'));
|
||||
expect(r.services[0].ports).toEqual([{ hostIp: '', publishedPort: 8080, protocol: 'tcp' }]);
|
||||
});
|
||||
|
||||
it('parses ip:host:container and the /udp protocol', () => {
|
||||
const r = parseComposeDependencies(svc('web', 'ports:\n - "127.0.0.1:5353:53/udp"'));
|
||||
expect(r.services[0].ports).toEqual([{ hostIp: '127.0.0.1', publishedPort: 5353, protocol: 'udp' }]);
|
||||
});
|
||||
|
||||
it('drops a container-only port (no host publish)', () => {
|
||||
const r = parseComposeDependencies(svc('web', 'ports:\n - "80"'));
|
||||
expect(r.services[0].ports).toEqual([]);
|
||||
});
|
||||
|
||||
it('takes the low end of a published range', () => {
|
||||
const r = parseComposeDependencies(svc('web', 'ports:\n - "8000-8002:80"'));
|
||||
expect(r.services[0].ports[0].publishedPort).toBe(8000);
|
||||
});
|
||||
|
||||
it('parses long-form ports with host_ip and protocol', () => {
|
||||
const r = parseComposeDependencies('services:\n web:\n ports:\n - target: 80\n published: 8080\n host_ip: 10.0.0.5\n protocol: udp\n');
|
||||
expect(r.services[0].ports).toEqual([{ hostIp: '10.0.0.5', publishedPort: 8080, protocol: 'udp' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseComposeDependencies - top-level resources', () => {
|
||||
it('normalizes external (bool), legacy external object, and name: override', () => {
|
||||
const r = parseComposeDependencies('services:\n web:\n image: nginx\nnetworks:\n a:\n b:\n external: true\n c:\n external:\n name: legacy_net\n d:\n name: custom_net\nvolumes:\n v:\n external: true\n');
|
||||
expect(r.networks.a).toEqual({ external: false });
|
||||
expect(r.networks.b).toEqual({ external: true });
|
||||
expect(r.networks.c).toEqual({ name: 'legacy_net', external: true });
|
||||
expect(r.networks.d).toEqual({ name: 'custom_net', external: false });
|
||||
expect(r.volumes.v).toEqual({ external: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseComposeDependencies - fail soft', () => {
|
||||
it('reports a parseError for invalid YAML and never throws', () => {
|
||||
const r = parseComposeDependencies('services:\n web:\n - this: : is broken\n :::');
|
||||
expect(r.parseError).toBeTruthy();
|
||||
expect(r.services).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a parseError when there are no services', () => {
|
||||
const r = parseComposeDependencies('networks:\n a:\n');
|
||||
expect(r.parseError).toBe('No services found in this file.');
|
||||
});
|
||||
|
||||
it('reports a parseError for an oversized file', () => {
|
||||
const r = parseComposeDependencies('x'.repeat(1_048_577));
|
||||
expect(r.parseError).toBe('Compose file is too large to parse.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Unit tests for the dependency-map graph builder: runtime edge derivation,
|
||||
* the four anomaly flags (missing dependency, port conflict, orphan,
|
||||
* cross-stack shared), and fail-soft compose parsing.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
assembleGraph,
|
||||
detectPortConflicts,
|
||||
detectMissingDependencies,
|
||||
buildLocalGraph,
|
||||
type PortClaim,
|
||||
} from '../services/DependencyGraphService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import type {
|
||||
DependencySnapshot,
|
||||
DependencyContainer,
|
||||
DependencyNetwork,
|
||||
DependencyVolume,
|
||||
} from '../services/DockerController';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import type { DeclaredCompose } from '../helpers/composeDependencyParse';
|
||||
|
||||
// ── builders ────────────────────────────────────────────────────────────
|
||||
|
||||
const emptyDeclared = (): DeclaredCompose => ({ services: [], networks: {}, volumes: {} });
|
||||
|
||||
function snap(partial: Partial<DependencySnapshot>): DependencySnapshot {
|
||||
return { containers: [], networks: [], volumes: [], ...partial };
|
||||
}
|
||||
|
||||
function container(p: Partial<DependencyContainer> & { id: string }): DependencyContainer {
|
||||
return {
|
||||
name: p.id, service: null, composeProject: null, stack: null,
|
||||
state: 'running', image: 'img:latest', networks: [], volumes: [], ports: [], ...p,
|
||||
};
|
||||
}
|
||||
|
||||
function network(p: Partial<DependencyNetwork> & { name: string }): DependencyNetwork {
|
||||
return { id: p.name, driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null, ...p };
|
||||
}
|
||||
|
||||
function volume(p: Partial<DependencyVolume> & { name: string }): DependencyVolume {
|
||||
return { driver: 'local', composeProject: null, stack: null, ...p };
|
||||
}
|
||||
|
||||
const declMap = (entries: [string, DeclaredCompose][]): Map<string, DeclaredCompose> => new Map(entries);
|
||||
|
||||
// ── assembleGraph: runtime edges + identity ──────────────────────────────
|
||||
|
||||
describe('assembleGraph - runtime structure', () => {
|
||||
it('emits host, stack, service nodes with compose service identity and runtime edges', () => {
|
||||
const snapshot = snap({
|
||||
containers: [
|
||||
container({
|
||||
id: 'c1', name: 'web-1', service: 'web', composeProject: 'web', stack: 'web',
|
||||
networks: [{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }],
|
||||
volumes: ['web_data'],
|
||||
ports: [{ ip: '', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }],
|
||||
}),
|
||||
],
|
||||
networks: [network({ name: 'web_frontend', id: 'net1', composeProject: 'web', stack: 'web' })],
|
||||
volumes: [volume({ name: 'web_data', composeProject: 'web', stack: 'web' })],
|
||||
});
|
||||
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', emptyDeclared()]]), parseErrors: [] });
|
||||
|
||||
expect(g.nodes.find((n) => n.kind === 'host')?.label).toBe('hub');
|
||||
const svc = g.nodes.find((n) => n.id === 'svc:web:web');
|
||||
expect(svc?.kind).toBe('service');
|
||||
expect(svc?.label).toBe('web');
|
||||
expect(g.nodes.some((n) => n.id === 'net:web_frontend')).toBe(true);
|
||||
expect(g.nodes.some((n) => n.id === 'vol:web_data')).toBe(true);
|
||||
expect(g.nodes.some((n) => n.kind === 'port' && n.id === 'port:*:8080/tcp')).toBe(true);
|
||||
|
||||
const kinds = g.edges.map((e) => e.kind);
|
||||
expect(kinds).toContain('stack-node');
|
||||
expect(kinds).toContain('stack-service');
|
||||
expect(kinds).toContain('service-network');
|
||||
expect(kinds).toContain('service-volume');
|
||||
expect(kinds).toContain('service-port');
|
||||
});
|
||||
|
||||
it('skips system networks (bridge/host/none)', () => {
|
||||
const snapshot = snap({
|
||||
containers: [container({ id: 'c1', service: 'app', stack: 'app', networks: [{ name: 'bridge', id: 'b', ip: '' }] })],
|
||||
networks: [network({ name: 'bridge', id: 'b', isSystem: true })],
|
||||
});
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['app'], snapshot, declaredByStack: declMap([['app', emptyDeclared()]]), parseErrors: [] });
|
||||
expect(g.nodes.some((n) => n.id === 'net:bridge')).toBe(false);
|
||||
expect(g.edges.some((e) => e.kind === 'service-network')).toBe(false);
|
||||
});
|
||||
|
||||
it('passes compose parse errors through unchanged', () => {
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot: snap({}), declaredByStack: declMap([['web', emptyDeclared()]]), parseErrors: [{ stack: 'web', error: 'bad yaml' }] });
|
||||
expect(g.parseErrors).toEqual([{ stack: 'web', error: 'bad yaml' }]);
|
||||
});
|
||||
|
||||
it('returns only the host node for an empty node', () => {
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot: snap({}), declaredByStack: new Map(), parseErrors: [] });
|
||||
expect(g.nodes).toHaveLength(1);
|
||||
expect(g.nodes[0].kind).toBe('host');
|
||||
expect(g.flags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scales to a large stack without error', () => {
|
||||
const containers: DependencyContainer[] = [];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
containers.push(container({ id: `c${i}`, service: `svc${i}`, stack: 'big' }));
|
||||
}
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['big'], snapshot: snap({ containers }), declaredByStack: declMap([['big', emptyDeclared()]]), parseErrors: [] });
|
||||
expect(g.nodes.filter((n) => n.kind === 'service')).toHaveLength(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ── assembleGraph: declared-only services + depends_on ───────────────────
|
||||
|
||||
describe('assembleGraph - declared services and depends_on', () => {
|
||||
it('adds declared-only service nodes (absent) with a depends-on edge and missing-dependency flag', () => {
|
||||
const snapshot = snap({
|
||||
containers: [container({ id: 'c1', service: 'web', stack: 'web' })],
|
||||
});
|
||||
const declared: DeclaredCompose = {
|
||||
services: [
|
||||
{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] },
|
||||
{ name: 'db', dependsOn: [], networks: [], volumes: [], ports: [] },
|
||||
],
|
||||
networks: {}, volumes: {},
|
||||
};
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
|
||||
|
||||
const db = g.nodes.find((n) => n.id === 'svc:web:db');
|
||||
expect(db?.state).toBe('absent');
|
||||
expect(g.edges.some((e) => e.kind === 'depends-on' && e.declaredOnly && e.source === 'svc:web:web' && e.target === 'svc:web:db')).toBe(true);
|
||||
expect(g.flags.some((f) => f.kind === 'missing-dependency')).toBe(true);
|
||||
expect(g.nodes.find((n) => n.id === 'svc:web:web')?.flags).toContain('missing-dependency');
|
||||
});
|
||||
|
||||
it('flags a depends_on target whose container is exited (not just absent)', () => {
|
||||
const snapshot = snap({
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'web', stack: 'web', state: 'running' }),
|
||||
container({ id: 'c2', service: 'db', stack: 'web', state: 'exited' }),
|
||||
],
|
||||
});
|
||||
const declared: DeclaredCompose = {
|
||||
services: [
|
||||
{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] },
|
||||
{ name: 'db', dependsOn: [], networks: [], volumes: [], ports: [] },
|
||||
],
|
||||
networks: {}, volumes: {},
|
||||
};
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
|
||||
expect(g.flags.some((f) => f.kind === 'missing-dependency')).toBe(true);
|
||||
expect(g.nodes.find((n) => n.id === 'svc:web:web')?.flags).toContain('missing-dependency');
|
||||
});
|
||||
|
||||
it('does not flag internal dependencies of a fully stopped stack', () => {
|
||||
const snapshot = snap({
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'web', stack: 'web', state: 'exited' }),
|
||||
container({ id: 'c2', service: 'db', stack: 'web', state: 'exited' }),
|
||||
],
|
||||
});
|
||||
const declared: DeclaredCompose = {
|
||||
services: [
|
||||
{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] },
|
||||
{ name: 'db', dependsOn: [], networks: [], volumes: [], ports: [] },
|
||||
],
|
||||
networks: {}, volumes: {},
|
||||
};
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
|
||||
expect(g.flags.some((f) => f.kind === 'missing-dependency')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── assembleGraph: flags ─────────────────────────────────────────────────
|
||||
|
||||
describe('assembleGraph - flags', () => {
|
||||
it('flags a cross-stack shared network', () => {
|
||||
const snapshot = snap({
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'a', stack: 'alpha', networks: [{ name: 'shared', id: 'sh', ip: '' }] }),
|
||||
container({ id: 'c2', service: 'b', stack: 'beta', networks: [{ name: 'shared', id: 'sh', ip: '' }] }),
|
||||
],
|
||||
networks: [network({ name: 'shared', id: 'sh' })],
|
||||
});
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['alpha', 'beta'], snapshot, declaredByStack: declMap([['alpha', emptyDeclared()], ['beta', emptyDeclared()]]), parseErrors: [] });
|
||||
expect(g.nodes.find((n) => n.id === 'net:shared')?.flags).toContain('cross-stack-shared');
|
||||
expect(g.flags.some((f) => f.kind === 'cross-stack-shared')).toBe(true);
|
||||
});
|
||||
|
||||
it('flags an orphaned (unmanaged, unreferenced) network and volume', () => {
|
||||
const snapshot = snap({
|
||||
networks: [network({ name: 'dangling_net', id: 'dn', stack: null })],
|
||||
volumes: [volume({ name: 'dangling_vol', stack: null })],
|
||||
});
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot, declaredByStack: new Map(), parseErrors: [] });
|
||||
expect(g.nodes.find((n) => n.id === 'net:dangling_net')?.flags).toContain('orphan');
|
||||
expect(g.nodes.find((n) => n.id === 'vol:dangling_vol')?.flags).toContain('orphan');
|
||||
expect(g.flags.filter((f) => f.kind === 'orphan')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not flag a system network as orphan', () => {
|
||||
const snapshot = snap({ networks: [network({ name: 'bridge', id: 'b', isSystem: true })] });
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot, declaredByStack: new Map(), parseErrors: [] });
|
||||
expect(g.flags.some((f) => f.kind === 'orphan')).toBe(false);
|
||||
});
|
||||
|
||||
it('represents an orphan container as a flagged synthetic stack', () => {
|
||||
const snapshot = snap({
|
||||
containers: [container({ id: 'c1', name: 'ghost-1', service: 'ghost', composeProject: 'ghoststack', stack: null })],
|
||||
});
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', emptyDeclared()]]), parseErrors: [] });
|
||||
const orphanStack = g.nodes.find((n) => n.id === 'stack:__orphan__:ghoststack');
|
||||
expect(orphanStack?.flags).toContain('orphan');
|
||||
expect(g.nodes.some((n) => n.id === 'svc:__orphan__:ghoststack:ghost')).toBe(true);
|
||||
});
|
||||
|
||||
it('groups multiple orphan containers under their compose project', () => {
|
||||
const snapshot = snap({
|
||||
containers: [
|
||||
container({ id: 'c1', name: 'g-1', service: 'svcA', composeProject: 'ghost', stack: null }),
|
||||
container({ id: 'c2', name: 'g-2', service: 'svcB', composeProject: 'ghost', stack: null }),
|
||||
container({ id: 'c3', name: 'h-1', service: 'svcC', composeProject: 'other', stack: null }),
|
||||
],
|
||||
});
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot, declaredByStack: new Map(), parseErrors: [] });
|
||||
expect(g.nodes.filter((n) => n.id.startsWith('stack:__orphan__:')).length).toBe(2);
|
||||
expect(g.nodes.filter((n) => n.id.startsWith('svc:__orphan__:ghost:')).length).toBe(2);
|
||||
});
|
||||
|
||||
it('attaches the port-conflict flag to the port node and the claiming services', () => {
|
||||
const snapshot = snap({
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'a', stack: 'alpha', ports: [{ ip: '', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }] }),
|
||||
container({ id: 'c2', service: 'b', stack: 'beta', ports: [{ ip: '', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }] }),
|
||||
],
|
||||
});
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['alpha', 'beta'], snapshot, declaredByStack: declMap([['alpha', emptyDeclared()], ['beta', emptyDeclared()]]), parseErrors: [] });
|
||||
expect(g.nodes.find((n) => n.id === 'port:*:8080/tcp')?.flags).toContain('port-conflict');
|
||||
expect(g.nodes.find((n) => n.id === 'svc:alpha:a')?.flags).toContain('port-conflict');
|
||||
const flag = g.flags.find((f) => f.kind === 'port-conflict');
|
||||
expect(flag?.subjects).toContain('port:*:8080/tcp');
|
||||
});
|
||||
|
||||
it('flags a declared volume that does not exist at runtime', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: [], volumes: ['data'], ports: [] }], networks: {}, volumes: { data: { external: false } } };
|
||||
const snapshot = snap({ containers: [container({ id: 'c1', service: 'web', stack: 'web' })] });
|
||||
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
|
||||
const missing = g.flags.filter((f) => f.kind === 'missing-dependency');
|
||||
expect(missing.length).toBe(1);
|
||||
expect(g.nodes.find((n) => n.id === 'svc:web:web')?.flags).toContain('missing-dependency');
|
||||
});
|
||||
});
|
||||
|
||||
// ── detectPortConflicts ──────────────────────────────────────────────────
|
||||
|
||||
describe('detectPortConflicts', () => {
|
||||
const claim = (p: Partial<PortClaim>): PortClaim => ({ stack: 's', service: 'svc', hostIp: '', publishedPort: 80, protocol: 'tcp', ...p });
|
||||
|
||||
it('flags two stacks claiming the same host port', () => {
|
||||
const conflicts = detectPortConflicts([claim({ stack: 'a', publishedPort: 8080 }), claim({ stack: 'b', publishedPort: 8080 })]);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0].port).toBe(8080);
|
||||
expect(conflicts[0].protocol).toBe('tcp');
|
||||
});
|
||||
|
||||
it('flags only the clashing specific-IP claimants, not an unrelated bind on the same port', () => {
|
||||
const conflicts = detectPortConflicts([
|
||||
claim({ stack: 'a', service: 'x', hostIp: '10.0.0.1', publishedPort: 443 }),
|
||||
claim({ stack: 'b', service: 'y', hostIp: '10.0.0.1', publishedPort: 443 }),
|
||||
claim({ stack: 'c', service: 'z', hostIp: '10.0.0.2', publishedPort: 443 }),
|
||||
]);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0].scopes).toEqual(['10.0.0.1']);
|
||||
const stacks = conflicts[0].claimants.map((c) => c.stack).sort();
|
||||
expect(stacks).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not flag IPv4 + IPv6 bindings of one service publish', () => {
|
||||
const conflicts = detectPortConflicts([
|
||||
claim({ stack: 'a', service: 'x', hostIp: '0.0.0.0', publishedPort: 8080 }),
|
||||
claim({ stack: 'a', service: 'x', hostIp: '::', publishedPort: 8080 }),
|
||||
]);
|
||||
expect(conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags two services in the same stack claiming the same port', () => {
|
||||
const conflicts = detectPortConflicts([claim({ stack: 'a', service: 'x', publishedPort: 9000 }), claim({ stack: 'a', service: 'y', publishedPort: 9000 })]);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not flag tcp vs udp on the same port', () => {
|
||||
const conflicts = detectPortConflicts([claim({ stack: 'a', publishedPort: 53, protocol: 'tcp' }), claim({ stack: 'b', publishedPort: 53, protocol: 'udp' })]);
|
||||
expect(conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not flag the same port bound to different specific host IPs', () => {
|
||||
const conflicts = detectPortConflicts([claim({ stack: 'a', hostIp: '10.0.0.1', publishedPort: 443 }), claim({ stack: 'b', hostIp: '10.0.0.2', publishedPort: 443 })]);
|
||||
expect(conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags the same port bound to the same specific host IP', () => {
|
||||
const conflicts = detectPortConflicts([claim({ stack: 'a', hostIp: '10.0.0.1', publishedPort: 443 }), claim({ stack: 'b', hostIp: '10.0.0.1', publishedPort: 443 })]);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('flags a wildcard bind clashing with a specific-IP bind', () => {
|
||||
const conflicts = detectPortConflicts([claim({ stack: 'a', hostIp: '', publishedPort: 443 }), claim({ stack: 'b', hostIp: '10.0.0.2', publishedPort: 443 })]);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not flag a single claimant', () => {
|
||||
expect(detectPortConflicts([claim({ publishedPort: 80 })])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── detectMissingDependencies ────────────────────────────────────────────
|
||||
|
||||
describe('detectMissingDependencies', () => {
|
||||
const base = (declared: DeclaredCompose, over: Partial<Parameters<typeof detectMissingDependencies>[0]> = {}) => ({
|
||||
stack: 'web', declared, runningServices: new Set<string>(['web']), hasContainers: true,
|
||||
stackNetworkNames: ['web_frontend'], stackVolumeNames: ['web_data'],
|
||||
allNetworkNames: new Set(['web_frontend']), allVolumeNames: new Set(['web_data']), ...over,
|
||||
});
|
||||
|
||||
it('returns nothing for a stack with no containers', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] }], networks: {}, volumes: {} };
|
||||
expect(detectMissingDependencies(base(declared, { hasContainers: false }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags a depends_on target that is not running', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] }], networks: {}, volumes: {} };
|
||||
const out = detectMissingDependencies(base(declared));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].kind).toBe('service');
|
||||
expect(out[0].target).toBe('db');
|
||||
});
|
||||
|
||||
it('does not flag an external network that exists on the host', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { external: true } }, volumes: {} };
|
||||
expect(detectMissingDependencies(base(declared, { allNetworkNames: new Set(['proxy']) }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags an external network that does not exist on the host', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { external: true } }, volumes: {} };
|
||||
const out = detectMissingDependencies(base(declared, { allNetworkNames: new Set() }));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].kind).toBe('network');
|
||||
});
|
||||
|
||||
it('does not flag a network present via the project-prefix suffix match', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['frontend'], volumes: [], ports: [] }], networks: { frontend: { external: false } }, volumes: {} };
|
||||
expect(detectMissingDependencies(base(declared))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches a network by its explicit name: override', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { name: 'shared_net', external: false } }, volumes: {} };
|
||||
expect(detectMissingDependencies(base(declared, { stackNetworkNames: [], allNetworkNames: new Set(['shared_net']) }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags a name:-override network that is absent at runtime', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { name: 'shared_net', external: false } }, volumes: {} };
|
||||
const out = detectMissingDependencies(base(declared, { stackNetworkNames: [], allNetworkNames: new Set() }));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].kind).toBe('network');
|
||||
});
|
||||
|
||||
it('flags a declared network that does not exist at runtime', () => {
|
||||
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['backend'], volumes: [], ports: [] }], networks: { backend: { external: false } }, volumes: {} };
|
||||
const out = detectMissingDependencies(base(declared));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].kind).toBe('network');
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildLocalGraph: fail-soft orchestration (mocked) ────────────────────
|
||||
|
||||
describe('buildLocalGraph - fail-soft', () => {
|
||||
it('records a parse error for an unreadable stack and still builds the rest', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue(snap({})),
|
||||
} as unknown as DockerController);
|
||||
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
|
||||
getStacks: vi.fn().mockResolvedValue(['web', 'broken']),
|
||||
getStackContent: vi.fn().mockImplementation(async (s: string) => {
|
||||
if (s === 'broken') throw new Error('EISDIR');
|
||||
return 'services:\n web:\n image: nginx';
|
||||
}),
|
||||
} as unknown as FileSystemService);
|
||||
|
||||
const g = await buildLocalGraph(1, 'hub');
|
||||
expect(g.parseErrors.some((p) => p.stack === 'broken')).toBe(true);
|
||||
expect(g.nodes.some((n) => n.id === 'stack:web')).toBe(true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Unit tests for the fleet dependency-map merge: per-node id namespacing,
|
||||
* authoritative node attribution, and graceful partial-failure handling.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult, type LocalDependencyGraph } from '../services/DependencyGraphService';
|
||||
|
||||
function localGraph(nodeId: number, nodeName: string, stack: string): LocalDependencyGraph {
|
||||
return {
|
||||
nodeId,
|
||||
nodeName,
|
||||
nodes: [
|
||||
{ id: 'host', kind: 'host', label: nodeName, nodeId, nodeName, stack: null, flags: [] },
|
||||
{ id: `stack:${stack}`, kind: 'stack', label: stack, nodeId, nodeName, stack, flags: [] },
|
||||
],
|
||||
edges: [{ id: `e:host-stack:${stack}`, source: 'host', target: `stack:${stack}`, kind: 'stack-node' }],
|
||||
flags: [{ kind: 'orphan', nodeId, nodeName, subjects: [`stack:${stack}`], detail: 'x' }],
|
||||
parseErrors: [{ stack, error: 'bad' }],
|
||||
};
|
||||
}
|
||||
|
||||
const ok = (nodeId: number, nodeName: string, stack: string): FleetNodeGraphResult => ({
|
||||
nodeId, nodeName, status: 'ok', graph: localGraph(nodeId, nodeName, stack), error: null,
|
||||
});
|
||||
const err = (nodeId: number, nodeName: string, error: string): FleetNodeGraphResult => ({
|
||||
nodeId, nodeName, status: 'error', graph: null, error,
|
||||
});
|
||||
|
||||
describe('mergeFleetGraph', () => {
|
||||
it('namespaces ids by node so identical stack names stay distinct', () => {
|
||||
const merged = mergeFleetGraph([ok(1, 'hub', 'web'), ok(2, 'edge', 'web')]);
|
||||
const stackNodes = merged.nodes.filter((n) => n.kind === 'stack');
|
||||
expect(stackNodes.map((n) => n.id).sort()).toEqual(['n1:stack:web', 'n2:stack:web']);
|
||||
expect(merged.edges.map((e) => e.id).sort()).toEqual(['n1:e:host-stack:web', 'n2:e:host-stack:web']);
|
||||
});
|
||||
|
||||
it('re-stamps host label and attribution from the hub registry', () => {
|
||||
const graph = localGraph(1, 'wrong-self-name', 'web');
|
||||
const merged = mergeFleetGraph([{ nodeId: 7, nodeName: 'authoritative', status: 'ok', graph, error: null }]);
|
||||
const host = merged.nodes.find((n) => n.kind === 'host');
|
||||
expect(host?.label).toBe('authoritative');
|
||||
expect(host?.nodeName).toBe('authoritative');
|
||||
expect(host?.id).toBe('n7:host');
|
||||
});
|
||||
|
||||
it('namespaces flag subjects and carries parse errors with node attribution', () => {
|
||||
const merged = mergeFleetGraph([ok(3, 'edge', 'api')]);
|
||||
expect(merged.flags[0].subjects).toEqual(['n3:stack:api']);
|
||||
expect(merged.parseErrors).toEqual([{ nodeId: 3, nodeName: 'edge', stack: 'api', error: 'bad' }]);
|
||||
});
|
||||
|
||||
it('degrades a failed node to nodeErrors while keeping healthy nodes', () => {
|
||||
const merged = mergeFleetGraph([ok(1, 'hub', 'web'), err(2, 'edge', 'unreachable')]);
|
||||
expect(merged.nodeErrors).toEqual([{ nodeId: 2, nodeName: 'edge', error: 'unreachable' }]);
|
||||
expect(merged.nodes.some((n) => n.id === 'n1:stack:web')).toBe(true);
|
||||
expect(merged.nodes.some((n) => n.id.startsWith('n2:'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns an empty graph with full nodeErrors when every node fails', () => {
|
||||
const merged = mergeFleetGraph([err(1, 'hub', 'down'), err(2, 'edge', 'down')]);
|
||||
expect(merged.nodes).toHaveLength(0);
|
||||
expect(merged.edges).toHaveLength(0);
|
||||
expect(merged.nodeErrors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocalDependencyGraph', () => {
|
||||
it('accepts a well-formed graph and one with parseErrors absent', () => {
|
||||
expect(isLocalDependencyGraph({ nodes: [{ id: 'host', flags: [] }], edges: [{ id: 'e', source: 'a', target: 'b' }], flags: [{ subjects: ['a'] }], parseErrors: [{ stack: 's', error: 'x' }] })).toBe(true);
|
||||
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects null and non-array core fields', () => {
|
||||
expect(isLocalDependencyGraph(null)).toBe(false);
|
||||
expect(isLocalDependencyGraph({ nodes: {}, edges: [], flags: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a node missing its flags array', () => {
|
||||
expect(isLocalDependencyGraph({ nodes: [{ id: 'x' }], edges: [], flags: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects flags whose subjects are missing or non-string (would corrupt merge)', () => {
|
||||
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [{}] })).toBe(false);
|
||||
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [{ subjects: [1] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed parseErrors elements (would throw in merge)', () => {
|
||||
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [], parseErrors: [null] })).toBe(false);
|
||||
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [], parseErrors: ['oops'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ vi.mock('util', () => ({
|
||||
}));
|
||||
|
||||
import DockerController from '../services/DockerController';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -1111,3 +1112,70 @@ describe('DockerController - disconnectContainerFromNetwork', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getDependencySnapshot ──────────────────────────────────────────────
|
||||
|
||||
describe('DockerController - getDependencySnapshot', () => {
|
||||
beforeEach(() => {
|
||||
// resolveProjectNameMap caches under a constant key; clear it so each test
|
||||
// resolves stack ownership from its own mocked compose set.
|
||||
CacheService.getInstance().invalidate('project-name-map');
|
||||
});
|
||||
|
||||
it('maps service identity, networks, volume mounts, and published ports', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
{
|
||||
Id: 'abc123def456',
|
||||
Names: ['/web-1'],
|
||||
Image: 'nginx:alpine',
|
||||
State: 'running',
|
||||
Labels: { 'com.docker.compose.project': 'web', 'com.docker.compose.service': 'api' },
|
||||
NetworkSettings: { Networks: { web_frontend: { NetworkID: 'net1', IPAddress: '172.18.0.2' } } },
|
||||
Mounts: [
|
||||
{ Type: 'volume', Name: 'web_data', Destination: '/data' },
|
||||
{ Type: 'bind', Source: '/host/path', Destination: '/app' },
|
||||
],
|
||||
Ports: [
|
||||
{ IP: '0.0.0.0', PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
|
||||
{ PrivatePort: 9090, Type: 'tcp' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
mockDocker.listNetworks.mockResolvedValue([
|
||||
{ Id: 'b', Name: 'bridge' },
|
||||
{ Id: 'net1', Name: 'web_frontend', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'web' } },
|
||||
]);
|
||||
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'web_data', Driver: 'local', Labels: { 'com.docker.compose.project': 'web' } }] });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const snap = await dc.getDependencySnapshot(['web']);
|
||||
|
||||
const c = snap.containers[0];
|
||||
expect(c.service).toBe('api');
|
||||
expect(c.stack).toBe('web');
|
||||
expect(c.networks).toEqual([{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }]);
|
||||
expect(c.volumes).toEqual(['web_data']); // bind mount dropped
|
||||
expect(c.ports).toEqual([{ ip: '0.0.0.0', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }]); // unpublished 9090 dropped
|
||||
|
||||
expect(snap.networks.find((n) => n.name === 'bridge')?.isSystem).toBe(true);
|
||||
const frontend = snap.networks.find((n) => n.name === 'web_frontend');
|
||||
expect(frontend?.isSystem).toBe(false);
|
||||
expect(frontend?.stack).toBe('web');
|
||||
|
||||
expect(snap.volumes[0]).toMatchObject({ name: 'web_data', stack: 'web', composeProject: 'web' });
|
||||
});
|
||||
|
||||
it('classifies a non-compose container as having no service or stack', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
{ Id: 'x', Names: ['/manual'], Image: 'redis', State: 'running', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
|
||||
]);
|
||||
mockDocker.listNetworks.mockResolvedValue([]);
|
||||
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const snap = await dc.getDependencySnapshot([]);
|
||||
expect(snap.containers[0].service).toBeNull();
|
||||
expect(snap.containers[0].stack).toBeNull();
|
||||
expect(snap.containers[0].composeProject).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import YAML from 'yaml';
|
||||
|
||||
// Refuse to parse anything beyond this bound so a malformed (or adversarial)
|
||||
// compose file cannot exhaust heap while building the dependency map. Mirrors
|
||||
// the cap in composePreview.ts.
|
||||
const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB
|
||||
|
||||
/** A host-published port declared in a compose service. */
|
||||
export interface DeclaredPort {
|
||||
/** Host interface ('' means all interfaces). */
|
||||
hostIp: string;
|
||||
publishedPort: number;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
export interface DeclaredService {
|
||||
name: string;
|
||||
/** depends_on targets (list or map form, normalized to names). */
|
||||
dependsOn: string[];
|
||||
/** Service-level network keys (list or map form). */
|
||||
networks: string[];
|
||||
/** Named-volume source keys only (bind mounts and anonymous volumes excluded). */
|
||||
volumes: string[];
|
||||
ports: DeclaredPort[];
|
||||
}
|
||||
|
||||
/** A top-level networks:/volumes: entry. */
|
||||
export interface DeclaredResource {
|
||||
/** Explicit `name:` override, when set. */
|
||||
name?: string;
|
||||
external: boolean;
|
||||
}
|
||||
|
||||
export interface DeclaredCompose {
|
||||
services: DeclaredService[];
|
||||
networks: Record<string, DeclaredResource>;
|
||||
volumes: Record<string, DeclaredResource>;
|
||||
/** Set when the file could not be parsed; the other fields are then empty. */
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number') return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Extracts keys from a depends_on / networks block in list or map form. */
|
||||
function collectKeys(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(asString).filter((v): v is string => v !== undefined);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value as Record<string, unknown>);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** True when a volume source is a named volume (not a bind mount or anonymous). */
|
||||
function isNamedVolumeSource(source: string): boolean {
|
||||
if (!source) return false;
|
||||
if (source.includes('/')) return false; // bind mount path
|
||||
if (source.startsWith('.') || source.startsWith('~')) return false;
|
||||
if (/^[a-zA-Z]:[\\/]/.test(source)) return false; // Windows drive path
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pulls the named-volume source from a service volume entry, or null. */
|
||||
function namedVolumeSource(entry: unknown): string | null {
|
||||
const short = asString(entry);
|
||||
if (short !== undefined) {
|
||||
const source = short.split(':')[0];
|
||||
return isNamedVolumeSource(source) ? source : null;
|
||||
}
|
||||
if (entry && typeof entry === 'object') {
|
||||
const obj = entry as Record<string, unknown>;
|
||||
if (obj.type === 'volume') {
|
||||
const source = asString(obj.source);
|
||||
return source ? source : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parses a service `ports:` entry into a host-published port, or null. */
|
||||
function declaredPort(entry: unknown): DeclaredPort | null {
|
||||
const short = asString(entry);
|
||||
if (short !== undefined) {
|
||||
const [spec, proto] = short.split('/');
|
||||
const parts = spec.split(':');
|
||||
let hostIp = '';
|
||||
let hostPart: string | undefined;
|
||||
if (parts.length >= 3) {
|
||||
hostIp = parts[0];
|
||||
hostPart = parts[1];
|
||||
} else if (parts.length === 2) {
|
||||
hostPart = parts[0];
|
||||
} else {
|
||||
return null; // container-only EXPOSE, not host-published
|
||||
}
|
||||
const published = parseInt((hostPart ?? '').split('-')[0], 10);
|
||||
if (!Number.isFinite(published) || published <= 0) return null;
|
||||
return { hostIp, publishedPort: published, protocol: proto || 'tcp' };
|
||||
}
|
||||
if (entry && typeof entry === 'object') {
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const publishedRaw = asString(obj.published);
|
||||
if (publishedRaw === undefined) return null; // not host-published
|
||||
const published = parseInt(publishedRaw.split('-')[0], 10);
|
||||
if (!Number.isFinite(published) || published <= 0) return null;
|
||||
return {
|
||||
hostIp: asString(obj.host_ip) ?? '',
|
||||
publishedPort: published,
|
||||
protocol: asString(obj.protocol) ?? 'tcp',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Normalizes a top-level networks:/volumes: entry to { name?, external }. */
|
||||
function declaredResource(entry: unknown): DeclaredResource {
|
||||
if (!entry || typeof entry !== 'object') return { external: false };
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const name = asString(obj.name);
|
||||
// `external` may be a boolean or the legacy `{ name: ... }` object form.
|
||||
let external = false;
|
||||
let externalName: string | undefined;
|
||||
if (obj.external === true) {
|
||||
external = true;
|
||||
} else if (obj.external && typeof obj.external === 'object') {
|
||||
external = true;
|
||||
externalName = asString((obj.external as Record<string, unknown>).name);
|
||||
}
|
||||
const resolved = name ?? externalName;
|
||||
return resolved ? { name: resolved, external } : { external };
|
||||
}
|
||||
|
||||
function collectResources(value: unknown): Record<string, DeclaredResource> {
|
||||
const out: Record<string, DeclaredResource> = {};
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = declaredResource(entry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a compose file's declared dependency metadata: per-service depends_on,
|
||||
* networks, named volumes, and host-published ports, plus the top-level
|
||||
* networks:/volumes: declarations with their name: overrides and external
|
||||
* flags. Never throws: parse failures are reported through `parseError`.
|
||||
*
|
||||
* Runtime edges in the dependency map come from the live container snapshot;
|
||||
* this declared view is used only to flag missing dependencies and to gather
|
||||
* declared port claimants for conflict detection.
|
||||
*/
|
||||
export function parseComposeDependencies(content: string): DeclaredCompose {
|
||||
if (content.length > MAX_COMPOSE_PARSE_BYTES) {
|
||||
return { services: [], networks: {}, volumes: {}, parseError: 'Compose file is too large to parse.' };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = YAML.parse(content);
|
||||
} catch (error) {
|
||||
return { services: [], networks: {}, volumes: {}, parseError: `Could not parse compose file: ${(error as Error).message}` };
|
||||
}
|
||||
|
||||
const root = (parsed ?? {}) as Record<string, unknown>;
|
||||
const rawServices = root.services;
|
||||
if (!rawServices || typeof rawServices !== 'object') {
|
||||
return { services: [], networks: {}, volumes: {}, parseError: 'No services found in this file.' };
|
||||
}
|
||||
|
||||
const services: DeclaredService[] = [];
|
||||
for (const [name, raw] of Object.entries(rawServices as Record<string, unknown>)) {
|
||||
const svc = (raw ?? {}) as Record<string, unknown>;
|
||||
const volumes = Array.isArray(svc.volumes)
|
||||
? svc.volumes.map(namedVolumeSource).filter((v): v is string => v !== null)
|
||||
: [];
|
||||
const ports = Array.isArray(svc.ports)
|
||||
? svc.ports.map(declaredPort).filter((p): p is DeclaredPort => p !== null)
|
||||
: [];
|
||||
services.push({
|
||||
name,
|
||||
dependsOn: collectKeys(svc.depends_on),
|
||||
networks: collectKeys(svc.networks),
|
||||
volumes,
|
||||
ports,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
services,
|
||||
networks: collectResources(root.networks),
|
||||
volumes: collectResources(root.volumes),
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { fileExplorerMetricsRouter } from './routes/fileExplorerMetrics';
|
||||
import { stackActivityMetricsRouter } from './routes/stackActivityMetrics';
|
||||
import { secretsRouter } from './routes/secrets';
|
||||
import { diagnosticsRouter } from './routes/diagnostics';
|
||||
import { dependencyMapRouter } from './routes/dependencyMap';
|
||||
|
||||
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
||||
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
||||
@@ -143,6 +144,7 @@ app.use('/api/containers', containersRouter);
|
||||
app.use('/api/ports', portsRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/diagnostics', diagnosticsRouter);
|
||||
app.use('/api/dependency-map', dependencyMapRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/stacks', stackActivityRouter);
|
||||
app.use('/api/stacks', stacksRouter);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { buildLocalGraph } from '../services/DependencyGraphService';
|
||||
|
||||
export const dependencyMapRouter = Router();
|
||||
|
||||
/**
|
||||
* Per-node dependency graph. Auth-only (never tier-gated) so the hub's
|
||||
* fleet-wide fan-out can reach this route on every node, including Community
|
||||
* remotes. Served against the local Docker of whichever node handles it.
|
||||
*/
|
||||
dependencyMapRouter.get('/node-graph', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const nodeId = req.nodeId;
|
||||
const name = DatabaseService.getInstance().getNodes().find((n) => n.id === nodeId)?.name ?? 'This node';
|
||||
const graph = await buildLocalGraph(nodeId, name);
|
||||
res.json(graph);
|
||||
} catch (error) {
|
||||
console.error('[DependencyMap] node-graph error:', error);
|
||||
res.status(500).json({ error: 'Failed to build dependency graph' });
|
||||
}
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cach
|
||||
import { activeBulkActions } from './labels';
|
||||
import { runLocalLabelStop, type LabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
|
||||
@@ -642,6 +643,65 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fleet-wide dependency map. Auth-only (read-only visibility, Community). Fans
|
||||
* out to every node, building each node's local graph in-process for the hub
|
||||
* and via its auth-only per-node route for remotes, then merges with per-node
|
||||
* attribution. Unreachable nodes degrade to nodeErrors so the rest still draws.
|
||||
*/
|
||||
fleetRouter.get('/dependency-map', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node: Node): Promise<FleetNodeGraphResult> => {
|
||||
if (node.type === 'local') {
|
||||
const graph = await buildLocalGraph(node.id, node.name);
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'ok', graph, error: null };
|
||||
}
|
||||
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target) {
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: formatNoTargetError(node) };
|
||||
}
|
||||
|
||||
const resp = await fetch(
|
||||
`${target.apiUrl.replace(/\/$/, '')}/api/dependency-map/node-graph`,
|
||||
{
|
||||
headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.json().catch(() => null) as { error?: string } | null;
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: errBody?.error ?? `Remote returned ${resp.status}` };
|
||||
}
|
||||
const graph = await resp.json().catch(() => null);
|
||||
// Shape-guard a reachable-but-malformed payload (proxy HTML, version
|
||||
// drift) so one bad node degrades to a nodeError instead of crashing
|
||||
// mergeFleetGraph and 500-ing the whole fleet map.
|
||||
if (!isLocalDependencyGraph(graph)) {
|
||||
console.error(`[Fleet] Dependency map: node ${sanitizeForLog(node.name)} returned a payload that failed shape validation (status ${resp.status})`);
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: 'Remote returned an unexpected dependency-graph payload' };
|
||||
}
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'ok', graph, error: null };
|
||||
}),
|
||||
);
|
||||
|
||||
const perNode: FleetNodeGraphResult[] = results.map((result, i) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
console.error(`[Fleet] Dependency map fetch failed for node ${nodes[i].name}:`, result.reason);
|
||||
return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', graph: null, error: getErrorMessage(result.reason, 'Failed to reach node') };
|
||||
});
|
||||
|
||||
res.json(mergeFleetGraph(perNode));
|
||||
} catch (error) {
|
||||
console.error('[Fleet] Dependency map error:', error);
|
||||
res.status(500).json({ error: 'Failed to build fleet dependency map' });
|
||||
}
|
||||
});
|
||||
|
||||
fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import DockerController from './DockerController';
|
||||
import type {
|
||||
DependencySnapshot,
|
||||
DependencyContainer,
|
||||
DependencyNetwork,
|
||||
DependencyVolume,
|
||||
} from './DockerController';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
|
||||
import type { DeclaredCompose } from '../helpers/composeDependencyParse';
|
||||
|
||||
export type DepNodeKind = 'host' | 'stack' | 'service' | 'network' | 'volume' | 'port';
|
||||
export type DepFlagKind = 'missing-dependency' | 'port-conflict' | 'orphan' | 'cross-stack-shared';
|
||||
export type DepEdgeKind =
|
||||
| 'stack-node'
|
||||
| 'stack-service'
|
||||
| 'depends-on'
|
||||
| 'service-network'
|
||||
| 'service-volume'
|
||||
| 'service-port';
|
||||
|
||||
export interface DepNode {
|
||||
id: string;
|
||||
kind: DepNodeKind;
|
||||
label: string;
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
/** Owning stack, or null for the host and shared resources. */
|
||||
stack: string | null;
|
||||
managedStatus?: 'managed' | 'unmanaged' | 'system';
|
||||
/** Container/service state when applicable (e.g. running, exited, absent). */
|
||||
state?: string;
|
||||
flags: DepFlagKind[];
|
||||
}
|
||||
|
||||
export interface DepEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
kind: DepEdgeKind;
|
||||
/** Declared in compose but not observed at runtime. */
|
||||
declaredOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface DepFlag {
|
||||
kind: DepFlagKind;
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
/** Graph-node ids the flag applies to. */
|
||||
subjects: string[];
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface LocalDependencyGraph {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
nodes: DepNode[];
|
||||
edges: DepEdge[];
|
||||
flags: DepFlag[];
|
||||
parseErrors: { stack: string; error: string }[];
|
||||
}
|
||||
|
||||
export type FleetNodeGraphResult =
|
||||
| { nodeId: number; nodeName: string; status: 'ok'; graph: LocalDependencyGraph; error: null }
|
||||
| { nodeId: number; nodeName: string; status: 'error'; graph: null; error: string };
|
||||
|
||||
export interface FleetDependencyMap {
|
||||
nodes: DepNode[];
|
||||
edges: DepEdge[];
|
||||
flags: DepFlag[];
|
||||
nodeErrors: { nodeId: number; nodeName: string; error: string }[];
|
||||
parseErrors: { nodeId: number; nodeName: string; stack: string; error: string }[];
|
||||
}
|
||||
|
||||
// --- ID helpers (local, pre-merge namespace) ---------------------------------
|
||||
|
||||
const hostId = (): string => 'host';
|
||||
const stackId = (stack: string): string => `stack:${stack}`;
|
||||
const serviceId = (stack: string, service: string): string => `svc:${stack}:${service}`;
|
||||
const networkId = (name: string): string => `net:${name}`;
|
||||
const volumeId = (name: string): string => `vol:${name}`;
|
||||
/** Normalizes an all-interfaces host IP to '*' so equivalent binds collapse. */
|
||||
const portScope = (ip: string): string => (ip === '' || ip === '0.0.0.0' || ip === '::' ? '*' : ip);
|
||||
const portId = (ip: string, port: number, proto: string): string => `port:${portScope(ip)}:${port}/${proto}`;
|
||||
|
||||
/** Container states that count as "up" for depends_on satisfaction. */
|
||||
const RUNNING_STATES = new Set(['running', 'restarting']);
|
||||
|
||||
// --- Pure flag detectors (unit-tested directly) ------------------------------
|
||||
|
||||
export interface PortClaim {
|
||||
stack: string;
|
||||
service: string;
|
||||
hostIp: string;
|
||||
publishedPort: number;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
export interface PortConflict {
|
||||
port: number;
|
||||
protocol: string;
|
||||
/** Normalized host scopes that actually clash ('*' or a specific IP). */
|
||||
scopes: string[];
|
||||
claimants: { stack: string; service: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups port claims by published port + protocol, then does pairwise
|
||||
* scope-overlap detection within each group. Two binds overlap when they share
|
||||
* a specific host IP or at least one binds all interfaces ('*'). Only the
|
||||
* claimants and scopes that genuinely clash are returned, so a same-stack
|
||||
* two-service collision is caught while tcp-vs-udp, distinct specific-IP binds,
|
||||
* and the two bindings of one service's single publish (IPv4 + IPv6) are not
|
||||
* over-flagged.
|
||||
*/
|
||||
export function detectPortConflicts(claims: PortClaim[]): PortConflict[] {
|
||||
const groups = new Map<string, PortClaim[]>();
|
||||
for (const claim of claims) {
|
||||
const key = `${claim.publishedPort}/${claim.protocol}`;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(claim);
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const claimantKey = (c: PortClaim): string => JSON.stringify([c.stack, c.service]);
|
||||
const overlaps = (a: PortClaim, b: PortClaim): boolean => {
|
||||
const sa = portScope(a.hostIp);
|
||||
const sb = portScope(b.hostIp);
|
||||
return sa === sb || sa === '*' || sb === '*';
|
||||
};
|
||||
|
||||
const conflicts: PortConflict[] = [];
|
||||
for (const list of groups.values()) {
|
||||
const conflicting = new Set<number>();
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
if (claimantKey(list[i]) === claimantKey(list[j])) continue;
|
||||
if (overlaps(list[i], list[j])) { conflicting.add(i); conflicting.add(j); }
|
||||
}
|
||||
}
|
||||
if (conflicting.size === 0) continue;
|
||||
|
||||
const clashing = [...conflicting].map((i) => list[i]);
|
||||
const claimants = [...new Map(clashing.map((c) => [claimantKey(c), { stack: c.stack, service: c.service }])).values()];
|
||||
const scopes = [...new Set(clashing.map((c) => portScope(c.hostIp)))];
|
||||
conflicts.push({ port: clashing[0].publishedPort, protocol: clashing[0].protocol, scopes, claimants });
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared network/volume key is present when a matching runtime resource
|
||||
* exists. External resources are operator-managed and not project-prefixed, so
|
||||
* their resolved name (a `name:` override or the key) must exist somewhere on
|
||||
* the host. Stack-owned resources match by exact `name:` or the `<project>_<key>`
|
||||
* suffix, which tolerates a top-level `name:` project override without false
|
||||
* positives.
|
||||
*/
|
||||
function declaredPresent(
|
||||
key: string,
|
||||
resource: { name?: string; external: boolean } | undefined,
|
||||
stackResourceNames: string[],
|
||||
allResourceNames: Set<string>,
|
||||
): boolean {
|
||||
const explicit = resource?.name;
|
||||
if (resource?.external) return allResourceNames.has(explicit ?? key);
|
||||
if (explicit) return stackResourceNames.includes(explicit) || allResourceNames.has(explicit);
|
||||
return stackResourceNames.some((n) => n === key || n.endsWith(`_${key}`));
|
||||
}
|
||||
|
||||
export interface MissingDepInput {
|
||||
stack: string;
|
||||
declared: DeclaredCompose;
|
||||
/** Service names with a running (or restarting) container in this stack. */
|
||||
runningServices: Set<string>;
|
||||
/** Whether this stack has any container at all (deployed). */
|
||||
hasContainers: boolean;
|
||||
/** Runtime network names owned by this stack. */
|
||||
stackNetworkNames: string[];
|
||||
/** Runtime volume names owned by this stack. */
|
||||
stackVolumeNames: string[];
|
||||
/** All runtime network names on the node. */
|
||||
allNetworkNames: Set<string>;
|
||||
/** All runtime volume names on the node. */
|
||||
allVolumeNames: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags compose-declared dependencies that are not present at runtime: a
|
||||
* running service whose depends_on target is not running, or a declared
|
||||
* (non-external) network/volume that does not exist. Only evaluated for deployed
|
||||
* stacks (hasContainers), so a never-deployed stack flags nothing. The depends_on
|
||||
* check is additionally gated on the depending service itself running, so a
|
||||
* deliberately stopped stack does not flag its own internal service dependencies;
|
||||
* network/volume presence is still checked for any deployed stack.
|
||||
*/
|
||||
export function detectMissingDependencies(
|
||||
input: MissingDepInput,
|
||||
): { kind: 'service' | 'network' | 'volume'; service: string; target: string; detail: string }[] {
|
||||
const out: { kind: 'service' | 'network' | 'volume'; service: string; target: string; detail: string }[] = [];
|
||||
if (!input.hasContainers) return out;
|
||||
|
||||
for (const svc of input.declared.services) {
|
||||
if (input.runningServices.has(svc.name)) {
|
||||
for (const dep of svc.dependsOn) {
|
||||
if (!input.runningServices.has(dep)) {
|
||||
out.push({ kind: 'service', service: svc.name, target: dep, detail: `Service "${svc.name}" depends on "${dep}", which is not running.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const net of svc.networks) {
|
||||
if (!declaredPresent(net, input.declared.networks[net], input.stackNetworkNames, input.allNetworkNames)) {
|
||||
out.push({ kind: 'network', service: svc.name, target: net, detail: `Service "${svc.name}" references network "${net}", which does not exist.` });
|
||||
}
|
||||
}
|
||||
for (const vol of svc.volumes) {
|
||||
if (!declaredPresent(vol, input.declared.volumes[vol], input.stackVolumeNames, input.allVolumeNames)) {
|
||||
out.push({ kind: 'volume', service: svc.name, target: vol, detail: `Service "${svc.name}" references volume "${vol}", which does not exist.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Local graph builder -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the dependency graph for a single node from one Docker snapshot plus
|
||||
* the declared compose metadata of each stack. Edges and resource nodes come
|
||||
* from runtime (what is actually deployed); declared data drives the
|
||||
* missing-dependency and port-conflict flags. Per-stack compose parsing fails
|
||||
* soft: a malformed file is recorded in parseErrors and never aborts the graph.
|
||||
* Note: getStacks() itself fails soft (returns []), so a node whose compose
|
||||
* directory is unreadable or misconfigured shows no managed stacks rather than
|
||||
* an error; only Docker failures here surface as a node-level error.
|
||||
*/
|
||||
export async function buildLocalGraph(nodeId: number, nodeName: string): Promise<LocalDependencyGraph> {
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
const fs = FileSystemService.getInstance(nodeId);
|
||||
|
||||
const stacks = await fs.getStacks();
|
||||
const snapshot = await docker.getDependencySnapshot(stacks);
|
||||
|
||||
const declaredByStack = new Map<string, DeclaredCompose>();
|
||||
const parseErrors: { stack: string; error: string }[] = [];
|
||||
for (const stack of stacks) {
|
||||
try {
|
||||
const content = await fs.getStackContent(stack);
|
||||
const declared = parseComposeDependencies(content);
|
||||
if (declared.parseError) parseErrors.push({ stack, error: declared.parseError });
|
||||
declaredByStack.set(stack, declared);
|
||||
} catch (error) {
|
||||
parseErrors.push({ stack, error: (error as Error)?.message ?? 'Failed to read compose file' });
|
||||
declaredByStack.set(stack, { services: [], networks: {}, volumes: {} });
|
||||
}
|
||||
}
|
||||
|
||||
return assembleGraph({ nodeId, nodeName, stacks, snapshot, declaredByStack, parseErrors });
|
||||
}
|
||||
|
||||
interface AssembleInput {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stacks: string[];
|
||||
snapshot: DependencySnapshot;
|
||||
declaredByStack: Map<string, DeclaredCompose>;
|
||||
parseErrors: { stack: string; error: string }[];
|
||||
}
|
||||
|
||||
/** Pure assembly step (no Docker / FS access) so it is directly unit-testable. */
|
||||
export function assembleGraph(input: AssembleInput): LocalDependencyGraph {
|
||||
const { nodeId, nodeName, stacks, snapshot } = input;
|
||||
const knownStacks = new Set(stacks);
|
||||
|
||||
const nodes: DepNode[] = [];
|
||||
const edges: DepEdge[] = [];
|
||||
const flags: DepFlag[] = [];
|
||||
const nodeById = new Map<string, DepNode>();
|
||||
|
||||
const addNode = (n: DepNode): DepNode => {
|
||||
const existing = nodeById.get(n.id);
|
||||
if (existing) return existing;
|
||||
nodeById.set(n.id, n);
|
||||
nodes.push(n);
|
||||
return n;
|
||||
};
|
||||
const addEdge = (e: DepEdge): void => {
|
||||
if (!edges.some((x) => x.id === e.id)) edges.push(e);
|
||||
};
|
||||
const addFlagToNode = (id: string, kind: DepFlagKind): void => {
|
||||
const n = nodeById.get(id);
|
||||
if (n && !n.flags.includes(kind)) n.flags.push(kind);
|
||||
};
|
||||
|
||||
// Host root.
|
||||
addNode({ id: hostId(), kind: 'host', label: nodeName, nodeId, nodeName, stack: null, flags: [] });
|
||||
|
||||
// Index snapshot.
|
||||
const networksById = new Map<string, DependencyNetwork>();
|
||||
const networksByName = new Map<string, DependencyNetwork>();
|
||||
for (const net of snapshot.networks) {
|
||||
networksById.set(net.id, net);
|
||||
networksByName.set(net.name, net);
|
||||
}
|
||||
const volumesByName = new Map<string, DependencyVolume>();
|
||||
for (const vol of snapshot.volumes) volumesByName.set(vol.name, vol);
|
||||
|
||||
const knownContainers = snapshot.containers.filter((c) => c.stack && knownStacks.has(c.stack));
|
||||
const orphanContainers = snapshot.containers.filter((c) => c.composeProject && !c.stack);
|
||||
|
||||
// Runtime reference maps for cross-stack and orphan detection.
|
||||
const networkStacks = new Map<string, Set<string>>();
|
||||
const volumeStacks = new Map<string, Set<string>>();
|
||||
const referencedNetworkIds = new Set<string>();
|
||||
const referencedNetworkNames = new Set<string>();
|
||||
const referencedVolumeNames = new Set<string>();
|
||||
const runtimeServicesByStack = new Map<string, Set<string>>();
|
||||
const runningServicesByStack = new Map<string, Set<string>>();
|
||||
const portClaims: PortClaim[] = [];
|
||||
|
||||
const trackRef = (map: Map<string, Set<string>>, key: string, stack: string): void => {
|
||||
const set = map.get(key) ?? new Set<string>();
|
||||
set.add(stack);
|
||||
map.set(key, set);
|
||||
};
|
||||
|
||||
for (const c of snapshot.containers) {
|
||||
for (const n of c.networks) {
|
||||
referencedNetworkIds.add(n.id);
|
||||
referencedNetworkNames.add(n.name);
|
||||
}
|
||||
for (const v of c.volumes) referencedVolumeNames.add(v);
|
||||
}
|
||||
|
||||
// Stack and service nodes (union of runtime + declared services).
|
||||
for (const stack of stacks) {
|
||||
const declared = input.declaredByStack.get(stack) ?? { services: [], networks: {}, volumes: {} };
|
||||
const containers = knownContainers.filter((c) => c.stack === stack);
|
||||
const runtimeServices = new Set<string>();
|
||||
const runningServices = new Set<string>();
|
||||
runtimeServicesByStack.set(stack, runtimeServices);
|
||||
runningServicesByStack.set(stack, runningServices);
|
||||
|
||||
const hasContainers = containers.length > 0;
|
||||
addNode({ id: stackId(stack), kind: 'stack', label: stack, nodeId, nodeName, stack, managedStatus: 'managed', state: hasContainers ? 'deployed' : 'not deployed', flags: [] });
|
||||
addEdge({ id: `e:host-${stackId(stack)}`, source: hostId(), target: stackId(stack), kind: 'stack-node' });
|
||||
|
||||
// Service nodes from runtime containers.
|
||||
for (const c of containers) {
|
||||
const svcName = c.service ?? c.name;
|
||||
runtimeServices.add(svcName);
|
||||
if (RUNNING_STATES.has(c.state)) runningServices.add(svcName);
|
||||
const sid = serviceId(stack, svcName);
|
||||
addNode({ id: sid, kind: 'service', label: svcName, nodeId, nodeName, stack, managedStatus: 'managed', state: c.state, flags: [] });
|
||||
addEdge({ id: `e:${stackId(stack)}-${sid}`, source: stackId(stack), target: sid, kind: 'stack-service' });
|
||||
|
||||
buildResourceEdges(c, stack, sid, { addNode, addEdge, networksById, networksByName, volumesByName, nodeId, nodeName, networkStacks, volumeStacks, trackRef, portClaims });
|
||||
}
|
||||
|
||||
// Declared-only service nodes (in compose, no running container).
|
||||
for (const svc of declared.services) {
|
||||
if (runtimeServices.has(svc.name)) continue;
|
||||
const sid = serviceId(stack, svc.name);
|
||||
addNode({ id: sid, kind: 'service', label: svc.name, nodeId, nodeName, stack, managedStatus: 'managed', state: 'absent', flags: [] });
|
||||
addEdge({ id: `e:${stackId(stack)}-${sid}`, source: stackId(stack), target: sid, kind: 'stack-service' });
|
||||
// Declared ports still count as conflict claimants.
|
||||
for (const p of svc.ports) portClaims.push({ stack, service: svc.name, hostIp: p.hostIp, publishedPort: p.publishedPort, protocol: p.protocol });
|
||||
}
|
||||
|
||||
// depends-on edges (declared).
|
||||
for (const svc of declared.services) {
|
||||
for (const dep of svc.dependsOn) {
|
||||
const from = serviceId(stack, svc.name);
|
||||
const to = serviceId(stack, dep);
|
||||
if (nodeById.has(from) && nodeById.has(to)) {
|
||||
addEdge({ id: `e:dep:${from}->${to}`, source: from, target: to, kind: 'depends-on', declaredOnly: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flags ---
|
||||
|
||||
// Missing dependencies.
|
||||
const allNetworkNames = new Set(snapshot.networks.map((n) => n.name));
|
||||
const allVolumeNames = new Set(snapshot.volumes.map((v) => v.name));
|
||||
for (const stack of stacks) {
|
||||
const declared = input.declaredByStack.get(stack) ?? { services: [], networks: {}, volumes: {} };
|
||||
const missing = detectMissingDependencies({
|
||||
stack,
|
||||
declared,
|
||||
runningServices: runningServicesByStack.get(stack) ?? new Set(),
|
||||
hasContainers: (runtimeServicesByStack.get(stack)?.size ?? 0) > 0,
|
||||
stackNetworkNames: snapshot.networks.filter((n) => n.stack === stack).map((n) => n.name),
|
||||
stackVolumeNames: snapshot.volumes.filter((v) => v.stack === stack).map((v) => v.name),
|
||||
allNetworkNames,
|
||||
allVolumeNames,
|
||||
});
|
||||
for (const m of missing) {
|
||||
const subject = serviceId(stack, m.service);
|
||||
addFlagToNode(subject, 'missing-dependency');
|
||||
flags.push({ kind: 'missing-dependency', nodeId, nodeName, subjects: [subject], detail: m.detail });
|
||||
}
|
||||
}
|
||||
|
||||
// Port conflicts. Flag only the port node(s) whose scope actually clashes,
|
||||
// by rebuilding the exact scope-prefixed port id from the conflict scopes.
|
||||
for (const conflict of detectPortConflicts(portClaims)) {
|
||||
const portNodeIds = conflict.scopes
|
||||
.map((scope) => `port:${scope}:${conflict.port}/${conflict.protocol}`)
|
||||
.filter((id) => nodeById.has(id));
|
||||
const serviceSubjects = conflict.claimants.map((c) => serviceId(c.stack, c.service)).filter((id) => nodeById.has(id));
|
||||
const stacksInvolved = [...new Set(conflict.claimants.map((c) => c.stack))];
|
||||
for (const id of portNodeIds) addFlagToNode(id, 'port-conflict');
|
||||
for (const s of serviceSubjects) addFlagToNode(s, 'port-conflict');
|
||||
flags.push({ kind: 'port-conflict', nodeId, nodeName, subjects: [...portNodeIds, ...serviceSubjects], detail: `Port ${conflict.port}/${conflict.protocol} is claimed by ${stacksInvolved.join(', ')}.` });
|
||||
}
|
||||
|
||||
// Cross-stack shared networks/volumes.
|
||||
for (const [name, stackSet] of networkStacks) {
|
||||
if (stackSet.size >= 2) {
|
||||
addFlagToNode(networkId(name), 'cross-stack-shared');
|
||||
flags.push({ kind: 'cross-stack-shared', nodeId, nodeName, subjects: [networkId(name)], detail: `Network "${name}" is shared by ${[...stackSet].join(', ')}.` });
|
||||
}
|
||||
}
|
||||
for (const [name, stackSet] of volumeStacks) {
|
||||
if (stackSet.size >= 2) {
|
||||
addFlagToNode(volumeId(name), 'cross-stack-shared');
|
||||
flags.push({ kind: 'cross-stack-shared', nodeId, nodeName, subjects: [volumeId(name)], detail: `Volume "${name}" is shared by ${[...stackSet].join(', ')}.` });
|
||||
}
|
||||
}
|
||||
|
||||
// Orphaned networks/volumes (unmanaged, unreferenced, non-system).
|
||||
for (const net of snapshot.networks) {
|
||||
if (net.isSystem || net.stack) continue;
|
||||
if (referencedNetworkIds.has(net.id) || referencedNetworkNames.has(net.name)) continue;
|
||||
const id = networkId(net.name);
|
||||
addNode({ id, kind: 'network', label: net.name, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', flags: ['orphan'] });
|
||||
flags.push({ kind: 'orphan', nodeId, nodeName, subjects: [id], detail: `Network "${net.name}" is not used by any container.` });
|
||||
}
|
||||
for (const vol of snapshot.volumes) {
|
||||
if (vol.stack) continue;
|
||||
if (referencedVolumeNames.has(vol.name)) continue;
|
||||
const id = volumeId(vol.name);
|
||||
addNode({ id, kind: 'volume', label: vol.name, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', flags: ['orphan'] });
|
||||
flags.push({ kind: 'orphan', nodeId, nodeName, subjects: [id], detail: `Volume "${vol.name}" is not used by any container.` });
|
||||
}
|
||||
|
||||
// Orphan containers (compose project not owned by any known stack).
|
||||
const orphanByProject = new Map<string, DependencyContainer[]>();
|
||||
for (const c of orphanContainers) {
|
||||
const project = c.composeProject as string;
|
||||
const list = orphanByProject.get(project) ?? [];
|
||||
list.push(c);
|
||||
orphanByProject.set(project, list);
|
||||
}
|
||||
for (const [project, containers] of orphanByProject) {
|
||||
const sId = `stack:__orphan__:${project}`;
|
||||
addNode({ id: sId, kind: 'stack', label: `${project} (unmanaged)`, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', flags: ['orphan'] });
|
||||
addEdge({ id: `e:host-${sId}`, source: hostId(), target: sId, kind: 'stack-node' });
|
||||
flags.push({ kind: 'orphan', nodeId, nodeName, subjects: [sId], detail: `Stack "${project}" is running but not managed by Sencho.` });
|
||||
for (const c of containers) {
|
||||
const svcName = c.service ?? c.name;
|
||||
const svcNodeId = `svc:__orphan__:${project}:${svcName}`;
|
||||
addNode({ id: svcNodeId, kind: 'service', label: svcName, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', state: c.state, flags: ['orphan'] });
|
||||
addEdge({ id: `e:${sId}-${svcNodeId}`, source: sId, target: svcNodeId, kind: 'stack-service' });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodeId, nodeName, nodes, edges, flags, parseErrors: input.parseErrors };
|
||||
}
|
||||
|
||||
interface ResourceEdgeCtx {
|
||||
addNode: (n: DepNode) => DepNode;
|
||||
addEdge: (e: DepEdge) => void;
|
||||
networksById: Map<string, DependencyNetwork>;
|
||||
networksByName: Map<string, DependencyNetwork>;
|
||||
volumesByName: Map<string, DependencyVolume>;
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
networkStacks: Map<string, Set<string>>;
|
||||
volumeStacks: Map<string, Set<string>>;
|
||||
trackRef: (map: Map<string, Set<string>>, key: string, stack: string) => void;
|
||||
portClaims: PortClaim[];
|
||||
}
|
||||
|
||||
/** Emits a running container's network / volume / port nodes and edges. */
|
||||
function buildResourceEdges(c: DependencyContainer, stack: string, sid: string, ctx: ResourceEdgeCtx): void {
|
||||
for (const n of c.networks) {
|
||||
const meta = ctx.networksById.get(n.id) ?? ctx.networksByName.get(n.name);
|
||||
if (meta?.isSystem) continue; // skip bridge/host/none noise
|
||||
const id = networkId(n.name);
|
||||
ctx.addNode({ id, kind: 'network', label: n.name, nodeId: ctx.nodeId, nodeName: ctx.nodeName, stack: meta?.stack ?? null, managedStatus: meta?.stack ? 'managed' : 'unmanaged', flags: [] });
|
||||
ctx.addEdge({ id: `e:${sid}-${id}`, source: sid, target: id, kind: 'service-network' });
|
||||
ctx.trackRef(ctx.networkStacks, n.name, stack);
|
||||
}
|
||||
for (const volName of c.volumes) {
|
||||
const meta = ctx.volumesByName.get(volName);
|
||||
const id = volumeId(volName);
|
||||
ctx.addNode({ id, kind: 'volume', label: volName, nodeId: ctx.nodeId, nodeName: ctx.nodeName, stack: meta?.stack ?? null, managedStatus: meta?.stack ? 'managed' : 'unmanaged', flags: [] });
|
||||
ctx.addEdge({ id: `e:${sid}-${id}`, source: sid, target: id, kind: 'service-volume' });
|
||||
ctx.trackRef(ctx.volumeStacks, volName, stack);
|
||||
}
|
||||
const svcName = c.service ?? c.name;
|
||||
for (const p of c.ports) {
|
||||
const id = portId(p.ip, p.publishedPort, p.protocol);
|
||||
ctx.addNode({ id, kind: 'port', label: `${portScope(p.ip)}:${p.publishedPort}/${p.protocol}`, nodeId: ctx.nodeId, nodeName: ctx.nodeName, stack, flags: [] });
|
||||
ctx.addEdge({ id: `e:${sid}-${id}`, source: sid, target: id, kind: 'service-port' });
|
||||
ctx.portClaims.push({ stack, service: svcName, hostIp: p.ip, publishedPort: p.publishedPort, protocol: p.protocol });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fleet merge -------------------------------------------------------------
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object';
|
||||
|
||||
/**
|
||||
* Validates a parsed remote node-graph payload deeply enough that mergeFleetGraph
|
||||
* cannot throw on it: nodes/edges/flags are arrays of the right shallow shape
|
||||
* (each flag carries a subjects array, each node a flags array), and parseErrors
|
||||
* is an array when present. A reachable-but-malformed remote thus degrades to a
|
||||
* single node error instead of crashing the whole fleet map.
|
||||
*/
|
||||
export function isLocalDependencyGraph(g: unknown): g is LocalDependencyGraph {
|
||||
if (!isObject(g)) return false;
|
||||
if (!Array.isArray(g.nodes) || !Array.isArray(g.edges) || !Array.isArray(g.flags)) return false;
|
||||
if (g.parseErrors !== undefined && !Array.isArray(g.parseErrors)) return false;
|
||||
if (!g.nodes.every((n) => isObject(n) && typeof n.id === 'string' && Array.isArray(n.flags))) return false;
|
||||
if (!g.edges.every((e) => isObject(e) && typeof e.id === 'string' && typeof e.source === 'string' && typeof e.target === 'string')) return false;
|
||||
// subjects elements must be strings (merge concatenates them) and parseErrors
|
||||
// elements must be {stack,error} objects (merge dereferences them), or a
|
||||
// payload could pass the array check yet still throw / corrupt ids in merge.
|
||||
if (!g.flags.every((f) => isObject(f) && Array.isArray(f.subjects) && f.subjects.every((s) => typeof s === 'string'))) return false;
|
||||
if (Array.isArray(g.parseErrors) && !g.parseErrors.every((pe) => isObject(pe) && typeof pe.stack === 'string' && typeof pe.error === 'string')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges each node's local graph into one fleet map. Every id is namespaced by
|
||||
* the node id so identically named stacks/resources on different nodes stay
|
||||
* distinct, and node attribution is re-stamped from the hub's node records (so
|
||||
* a remote's view of its own name does not leak through).
|
||||
*/
|
||||
export function mergeFleetGraph(results: FleetNodeGraphResult[]): FleetDependencyMap {
|
||||
const nodes: DepNode[] = [];
|
||||
const edges: DepEdge[] = [];
|
||||
const flags: DepFlag[] = [];
|
||||
const nodeErrors: { nodeId: number; nodeName: string; error: string }[] = [];
|
||||
const parseErrors: { nodeId: number; nodeName: string; stack: string; error: string }[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== 'ok') {
|
||||
nodeErrors.push({ nodeId: result.nodeId, nodeName: result.nodeName, error: result.error });
|
||||
continue;
|
||||
}
|
||||
const prefix = `n${result.nodeId}:`;
|
||||
const g = result.graph;
|
||||
for (const n of g.nodes) {
|
||||
// The host node's label is the node's name; take it from the hub's
|
||||
// node records, never from the remote's self-reported name.
|
||||
const label = n.kind === 'host' ? result.nodeName : n.label;
|
||||
nodes.push({ ...n, id: prefix + n.id, label, nodeId: result.nodeId, nodeName: result.nodeName });
|
||||
}
|
||||
for (const e of g.edges) {
|
||||
edges.push({ ...e, id: prefix + e.id, source: prefix + e.source, target: prefix + e.target });
|
||||
}
|
||||
for (const f of g.flags) {
|
||||
flags.push({ ...f, nodeId: result.nodeId, nodeName: result.nodeName, subjects: f.subjects.map((s) => prefix + s) });
|
||||
}
|
||||
for (const pe of g.parseErrors ?? []) {
|
||||
parseErrors.push({ nodeId: result.nodeId, nodeName: result.nodeName, stack: pe.stack, error: pe.error });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges, flags, nodeErrors, parseErrors };
|
||||
}
|
||||
@@ -91,6 +91,61 @@ export interface TopologyNetwork {
|
||||
containers: TopologyContainer[];
|
||||
}
|
||||
|
||||
/** A host-published port binding on a container. */
|
||||
export interface DependencyPort {
|
||||
/** Host interface the port binds to ('' or '0.0.0.0'/'::' means all interfaces). */
|
||||
ip: string;
|
||||
publishedPort: number;
|
||||
privatePort: number | null;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
/** A container as seen by the dependency map: compose identity plus its real
|
||||
* runtime network/volume names and published ports. */
|
||||
export interface DependencyContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
/** com.docker.compose.service label, or null for non-compose containers. */
|
||||
service: string | null;
|
||||
/** Raw com.docker.compose.project label (may not map to a known stack). */
|
||||
composeProject: string | null;
|
||||
/** Resolved Sencho stack, or null when the container is not Sencho-managed. */
|
||||
stack: string | null;
|
||||
state: string;
|
||||
image: string;
|
||||
networks: { name: string; id: string; ip: string }[];
|
||||
/** Named-volume sources mounted by the container (bind mounts excluded). */
|
||||
volumes: string[];
|
||||
ports: DependencyPort[];
|
||||
}
|
||||
|
||||
export interface DependencyNetwork {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
scope: string;
|
||||
isSystem: 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. */
|
||||
stack: string | null;
|
||||
}
|
||||
|
||||
export interface DependencyVolume {
|
||||
name: string;
|
||||
driver: string;
|
||||
composeProject: string | null;
|
||||
/** Resolved Sencho stack this volume belongs to, or null. */
|
||||
stack: string | null;
|
||||
}
|
||||
|
||||
/** One node-scoped snapshot of everything the dependency map needs. */
|
||||
export interface DependencySnapshot {
|
||||
containers: DependencyContainer[];
|
||||
networks: DependencyNetwork[];
|
||||
volumes: DependencyVolume[];
|
||||
}
|
||||
|
||||
export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
|
||||
|
||||
export interface CreateNetworkOptions {
|
||||
@@ -846,6 +901,95 @@ class DockerController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot, node-scoped snapshot for the dependency map: every container's
|
||||
* compose service identity, real network/volume names, and protocol/IP-typed
|
||||
* published ports, plus the full network and volume inventory. Three Docker
|
||||
* list calls and no per-container inspect keep it cheap at fleet scale.
|
||||
*/
|
||||
public async getDependencySnapshot(knownStackNames: string[]): Promise<DependencySnapshot> {
|
||||
const knownSet = new Set(knownStackNames);
|
||||
|
||||
const [rawContainers, rawNetworks, rawVolumeData, projectToStack] = await Promise.all([
|
||||
this.docker.listContainers({ all: true }),
|
||||
this.docker.listNetworks(),
|
||||
this.docker.listVolumes(),
|
||||
DockerController.resolveProjectNameMap(knownStackNames),
|
||||
]);
|
||||
|
||||
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
||||
const resolvedBase = path.resolve(COMPOSE_DIR);
|
||||
|
||||
const containersRaw = this.validateApiData<any[]>(rawContainers);
|
||||
const networksRaw = this.validateApiData<any[]>(rawNetworks);
|
||||
const volumesRaw: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
|
||||
const containers: DependencyContainer[] = containersRaw.map((c: any) => {
|
||||
const netSettings: Record<string, { NetworkID?: string; IPAddress?: string }> =
|
||||
c.NetworkSettings?.Networks ?? {};
|
||||
const networks = Object.entries(netSettings).map(([name, info]) => ({
|
||||
name,
|
||||
id: info.NetworkID ?? '',
|
||||
ip: info.IPAddress ?? '',
|
||||
}));
|
||||
|
||||
const volumes: string[] = Array.isArray(c.Mounts)
|
||||
? c.Mounts
|
||||
.filter((m: any) => m?.Type === 'volume' && typeof m.Name === 'string' && m.Name)
|
||||
.map((m: any) => m.Name as string)
|
||||
: [];
|
||||
|
||||
const ports: DependencyPort[] = Array.isArray(c.Ports)
|
||||
? c.Ports
|
||||
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
|
||||
.map((p: any) => ({
|
||||
ip: typeof p.IP === 'string' ? p.IP : '',
|
||||
publishedPort: p.PublicPort as number,
|
||||
privatePort: typeof p.PrivatePort === 'number' ? p.PrivatePort : null,
|
||||
protocol: typeof p.Type === 'string' ? p.Type : 'tcp',
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: c.Id,
|
||||
name: (c.Names?.[0] ?? '').replace(/^\//, '') || (c.Id ?? '').substring(0, 12),
|
||||
service: c.Labels?.['com.docker.compose.service'] ?? null,
|
||||
composeProject: c.Labels?.['com.docker.compose.project'] ?? null,
|
||||
stack: DockerController.resolveContainerStack(c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase),
|
||||
state: c.State ?? 'unknown',
|
||||
image: c.Image ?? '',
|
||||
networks,
|
||||
volumes,
|
||||
ports,
|
||||
};
|
||||
});
|
||||
|
||||
const networks: DependencyNetwork[] = networksRaw.map((net: any) => {
|
||||
const project = net.Labels?.['com.docker.compose.project'] ?? null;
|
||||
return {
|
||||
id: net.Id,
|
||||
name: net.Name,
|
||||
driver: net.Driver ?? 'bridge',
|
||||
scope: net.Scope ?? 'local',
|
||||
isSystem: DockerController.SYSTEM_NETWORKS.has(net.Name),
|
||||
composeProject: project,
|
||||
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
|
||||
};
|
||||
});
|
||||
|
||||
const volumes: DependencyVolume[] = volumesRaw.map((vol: any) => {
|
||||
const project = vol.Labels?.['com.docker.compose.project'] ?? null;
|
||||
return {
|
||||
name: vol.Name,
|
||||
driver: vol.Driver ?? 'local',
|
||||
composeProject: project,
|
||||
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
|
||||
};
|
||||
});
|
||||
|
||||
return { containers, networks, volumes };
|
||||
}
|
||||
|
||||
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
|
||||
private static resolveProjectLabel(
|
||||
project: string | undefined,
|
||||
|
||||
@@ -38,13 +38,14 @@ A single rail summarises the state of every registered node so you can read the
|
||||
|
||||
### Tabs
|
||||
|
||||
The Fleet view is a tab strip. Four tab triggers are visible to every tier; the Deployments, Routing, Federation, and Secrets triggers only render when the active license unlocks them. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs.
|
||||
The Fleet view is a tab strip. Five tab triggers are visible to every tier; the Deployments, Routing, Federation, and Secrets triggers only render when the active license unlocks them. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs.
|
||||
|
||||
| Tab | Tier | What it does |
|
||||
|-----|------|--------------|
|
||||
| **Overview** | Community | The grid or topology view of every node and its health. Covered in the next section. |
|
||||
| **Snapshots** | Community | Snapshot every compose file across the fleet. See [Fleet-Wide Backups](/features/fleet-backups). |
|
||||
| **Status** | Community | One card per node summarising which automations and security features are configured. Covered below. |
|
||||
| **Dependencies** | Community | A read-only map of how stacks, services, networks, volumes, and ports relate across the fleet, with anomaly flags. Covered below. |
|
||||
| **Deployments** | Admiral | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). |
|
||||
| **Routing** | Admiral | Cross-node service routing via Sencho Mesh. See [Sencho Mesh](/features/sencho-mesh). |
|
||||
| **Federation** | Admiral | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). |
|
||||
@@ -202,6 +203,40 @@ Offline nodes show a muted card with the heading and the message `Node is unreac
|
||||
|
||||
The tab fetches each node's configuration in parallel; a single dead node does not block the rest from rendering.
|
||||
|
||||
## The Dependency Map tab
|
||||
|
||||
The **Dependencies** tab draws a read-only map of how everything on the fleet fits together: each node, the stacks on it, and how those stacks' services connect to networks, volumes, and published ports. It is built for troubleshooting ("which stacks share this network?", "what is still claiming port 8080?", "why is this volume sitting unused?") rather than for editing anything. Nothing on this tab changes your stacks.
|
||||
|
||||
The map is assembled from what Docker and your compose files already describe, so it reflects the live state every time you open the tab or press **Refresh**.
|
||||
|
||||
### What the map shows
|
||||
|
||||
The graph reads left to right: each **node** branches into its **stacks**, and a stack expands into its **services**, with each service linked to the **networks** it joins, the **named volumes** it mounts, and the **host ports** it publishes. A dashed link between two services marks a declared `depends_on` relationship.
|
||||
|
||||
To stay readable on large fleets, the graph starts collapsed: you see the nodes and their stacks, and the element count stays small no matter how big the fleet is. Click any stack to expand its services and resources; click again to collapse it.
|
||||
|
||||
### Anomaly flags
|
||||
|
||||
A summary strip above the map counts four kinds of issue, and the affected elements are ringed in the graph (and tagged in the list):
|
||||
|
||||
| Flag | What it means |
|
||||
|------|---------------|
|
||||
| **Missing deps** | A service declares a `depends_on` target, network, or volume that is not present at runtime. The service that points at the missing thing is flagged. |
|
||||
| **Port conflicts** | Two services claim the same host interface, port, and protocol on the same node, so they cannot both bind it. |
|
||||
| **Orphans** | A network or volume that no container uses, or a stack that is running on the node but is not managed by Sencho. |
|
||||
| **Shared** | A network or volume that more than one stack uses. Often intentional, but worth knowing before you change or remove it. |
|
||||
|
||||
### Filtering and the list view
|
||||
|
||||
- **Search** narrows the map to matching stacks, services, and resources, expanding the stacks that contain a match.
|
||||
- **Node** chips limit the map to one or more nodes.
|
||||
- Clicking a flag in the summary strip filters to just the elements carrying that flag.
|
||||
- The **Graph / List** toggle swaps the diagram for a flat table (Node, Stack, Type, Name, State, Flags). The list has no size limit, so it is the surface to reach for on very large fleets, or when the graph suggests narrowing with a filter first.
|
||||
|
||||
### When a node can't be reached
|
||||
|
||||
The map is fleet-wide: the control instance gathers each node's view and merges them. If a node is offline or unreachable, a banner names it and the rest of the fleet still draws, so one dark node never blanks the whole map.
|
||||
|
||||
## Node Updates
|
||||
|
||||
Click **Check Updates** in the page header to open the **Node Updates** sheet. From here you can read every node's current Sencho version, see which nodes have an update available, and trigger updates one node at a time or across the whole fleet.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
RefreshCw, Search, Camera, Plus,
|
||||
Network, SlidersHorizontal,
|
||||
Send, KeyRound, ArrowLeftRight, Wrench,
|
||||
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
|
||||
} from 'lucide-react';
|
||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
|
||||
@@ -26,6 +26,7 @@ import { FederationTab } from './fleet/FederationTab';
|
||||
import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
||||
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
import { SecretsTab } from './fleet/secrets/SecretsTab';
|
||||
import { DependencyMapTab } from './fleet/DependencyMapTab';
|
||||
import { useNodeActions } from './nodes/useNodeActions';
|
||||
import { SettingsPrimaryButton } from './settings/SettingsActions';
|
||||
|
||||
@@ -89,6 +90,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="dependencies">
|
||||
<TabsTrigger value="dependencies">
|
||||
<Workflow className="w-4 h-4 mr-1.5" />Dependencies
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="deployments">
|
||||
@@ -201,6 +207,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<TabsContent value="configuration">
|
||||
<FleetConfiguration />
|
||||
</TabsContent>
|
||||
<TabsContent value="dependencies">
|
||||
<DependencyMapTab />
|
||||
</TabsContent>
|
||||
{isPaid && (
|
||||
<TabsContent value="deployments">
|
||||
<DeploymentsTab />
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
RefreshCw, Search, Server, Layers, Box, Network, Database, Plug,
|
||||
ChevronRight, ChevronDown, TriangleAlert, Share2,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import {
|
||||
layoutDependencyGraph,
|
||||
DEP_NODE_DIMS,
|
||||
type FleetDependencyMap,
|
||||
type DepNode,
|
||||
type DepNodeKind,
|
||||
type DepFlagKind,
|
||||
type DepFlowData,
|
||||
} from '@/lib/dependency-map-layout';
|
||||
|
||||
// Above this many visible graph nodes the graph becomes unreadable; we prompt
|
||||
// the operator to filter or switch to the (uncapped) list instead.
|
||||
const GRAPH_NODE_CEILING = 300;
|
||||
|
||||
// ReactFlow MiniMap fills cannot resolve CSS custom properties; these raw oklch
|
||||
// values approximate the default accent/severity palette and do not track theme.
|
||||
const MINIMAP_BRAND = 'oklch(0.78 0.11 195)';
|
||||
const MINIMAP_WARNING = 'oklch(0.75 0.14 75)';
|
||||
const MINIMAP_DESTRUCTIVE = 'oklch(0.62 0.21 25)';
|
||||
const MINIMAP_MUTED = 'oklch(0.55 0 0)';
|
||||
|
||||
const KIND_ICON: Record<DepNodeKind, typeof Server> = {
|
||||
host: Server,
|
||||
stack: Layers,
|
||||
service: Box,
|
||||
network: Network,
|
||||
volume: Database,
|
||||
port: Plug,
|
||||
};
|
||||
|
||||
const FLAG_META: { kind: DepFlagKind; label: string; severity: 'warning' | 'destructive' }[] = [
|
||||
{ kind: 'missing-dependency', label: 'Missing deps', severity: 'destructive' },
|
||||
{ kind: 'port-conflict', label: 'Port conflicts', severity: 'destructive' },
|
||||
{ kind: 'orphan', label: 'Orphans', severity: 'warning' },
|
||||
{ kind: 'cross-stack-shared', label: 'Shared', severity: 'warning' },
|
||||
];
|
||||
|
||||
const DESTRUCTIVE_FLAGS = new Set<DepFlagKind>(['missing-dependency', 'port-conflict']);
|
||||
|
||||
function worstSeverity(flags: DepFlagKind[]): 'destructive' | 'warning' | null {
|
||||
if (flags.length === 0) return null;
|
||||
return flags.some((f) => DESTRUCTIVE_FLAGS.has(f)) ? 'destructive' : 'warning';
|
||||
}
|
||||
|
||||
// ── Node card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DependencyNodeCard({ data }: { data: DepFlowData }) {
|
||||
const dep = data.dep;
|
||||
const Icon = KIND_ICON[dep.kind];
|
||||
const severity = worstSeverity(dep.flags);
|
||||
const isHost = dep.kind === 'host';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden',
|
||||
isHost && 'ring-1 ring-brand/40',
|
||||
severity === 'destructive' && 'ring-1 ring-destructive/50',
|
||||
severity === 'warning' && 'ring-1 ring-warning/40',
|
||||
data.expandable && 'cursor-pointer',
|
||||
)}
|
||||
style={{ width: DEP_NODE_DIMS[dep.kind].w }}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Icon className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-xs font-medium text-stat-value truncate" title={dep.label}>{dep.label}</span>
|
||||
{data.expandable && (
|
||||
data.expanded
|
||||
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-auto shrink-0" strokeWidth={2} />
|
||||
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground ml-auto shrink-0" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
{(dep.state || severity || (data.expandable && data.childCount > 0)) && (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1.5 font-mono text-[9px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{dep.state && <span>{dep.state}</span>}
|
||||
{data.expandable && data.childCount > 0 && <span>· {data.childCount} svc</span>}
|
||||
{severity && (
|
||||
<span className={cn('ml-auto inline-flex items-center gap-1', severity === 'destructive' ? 'text-destructive' : 'text-warning')}>
|
||||
<TriangleAlert className="h-2.5 w-2.5" strokeWidth={2} />
|
||||
{dep.flags.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = { dep: DependencyNodeCard };
|
||||
|
||||
// ── Adjacency helpers ──────────────────────────────────────────────────────
|
||||
|
||||
interface Adjacency {
|
||||
byId: Map<string, DepNode>;
|
||||
stackChildren: Map<string, string[]>;
|
||||
serviceResources: Map<string, string[]>;
|
||||
/** Reverse of serviceResources: resource id -> services that reference it. */
|
||||
resourceServices: Map<string, string[]>;
|
||||
hostByNodeId: Map<number, string>;
|
||||
serviceStack: Map<string, string>;
|
||||
}
|
||||
|
||||
function buildAdjacency(map: FleetDependencyMap): Adjacency {
|
||||
const byId = new Map(map.nodes.map((n) => [n.id, n]));
|
||||
const stackChildren = new Map<string, string[]>();
|
||||
const serviceResources = new Map<string, string[]>();
|
||||
const resourceServices = new Map<string, string[]>();
|
||||
const serviceStack = new Map<string, string>();
|
||||
const hostByNodeId = new Map<number, string>();
|
||||
|
||||
for (const n of map.nodes) if (n.kind === 'host') hostByNodeId.set(n.nodeId, n.id);
|
||||
|
||||
for (const e of map.edges) {
|
||||
if (e.kind === 'stack-service') {
|
||||
const arr = stackChildren.get(e.source) ?? [];
|
||||
arr.push(e.target);
|
||||
stackChildren.set(e.source, arr);
|
||||
serviceStack.set(e.target, e.source);
|
||||
} else if (e.kind === 'service-network' || e.kind === 'service-volume' || e.kind === 'service-port') {
|
||||
const fwd = serviceResources.get(e.source) ?? [];
|
||||
fwd.push(e.target);
|
||||
serviceResources.set(e.source, fwd);
|
||||
const rev = resourceServices.get(e.target) ?? [];
|
||||
rev.push(e.source);
|
||||
resourceServices.set(e.target, rev);
|
||||
}
|
||||
}
|
||||
return { byId, stackChildren, serviceResources, resourceServices, hostByNodeId, serviceStack };
|
||||
}
|
||||
|
||||
// ── Tab ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'graph' | 'list';
|
||||
|
||||
export function DependencyMapTab() {
|
||||
const [data, setData] = useState<FleetDependencyMap | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<ViewMode>('graph');
|
||||
const [search, setSearch] = useState('');
|
||||
const [nodeFilter, setNodeFilter] = useState<Set<number>>(new Set());
|
||||
const [flagFilter, setFlagFilter] = useState<Set<DepFlagKind>>(new Set());
|
||||
const [expandedStacks, setExpandedStacks] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchMap = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/dependency-map', { localOnly: true });
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
setData(await res.json() as FleetDependencyMap);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load dependency map';
|
||||
setError(message);
|
||||
toast.error(`Failed to load dependency map: ${message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void fetchMap(); }, [fetchMap]);
|
||||
|
||||
const adjacency = useMemo(() => (data ? buildAdjacency(data) : null), [data]);
|
||||
|
||||
const flagCounts = useMemo(() => {
|
||||
const counts = new Map<DepFlagKind, number>();
|
||||
for (const f of data?.flags ?? []) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [data]);
|
||||
|
||||
const presentNodeIds = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
for (const n of data?.nodes ?? []) set.add(n.nodeId);
|
||||
for (const e of data?.nodeErrors ?? []) set.add(e.nodeId);
|
||||
return [...set];
|
||||
}, [data]);
|
||||
|
||||
const nodeNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const n of data?.nodes ?? []) m.set(n.nodeId, n.nodeName);
|
||||
for (const e of data?.nodeErrors ?? []) m.set(e.nodeId, e.nodeName);
|
||||
return m;
|
||||
}, [data]);
|
||||
|
||||
const term = search.trim().toLowerCase();
|
||||
const searchActive = term !== '';
|
||||
const flagActive = flagFilter.size > 0;
|
||||
|
||||
const passNode = useCallback((n: DepNode) => nodeFilter.size === 0 || nodeFilter.has(n.nodeId), [nodeFilter]);
|
||||
const textMatch = useCallback((n: DepNode) =>
|
||||
n.label.toLowerCase().includes(term) || (n.stack ?? '').toLowerCase().includes(term) || n.nodeName.toLowerCase().includes(term), [term]);
|
||||
const flagMatch = useCallback((n: DepNode) => n.flags.some((f) => flagFilter.has(f)), [flagFilter]);
|
||||
const matchesFilters = useCallback((n: DepNode) =>
|
||||
(!searchActive || textMatch(n)) && (!flagActive || flagMatch(n)), [searchActive, textMatch, flagActive, flagMatch]);
|
||||
|
||||
// Resolve the set of nodes to draw (collapse + filters).
|
||||
const visible = useMemo(() => {
|
||||
if (!data || !adjacency) return { nodes: [] as DepNode[], expanded: new Set<string>() };
|
||||
const filterActive = searchActive || flagActive;
|
||||
const visibleIds = new Set<string>();
|
||||
const expanded = new Set(expandedStacks);
|
||||
|
||||
const addWithHost = (n: DepNode) => {
|
||||
visibleIds.add(n.id);
|
||||
const host = adjacency.hostByNodeId.get(n.nodeId);
|
||||
if (host) visibleIds.add(host);
|
||||
};
|
||||
|
||||
if (filterActive) {
|
||||
const revealService = (svcId: string) => {
|
||||
const svcNode = adjacency.byId.get(svcId);
|
||||
if (!svcNode || !passNode(svcNode)) return;
|
||||
addWithHost(svcNode);
|
||||
const parentStack = adjacency.serviceStack.get(svcId);
|
||||
if (parentStack) { visibleIds.add(parentStack); expanded.add(parentStack); }
|
||||
};
|
||||
for (const n of data.nodes) {
|
||||
if (!passNode(n) || n.kind === 'host' || n.kind === 'stack') continue;
|
||||
if (!matchesFilters(n)) continue;
|
||||
addWithHost(n);
|
||||
if (n.kind === 'service') {
|
||||
const parentStack = adjacency.serviceStack.get(n.id);
|
||||
if (parentStack) { visibleIds.add(parentStack); expanded.add(parentStack); }
|
||||
for (const r of adjacency.serviceResources.get(n.id) ?? []) visibleIds.add(r);
|
||||
} else {
|
||||
// A matching resource reveals the services that claim it (and their
|
||||
// stacks) so "what claims port 8080?" and "which stacks share this
|
||||
// network?" show the connected services, not an isolated node.
|
||||
for (const svc of adjacency.resourceServices.get(n.id) ?? []) revealService(svc);
|
||||
}
|
||||
}
|
||||
// Stacks that match directly (e.g. orphan synthetic stacks).
|
||||
for (const n of data.nodes) {
|
||||
if (n.kind === 'stack' && passNode(n) && matchesFilters(n)) addWithHost(n);
|
||||
}
|
||||
} else {
|
||||
for (const n of data.nodes) {
|
||||
if (!passNode(n)) continue;
|
||||
if (n.kind === 'host' || n.kind === 'stack') { visibleIds.add(n.id); continue; }
|
||||
// Standalone orphan resources surface by default.
|
||||
if ((n.kind === 'network' || n.kind === 'volume') && n.flags.includes('orphan')) addWithHost(n);
|
||||
}
|
||||
for (const sid of expanded) {
|
||||
if (!visibleIds.has(sid)) continue;
|
||||
for (const svc of adjacency.stackChildren.get(sid) ?? []) {
|
||||
const svcNode = adjacency.byId.get(svc);
|
||||
if (!svcNode || !passNode(svcNode)) continue;
|
||||
visibleIds.add(svc);
|
||||
for (const r of adjacency.serviceResources.get(svc) ?? []) visibleIds.add(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: data.nodes.filter((n) => visibleIds.has(n.id)), expanded };
|
||||
}, [data, adjacency, searchActive, flagActive, expandedStacks, passNode, matchesFilters]);
|
||||
|
||||
const childCounts = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
if (adjacency) for (const [stack, kids] of adjacency.stackChildren) m.set(stack, kids.length);
|
||||
return m;
|
||||
}, [adjacency]);
|
||||
|
||||
const overCeiling = visible.nodes.length > GRAPH_NODE_CEILING;
|
||||
|
||||
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || view !== 'graph' || overCeiling) {
|
||||
setFlowNodes([]);
|
||||
setFlowEdges([]);
|
||||
return;
|
||||
}
|
||||
const visibleSet = new Set(visible.nodes.map((n) => n.id));
|
||||
const edges = data.edges.filter((e) => visibleSet.has(e.source) && visibleSet.has(e.target));
|
||||
const { nodes: fn, edges: fe } = layoutDependencyGraph(visible.nodes, edges, visible.expanded, childCounts);
|
||||
setFlowNodes(fn);
|
||||
setFlowEdges(fe);
|
||||
}, [data, view, overCeiling, visible, childCounts, setFlowNodes, setFlowEdges]);
|
||||
|
||||
const handleNodeClick = useCallback((_e: React.MouseEvent, flowNode: Node) => {
|
||||
const dep = (flowNode.data as DepFlowData | undefined)?.dep;
|
||||
if (dep?.kind !== 'stack') return;
|
||||
setExpandedStacks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(flowNode.id)) next.delete(flowNode.id); else next.add(flowNode.id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const miniMapColor = useCallback((n: Node) => {
|
||||
const dep = (n.data as DepFlowData | undefined)?.dep;
|
||||
const sev = dep ? worstSeverity(dep.flags) : null;
|
||||
if (sev === 'destructive') return MINIMAP_DESTRUCTIVE;
|
||||
if (sev === 'warning') return MINIMAP_WARNING;
|
||||
if (dep?.kind === 'host') return MINIMAP_BRAND;
|
||||
return MINIMAP_MUTED;
|
||||
}, []);
|
||||
|
||||
const toggleFlag = (kind: DepFlagKind) => setFlagFilter((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(kind)) next.delete(kind); else next.add(kind);
|
||||
return next;
|
||||
});
|
||||
const toggleNode = (id: number) => setNodeFilter((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const listRows = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.nodes
|
||||
.filter((n) => n.kind !== 'host')
|
||||
.filter(passNode)
|
||||
.filter(matchesFilters);
|
||||
}, [data, passNode, matchesFilters]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <div className="rounded-lg border border-card-border bg-card p-10 text-center text-sm text-muted-foreground">Loading dependency map…</div>;
|
||||
}
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border bg-card p-10 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-3">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchMap()} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search stacks, services, resources…"
|
||||
className="h-8 w-64 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={setView}
|
||||
ariaLabel="View mode"
|
||||
options={[
|
||||
{ value: 'graph', label: 'Graph', icon: Share2 },
|
||||
{ value: 'list', label: 'List', icon: Layers },
|
||||
]}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchMap()} disabled={loading} className="gap-2 ml-auto">
|
||||
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Flag summary strip */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{FLAG_META.map((f) => {
|
||||
const count = flagCounts.get(f.kind) ?? 0;
|
||||
const active = flagFilter.has(f.kind);
|
||||
return (
|
||||
<button
|
||||
key={f.kind}
|
||||
type="button"
|
||||
onClick={() => toggleFlag(f.kind)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors',
|
||||
active ? 'border-brand/60 bg-brand/15 text-brand'
|
||||
: count > 0 ? 'border-card-border text-foreground hover:bg-muted/40'
|
||||
: 'border-card-border text-muted-foreground/60',
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', f.severity === 'destructive' ? 'bg-destructive' : 'bg-warning')} />
|
||||
{f.label}
|
||||
<span className="tabular-nums">{count > 99 ? '99+' : count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Node filter chips (only with more than one node present) */}
|
||||
{presentNodeIds.length > 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">Node</span>
|
||||
{presentNodeIds.map((id) => {
|
||||
const active = nodeFilter.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => toggleNode(id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors',
|
||||
active ? 'border-brand/60 bg-brand/15 text-brand' : 'border-card-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<Server className="h-3 w-3" strokeWidth={2} />
|
||||
{nodeNameById.get(id) ?? `Node ${id}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unreachable-node banner */}
|
||||
{data.nodeErrors.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
<TriangleAlert className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span>
|
||||
{data.nodeErrors.length === 1 ? '1 node could not be reached' : `${data.nodeErrors.length} nodes could not be reached`}: {data.nodeErrors.map((e) => e.nodeName).join(', ')}. The rest of the fleet is shown below.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.parseErrors.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
<TriangleAlert className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span>
|
||||
{data.parseErrors.length === 1 ? '1 stack could not be parsed' : `${data.parseErrors.length} stacks could not be parsed`}: {data.parseErrors.map((e) => `${e.nodeName}/${e.stack}`).join(', ')}. Their declared dependencies are not shown.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{view === 'graph' ? (
|
||||
overCeiling ? (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
|
||||
<Network className="h-6 w-6 text-muted-foreground mx-auto mb-3" strokeWidth={1.5} />
|
||||
<p className="text-sm text-muted-foreground mb-1">This fleet is large to draw at once ({visible.nodes.length} elements).</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">Narrow it with search or filters, or switch to the list.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setView('list')}>Switch to list</Button>
|
||||
</div>
|
||||
) : flowNodes.length === 0 ? (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center text-sm text-muted-foreground">
|
||||
{searchActive || flagActive ? 'No elements match the current filters.' : 'No stacks to map on this fleet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/30 border-b border-card-border">
|
||||
Click a stack to expand its services, networks, volumes, and ports.
|
||||
</div>
|
||||
<div className="h-[560px] w-full">
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesConnectable={false}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background gap={20} size={1} className="opacity-30" />
|
||||
<Controls
|
||||
className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap className="!bg-card !border-card-border !shadow-card-bevel" nodeColor={miniMapColor} maskColor="oklch(0 0 0 / 0.2)" pannable zoomable />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<DependencyList rows={listRows} nodeColumn={presentNodeIds.length > 1} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List view ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DependencyList({ rows, nodeColumn }: { rows: DepNode[]; nodeColumn: boolean }) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="rounded-lg border border-card-border bg-card p-10 text-center text-sm text-muted-foreground">No elements match the current filters.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-card-border font-mono text-[9px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{nodeColumn && <th className="text-left px-3 py-2 font-normal">Node</th>}
|
||||
<th className="text-left px-3 py-2 font-normal">Stack</th>
|
||||
<th className="text-left px-3 py-2 font-normal">Type</th>
|
||||
<th className="text-left px-3 py-2 font-normal">Name</th>
|
||||
<th className="text-left px-3 py-2 font-normal">State</th>
|
||||
<th className="text-left px-3 py-2 font-normal">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((n) => {
|
||||
const sev = worstSeverity(n.flags);
|
||||
return (
|
||||
<tr key={n.id} className="border-b border-card-border/50 last:border-0">
|
||||
{nodeColumn && <td className="px-3 py-1.5 text-muted-foreground">{n.nodeName}</td>}
|
||||
<td className="px-3 py-1.5 text-muted-foreground">{n.stack ?? '—'}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-[11px] text-muted-foreground">{n.kind}</td>
|
||||
<td className="px-3 py-1.5 text-stat-value">{n.label}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">{n.state ?? '—'}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{n.flags.length === 0 ? <span className="text-muted-foreground">—</span> : (
|
||||
<span className={cn('inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-[0.12em]', sev === 'destructive' ? 'text-destructive' : 'text-warning')}>
|
||||
<TriangleAlert className="h-3 w-3" strokeWidth={2} />
|
||||
{n.flags.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import type { Edge, Node } from '@xyflow/react';
|
||||
|
||||
// Mirrors the payload from GET /api/fleet/dependency-map. The repo defines
|
||||
// types per side rather than sharing a package, matching how the topology and
|
||||
// fleet views re-declare their backend shapes.
|
||||
export type DepNodeKind = 'host' | 'stack' | 'service' | 'network' | 'volume' | 'port';
|
||||
export type DepFlagKind = 'missing-dependency' | 'port-conflict' | 'orphan' | 'cross-stack-shared';
|
||||
export type DepEdgeKind =
|
||||
| 'stack-node'
|
||||
| 'stack-service'
|
||||
| 'depends-on'
|
||||
| 'service-network'
|
||||
| 'service-volume'
|
||||
| 'service-port';
|
||||
|
||||
export interface DepNode {
|
||||
id: string;
|
||||
kind: DepNodeKind;
|
||||
label: string;
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stack: string | null;
|
||||
managedStatus?: 'managed' | 'unmanaged' | 'system';
|
||||
state?: string;
|
||||
flags: DepFlagKind[];
|
||||
}
|
||||
|
||||
export interface DepEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
kind: DepEdgeKind;
|
||||
declaredOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface DepFlag {
|
||||
kind: DepFlagKind;
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
subjects: string[];
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface FleetDependencyMap {
|
||||
nodes: DepNode[];
|
||||
edges: DepEdge[];
|
||||
flags: DepFlag[];
|
||||
nodeErrors: { nodeId: number; nodeName: string; error: string }[];
|
||||
parseErrors: { nodeId: number; nodeName: string; stack: string; error: string }[];
|
||||
}
|
||||
|
||||
export interface DepFlowData extends Record<string, unknown> {
|
||||
dep: DepNode;
|
||||
expanded: boolean;
|
||||
expandable: boolean;
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
export const DEP_NODE_DIMS: Record<DepNodeKind, { w: number; h: number }> = {
|
||||
host: { w: 200, h: 54 },
|
||||
stack: { w: 200, h: 58 },
|
||||
service: { w: 174, h: 50 },
|
||||
network: { w: 162, h: 46 },
|
||||
volume: { w: 162, h: 46 },
|
||||
port: { w: 150, h: 46 },
|
||||
};
|
||||
|
||||
// ReactFlow inline styles cannot resolve CSS custom properties, so raw oklch
|
||||
// values are used. They approximate the default cyan accent and do not track a
|
||||
// user's chosen accent or light/dark theme.
|
||||
const EDGE_BRAND = 'oklch(0.78 0.11 195)';
|
||||
const EDGE_MUTED = 'oklch(0.55 0 0)';
|
||||
|
||||
function edgeStyle(e: DepEdge): { stroke: string; strokeWidth: number; strokeDasharray?: string } {
|
||||
if (e.declaredOnly) return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' };
|
||||
if (e.kind === 'stack-node' || e.kind === 'stack-service') return { stroke: EDGE_MUTED, strokeWidth: 1 };
|
||||
return { stroke: EDGE_BRAND, strokeWidth: 1.5 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions an already-filtered set of dependency nodes/edges with dagre and
|
||||
* maps them to ReactFlow nodes/edges. Visibility (collapse/expand and filters)
|
||||
* is decided by the caller; this function only lays out what it is given.
|
||||
*/
|
||||
export function layoutDependencyGraph(
|
||||
nodes: DepNode[],
|
||||
edges: DepEdge[],
|
||||
expanded: Set<string>,
|
||||
childCounts: Map<string, number>,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
if (nodes.length === 0) return { nodes: [], edges: [] };
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: 'LR', ranksep: 120, nodesep: 22, marginx: 20, marginy: 20 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const n of nodes) {
|
||||
const d = DEP_NODE_DIMS[n.kind];
|
||||
g.setNode(n.id, { width: d.w, height: d.h });
|
||||
}
|
||||
const visibleEdges = edges.filter((e) => g.hasNode(e.source) && g.hasNode(e.target));
|
||||
for (const e of visibleEdges) g.setEdge(e.source, e.target);
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const flowNodes: Node[] = nodes.map((n) => {
|
||||
const p = g.node(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'dep',
|
||||
position: { x: p.x - p.width / 2, y: p.y - p.height / 2 },
|
||||
data: {
|
||||
dep: n,
|
||||
expanded: expanded.has(n.id),
|
||||
expandable: n.kind === 'stack',
|
||||
childCount: childCounts.get(n.id) ?? 0,
|
||||
} satisfies DepFlowData,
|
||||
draggable: true,
|
||||
};
|
||||
});
|
||||
|
||||
const flowEdges: Edge[] = visibleEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: edgeStyle(e),
|
||||
animated: false,
|
||||
}));
|
||||
|
||||
return { nodes: flowNodes, edges: flowEdges };
|
||||
}
|
||||
Reference in New Issue
Block a user