feat(topology): overhaul network topology with dagre layout, enriched nodes, and click-to-logs (#447)

Replace N+1 Docker API calls (inspectNetwork per network) with a
container-centric approach that fetches all networks and containers
in 2 parallel calls, then maps relationships in memory.

Add dagre auto-layout algorithm for hierarchical DAG visualization,
replacing the static two-row layout that caused edge spaghetti at scale.

Add "Show system networks" toggle, enrich container nodes with running
state indicators, stack badges, and base image names. Clicking a
running container opens its log viewer directly from the topology graph.
This commit is contained in:
Anso
2026-04-08 20:05:22 -04:00
committed by GitHub
parent 1524b64847
commit 3ee4fe6e44
9 changed files with 346 additions and 98 deletions
+3 -19
View File
@@ -5194,27 +5194,11 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
try {
const includeSystem = req.query.includeSystem === 'true';
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const dockerController = DockerController.getInstance(req.nodeId);
const { networks } = await dockerController.getClassifiedResources(knownStacks);
const userNetworks = networks.filter(n => n.managedStatus !== 'system');
const inspected = await Promise.all(
userNetworks.map(async (net) => {
try {
const detail = await dockerController.inspectNetwork(net.Id);
const containers = Object.entries(detail.Containers || {}).map(([id, c]) => {
const info = c as { Name: string; IPv4Address: string };
return { id, name: info.Name, ip: info.IPv4Address };
});
return { ...net, containers };
} catch {
return { ...net, containers: [] };
}
})
);
res.json(inspected);
const topology = await dockerController.getTopologyData(knownStacks, includeSystem);
res.json(topology);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Failed to fetch network topology';
console.error('Failed to fetch network topology:', error);
+109 -2
View File
@@ -55,6 +55,25 @@ export interface ClassifiedNetwork {
managedStatus: 'managed' | 'unmanaged' | 'system';
}
export interface TopologyContainer {
id: string;
name: string;
ip: string;
state: string;
image: string;
stack: string | null;
}
export interface TopologyNetwork {
Id: string;
Name: string;
Driver: string;
Scope: string;
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'system';
containers: TopologyContainer[];
}
export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
export interface CreateNetworkOptions {
@@ -67,6 +86,7 @@ export interface CreateNetworkOptions {
}
class DockerController {
private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
private docker: Docker;
private nodeId: number;
@@ -184,7 +204,6 @@ class DockerController {
volumes: ClassifiedVolume[];
networks: ClassifiedNetwork[];
}> {
const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
const knownSet = new Set(knownStackNames);
const [rawImages, rawVolumeData, rawNetworks, allContainers, projectToStack] = await Promise.all([
@@ -239,7 +258,7 @@ class DockerController {
});
const networks: ClassifiedNetwork[] = this.validateApiData<any[]>(rawNetworks).map((net: any) => {
if (SYSTEM_NETWORKS.has(net.Name)) {
if (DockerController.SYSTEM_NETWORKS.has(net.Name)) {
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const };
}
const stack = DockerController.resolveProjectLabel(net.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
@@ -397,6 +416,94 @@ class DockerController {
return this.validateApiData<any[]>(containers);
}
/**
* Builds topology data with 2 Docker API calls instead of N+1.
* Fetches all networks + all containers in parallel, then maps
* container-to-network relationships in memory using NetworkSettings.
*/
public async getTopologyData(
knownStackNames: string[],
includeSystem: boolean,
): Promise<TopologyNetwork[]> {
const knownSet = new Set(knownStackNames);
const [rawNetworks, rawContainers, projectToStack] = await Promise.all([
this.docker.listNetworks(),
this.docker.listContainers({ all: true }),
DockerController.resolveProjectNameMap(knownStackNames),
]);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const networks = this.validateApiData<any[]>(rawNetworks);
const containers = this.validateApiData<any[]>(rawContainers);
// Build network map, optionally filtering system networks
const networkMap = new Map<string, TopologyNetwork>();
for (const net of networks) {
const isSystem = DockerController.SYSTEM_NETWORKS.has(net.Name);
if (isSystem && !includeSystem) continue;
const stack = isSystem
? null
: DockerController.resolveProjectLabel(
net.Labels?.['com.docker.compose.project'],
knownSet,
projectToStack,
);
const managedStatus: TopologyNetwork['managedStatus'] = isSystem
? 'system'
: stack ? 'managed' : 'unmanaged';
networkMap.set(net.Id, {
Id: net.Id,
Name: net.Name,
Driver: net.Driver ?? 'bridge',
Scope: net.Scope ?? 'local',
managedBy: stack,
managedStatus,
containers: [],
});
}
// Map containers to their networks via NetworkSettings.
// Stack resolution is deferred until a network match is found to avoid
// wasted work for containers not attached to any tracked network.
for (const c of containers) {
const netSettings: Record<string, { NetworkID?: string; IPAddress?: string }> =
c.NetworkSettings?.Networks ?? {};
let containerStack: string | null | undefined;
let stackResolved = false;
for (const [, netInfo] of Object.entries(netSettings)) {
const netId = netInfo.NetworkID;
if (!netId) continue;
const topology = networkMap.get(netId);
if (!topology) continue;
if (!stackResolved) {
containerStack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
stackResolved = true;
}
topology.containers.push({
id: c.Id,
name: (c.Names?.[0] ?? '').replace(/^\//, ''),
ip: netInfo.IPAddress ?? '',
state: c.State ?? 'unknown',
image: c.Image ?? '',
stack: containerStack ?? null,
});
}
}
return Array.from(networkMap.values());
}
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
private static resolveProjectLabel(
project: string | undefined,