feat: add NetworkGraphViewer

This commit is contained in:
Penlane
2026-04-07 15:52:20 +02:00
committed by jarek
parent f27c0b066f
commit e35d485ae9
@@ -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>