feat: add NetworkGraphViewer
This commit is contained in:
1 parent
f27c0b066f
commit
e35d485ae9
1 file changed
+832
@@ -0,0 +1,832 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import cytoscape from "cytoscape";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Database,
|
||||||
|
Network,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Maximize2,
|
||||||
|
RotateCcw,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
LayoutGrid,
|
||||||
|
GitBranch,
|
||||||
|
Circle,
|
||||||
|
Target,
|
||||||
|
Sparkles,
|
||||||
|
Share2,
|
||||||
|
Server,
|
||||||
|
Globe,
|
||||||
|
MonitorSmartphone,
|
||||||
|
Cpu,
|
||||||
|
CircleOff,
|
||||||
|
} from "lucide-svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import type { NetworkInfo } from "$lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
networks: NetworkInfo[];
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { networks, class: className = "" }: Props = $props();
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement | null = $state(null);
|
||||||
|
let cy: cytoscape.Core | null = null;
|
||||||
|
let graphInitialized = $state(false);
|
||||||
|
let selectedNode = $state<any>(null);
|
||||||
|
let selectedEdge = $state<any>(null);
|
||||||
|
|
||||||
|
// Theme state
|
||||||
|
let graphTheme = $state<"light" | "dark">("light");
|
||||||
|
|
||||||
|
// Layout state
|
||||||
|
type LayoutType = "breadthfirst" | "grid" | "circle" | "concentric" | "cose";
|
||||||
|
let currentLayout = $state<LayoutType>("breadthfirst");
|
||||||
|
let showLayoutMenu = $state(false);
|
||||||
|
|
||||||
|
const layoutOptions: { value: LayoutType; label: string; icon: string }[] = [
|
||||||
|
{ value: "breadthfirst", label: "Tree", icon: "tree" },
|
||||||
|
{ value: "grid", label: "Grid", icon: "grid" },
|
||||||
|
{ value: "circle", label: "Circle", icon: "circle" },
|
||||||
|
{ value: "concentric", label: "Radial", icon: "radial" },
|
||||||
|
{ value: "cose", label: "Force", icon: "force" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildGraphElements(nets: NetworkInfo[]) {
|
||||||
|
interface ContainerResult {
|
||||||
|
containerId: string;
|
||||||
|
containerName: string;
|
||||||
|
networks: {
|
||||||
|
ipv4: string;
|
||||||
|
netName: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
const elements: cytoscape.ElementDefinition[] = [];
|
||||||
|
const networks = nets;
|
||||||
|
|
||||||
|
// Derive services from networks
|
||||||
|
const serviceMap = networks.reduce<Record<string, ContainerResult>>((svcs, network) => {
|
||||||
|
Object.entries(network.containers).forEach(([id, config]) => {
|
||||||
|
if (!svcs[id]) {
|
||||||
|
svcs[id] = {
|
||||||
|
containerId: id,
|
||||||
|
containerName: config.name,
|
||||||
|
networks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
svcs[id].networks.push({
|
||||||
|
ipv4: config.ipv4Address,
|
||||||
|
netName: network.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return svcs;
|
||||||
|
}, {});
|
||||||
|
const services = Object.values(serviceMap);
|
||||||
|
|
||||||
|
// Add service nodes
|
||||||
|
services.forEach((service) => {
|
||||||
|
elements.push({
|
||||||
|
data: {
|
||||||
|
id: `service-${service.containerName}`,
|
||||||
|
label: service.containerName,
|
||||||
|
caption: '',
|
||||||
|
type: "service",
|
||||||
|
config: service,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add network nodes
|
||||||
|
networks.forEach((network) => {
|
||||||
|
const driver = network.driver;
|
||||||
|
elements.push({
|
||||||
|
data: {
|
||||||
|
id: `network-${network.name}`,
|
||||||
|
label: network.name,
|
||||||
|
caption: driver,
|
||||||
|
type: "network",
|
||||||
|
driver: driver,
|
||||||
|
external: !network.internal,
|
||||||
|
config: network,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect services to networks
|
||||||
|
services.forEach((service) => {
|
||||||
|
const serviceNetworks = service.networks;
|
||||||
|
if (serviceNetworks) {
|
||||||
|
serviceNetworks.forEach((network) => {
|
||||||
|
const netName = network.netName;
|
||||||
|
const foundName = networks.find((network) => network.name === netName);
|
||||||
|
if (foundName || netName === "default") {
|
||||||
|
const targetId = foundName ? `network-${netName}` : "network-default";
|
||||||
|
const defaultNet = networks.find((network) => network.name === "default");
|
||||||
|
if (netName === "default" && !defaultNet) {
|
||||||
|
const defaultExists = elements.find((e) => e.data.id === "network-default");
|
||||||
|
if (!defaultExists) {
|
||||||
|
elements.push({
|
||||||
|
data: {
|
||||||
|
id: "network-default",
|
||||||
|
label: "default",
|
||||||
|
type: "network",
|
||||||
|
driver: "bridge",
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elements.push({
|
||||||
|
data: {
|
||||||
|
id: `net-${service.containerName}-${netName}`,
|
||||||
|
source: `service-${service.containerName}`,
|
||||||
|
target: targetId,
|
||||||
|
type: "network-connection",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG icons as data URLs for nodes
|
||||||
|
function getSvgIcon(type: string, color: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
service: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`,
|
||||||
|
network: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/></svg>`,
|
||||||
|
};
|
||||||
|
const svg = icons[type] || icons.service;
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGraph(useExistingData = false, skipLayout = false) {
|
||||||
|
if (!containerEl) return;
|
||||||
|
|
||||||
|
// Even if parsing failed, we get at least an empty structure to render
|
||||||
|
if (!networks) {
|
||||||
|
networks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = buildGraphElements(networks);
|
||||||
|
|
||||||
|
// If skipping layout, store current positions before destroying
|
||||||
|
let savedPositions: Map<string, { x: number; y: number }> | null = null;
|
||||||
|
if (skipLayout && cy) {
|
||||||
|
savedPositions = new Map();
|
||||||
|
cy.nodes().forEach((node) => {
|
||||||
|
const pos = node.position();
|
||||||
|
savedPositions!.set(node.id(), { x: pos.x, y: pos.y });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cy) {
|
||||||
|
cy.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-based colors
|
||||||
|
const isDark = graphTheme === "dark";
|
||||||
|
const colors = {
|
||||||
|
service: {
|
||||||
|
bg: isDark ? "#3b82f6" : "#dbeafe",
|
||||||
|
border: isDark ? "#2563eb" : "#93c5fd",
|
||||||
|
text: isDark ? "#ffffff" : "#1e3a5f",
|
||||||
|
icon: isDark ? "#ffffff" : "#2563eb",
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
bg: isDark ? "#8b5cf6" : "#ede9fe",
|
||||||
|
border: isDark ? "#7c3aed" : "#c4b5fd",
|
||||||
|
text: isDark ? "#ffffff" : "#3b1e5f",
|
||||||
|
icon: isDark ? "#ffffff" : "#7c3aed",
|
||||||
|
},
|
||||||
|
edge: isDark ? "#64748b" : "#94a3b8",
|
||||||
|
selected: isDark ? "#fbbf24" : "#18181b",
|
||||||
|
caption: isDark ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.5)",
|
||||||
|
};
|
||||||
|
|
||||||
|
cy = cytoscape({
|
||||||
|
container: containerEl,
|
||||||
|
elements,
|
||||||
|
style: [
|
||||||
|
// Service nodes
|
||||||
|
{
|
||||||
|
selector: 'node[type="service"]',
|
||||||
|
style: {
|
||||||
|
"background-color": colors.service.bg,
|
||||||
|
"border-color": colors.service.border,
|
||||||
|
"border-width": 2,
|
||||||
|
label: (ele: any) => `${ele.data("label")}\n${ele.data("caption") || ""}`,
|
||||||
|
color: colors.service.text,
|
||||||
|
"text-valign": "center",
|
||||||
|
"text-halign": "center",
|
||||||
|
"font-size": "10px",
|
||||||
|
"font-weight": 600,
|
||||||
|
width: 150,
|
||||||
|
height: 55,
|
||||||
|
shape: "roundrectangle",
|
||||||
|
"text-wrap": "wrap",
|
||||||
|
"text-max-width": "115px",
|
||||||
|
"text-overflow-wrap": "anywhere",
|
||||||
|
"line-height": 1.2,
|
||||||
|
"background-image": getSvgIcon("service", colors.service.icon),
|
||||||
|
"background-width": "16px",
|
||||||
|
"background-height": "16px",
|
||||||
|
"background-position-x": "8px",
|
||||||
|
"background-position-y": "50%",
|
||||||
|
"background-clip": "none",
|
||||||
|
"text-margin-x": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Network nodes
|
||||||
|
{
|
||||||
|
selector: 'node[type="network"]',
|
||||||
|
style: {
|
||||||
|
"background-color": colors.network.bg,
|
||||||
|
"border-color": colors.network.border,
|
||||||
|
"border-width": 2,
|
||||||
|
label: (ele: any) => `${ele.data("label")}\nnetwork: ${ele.data("caption") || "bridge"}`,
|
||||||
|
color: colors.network.text,
|
||||||
|
"text-valign": "center",
|
||||||
|
"text-halign": "center",
|
||||||
|
"font-size": "9px",
|
||||||
|
"font-weight": 600,
|
||||||
|
width: 120,
|
||||||
|
height: 46,
|
||||||
|
shape: "roundrectangle",
|
||||||
|
"text-wrap": "wrap",
|
||||||
|
"text-max-width": "90px",
|
||||||
|
"text-overflow-wrap": "anywhere",
|
||||||
|
"line-height": 1.2,
|
||||||
|
"background-image": getSvgIcon("network", colors.network.icon),
|
||||||
|
"background-width": "14px",
|
||||||
|
"background-height": "14px",
|
||||||
|
"background-position-x": "6px",
|
||||||
|
"background-position-y": "50%",
|
||||||
|
"background-clip": "none",
|
||||||
|
"text-margin-x": 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Link edges
|
||||||
|
{
|
||||||
|
selector: 'edge[type="link"]',
|
||||||
|
style: {
|
||||||
|
width: 2,
|
||||||
|
"line-color": "#64748b",
|
||||||
|
"target-arrow-color": "#64748b",
|
||||||
|
"target-arrow-shape": "triangle",
|
||||||
|
"curve-style": "bezier",
|
||||||
|
"line-style": "dashed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Network connection edges
|
||||||
|
{
|
||||||
|
selector: 'edge[type="network-connection"]',
|
||||||
|
style: {
|
||||||
|
width: 1.5,
|
||||||
|
"line-color": "#a78bfa",
|
||||||
|
"curve-style": "bezier",
|
||||||
|
"line-style": "dotted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Selected node
|
||||||
|
{
|
||||||
|
selector: "node:selected",
|
||||||
|
style: {
|
||||||
|
"border-width": 3,
|
||||||
|
"border-color": "#18181b",
|
||||||
|
"overlay-color": "#18181b",
|
||||||
|
"overlay-padding": 3,
|
||||||
|
"overlay-opacity": 0.15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Selected edge
|
||||||
|
{
|
||||||
|
selector: "edge:selected",
|
||||||
|
style: {
|
||||||
|
width: 3,
|
||||||
|
"line-color": "#f59e0b",
|
||||||
|
"target-arrow-color": "#f59e0b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Connection mode - highlight services
|
||||||
|
{
|
||||||
|
selector: "node.connection-source",
|
||||||
|
style: {
|
||||||
|
"border-width": 4,
|
||||||
|
"border-color": "#22c55e",
|
||||||
|
"overlay-color": "#22c55e",
|
||||||
|
"overlay-padding": 5,
|
||||||
|
"overlay-opacity": 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "node.connection-target",
|
||||||
|
style: {
|
||||||
|
"border-color": "#3b82f6",
|
||||||
|
"border-width": 3,
|
||||||
|
"overlay-color": "#3b82f6",
|
||||||
|
"overlay-padding": 3,
|
||||||
|
"overlay-opacity": 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout:
|
||||||
|
skipLayout && savedPositions
|
||||||
|
? { name: "preset" }
|
||||||
|
: {
|
||||||
|
name: "breadthfirst",
|
||||||
|
directed: true,
|
||||||
|
padding: 50,
|
||||||
|
spacingFactor: 1.5,
|
||||||
|
avoidOverlap: true,
|
||||||
|
nodeDimensionsIncludeLabels: true,
|
||||||
|
},
|
||||||
|
wheelSensitivity: 0.3,
|
||||||
|
minZoom: 0.3,
|
||||||
|
maxZoom: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore saved positions if skipping layout
|
||||||
|
if (skipLayout && savedPositions) {
|
||||||
|
cy.nodes().forEach((node) => {
|
||||||
|
const savedPos = savedPositions!.get(node.id());
|
||||||
|
if (savedPos) {
|
||||||
|
node.position(savedPos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle node selection
|
||||||
|
cy.on("tap", "node", (evt) => {
|
||||||
|
const nodeData = evt.target.data();
|
||||||
|
console.log("Node tapped:", nodeData);
|
||||||
|
|
||||||
|
selectedNode = nodeData;
|
||||||
|
selectedEdge = null;
|
||||||
|
console.log("selectedNode set to:", selectedNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle edge selection
|
||||||
|
cy.on("tap", "edge", (evt) => {
|
||||||
|
selectedEdge = evt.target.data();
|
||||||
|
selectedNode = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.on("tap", (evt) => {
|
||||||
|
if (evt.target === cy) {
|
||||||
|
selectedNode = null;
|
||||||
|
selectedEdge = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
graphInitialized = true;
|
||||||
|
|
||||||
|
// Ensure the graph renders correctly after container is sized
|
||||||
|
setTimeout(() => {
|
||||||
|
if (cy) {
|
||||||
|
cy.resize();
|
||||||
|
cy.fit(undefined, 50);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
if (cy) cy.zoom(cy.zoom() * 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
if (cy) cy.zoom(cy.zoom() / 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitToScreen() {
|
||||||
|
if (cy) cy.fit(undefined, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported function to handle container resize
|
||||||
|
export function resize() {
|
||||||
|
if (cy && containerEl) {
|
||||||
|
// Cytoscape caches container dimensions aggressively
|
||||||
|
// We need to unmount and remount to the container
|
||||||
|
cy!.unmount();
|
||||||
|
|
||||||
|
// Wait for DOM to update
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (cy && containerEl) {
|
||||||
|
cy!.mount(containerEl);
|
||||||
|
cy!.resize();
|
||||||
|
cy!.fit(undefined, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLayoutConfig(layoutName: LayoutType): cytoscape.LayoutOptions {
|
||||||
|
const baseConfig = {
|
||||||
|
padding: 50,
|
||||||
|
avoidOverlap: true,
|
||||||
|
nodeDimensionsIncludeLabels: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (layoutName) {
|
||||||
|
case "breadthfirst":
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
name: "breadthfirst",
|
||||||
|
directed: true,
|
||||||
|
spacingFactor: 1.5,
|
||||||
|
};
|
||||||
|
case "grid":
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
name: "grid",
|
||||||
|
rows: undefined,
|
||||||
|
cols: undefined,
|
||||||
|
};
|
||||||
|
case "circle":
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
name: "circle",
|
||||||
|
spacingFactor: 1.2,
|
||||||
|
};
|
||||||
|
case "concentric":
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
name: "concentric",
|
||||||
|
minNodeSpacing: 50,
|
||||||
|
concentric: (node: any) => {
|
||||||
|
// Services at center, resources around
|
||||||
|
return node.data("type") === "service" ? 2 : 1;
|
||||||
|
},
|
||||||
|
levelWidth: () => 1,
|
||||||
|
};
|
||||||
|
case "cose":
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
name: "cose",
|
||||||
|
idealEdgeLength: () => 100,
|
||||||
|
nodeOverlap: 20,
|
||||||
|
animate: true,
|
||||||
|
animationDuration: 500,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { ...baseConfig, name: layoutName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayout(layoutName: LayoutType) {
|
||||||
|
if (!cy) return;
|
||||||
|
currentLayout = layoutName;
|
||||||
|
showLayoutMenu = false;
|
||||||
|
cy.layout(getLayoutConfig(layoutName)).run();
|
||||||
|
cy.fit(undefined, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout() {
|
||||||
|
if (cy) {
|
||||||
|
cy.layout(getLayoutConfig(currentLayout)).run();
|
||||||
|
cy.fit(undefined, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Follow app theme from localStorage
|
||||||
|
const appTheme = localStorage.getItem("theme");
|
||||||
|
if (appTheme === "dark" || appTheme === "light") {
|
||||||
|
graphTheme = appTheme;
|
||||||
|
} else {
|
||||||
|
// Fallback to system preference
|
||||||
|
graphTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create graph when container element becomes available
|
||||||
|
$effect(() => {
|
||||||
|
if (containerEl && networks && !graphInitialized) {
|
||||||
|
createGraph();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (cy) {
|
||||||
|
cy.destroy();
|
||||||
|
cy = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleGraphTheme() {
|
||||||
|
graphTheme = graphTheme === "light" ? "dark" : "light";
|
||||||
|
createGraph(true); // Recreate graph with new theme, preserve local edits
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeIcon(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case "service":
|
||||||
|
return Box;
|
||||||
|
case "network":
|
||||||
|
return Network;
|
||||||
|
default:
|
||||||
|
return Database;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeColor(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case "service":
|
||||||
|
return "bg-blue-500";
|
||||||
|
case "network":
|
||||||
|
return "bg-violet-500";
|
||||||
|
default:
|
||||||
|
return "bg-slate-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full {className}">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center justify-between px-2 py-1.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 min-h-[40px]">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap"></div>
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="flex items-center gap-0.5">
|
||||||
|
<!-- Layout selector -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
onclick={() => (showLayoutMenu = !showLayoutMenu)}
|
||||||
|
class="h-6 px-2 flex items-center gap-1 rounded text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
title="Change layout"
|
||||||
|
>
|
||||||
|
{#if currentLayout === "breadthfirst"}
|
||||||
|
<GitBranch class="w-3 h-3" />
|
||||||
|
{:else if currentLayout === "grid"}
|
||||||
|
<LayoutGrid class="w-3 h-3" />
|
||||||
|
{:else if currentLayout === "circle"}
|
||||||
|
<Circle class="w-3 h-3" />
|
||||||
|
{:else if currentLayout === "concentric"}
|
||||||
|
<Target class="w-3 h-3" />
|
||||||
|
{:else}
|
||||||
|
<Sparkles class="w-3 h-3" />
|
||||||
|
{/if}
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{#if showLayoutMenu}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-1 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 py-1 z-20 min-w-[120px]"
|
||||||
|
onmouseleave={() => (showLayoutMenu = false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'breadthfirst'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200'}"
|
||||||
|
onclick={() => applyLayout("breadthfirst")}
|
||||||
|
>
|
||||||
|
<GitBranch class="w-3.5 h-3.5" />
|
||||||
|
Tree
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'grid'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200'}"
|
||||||
|
onclick={() => applyLayout("grid")}
|
||||||
|
>
|
||||||
|
<LayoutGrid class="w-3.5 h-3.5" />
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'circle'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200'}"
|
||||||
|
onclick={() => applyLayout("circle")}
|
||||||
|
>
|
||||||
|
<Circle class="w-3.5 h-3.5" />
|
||||||
|
Circle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'concentric'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200'}"
|
||||||
|
onclick={() => applyLayout("concentric")}
|
||||||
|
>
|
||||||
|
<Target class="w-3.5 h-3.5" />
|
||||||
|
Radial
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'cose'
|
||||||
|
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200'}"
|
||||||
|
onclick={() => applyLayout("cose")}
|
||||||
|
>
|
||||||
|
<Sparkles class="w-3.5 h-3.5" />
|
||||||
|
Force
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<button
|
||||||
|
onclick={toggleGraphTheme}
|
||||||
|
class="h-6 w-6 flex items-center justify-center rounded text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
title={graphTheme === "light" ? "Switch to dark theme" : "Switch to light theme"}
|
||||||
|
>
|
||||||
|
{#if graphTheme === "light"}
|
||||||
|
<Moon class="w-3.5 h-3.5" />
|
||||||
|
{:else}
|
||||||
|
<Sun class="w-3.5 h-3.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
|
||||||
|
<Button variant="ghost" size="sm" onclick={zoomOut} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
|
||||||
|
<ZoomOut class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={zoomIn} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
|
||||||
|
<ZoomIn class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={fitToScreen} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
|
||||||
|
<Maximize2 class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={resetLayout} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
|
||||||
|
<RotateCcw class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 flex min-h-0 h-full">
|
||||||
|
<!-- Graph container -->
|
||||||
|
<div class="flex-1 relative h-full {graphTheme === 'dark' ? 'bg-zinc-900' : 'bg-zinc-100'}">
|
||||||
|
<div bind:this={containerEl} class="w-full h-full"></div>
|
||||||
|
|
||||||
|
<!-- Footer: Legend -->
|
||||||
|
<div class="absolute bottom-2 left-2 pointer-events-none z-10">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs bg-white/80 dark:bg-zinc-800/80 backdrop-blur-sm rounded px-2 py-1 shadow-sm border border-zinc-200/50 dark:border-zinc-700/50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<div class="w-2 h-2 rounded-sm bg-blue-500 flex-shrink-0"></div>
|
||||||
|
<span class="text-zinc-600 dark:text-zinc-300">Service</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<div class="w-2 h-2 rounded-sm bg-violet-500 flex-shrink-0"></div>
|
||||||
|
<span class="text-zinc-600 dark:text-zinc-300">Network</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Details panel (overlay) -->
|
||||||
|
{#if selectedNode || selectedEdge}
|
||||||
|
<div class="absolute top-0 right-0 bottom-0 w-[420px] border-l border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95 shadow-lg z-20 flex flex-col">
|
||||||
|
<!-- Sticky header -->
|
||||||
|
{#if selectedNode}
|
||||||
|
{@const NodeIcon = getNodeIcon(selectedNode.type)}
|
||||||
|
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="p-1.5 rounded {getNodeColor(selectedNode.type)}">
|
||||||
|
<NodeIcon class="w-3.5 h-3.5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100">
|
||||||
|
{selectedNode.label}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-zinc-500 dark:text-zinc-400 capitalize">
|
||||||
|
{selectedNode.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
||||||
|
onclick={() => {
|
||||||
|
selectedNode = null;
|
||||||
|
selectedEdge = null;
|
||||||
|
}}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if selectedEdge}
|
||||||
|
<!-- Sticky header for edge -->
|
||||||
|
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100 capitalize">
|
||||||
|
{selectedEdge.type.replace("-", " ")}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{selectedEdge.source.replace(/^(service|network)-/, "")}
|
||||||
|
→
|
||||||
|
{selectedEdge.target.replace(/^(service|network)-/, "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
||||||
|
onclick={() => {
|
||||||
|
selectedNode = null;
|
||||||
|
selectedEdge = null;
|
||||||
|
}}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Scrollable content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-3">
|
||||||
|
{#if selectedNode}
|
||||||
|
{#if selectedNode.type === "service"}
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<!-- Container Id -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Container Id</span>
|
||||||
|
</div>
|
||||||
|
<Input value={selectedNode.config.containerId} placeholder="containerId" class="h-8 text-xs" readonly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if selectedNode.type === "network"}
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<!-- Driver -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Driver</span>
|
||||||
|
<!-- Simulate the select element -->
|
||||||
|
<div class="flex items-center justify-between w-fit h-8 px-3 py-2 text-xs border rounded-md border-input bg-background shadow-sm dark:bg-input/30">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
{#if selectedNode.config.driver === "bridge"}
|
||||||
|
<Share2 class="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
{:else if selectedNode.config.driver === "host"}
|
||||||
|
<Server class="w-3.5 h-3.5 text-sky-500" />
|
||||||
|
{:else if selectedNode.config.driver === "overlay"}
|
||||||
|
<Globe class="w-3.5 h-3.5 text-violet-500" />
|
||||||
|
{:else if selectedNode.config.driver === "macvlan"}
|
||||||
|
<MonitorSmartphone class="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
{:else if selectedNode.config.driver === "ipvlan"}
|
||||||
|
<Cpu class="w-3.5 h-3.5 text-orange-500" />
|
||||||
|
{:else}
|
||||||
|
<CircleOff class="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
<span class="capitalize">{selectedNode.config.driver}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IPAM Config -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">IPAM configuration</span>
|
||||||
|
<div class="space-y-4 pt-2">
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Subnet</span>
|
||||||
|
<Input value={selectedNode.config.ipam?.config?.[0].subnet} placeholder="172.20.0.0/16" class="h-9 pt-3 text-xs" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Gateway</span>
|
||||||
|
<Input value={selectedNode.config.ipam?.config?.[0].gateway} placeholder="172.20.0.1" class="h-9 pt-3 text-xs" readonly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boolean flags -->
|
||||||
|
<div class="space-y-2 pointer-events-none select-none">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={selectedNode.config.external} class="rounded border-zinc-300" />
|
||||||
|
<span class="text-xs text-zinc-600">External network</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={selectedNode.config.internal} class="rounded border-zinc-300" />
|
||||||
|
<span class="text-xs text-zinc-600">Internal network</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={selectedNode.config.attachable} class="rounded border-zinc-300" />
|
||||||
|
<span class="text-xs text-zinc-600">Attachable</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if selectedEdge}
|
||||||
|
{#if selectedEdge.type === "network-connection"}
|
||||||
|
<p class="text-xs text-zinc-500 dark:text-zinc-400">Service connected to this network.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in new issue
Block a user