mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
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:
@@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* **topology:** Replace N+1 Docker API calls with container-centric approach (2 calls instead of N+1). Resolves performance issues on hosts with many networks.
|
||||||
|
* **topology:** Add dagre auto-layout algorithm for hierarchical DAG visualization, replacing the static two-row layout that caused spaghetti edges at scale.
|
||||||
|
* **topology:** Add "Show system networks" toggle to display bridge, host, and none networks (off by default).
|
||||||
|
* **topology:** Enrich container nodes with running state indicator, stack badge, and base image name.
|
||||||
|
* **topology:** Click a running container node to open its log viewer directly from the topology graph.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
* **fleet:** fix false "Update available" on remote nodes whose `api_url` has a trailing slash, causing `fetchRemoteMeta` to construct a double-slash URL that fails silently
|
* **fleet:** fix false "Update available" on remote nodes whose `api_url` has a trailing slash, causing `fetchRemoteMeta` to construct a double-slash URL that fails silently
|
||||||
|
|||||||
+3
-19
@@ -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) => {
|
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
const includeSystem = req.query.includeSystem === 'true';
|
||||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||||
const dockerController = DockerController.getInstance(req.nodeId);
|
const dockerController = DockerController.getInstance(req.nodeId);
|
||||||
const { networks } = await dockerController.getClassifiedResources(knownStacks);
|
const topology = await dockerController.getTopologyData(knownStacks, includeSystem);
|
||||||
const userNetworks = networks.filter(n => n.managedStatus !== 'system');
|
res.json(topology);
|
||||||
|
|
||||||
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);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to fetch network topology';
|
const msg = error instanceof Error ? error.message : 'Failed to fetch network topology';
|
||||||
console.error('Failed to fetch network topology:', error);
|
console.error('Failed to fetch network topology:', error);
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ export interface ClassifiedNetwork {
|
|||||||
managedStatus: 'managed' | 'unmanaged' | 'system';
|
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 type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
|
||||||
|
|
||||||
export interface CreateNetworkOptions {
|
export interface CreateNetworkOptions {
|
||||||
@@ -67,6 +86,7 @@ export interface CreateNetworkOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DockerController {
|
class DockerController {
|
||||||
|
private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
|
||||||
private docker: Docker;
|
private docker: Docker;
|
||||||
private nodeId: number;
|
private nodeId: number;
|
||||||
|
|
||||||
@@ -184,7 +204,6 @@ class DockerController {
|
|||||||
volumes: ClassifiedVolume[];
|
volumes: ClassifiedVolume[];
|
||||||
networks: ClassifiedNetwork[];
|
networks: ClassifiedNetwork[];
|
||||||
}> {
|
}> {
|
||||||
const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
|
|
||||||
const knownSet = new Set(knownStackNames);
|
const knownSet = new Set(knownStackNames);
|
||||||
|
|
||||||
const [rawImages, rawVolumeData, rawNetworks, allContainers, projectToStack] = await Promise.all([
|
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) => {
|
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 };
|
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);
|
const stack = DockerController.resolveProjectLabel(net.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
|
||||||
@@ -397,6 +416,94 @@ class DockerController {
|
|||||||
return this.validateApiData<any[]>(containers);
|
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. */
|
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
|
||||||
private static resolveProjectLabel(
|
private static resolveProjectLabel(
|
||||||
project: string | undefined,
|
project: string | undefined,
|
||||||
|
|||||||
Generated
+16
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -307,6 +308,21 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dagrejs/dagre": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dagrejs/graphlib": "4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dagrejs/graphlib": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import ScheduledOperationsView from './ScheduledOperationsView';
|
|||||||
import AutoUpdatePoliciesView from './AutoUpdatePoliciesView';
|
import AutoUpdatePoliciesView from './AutoUpdatePoliciesView';
|
||||||
import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
|
import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
|
||||||
import type { SenchoNavigateDetail } from './NodeManager';
|
import type { SenchoNavigateDetail } from './NodeManager';
|
||||||
|
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
||||||
|
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import type { Node } from '@/context/NodeContext';
|
import type { Node } from '@/context/NodeContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -1270,6 +1272,18 @@ export default function EditorLayout() {
|
|||||||
setLogContainer(null);
|
setLogContainer(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Listen for topology click-to-logs events (ref avoids stale closure)
|
||||||
|
const openLogViewerRef = useRef(openLogViewer);
|
||||||
|
openLogViewerRef.current = openLogViewer;
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const { containerId, containerName } = (e as CustomEvent<SenchoOpenLogsDetail>).detail;
|
||||||
|
openLogViewerRef.current(containerId, containerName);
|
||||||
|
};
|
||||||
|
window.addEventListener(SENCHO_OPEN_LOGS_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Safe container list with fallback
|
// Safe container list with fallback
|
||||||
const safeContainers = containers || [];
|
const safeContainers = containers || [];
|
||||||
// Safe content strings with fallback
|
// Safe content strings with fallback
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -13,39 +13,75 @@ import {
|
|||||||
Position,
|
Position,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import dagre from '@dagrejs/dagre';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { Container, Network, Loader2 } from 'lucide-react';
|
import { Container, Network, Loader2 } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TopologyContainer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
state: string;
|
||||||
|
image: string;
|
||||||
|
stack: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface TopologyNetwork {
|
interface TopologyNetwork {
|
||||||
Id: string;
|
Id: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
Driver: string;
|
Driver: string;
|
||||||
managedStatus: 'managed' | 'unmanaged' | 'system';
|
managedStatus: 'managed' | 'unmanaged' | 'system';
|
||||||
containers: Array<{ id: string; name: string; ip: string }>;
|
containers: TopologyContainer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerNode {
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
id: string;
|
|
||||||
name: string;
|
function stateColor(state: string): string {
|
||||||
networks: string[];
|
switch (state) {
|
||||||
ipAddresses: Record<string, string>;
|
case 'running': return 'bg-success';
|
||||||
|
case 'restarting':
|
||||||
|
case 'paused':
|
||||||
|
case 'created': return 'bg-warning';
|
||||||
|
default: return 'bg-destructive';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Custom Nodes ──────────────────────────────────────────────────────────────
|
// ── Custom Nodes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ContainerNodeComponent({ data }: { data: { label: string; networks: string[]; ipAddresses: Record<string, string> } }) {
|
interface ContainerNodeData {
|
||||||
|
label: string;
|
||||||
|
containerId: string;
|
||||||
|
networks: string[];
|
||||||
|
ipAddresses: Record<string, string>;
|
||||||
|
state: string;
|
||||||
|
image: string;
|
||||||
|
stack: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 min-w-[160px]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 min-w-[160px]',
|
||||||
|
(!data.state || data.state === 'running') && 'cursor-pointer hover:border-t-card-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Handle type="target" position={Position.Top} className="!bg-muted-foreground !w-2 !h-2" />
|
<Handle type="target" position={Position.Top} className="!bg-muted-foreground !w-2 !h-2" />
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={cn('w-2 h-2 rounded-full shrink-0', stateColor(data.state))} />
|
||||||
<Container className="w-3.5 h-3.5 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
<Container className="w-3.5 h-3.5 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||||
<span className="text-xs font-medium truncate max-w-[140px]">{data.label}</span>
|
<span className="text-xs font-medium truncate max-w-[140px]">{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{data.stack && (
|
||||||
|
<Badge variant="outline" className="text-[9px] h-4 px-1 font-mono mb-1">{data.stack}</Badge>
|
||||||
|
)}
|
||||||
{data.networks.length > 0 && (
|
{data.networks.length > 0 && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{data.networks.map(netName => (
|
{data.networks.map(netName => (
|
||||||
@@ -58,6 +94,9 @@ function ContainerNodeComponent({ data }: { data: { label: string; networks: str
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<span className="block font-mono text-[10px] text-muted-foreground/60 truncate max-w-[180px] mt-0.5">
|
||||||
|
{data.image}
|
||||||
|
</span>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground !w-2 !h-2" />
|
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground !w-2 !h-2" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -99,81 +138,123 @@ const EDGE_COLORS = [
|
|||||||
'oklch(0.70 0.10 340)', // pink
|
'oklch(0.70 0.10 340)', // pink
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Layout Helper ─────────────────────────────────────────────────────────────
|
// ── Layout Helper (dagre) ────────────────────────────────────���───────────────
|
||||||
|
|
||||||
function layoutGraph(
|
function layoutGraph(
|
||||||
networksList: TopologyNetwork[],
|
networksList: TopologyNetwork[],
|
||||||
): { nodes: Node[]; edges: Edge[] } {
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
const flowNodes: Node[] = [];
|
const g = new dagre.graphlib.Graph();
|
||||||
const flowEdges: Edge[] = [];
|
g.setGraph({ rankdir: 'TB', ranksep: 120, nodesep: 60 });
|
||||||
const containerDataMap = new Map<string, ContainerNode>();
|
g.setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
networksList.forEach(net => {
|
// Deduplicate containers across networks
|
||||||
net.containers.forEach(c => {
|
const containerMap = new Map<string, {
|
||||||
if (!containerDataMap.has(c.id)) {
|
name: string;
|
||||||
containerDataMap.set(c.id, { id: c.id, name: c.name, networks: [], ipAddresses: {} });
|
networks: string[];
|
||||||
|
ipAddresses: Record<string, string>;
|
||||||
|
state: string;
|
||||||
|
image: string;
|
||||||
|
stack: string | null;
|
||||||
|
}>();
|
||||||
|
for (const net of networksList) {
|
||||||
|
for (const c of net.containers) {
|
||||||
|
if (!containerMap.has(c.id)) {
|
||||||
|
containerMap.set(c.id, {
|
||||||
|
name: c.name, networks: [], ipAddresses: {},
|
||||||
|
state: c.state, image: c.image, stack: c.stack,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const cd = containerDataMap.get(c.id)!;
|
const entry = containerMap.get(c.id)!;
|
||||||
cd.networks.push(net.Name);
|
entry.networks.push(net.Name);
|
||||||
cd.ipAddresses[net.Name] = c.ip;
|
entry.ipAddresses[net.Name] = c.ip;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add nodes to dagre graph
|
||||||
|
for (const net of networksList) {
|
||||||
|
g.setNode(`net-${net.Id}`, { width: 160, height: 60 });
|
||||||
|
}
|
||||||
|
for (const [id] of containerMap) {
|
||||||
|
g.setNode(`ctr-${id}`, { width: 200, height: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add edges and collect for React Flow
|
||||||
|
const seenEdges = new Set<string>();
|
||||||
|
const edgeList: { netId: string; ctrId: string; color: string }[] = [];
|
||||||
|
networksList.forEach((net, ni) => {
|
||||||
|
const color = EDGE_COLORS[ni % EDGE_COLORS.length];
|
||||||
|
for (const c of net.containers) {
|
||||||
|
const edgeKey = `${net.Id}-${c.id}`;
|
||||||
|
if (!seenEdges.has(edgeKey)) {
|
||||||
|
seenEdges.add(edgeKey);
|
||||||
|
g.setEdge(`net-${net.Id}`, `ctr-${c.id}`);
|
||||||
|
edgeList.push({ netId: net.Id, ctrId: c.id, color });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const networkSpacing = 240;
|
dagre.layout(g);
|
||||||
const containerSpacing = 220;
|
|
||||||
|
|
||||||
networksList.forEach((net, i) => {
|
// Convert dagre positions (center-based) to React Flow positions (top-left)
|
||||||
|
const flowNodes: Node[] = [];
|
||||||
|
for (const net of networksList) {
|
||||||
|
const pos = g.node(`net-${net.Id}`);
|
||||||
flowNodes.push({
|
flowNodes.push({
|
||||||
id: `net-${net.Id}`,
|
id: `net-${net.Id}`,
|
||||||
type: 'network',
|
type: 'network',
|
||||||
position: { x: i * networkSpacing, y: 0 },
|
position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 },
|
||||||
data: { label: net.Name, driver: net.Driver, status: net.managedStatus },
|
data: { label: net.Name, driver: net.Driver, status: net.managedStatus },
|
||||||
draggable: true,
|
draggable: true,
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
for (const [id, ctr] of containerMap) {
|
||||||
const allContainers = Array.from(containerDataMap.values());
|
const pos = g.node(`ctr-${id}`);
|
||||||
allContainers.forEach((container, i) => {
|
|
||||||
flowNodes.push({
|
flowNodes.push({
|
||||||
id: `ctr-${container.id}`,
|
id: `ctr-${id}`,
|
||||||
type: 'container',
|
type: 'container',
|
||||||
position: { x: i * containerSpacing, y: 180 },
|
position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 },
|
||||||
data: {
|
data: {
|
||||||
label: container.name,
|
label: ctr.name,
|
||||||
networks: container.networks,
|
containerId: id,
|
||||||
ipAddresses: container.ipAddresses,
|
networks: ctr.networks,
|
||||||
|
ipAddresses: ctr.ipAddresses,
|
||||||
|
state: ctr.state,
|
||||||
|
image: ctr.image,
|
||||||
|
stack: ctr.stack,
|
||||||
},
|
},
|
||||||
draggable: true,
|
draggable: true,
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
networksList.forEach((net, ni) => {
|
const flowEdges: Edge[] = edgeList.map(({ netId, ctrId, color }) => ({
|
||||||
const color = EDGE_COLORS[ni % EDGE_COLORS.length];
|
id: `edge-${netId}-${ctrId}`,
|
||||||
net.containers.forEach(c => {
|
source: `net-${netId}`,
|
||||||
flowEdges.push({
|
target: `ctr-${ctrId}`,
|
||||||
id: `edge-${net.Id}-${c.id}`,
|
animated: true,
|
||||||
source: `net-${net.Id}`,
|
style: { stroke: color, strokeWidth: 1.5 },
|
||||||
target: `ctr-${c.id}`,
|
}));
|
||||||
animated: true,
|
|
||||||
style: { stroke: color, strokeWidth: 1.5 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return { nodes: flowNodes, edges: flowEdges };
|
return { nodes: flowNodes, edges: flowEdges };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Component ────────────────────────────────────────────────────────────
|
// ── Main Component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function NetworkTopologyView() {
|
interface NetworkTopologyViewProps {
|
||||||
|
onContainerClick?: (containerId: string, containerName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NetworkTopologyView({ onContainerClick }: NetworkTopologyViewProps) {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [includeSystem, setIncludeSystem] = useState(false);
|
||||||
|
const onContainerClickRef = useRef(onContainerClick);
|
||||||
|
onContainerClickRef.current = onContainerClick;
|
||||||
|
|
||||||
const fetchTopology = useCallback(async () => {
|
const fetchTopology = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/system/networks/topology');
|
const res = await apiFetch(`/system/networks/topology?includeSystem=${includeSystem}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch topology');
|
if (!res.ok) throw new Error('Failed to fetch topology');
|
||||||
const inspected = await res.json();
|
const inspected = await res.json();
|
||||||
|
|
||||||
@@ -186,10 +267,16 @@ export default function NetworkTopologyView() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [setNodes, setEdges]);
|
}, [setNodes, setEdges, includeSystem]);
|
||||||
|
|
||||||
useEffect(() => { fetchTopology(); }, [fetchTopology]);
|
useEffect(() => { fetchTopology(); }, [fetchTopology]);
|
||||||
|
|
||||||
|
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||||
|
if (node.type === 'container' && (!node.data.state || node.data.state === 'running')) {
|
||||||
|
onContainerClickRef.current?.(node.data.containerId as string, node.data.label as string);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
|
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
|
||||||
@@ -203,36 +290,51 @@ export default function NetworkTopologyView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground gap-3">
|
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground gap-3">
|
||||||
<Network className="w-8 h-8 opacity-40" strokeWidth={1.5} />
|
<Network className="w-8 h-8 opacity-40" strokeWidth={1.5} />
|
||||||
<p className="text-sm">No user-created networks found.</p>
|
<p className="text-sm">
|
||||||
<p className="text-xs opacity-70">Create a network or deploy stacks with custom networks to see the topology.</p>
|
{includeSystem ? 'No networks found.' : 'No user-created networks found.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs opacity-70">
|
||||||
|
{includeSystem
|
||||||
|
? 'No Docker networks are available on this node.'
|
||||||
|
: 'Create a network or deploy stacks with custom networks to see the topology.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[500px] w-full rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
|
<div className="rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
|
||||||
<ReactFlow
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-card-border">
|
||||||
nodes={nodes}
|
<Switch id="show-system" checked={includeSystem} onCheckedChange={setIncludeSystem} />
|
||||||
edges={edges}
|
<Label htmlFor="show-system" className="text-xs cursor-pointer">
|
||||||
onNodesChange={onNodesChange}
|
Show system networks
|
||||||
onEdgesChange={onEdgesChange}
|
</Label>
|
||||||
nodeTypes={nodeTypes}
|
</div>
|
||||||
fitView
|
<div className="h-[500px] w-full">
|
||||||
fitViewOptions={{ padding: 0.3 }}
|
<ReactFlow
|
||||||
proOptions={{ hideAttribution: true }}
|
nodes={nodes}
|
||||||
className="bg-background"
|
edges={edges}
|
||||||
>
|
onNodesChange={onNodesChange}
|
||||||
<Background gap={20} size={1} className="opacity-30" />
|
onEdgesChange={onEdgesChange}
|
||||||
<Controls className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted" />
|
onNodeClick={handleNodeClick}
|
||||||
<MiniMap
|
nodeTypes={nodeTypes}
|
||||||
className="!bg-card !border-card-border !shadow-card-bevel"
|
fitView
|
||||||
nodeColor={(node) => {
|
fitViewOptions={{ padding: 0.3 }}
|
||||||
if (node.type === 'network') return 'oklch(0.75 0.08 192)';
|
proOptions={{ hideAttribution: true }}
|
||||||
return 'oklch(0.50 0 0)';
|
className="bg-background"
|
||||||
}}
|
>
|
||||||
maskColor="oklch(0 0 0 / 0.2)"
|
<Background gap={20} size={1} className="opacity-30" />
|
||||||
/>
|
<Controls className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted" />
|
||||||
</ReactFlow>
|
<MiniMap
|
||||||
|
className="!bg-card !border-card-border !shadow-card-bevel"
|
||||||
|
nodeColor={(node) => {
|
||||||
|
if (node.type === 'network') return 'oklch(0.75 0.08 192)';
|
||||||
|
return 'oklch(0.50 0 0)';
|
||||||
|
}}
|
||||||
|
maskColor="oklch(0 0 0 / 0.2)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { PaidGate } from './PaidGate';
|
|||||||
import { CapabilityGate } from './CapabilityGate';
|
import { CapabilityGate } from './CapabilityGate';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
||||||
|
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
|
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
|
||||||
@@ -830,7 +832,13 @@ export default function ResourcesView() {
|
|||||||
<span className="text-sm">Loading topology...</span>
|
<span className="text-sm">Loading topology...</span>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<NetworkTopologyView />
|
<NetworkTopologyView
|
||||||
|
onContainerClick={(id, name) => {
|
||||||
|
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
|
||||||
|
detail: { containerId: id, containerName: name },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</CapabilityGate>
|
</CapabilityGate>
|
||||||
</PaidGate>
|
</PaidGate>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Cross-component custom event constants and typed detail interfaces. */
|
||||||
|
|
||||||
|
export const SENCHO_OPEN_LOGS_EVENT = 'sencho-open-logs';
|
||||||
|
|
||||||
|
export interface SenchoOpenLogsDetail {
|
||||||
|
containerId: string;
|
||||||
|
containerName: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user