mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
af4083175c
* 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.
92 lines
4.5 KiB
TypeScript
92 lines
4.5 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|