mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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,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');
|
||||
|
||||
Reference in New Issue
Block a user