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