mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user