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:
Anso
2026-06-06 11:18:36 -04:00
committed by GitHub
parent 13b6acab16
commit af4083175c
14 changed files with 2386 additions and 2 deletions
+23
View File
@@ -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' });
}
});
+60
View File
@@ -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');