From 019ca5c99b110d7e95583d6d0e1513672539c396 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 13 Aug 2026 01:58:19 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20style:=20refresh=20topology=20vi?= =?UTF-8?q?sualization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/dashboard/topology/page.tsx | 115 +++--- .../topology/controls/node-details-panel.tsx | 328 +++++++----------- .../topology/controls/topology-toolbar.tsx | 136 +++----- .../topology/layouts/hierarchical-layout.ts | 8 +- .../components/topology/nodes/k8s-node.tsx | 192 ++++------ .../components/topology/nodes/proxy-node.tsx | 277 +++++---------- .../components/topology/nodes/server-node.tsx | 285 +++++---------- .../components/topology/topology-canvas.tsx | 14 +- .../topology/topology-primitives.tsx | 214 ++++++++++++ apps/web/hooks/use-graph-layout.ts | 2 +- apps/web/lib/k8s-metrics.ts | 6 - apps/web/lib/topology-types.ts | 12 +- 12 files changed, 699 insertions(+), 890 deletions(-) create mode 100644 apps/web/components/topology/topology-primitives.tsx diff --git a/apps/web/app/dashboard/topology/page.tsx b/apps/web/app/dashboard/topology/page.tsx index 7338cbb..7cf2d99 100644 --- a/apps/web/app/dashboard/topology/page.tsx +++ b/apps/web/app/dashboard/topology/page.tsx @@ -1,89 +1,52 @@ "use client"; -import { Network, RefreshCw } from "lucide-react"; +import { Network } from "lucide-react"; +import { PageHeader, PageShell, StatePanel } from "@/components/page-layout"; import { TopologyCanvas } from "@/components/topology/topology-canvas"; import { useTopologyData } from "@/hooks/use-topology-data"; export default function TopologyPage() { const { graph, loading, error } = useTopologyData(); - if (loading) { - return ( -
-
-

Network Topology

-

- Real-time server infrastructure, proxy connections, and Kubernetes nodes -

+ const header = ( + +
-
-
- -

Loading topology...

-
-
-
- ); - } - - if (error) { - return ( -
-
-

Network Topology

-

- Real-time server infrastructure, proxy connections, and Kubernetes nodes -

-
-
-
-

Error loading topology

-

{error}

-

Auto-retrying...

-
-
-
- ); - } - - if (!graph || graph.nodes.length === 0) { - return ( -
-
-

Network Topology

-

- Real-time server infrastructure, proxy connections, and Kubernetes nodes -

-
-
-
-

No servers or infrastructure found

-

- Create a server to see it appear in the topology -

-
-
-
- ); - } + } + /> + ); return ( -
-
-
-
- -
-
-

Network Topology

-

- Real-time server infrastructure, proxy connections, and Kubernetes nodes -

-
-
-
- - -
+ + {header} + {loading ? ( + + ) : error ? ( + +

{error}

+

Auto-retrying...

+ + } + tone="error" + className="h-[calc(100vh-250px)]" + /> + ) : !graph || graph.nodes.length === 0 ? ( + + ) : ( + + )} +
); } diff --git a/apps/web/components/topology/controls/node-details-panel.tsx b/apps/web/components/topology/controls/node-details-panel.tsx index 70b6cd4..cb7d742 100644 --- a/apps/web/components/topology/controls/node-details-panel.tsx +++ b/apps/web/components/topology/controls/node-details-panel.tsx @@ -3,18 +3,14 @@ import { Box } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import type { K8sNodeMetadata, ProxyMetadata, ServerMetadata, TopologyNodeData, } from "@/lib/topology-types"; +import { DetailRow, DetailSection, HealthBadge } from "../topology-primitives"; interface NodeDetailsPanelProps { node: TopologyNodeData | null; @@ -22,11 +18,7 @@ interface NodeDetailsPanelProps { onClose: () => void; } -export function NodeDetailsPanel({ - node, - open, - onClose, -}: NodeDetailsPanelProps) { +export function NodeDetailsPanel({ node, open, onClose }: NodeDetailsPanelProps) { if (!node) return null; return ( @@ -37,7 +29,6 @@ export function NodeDetailsPanel({
- {/* Type and Status */}
@@ -47,28 +38,16 @@ export function NodeDetailsPanel({ ? "Reverse Proxy" : "Kubernetes Node"} - + {node.status} - +
- {node.type === "server" && ( - - )} - {node.type === "proxy" && ( - - )} + {node.type === "server" && } + {node.type === "proxy" && } {node.type === "k8s-node" && ( )} @@ -79,173 +58,79 @@ export function NodeDetailsPanel({ } function ServerDetails({ metadata }: { metadata: ServerMetadata }) { - const { server, podCount, readyPods, pods, k8sNodes, connectedProxies } = - metadata; + const { server, podCount, readyPods, pods, k8sNodes, connectedProxies } = metadata; return (
- - {server.description && ( - - )} - - - - + + {server.description && } + + + + - - {k8sNodes.length > 0 && ( -
-
- - - Running on {k8sNodes.length} K8s node(s): - -
- {k8sNodes.map((nodeName) => ( -
- {nodeName} -
- ))} -
- )} - {pods.map((pod) => ( -
-
- {pod.name} -
-
-
- Status: {pod.status} • {pod.ready} -
-
Restarts: {pod.restarts}
- {pod.nodeName && ( -
- - Node: {pod.nodeName} -
- )} -
-
- ))} +
{connectedProxies.length > 0 && ( - + -
- {connectedProxies.map((proxyId) => ( -
- {proxyId} -
- ))} -
)} - - - + + +
); } function ProxyDetails({ metadata }: { metadata: ProxyMetadata }) { - const { proxy, podCount, readyPods, pods, k8sNodes, connectedServers } = - metadata; + const { proxy, podCount, readyPods, pods, k8sNodes, connectedServers } = metadata; return (
- - {proxy.description && ( - - )} - - + {proxy.description && } + + - + - - {k8sNodes.length > 0 && ( -
-
- - - Running on {k8sNodes.length} K8s node(s): - -
- {k8sNodes.map((nodeName) => ( -
- {nodeName} -
- ))} -
- )} - {pods.map((pod) => ( -
-
- {pod.name} -
-
-
- Status: {pod.status} • {pod.ready} -
-
Restarts: {pod.restarts}
- {pod.nodeName && ( -
- - Node: {pod.nodeName} -
- )} -
-
- ))} +
- + {connectedServers.length > 0 && ( -
- {connectedServers.map((serverId) => ( -
- {serverId} -
- ))} -
+ )}
@@ -258,57 +143,43 @@ function K8sNodeDetails({ metadata }: { metadata: K8sNodeMetadata }) { return (
- - - {node.version && } - {node.internalIP && ( - - )} - {node.externalIP && ( - - )} + + + {node.version && } + {node.internalIP && } + {node.externalIP && } - - - {node.hostname && } + + + {node.hostname && } - - - + + + {serverPods.length > 0 && (

Server Pods:

-
- {serverPods.map((podName) => ( -
- {podName} -
- ))} -
+
)} {proxyPods.length > 0 && (

Proxy Pods:

-
- {proxyPods.map((podName) => ( -
- {podName} -
- ))} -
+
)}
@@ -316,26 +187,65 @@ function K8sNodeDetails({ metadata }: { metadata: K8sNodeMetadata }) { ); } -function DetailSection({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { +function KubernetesWorkloadDetails({ + podCount, + readyPods, + pods, + k8sNodes, +}: Pick) { return ( -
-

{title}

-
{children}
-
+ <> + + {k8sNodes.length > 0 && ( +
+
+ + Running on {k8sNodes.length} K8s node(s): +
+ +
+ )} + {pods.map((pod) => ( +
+
{pod.name}
+
+
+ Status: {pod.status} • {pod.ready} +
+
Restarts: {pod.restarts}
+ {pod.nodeName && ( +
+ + Node: {pod.nodeName} +
+ )} +
+
+ ))} + ); } -function DetailItem({ label, value }: { label: string; value: string }) { +function IdentifierList({ + items, + className, + withMargin = true, +}: { + items: string[]; + className: string; + withMargin?: boolean; +}) { return ( -
- {label}: - {value} +
+ {items.map((item) => ( +
+ {item} +
+ ))}
); } diff --git a/apps/web/components/topology/controls/topology-toolbar.tsx b/apps/web/components/topology/controls/topology-toolbar.tsx index 61c5083..9eab2b3 100644 --- a/apps/web/components/topology/controls/topology-toolbar.tsx +++ b/apps/web/components/topology/controls/topology-toolbar.tsx @@ -1,14 +1,17 @@ "use client"; +import type { LucideIcon } from "lucide-react"; import { Box, Filter, Globe, Search, Server } from "lucide-react"; import { useState } from "react"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; import type { TopologyFilters } from "@/lib/topology-types"; +import { HealthBadge } from "../topology-primitives"; + +type ToggleFilterKey = "showServers" | "showProxies" | "showK8sNodes" | "showConnections"; interface TopologyToolbarProps { filters: TopologyFilters; @@ -32,54 +35,54 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology onFiltersChange({ ...filters, searchQuery: value }); }; - const toggleFilter = (key: keyof TopologyFilters) => { + const toggleFilter = (key: ToggleFilterKey) => { onFiltersChange({ ...filters, [key]: !filters[key] }); }; + const stats = [ + { label: "Servers", value: metadata.totalServers, icon: Server }, + { label: "Proxies", value: metadata.totalProxies, icon: Globe }, + { label: "Nodes", value: metadata.totalK8sNodes, icon: Box }, + ]; + + const filterOptions: Array<{ + id: string; + label: string; + filter: ToggleFilterKey; + icon?: LucideIcon; + }> = [ + { id: "show-servers", label: "Servers", filter: "showServers", icon: Server }, + { id: "show-proxies", label: "Reverse Proxies", filter: "showProxies", icon: Globe }, + { id: "show-k8s-nodes", label: "K8s Nodes", filter: "showK8sNodes", icon: Box }, + { id: "show-connections", label: "Connection Lines", filter: "showConnections" }, + ]; + return ( -
- {/* Stats */} +
-
-
- - Servers + {stats.map(({ label, value, icon: Icon }) => ( +
+
+ + {label} +
+ {value}
- {metadata.totalServers} -
-
-
- - Proxies -
- {metadata.totalProxies} -
-
-
- - Nodes -
- {metadata.totalK8sNodes} -
+ ))}
- {/* Health Status */}
- -
+ {metadata.healthySystems} Healthy - - -
+ + {metadata.degradedSystems} Degraded - - -
+ + {metadata.unhealthySystems} Down - +
- {/* Search */}
- {/* Filters */}
diff --git a/apps/web/components/topology/layouts/hierarchical-layout.ts b/apps/web/components/topology/layouts/hierarchical-layout.ts index d83fe25..232a908 100644 --- a/apps/web/components/topology/layouts/hierarchical-layout.ts +++ b/apps/web/components/topology/layouts/hierarchical-layout.ts @@ -1,10 +1,10 @@ import type { TopologyEdge, TopologyNode } from "@/lib/topology-types"; const LAYOUT_CONFIG = { - TIER_SPACING: 300, // Vertical spacing between proxy and server tiers - NODE_SPACING: 250, // Horizontal spacing between nodes - START_X: 150, // Left padding - START_Y: 100, // Top padding + TIER_SPACING: 300, + NODE_SPACING: 250, + START_X: 150, + START_Y: 100, } as const; export function applyHierarchicalLayout( diff --git a/apps/web/components/topology/nodes/k8s-node.tsx b/apps/web/components/topology/nodes/k8s-node.tsx index 0b58ffe..c5d05fe 100644 --- a/apps/web/components/topology/nodes/k8s-node.tsx +++ b/apps/web/components/topology/nodes/k8s-node.tsx @@ -1,142 +1,100 @@ import type { NodeProps } from "@xyflow/react"; -import { Handle, Position } from "@xyflow/react"; import { Box, Cpu, HardDrive, Network, Server as ServerIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/cn"; import type { K8sNodeMetadata, TopologyNodeData } from "@/lib/topology-types"; +import { CompactRow, TopologyNodeCard } from "../topology-primitives"; export function K8sNodeComponent({ data, selected }: NodeProps) { const nodeData = data as TopologyNodeData; const metadata = nodeData.metadata as K8sNodeMetadata; const { node, podCount, serverPods, proxyPods, health, metrics } = metadata; - const getStatusBadge = () => { - switch (health) { - case "healthy": - return Healthy; - case "degraded": - return Degraded; - case "unhealthy": - return Unhealthy; - default: - return ( - - Unknown - - ); - } - }; - return ( -
} + iconClassName="bg-blue-500/10" + title={node.name || "Unknown"} + description="Kubernetes Node" + health={health} > - - -
-
-
-
- -
-
-

{node.name || "Unknown"}

-

Kubernetes Node

-
-
- {getStatusBadge()} -
- -
+
+ + {node.status} + + {node.version && ( - {node.status} + {node.version} - {node.version && ( - - {node.version} - - )} - - {node.roles} - -
- -
- Total Pods - {podCount} -
- -
-
- - Server Pods - - {serverPods.length} -
-
- - Proxy Pods - - {proxyPods.length} -
-
- - {metrics && (metrics.cpuUsage || metrics.memoryUsage) && ( -
- {metrics.cpuUsage && ( -
- - CPU - - {metrics.cpuUsage} -
- )} - {metrics.memoryUsage && ( -
- - Memory - - {metrics.memoryUsage} -
- )} -
)} + + {node.roles} + +
+ + {podCount} + + +
+ }> + {serverPods.length} + + }> + {proxyPods.length} + +
+ + {metrics && (metrics.cpuUsage || metrics.memoryUsage) && (
-
- Age - {node.age} -
- {node.hostname && ( -
- Hostname - {node.hostname} -
+ {metrics.cpuUsage && ( + } + valueClassName="font-semibold text-xs text-blue-600" + > + {metrics.cpuUsage} + + )} + {metrics.memoryUsage && ( + } + valueClassName="font-semibold text-xs text-blue-600" + > + {metrics.memoryUsage} + )}
+ )} - {(node.internalIP || node.externalIP) && ( -
- {node.internalIP && ( -
- Internal IP - {node.internalIP} -
- )} - {node.externalIP && ( -
- External IP - {node.externalIP} -
- )} -
+
+ {node.age} + {node.hostname && ( + + {node.hostname} + )}
- -
+ {(node.internalIP || node.externalIP) && ( +
+ {node.internalIP && ( + + {node.internalIP} + + )} + {node.externalIP && ( + + {node.externalIP} + + )} +
+ )} + ); } diff --git a/apps/web/components/topology/nodes/proxy-node.tsx b/apps/web/components/topology/nodes/proxy-node.tsx index 869d752..6775542 100644 --- a/apps/web/components/topology/nodes/proxy-node.tsx +++ b/apps/web/components/topology/nodes/proxy-node.tsx @@ -1,206 +1,105 @@ import type { NodeProps } from "@xyflow/react"; -import { Handle, Position } from "@xyflow/react"; -import { Box, Check, Copy, Cpu, Globe, HardDrive, Network } from "lucide-react"; -import { useState } from "react"; +import { Box, Cpu, Globe, HardDrive, Network } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cn"; import type { ProxyMetadata, TopologyNodeData } from "@/lib/topology-types"; +import { CompactRow, CopyableCode, MetricRow, TopologyNodeCard } from "../topology-primitives"; export function ProxyNode({ data, selected }: NodeProps) { - const nodeData = data as TopologyNodeData | TopologyNodeData; - const metadata = nodeData.metadata as ProxyMetadata | ProxyMetadata; + const nodeData = data as TopologyNodeData; + const metadata = nodeData.metadata as ProxyMetadata; const { proxy, readyPods, podCount, health, connectedServers } = metadata; - - const k8sNodes = "k8sNodes" in metadata ? metadata.k8sNodes : []; - const connectionInfo = "connectionInfo" in metadata ? metadata.connectionInfo : null; - const metrics = "metrics" in metadata ? metadata.metrics : undefined; - - const [copied, setCopied] = useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - if (connectionInfo?.connectionString) { - await navigator.clipboard.writeText(connectionInfo.connectionString); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - const getStatusBadge = () => { - switch (health) { - case "healthy": - return Healthy; - case "degraded": - return Degraded; - case "unhealthy": - return Unhealthy; - default: - return ( - - Unknown - - ); - } - }; + const { k8sNodes, connectionInfo, metrics, pods } = metadata; + const restartCount = pods.reduce((sum, pod) => sum + (pod.restarts || 0), 0); return ( -
} + iconClassName="bg-blue-500/10" + title={proxy.id} + description={proxy.description} + health={health} > - - -
-
-
-
- -
-
-

{proxy.id}

- {proxy.description && ( -

{proxy.description}

- )} -
-
- {getStatusBadge()} -
- -
+
+ + {proxy.type} + + {connectionInfo && ( - {proxy.type} + {connectionInfo.type} - {connectionInfo && ( - - {connectionInfo.type} - - )} -
- -
- Pods - - {readyPods}/{podCount} - -
- - {proxy.node_port && ( -
-
- - NodePort - - {proxy.node_port} -
-
- )} - - {connectionInfo?.connectionString && ( -
-
- Address -
-
- - {connectionInfo.connectionString} - - -
- {connectionInfo.note && ( -

{connectionInfo.note}

- )} -
- )} - -
-
- - Memory - - - {metrics?.memoryUsage && ( - {metrics.memoryUsage} / - )} - {proxy.memory}MB - -
-
- - CPU - - - {metrics?.cpuUsage && {metrics.cpuUsage} / } - - {proxy.cpu_request || "N/A"}/{proxy.cpu_limit || "N/A"} - - -
-
- - {"pods" in metadata && metadata.pods && metadata.pods.length > 0 && ( -
-
- Restarts - sum + (p.restarts || 0), 0) > 0 - ? "text-yellow-600" - : "text-green-600" - )} - > - {metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0)} - -
- {metadata.pods[0]?.age && ( -
- Age - {metadata.pods[0].age} -
- )} - {metadata.pods[0]?.ip && ( -
- Pod IP - {metadata.pods[0].ip} -
- )} -
- Routing To - {connectedServers.length} servers -
- {k8sNodes.length > 0 && ( -
- - K8s Node - - {k8sNodes[0]} -
- )} -
)}
- -
+ + {readyPods}/{podCount} + + + {proxy.node_port && ( +
+ }> + {proxy.node_port} + +
+ )} + + {connectionInfo?.connectionString && ( +
+ + + {connectionInfo.note && ( +

{connectionInfo.note}

+ )} +
+ )} + +
+ } + usage={metrics?.memoryUsage} + limit={`${proxy.memory}MB`} + /> + } + usage={metrics?.cpuUsage} + limit={`${proxy.cpu_request || "N/A"}/${proxy.cpu_limit || "N/A"}`} + /> +
+ + {pods.length > 0 && ( +
+ 0 ? "text-yellow-600" : "text-green-600"} + > + {restartCount} + + {pods[0]?.age && {pods[0].age}} + {pods[0]?.ip && ( + + {pods[0].ip} + + )} + + {connectedServers.length} servers + + {k8sNodes.length > 0 && ( + }> + {k8sNodes[0]} + + )} +
+ )} + ); } diff --git a/apps/web/components/topology/nodes/server-node.tsx b/apps/web/components/topology/nodes/server-node.tsx index 247a460..7d77c88 100644 --- a/apps/web/components/topology/nodes/server-node.tsx +++ b/apps/web/components/topology/nodes/server-node.tsx @@ -1,210 +1,109 @@ import type { NodeProps } from "@xyflow/react"; -import { Handle, Position } from "@xyflow/react"; -import { Box, Check, Copy, Cpu, HardDrive, Server } from "lucide-react"; -import { useState } from "react"; +import { Box, Cpu, HardDrive, Server } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cn"; import type { ServerMetadata, TopologyNodeData } from "@/lib/topology-types"; +import { CompactRow, CopyableCode, MetricRow, TopologyNodeCard } from "../topology-primitives"; export function ServerNode({ data, selected }: NodeProps) { - const nodeData = data as TopologyNodeData | TopologyNodeData; - const metadata = nodeData.metadata as ServerMetadata | ServerMetadata; + const nodeData = data as TopologyNodeData; + const metadata = nodeData.metadata as ServerMetadata; const { server, readyPods, podCount, health } = metadata; - - const k8sNodes = "k8sNodes" in metadata ? metadata.k8sNodes : []; - const connectedProxies = "connectedProxies" in metadata ? metadata.connectedProxies : []; - const connectionInfo = "connectionInfo" in metadata ? metadata.connectionInfo : null; - const metrics = "metrics" in metadata ? metadata.metrics : undefined; - - const [copied, setCopied] = useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - if (connectionInfo?.connectionString) { - await navigator.clipboard.writeText(connectionInfo.connectionString); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - const getStatusBadge = () => { - switch (health) { - case "healthy": - return Healthy; - case "degraded": - return Degraded; - case "unhealthy": - return Unhealthy; - default: - return ( - - Unknown - - ); - } - }; + const { k8sNodes, connectedProxies, connectionInfo, metrics, pods } = metadata; + const restartCount = pods.reduce((sum, pod) => sum + (pod.restarts || 0), 0); return ( -
} + iconClassName="bg-primary/10" + title={server.id} + description={server.description} + health={health} > - - -
-
-
-
- -
-
-

{server.id}

- {server.description && ( -

{server.description}

- )} -
-
- {getStatusBadge()} -
- -
+
+ + {server.jar_type} + + + MC {server.minecraft_version} + + + {server.type} + + {connectionInfo && ( - {server.jar_type} + {connectionInfo.type} - - MC {server.minecraft_version} - - - {server.type} - - {connectionInfo && ( - - {connectionInfo.type} - - )} -
- -
- Pods - - {readyPods}/{podCount} - -
- -
-
- - Memory - - - {metrics?.memoryUsage && ( - {metrics.memoryUsage} / - )} - - {server.memory_request}/{server.memory}MB - - -
-
- - CPU - - - {metrics?.cpuUsage && {metrics.cpuUsage} / } - - {server.cpu_request || "N/A"}/{server.cpu_limit || "N/A"} - - -
-
- - {connectionInfo?.connectionString && ( -
-
- - {connectionInfo.connectionString} - - -
- {connectionInfo.note && ( -

{connectionInfo.note}

- )} -
- )} - - {"pods" in metadata && metadata.pods && metadata.pods.length > 0 && ( -
-
- Restarts - sum + (p.restarts || 0), 0) > 0 - ? "text-yellow-600" - : "text-green-600" - )} - > - {metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0)} - -
- {metadata.pods[0]?.age && ( -
- Age - {metadata.pods[0].age} -
- )} - {metadata.pods[0]?.ip && ( -
- Pod IP - {metadata.pods[0].ip} -
- )} -
- )} - - {(k8sNodes.length > 0 || connectedProxies.length > 0) && ( -
- {connectedProxies.length > 0 && ( -
- Exposed By - - {connectedProxies.length} proxies - -
- )} - {k8sNodes.length > 0 && ( -
- - K8s Node - - {k8sNodes[0]} -
- )} -
)}
- -
+ + {readyPods}/{podCount} + + +
+ } + usage={metrics?.memoryUsage} + limit={`${server.memory_request}/${server.memory}MB`} + /> + } + usage={metrics?.cpuUsage} + limit={`${server.cpu_request || "N/A"}/${server.cpu_limit || "N/A"}`} + /> +
+ + {connectionInfo?.connectionString && ( +
+ + {connectionInfo.note && ( +

{connectionInfo.note}

+ )} +
+ )} + + {pods.length > 0 && ( +
+ 0 ? "text-yellow-600" : "text-green-600"} + > + {restartCount} + + {pods[0]?.age && {pods[0].age}} + {pods[0]?.ip && ( + + {pods[0].ip} + + )} +
+ )} + + {(k8sNodes.length > 0 || connectedProxies.length > 0) && ( +
+ {connectedProxies.length > 0 && ( + + {connectedProxies.length} proxies + + )} + {k8sNodes.length > 0 && ( + }> + {k8sNodes[0]} + + )} +
+ )} + ); } diff --git a/apps/web/components/topology/topology-canvas.tsx b/apps/web/components/topology/topology-canvas.tsx index 5fc80d5..95505ad 100644 --- a/apps/web/components/topology/topology-canvas.tsx +++ b/apps/web/components/topology/topology-canvas.tsx @@ -52,7 +52,7 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) { }, []); return ( -
+
- + { @@ -103,8 +103,8 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) { }; return colors[data.status] || "#94a3b8"; }} - maskColor="rgba(0, 0, 0, 0.05)" - className="bg-white/80 backdrop-blur-sm border shadow-lg" + maskColor="rgba(29, 31, 26, 0.08)" + className="border bg-card/95 shadow-md backdrop-blur-sm" /> diff --git a/apps/web/components/topology/topology-primitives.tsx b/apps/web/components/topology/topology-primitives.tsx new file mode 100644 index 0000000..02b7aa2 --- /dev/null +++ b/apps/web/components/topology/topology-primitives.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { Handle, Position } from "@xyflow/react"; +import { Check, Copy } from "lucide-react"; +import type { MouseEvent, ReactNode } from "react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/cn"; +import type { HealthStatus } from "@/lib/topology-types"; + +const healthLabels: Record = { + healthy: "Healthy", + degraded: "Degraded", + unhealthy: "Unhealthy", + unknown: "Unknown", +}; + +const solidHealthClasses: Record, string> = { + healthy: "bg-green-500 hover:bg-green-600", + degraded: "bg-yellow-500 hover:bg-yellow-600", + unhealthy: "bg-red-500 hover:bg-red-600", +}; + +interface HealthBadgeProps { + status: HealthStatus; + appearance?: "solid" | "summary" | "detail"; + children?: ReactNode; + className?: string; +} + +export function HealthBadge({ + status, + appearance = "solid", + children, + className, +}: HealthBadgeProps) { + const content = children ?? healthLabels[status]; + + if (appearance === "summary") { + const indicatorClasses = { + healthy: "bg-green-500", + degraded: "bg-yellow-500", + unhealthy: "bg-red-500", + unknown: "bg-gray-400", + }[status]; + + return ( + + + {content} + + ); + } + + if (appearance === "detail") { + return ( + + {content} + + ); + } + + if (status === "unknown") { + return ( + + {content} + + ); + } + + return {content}; +} + +interface TopologyNodeCardProps { + selected: boolean; + icon: ReactNode; + iconClassName: string; + title: ReactNode; + description?: ReactNode; + health: HealthStatus; + children: ReactNode; +} + +export function TopologyNodeCard({ + selected, + icon, + iconClassName, + title, + description, + health, + children, +}: TopologyNodeCardProps) { + return ( +
+ +
+
+
+
{icon}
+
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ +
+ {children} +
+ +
+ ); +} + +interface CompactRowProps { + label: ReactNode; + icon?: ReactNode; + children?: ReactNode; + className?: string; + valueClassName?: string; +} + +export function CompactRow({ label, icon, children, className, valueClassName }: CompactRowProps) { + return ( +
+ + {icon} + {label} + + {children !== undefined && ( + {children} + )} +
+ ); +} + +interface MetricRowProps { + label: string; + icon: ReactNode; + usage?: string; + limit: ReactNode; +} + +export function MetricRow({ label, icon, usage, limit }: MetricRowProps) { + return ( + + {usage && {usage} / } + {limit} + + ); +} + +export function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +export function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label}: + {value} +
+ ); +} + +interface CopyableCodeProps { + value: string; + title?: string; +} + +export function CopyableCode({ value, title = "Copy address" }: CopyableCodeProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async (event: MouseEvent) => { + event.stopPropagation(); + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ + {value} + + +
+ ); +} diff --git a/apps/web/hooks/use-graph-layout.ts b/apps/web/hooks/use-graph-layout.ts index 5e1c453..be3fe25 100644 --- a/apps/web/hooks/use-graph-layout.ts +++ b/apps/web/hooks/use-graph-layout.ts @@ -70,7 +70,7 @@ export function useGraphLayout({ nodes, edges }: LayoutInput) { const k8sNodePositions = new Map(); k8sWithApps.forEach((k8sNode) => { - const width = k8sNodeWidths.get(k8sNode.id)!; + const width = k8sNodeWidths.get(k8sNode.id) ?? nodeWidth; const centerX = currentX + width / 2; k8sNodePositions.set(k8sNode.id, { x: centerX, width }); diff --git a/apps/web/lib/k8s-metrics.ts b/apps/web/lib/k8s-metrics.ts index 5b98fdf..590e360 100644 --- a/apps/web/lib/k8s-metrics.ts +++ b/apps/web/lib/k8s-metrics.ts @@ -4,8 +4,6 @@ export interface ResourceMetrics { memoryUsage?: string; memoryUsagePercent?: number; } - -/** Convert CPU nanoseconds (e.g. "123456789n") to millicores (e.g. "123m") */ export function parseCpuUsage(cpuNano: string): string | undefined { const usageNano = Number.parseInt(cpuNano.replace("n", ""), 10); if (Number.isNaN(usageNano)) return undefined; @@ -24,8 +22,6 @@ export function calculateCpuPercent(cpuNano: string, capacityNano: string): numb return Math.round((usageNano / capNano) * 100); } - -/** Convert memory kibibytes (e.g. "1024Ki") to mebibytes (e.g. "1Mi") */ export function parseMemoryUsage(memoryKi: string): string | undefined { const usageKi = Number.parseInt(memoryKi.replace("Ki", ""), 10); if (Number.isNaN(usageKi)) return undefined; @@ -44,8 +40,6 @@ export function calculateMemoryPercent(memoryKi: string, capacityKi: string): nu return Math.round((usageKi / capKi) * 100); } - -/** Parse raw K8s metrics into a standardized format with optional percentages */ export function parseK8sMetrics( cpuUsage?: string, memoryUsage?: string, diff --git a/apps/web/lib/topology-types.ts b/apps/web/lib/topology-types.ts index 426683e..8ec3d38 100644 --- a/apps/web/lib/topology-types.ts +++ b/apps/web/lib/topology-types.ts @@ -23,8 +23,8 @@ export interface ResourceMetrics { export interface K8sNodeMetadata { node: K8sNodeSummary; podCount: number; - serverPods: string[]; // Pod names of servers - proxyPods: string[]; // Pod names of proxies + serverPods: string[]; + proxyPods: string[]; health: HealthStatus; metrics?: ResourceMetrics; } @@ -35,8 +35,8 @@ export interface ServerMetadata { readyPods: number; pods: PodInfo[]; health: HealthStatus; - connectedProxies: string[]; // IDs of reverse proxies pointing to this server - k8sNodes: string[]; // Names of K8s nodes running this server's pods + connectedProxies: string[]; + k8sNodes: string[]; connectionInfo?: ConnectionInfo | null; metrics?: ResourceMetrics; } @@ -47,8 +47,8 @@ export interface ProxyMetadata { readyPods: number; pods: PodInfo[]; health: HealthStatus; - connectedServers: string[]; // IDs of servers this proxy routes to - k8sNodes: string[]; // Names of K8s nodes running this proxy's pods + connectedServers: string[]; + k8sNodes: string[]; connectionInfo?: ConnectionInfo | null; metrics?: ResourceMetrics; }