🎨 style: refresh topology visualization

This commit is contained in:
2026-08-13 02:19:26 +07:00
parent e4729d4d57
commit 019ca5c99b
12 changed files with 699 additions and 890 deletions
+36 -73
View File
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
const header = (
<PageHeader
eyebrow="Network / Live Graph"
title="Topology"
description="Real-time server infrastructure, proxy connections, and Kubernetes nodes"
leading={
<div className="border border-foreground bg-primary p-3 shadow-[3px_3px_0_var(--foreground)]">
<Network className="size-6 text-primary-foreground" />
</div>
<div className="flex items-center justify-center h-[calc(100vh-250px)]">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading topology...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
</div>
<div className="flex items-center justify-center h-[calc(100vh-250px)] border-2 border-dashed rounded-lg">
<div className="flex flex-col items-center gap-2 p-6 text-center">
<p className="text-destructive font-semibold">Error loading topology</p>
<p className="text-muted-foreground text-sm">{error}</p>
<p className="text-muted-foreground text-xs mt-2">Auto-retrying...</p>
</div>
</div>
</div>
/>
);
}
if (!graph || graph.nodes.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
</div>
<div className="flex items-center justify-center h-[calc(100vh-250px)] border-2 border-dashed rounded-lg">
<div className="flex flex-col items-center gap-2 p-6 text-center">
<p className="text-muted-foreground">No servers or infrastructure found</p>
<p className="text-sm text-muted-foreground">
Create a server to see it appear in the topology
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Network className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
</div>
</div>
</div>
<PageShell>
{header}
{loading ? (
<StatePanel loading title="Loading topology..." className="h-[calc(100vh-250px)]" />
) : error ? (
<StatePanel
title="Error loading topology"
description={
<>
<p>{error}</p>
<p className="mt-2 text-xs">Auto-retrying...</p>
</>
}
tone="error"
className="h-[calc(100vh-250px)]"
/>
) : !graph || graph.nodes.length === 0 ? (
<StatePanel
title="No infrastructure found"
description="Create a server to see it appear in the topology."
className="h-[calc(100vh-250px)]"
/>
) : (
<TopologyCanvas graph={graph} />
</div>
)}
</PageShell>
);
}
@@ -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({
</SheetHeader>
<div className="space-y-4 pr-2">
{/* Type and Status */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline">
@@ -47,28 +38,16 @@ export function NodeDetailsPanel({
? "Reverse Proxy"
: "Kubernetes Node"}
</Badge>
<Badge
variant={
node.status === "healthy"
? "default"
: node.status === "degraded"
? "secondary"
: "destructive"
}
>
<HealthBadge status={node.status} appearance="detail">
{node.status}
</Badge>
</HealthBadge>
</div>
</div>
<Separator />
{node.type === "server" && (
<ServerDetails metadata={node.metadata as ServerMetadata} />
)}
{node.type === "proxy" && (
<ProxyDetails metadata={node.metadata as ProxyMetadata} />
)}
{node.type === "server" && <ServerDetails metadata={node.metadata as ServerMetadata} />}
{node.type === "proxy" && <ProxyDetails metadata={node.metadata as ProxyMetadata} />}
{node.type === "k8s-node" && (
<K8sNodeDetails metadata={node.metadata as K8sNodeMetadata} />
)}
@@ -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 (
<div className="space-y-4">
<DetailSection title="Server Configuration">
<DetailItem label="ID" value={server.id} />
{server.description && (
<DetailItem label="Description" value={server.description} />
)}
<DetailItem label="Type" value={server.type} />
<DetailItem label="Jar Type" value={server.jar_type} />
<DetailItem
label="Minecraft Version"
value={server.minecraft_version}
/>
<DetailItem label="Port" value={server.listen_port.toString()} />
<DetailRow label="ID" value={server.id} />
{server.description && <DetailRow label="Description" value={server.description} />}
<DetailRow label="Type" value={server.type} />
<DetailRow label="Jar Type" value={server.jar_type} />
<DetailRow label="Minecraft Version" value={server.minecraft_version} />
<DetailRow label="Port" value={server.listen_port.toString()} />
</DetailSection>
<DetailSection title="Kubernetes Info">
<DetailItem label="Pods" value={`${readyPods}/${podCount} Ready`} />
{k8sNodes.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm mb-2">
<Box className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
Running on {k8sNodes.length} K8s node(s):
</span>
</div>
{k8sNodes.map((nodeName) => (
<div
key={nodeName}
className="text-sm p-2 bg-blue-50 border border-blue-200 rounded ml-6"
>
<span className="font-mono text-xs break-all">{nodeName}</span>
</div>
))}
</div>
)}
{pods.map((pod) => (
<div key={pod.name} className="text-sm mt-2 p-2 bg-muted rounded">
<div className="font-medium font-mono text-xs break-all">
{pod.name}
</div>
<div className="text-xs text-muted-foreground space-y-0.5 mt-1">
<div>
Status: {pod.status} {pod.ready}
</div>
<div>Restarts: {pod.restarts}</div>
{pod.nodeName && (
<div className="flex items-center gap-1">
<Box className="h-3 w-3 shrink-0" />
<span className="break-all">Node: {pod.nodeName}</span>
</div>
)}
</div>
</div>
))}
<KubernetesWorkloadDetails
podCount={podCount}
readyPods={readyPods}
pods={pods}
k8sNodes={k8sNodes}
/>
</DetailSection>
{connectedProxies.length > 0 && (
<DetailSection title="Proxy Connections">
<DetailItem
label="Behind Proxies"
value={connectedProxies.length.toString()}
<DetailRow label="Behind Proxies" value={connectedProxies.length.toString()} />
<IdentifierList
items={connectedProxies}
className="text-sm bg-blue-50 border border-blue-200"
/>
<div className="mt-2 space-y-1">
{connectedProxies.map((proxyId) => (
<div
key={proxyId}
className="text-sm p-2 bg-blue-50 border border-blue-200 rounded font-mono break-all"
>
{proxyId}
</div>
))}
</div>
</DetailSection>
)}
<DetailSection title="Resources">
<DetailItem
label="Memory"
value={`${server.memory_request}MB / ${server.memory}MB`}
/>
<DetailItem
label="CPU Request"
value={server.cpu_request || "Not set"}
/>
<DetailItem label="CPU Limit" value={server.cpu_limit || "Not set"} />
<DetailRow label="Memory" value={`${server.memory_request}MB / ${server.memory}MB`} />
<DetailRow label="CPU Request" value={server.cpu_request || "Not set"} />
<DetailRow label="CPU Limit" value={server.cpu_limit || "Not set"} />
</DetailSection>
</div>
);
}
function ProxyDetails({ metadata }: { metadata: ProxyMetadata }) {
const { proxy, podCount, readyPods, pods, k8sNodes, connectedServers } =
metadata;
const { proxy, podCount, readyPods, pods, k8sNodes, connectedServers } = metadata;
return (
<div className="space-y-4">
<DetailSection title="Proxy Configuration">
<DetailItem label="ID" value={proxy.id} />
{proxy.description && (
<DetailItem label="Description" value={proxy.description} />
)}
<DetailItem label="Type" value={proxy.type} />
<DetailItem
<DetailRow label="ID" value={proxy.id} />
{proxy.description && <DetailRow label="Description" value={proxy.description} />}
<DetailRow label="Type" value={proxy.type} />
<DetailRow
label="External Address"
value={`${proxy.external_address}:${proxy.external_port}`}
/>
<DetailItem label="Listen Port" value={proxy.listen_port.toString()} />
<DetailRow label="Listen Port" value={proxy.listen_port.toString()} />
</DetailSection>
<DetailSection title="Kubernetes Info">
<DetailItem label="Pods" value={`${readyPods}/${podCount} Ready`} />
{k8sNodes.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm mb-2">
<Box className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
Running on {k8sNodes.length} K8s node(s):
</span>
</div>
{k8sNodes.map((nodeName) => (
<div
key={nodeName}
className="text-sm p-2 bg-blue-50 border border-blue-200 rounded ml-6"
>
<span className="font-mono text-xs break-all">{nodeName}</span>
</div>
))}
</div>
)}
{pods.map((pod) => (
<div key={pod.name} className="text-sm mt-2 p-2 bg-muted rounded">
<div className="font-medium font-mono text-xs break-all">
{pod.name}
</div>
<div className="text-xs text-muted-foreground space-y-0.5 mt-1">
<div>
Status: {pod.status} {pod.ready}
</div>
<div>Restarts: {pod.restarts}</div>
{pod.nodeName && (
<div className="flex items-center gap-1">
<Box className="h-3 w-3 shrink-0" />
<span className="break-all">Node: {pod.nodeName}</span>
</div>
)}
</div>
</div>
))}
<KubernetesWorkloadDetails
podCount={podCount}
readyPods={readyPods}
pods={pods}
k8sNodes={k8sNodes}
/>
</DetailSection>
<DetailSection title="Connected Servers">
<DetailItem label="Total" value={connectedServers.length.toString()} />
<DetailRow label="Total" value={connectedServers.length.toString()} />
{connectedServers.length > 0 && (
<div className="mt-2 space-y-1">
{connectedServers.map((serverId) => (
<div
key={serverId}
className="text-sm p-2 bg-green-50 border border-green-200 rounded font-mono break-all"
>
{serverId}
</div>
))}
</div>
<IdentifierList
items={connectedServers}
className="text-sm bg-green-50 border border-green-200"
/>
)}
</DetailSection>
</div>
@@ -258,57 +143,43 @@ function K8sNodeDetails({ metadata }: { metadata: K8sNodeMetadata }) {
return (
<div className="space-y-4">
<DetailSection title="Node Information">
<DetailItem label="Name" value={node.name || "Unknown"} />
<DetailItem label="Status" value={node.status} />
{node.version && <DetailItem label="Version" value={node.version} />}
{node.internalIP && (
<DetailItem label="Internal IP" value={node.internalIP} />
)}
{node.externalIP && (
<DetailItem label="External IP" value={node.externalIP} />
)}
<DetailRow label="Name" value={node.name || "Unknown"} />
<DetailRow label="Status" value={node.status} />
{node.version && <DetailRow label="Version" value={node.version} />}
{node.internalIP && <DetailRow label="Internal IP" value={node.internalIP} />}
{node.externalIP && <DetailRow label="External IP" value={node.externalIP} />}
</DetailSection>
<DetailSection title="Node Details">
<DetailItem label="Roles" value={node.roles} />
<DetailItem label="Age" value={node.age} />
{node.hostname && <DetailItem label="Hostname" value={node.hostname} />}
<DetailRow label="Roles" value={node.roles} />
<DetailRow label="Age" value={node.age} />
{node.hostname && <DetailRow label="Hostname" value={node.hostname} />}
</DetailSection>
<DetailSection title="Running Pods">
<DetailItem label="Total Pods" value={podCount.toString()} />
<DetailItem label="Server Pods" value={serverPods.length.toString()} />
<DetailItem label="Proxy Pods" value={proxyPods.length.toString()} />
<DetailRow label="Total Pods" value={podCount.toString()} />
<DetailRow label="Server Pods" value={serverPods.length.toString()} />
<DetailRow label="Proxy Pods" value={proxyPods.length.toString()} />
{serverPods.length > 0 && (
<div className="mt-3">
<p className="text-sm font-medium mb-2">Server Pods:</p>
<div className="space-y-1">
{serverPods.map((podName) => (
<div
key={podName}
className="text-xs p-2 bg-green-50 border border-green-200 rounded font-mono break-all"
>
{podName}
</div>
))}
</div>
<IdentifierList
items={serverPods}
className="text-xs bg-green-50 border border-green-200"
withMargin={false}
/>
</div>
)}
{proxyPods.length > 0 && (
<div className="mt-3">
<p className="text-sm font-medium mb-2">Proxy Pods:</p>
<div className="space-y-1">
{proxyPods.map((podName) => (
<div
key={podName}
className="text-xs p-2 bg-blue-50 border border-blue-200 rounded font-mono break-all"
>
{podName}
</div>
))}
</div>
<IdentifierList
items={proxyPods}
className="text-xs bg-blue-50 border border-blue-200"
withMargin={false}
/>
</div>
)}
</DetailSection>
@@ -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<ServerMetadata, "podCount" | "readyPods" | "pods" | "k8sNodes">) {
return (
<div className="space-y-2">
<h3 className="font-semibold text-sm">{title}</h3>
<div className="space-y-2">{children}</div>
<>
<DetailRow label="Pods" value={`${readyPods}/${podCount} Ready`} />
{k8sNodes.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm mb-2">
<Box className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Running on {k8sNodes.length} K8s node(s):</span>
</div>
<IdentifierList
items={k8sNodes}
className="text-xs bg-blue-50 border border-blue-200 ml-6"
withMargin={false}
/>
</div>
)}
{pods.map((pod) => (
<div key={pod.name} className="text-sm mt-2 p-2 bg-muted rounded">
<div className="font-medium font-mono text-xs break-all">{pod.name}</div>
<div className="text-xs text-muted-foreground space-y-0.5 mt-1">
<div>
Status: {pod.status} {pod.ready}
</div>
<div>Restarts: {pod.restarts}</div>
{pod.nodeName && (
<div className="flex items-center gap-1">
<Box className="h-3 w-3 shrink-0" />
<span className="break-all">Node: {pod.nodeName}</span>
</div>
)}
</div>
</div>
))}
</>
);
}
function DetailItem({ label, value }: { label: string; value: string }) {
function IdentifierList({
items,
className,
withMargin = true,
}: {
items: string[];
className: string;
withMargin?: boolean;
}) {
return (
<div className="flex justify-between items-start text-sm gap-4">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="font-medium text-right break-words">{value}</span>
<div className={withMargin ? "mt-2 space-y-1" : "space-y-1"}>
{items.map((item) => (
<div key={item} className={`p-2 rounded font-mono break-all ${className}`}>
{item}
</div>
))}
</div>
);
}
@@ -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 (
<div className="flex flex-col gap-3 p-4 bg-white/90 backdrop-blur-sm rounded-lg border shadow-lg min-w-[350px]">
{/* Stats */}
<div className="flex min-w-[280px] max-w-[calc(100vw-5rem)] flex-col gap-3 rounded-sm border-2 border-foreground bg-card/95 p-3 shadow-[5px_5px_0_color-mix(in_oklch,var(--foreground)_16%,transparent)] backdrop-blur-sm sm:min-w-[350px] sm:p-4">
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center p-2 bg-muted/50 rounded">
{stats.map(({ label, value, icon: Icon }) => (
<div key={label} className="flex flex-col items-center border bg-muted/50 p-2">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<Server className="h-3 w-3" />
<span className="text-xs">Servers</span>
<Icon className="h-3 w-3" />
<span className="text-xs">{label}</span>
</div>
<span className="text-lg font-bold">{metadata.totalServers}</span>
</div>
<div className="flex flex-col items-center p-2 bg-muted/50 rounded">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<Globe className="h-3 w-3" />
<span className="text-xs">Proxies</span>
</div>
<span className="text-lg font-bold">{metadata.totalProxies}</span>
</div>
<div className="flex flex-col items-center p-2 bg-muted/50 rounded">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<Box className="h-3 w-3" />
<span className="text-xs">Nodes</span>
</div>
<span className="text-lg font-bold">{metadata.totalK8sNodes}</span>
<span className="text-lg font-bold">{value}</span>
</div>
))}
</div>
{/* Health Status */}
<div className="flex gap-2 justify-between text-xs">
<Badge variant="outline" className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500" />
<HealthBadge status="healthy" appearance="summary">
{metadata.healthySystems} Healthy
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
</HealthBadge>
<HealthBadge status="degraded" appearance="summary">
{metadata.degradedSystems} Degraded
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-red-500" />
</HealthBadge>
<HealthBadge status="unhealthy" appearance="summary">
{metadata.unhealthySystems} Down
</Badge>
</HealthBadge>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
@@ -90,7 +93,6 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology
/>
</div>
{/* Filters */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
@@ -103,52 +105,22 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology
<div>
<h4 className="font-semibold mb-3">Show/Hide</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="show-servers" className="flex items-center gap-2 cursor-pointer">
<Server className="h-4 w-4" />
<span>Servers</span>
</Label>
<Switch
id="show-servers"
checked={filters.showServers}
onCheckedChange={() => toggleFilter("showServers")}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-proxies" className="flex items-center gap-2 cursor-pointer">
<Globe className="h-4 w-4" />
<span>Reverse Proxies</span>
</Label>
<Switch
id="show-proxies"
checked={filters.showProxies}
onCheckedChange={() => toggleFilter("showProxies")}
/>
</div>
<div className="flex items-center justify-between">
{filterOptions.map(({ id, label, filter, icon: Icon }) => (
<div key={id} className="flex items-center justify-between">
<Label
htmlFor="show-k8s-nodes"
className="flex items-center gap-2 cursor-pointer"
htmlFor={id}
className={Icon ? "flex items-center gap-2 cursor-pointer" : "cursor-pointer"}
>
<Box className="h-4 w-4" />
<span>K8s Nodes</span>
{Icon && <Icon className="h-4 w-4" />}
<span>{label}</span>
</Label>
<Switch
id="show-k8s-nodes"
checked={filters.showK8sNodes}
onCheckedChange={() => toggleFilter("showK8sNodes")}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="show-connections" className="cursor-pointer">
Connection Lines
</Label>
<Switch
id="show-connections"
checked={filters.showConnections}
onCheckedChange={() => toggleFilter("showConnections")}
id={id}
checked={filters[filter]}
onCheckedChange={() => toggleFilter(filter)}
/>
</div>
))}
</div>
</div>
</div>
@@ -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(
+46 -88
View File
@@ -1,55 +1,23 @@
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 <Badge className="bg-green-500 hover:bg-green-600 text-xs">Healthy</Badge>;
case "degraded":
return <Badge className="bg-yellow-500 hover:bg-yellow-600 text-xs">Degraded</Badge>;
case "unhealthy":
return <Badge className="bg-red-500 hover:bg-red-600 text-xs">Unhealthy</Badge>;
default:
return (
<Badge variant="secondary" className="text-xs">
Unknown
</Badge>
);
}
};
return (
<div
className={cn(
"rounded-lg border bg-card p-3 shadow-md hover:shadow-lg transition-all w-[300px]",
selected && "ring-2 ring-primary ring-offset-2 shadow-xl"
)}
<TopologyNodeCard
selected={selected}
icon={<Box className="h-4 w-4 text-blue-600" />}
iconClassName="bg-blue-500/10"
title={node.name || "Unknown"}
description="Kubernetes Node"
health={health}
>
<Handle type="target" position={Position.Top} className="w-3 h-3 !bg-gray-400" />
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="rounded-md bg-blue-500/10 p-1.5">
<Box className="h-4 w-4 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{node.name || "Unknown"}</p>
<p className="text-xs text-muted-foreground">Kubernetes Node</p>
</div>
</div>
{getStatusBadge()}
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] h-5">
{node.status}
@@ -64,79 +32,69 @@ export function K8sNodeComponent({ data, selected }: NodeProps) {
</Badge>
</div>
<div className="flex items-center justify-between text-xs bg-muted/50 rounded px-2 py-1.5">
<span className="text-muted-foreground">Total Pods</span>
<span className="font-semibold text-blue-600">{podCount}</span>
</div>
<CompactRow
label="Total Pods"
className="text-xs bg-muted/50 rounded px-2 py-1.5"
valueClassName="font-semibold text-blue-600"
>
{podCount}
</CompactRow>
<div className="space-y-1 text-[11px]">
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<ServerIcon className="h-3 w-3" /> Server Pods
</span>
<span className="font-medium">{serverPods.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Network className="h-3 w-3" /> Proxy Pods
</span>
<span className="font-medium">{proxyPods.length}</span>
</div>
<CompactRow label="Server Pods" icon={<ServerIcon className="h-3 w-3" />}>
{serverPods.length}
</CompactRow>
<CompactRow label="Proxy Pods" icon={<Network className="h-3 w-3" />}>
{proxyPods.length}
</CompactRow>
</div>
{metrics && (metrics.cpuUsage || metrics.memoryUsage) && (
<div className="space-y-1 text-[11px] pt-1 border-t">
{metrics.cpuUsage && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</span>
<span className="font-semibold text-xs text-blue-600">{metrics.cpuUsage}</span>
</div>
<CompactRow
label="CPU"
icon={<Cpu className="h-3 w-3" />}
valueClassName="font-semibold text-xs text-blue-600"
>
{metrics.cpuUsage}
</CompactRow>
)}
{metrics.memoryUsage && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<HardDrive className="h-3 w-3" /> Memory
</span>
<span className="font-semibold text-xs text-blue-600">{metrics.memoryUsage}</span>
</div>
<CompactRow
label="Memory"
icon={<HardDrive className="h-3 w-3" />}
valueClassName="font-semibold text-xs text-blue-600"
>
{metrics.memoryUsage}
</CompactRow>
)}
</div>
)}
<div className="space-y-1 text-[11px] pt-1 border-t">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Age</span>
<span className="font-medium">{node.age}</span>
</div>
<CompactRow label="Age">{node.age}</CompactRow>
{node.hostname && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Hostname</span>
<span className="font-medium font-mono text-[10px] truncate">{node.hostname}</span>
</div>
<CompactRow label="Hostname" valueClassName="font-mono text-[10px] truncate">
{node.hostname}
</CompactRow>
)}
</div>
{(node.internalIP || node.externalIP) && (
<div className="space-y-1 text-[11px] pt-1 border-t">
{node.internalIP && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Internal IP</span>
<span className="font-mono text-[10px] font-medium">{node.internalIP}</span>
</div>
<CompactRow label="Internal IP" valueClassName="font-mono text-[10px]">
{node.internalIP}
</CompactRow>
)}
{node.externalIP && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">External IP</span>
<span className="font-mono text-[10px] font-medium">{node.externalIP}</span>
</div>
<CompactRow label="External IP" valueClassName="font-mono text-[10px]">
{node.externalIP}
</CompactRow>
)}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
</div>
</TopologyNodeCard>
);
}
+53 -154
View File
@@ -1,74 +1,26 @@
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 <Badge className="bg-green-500 hover:bg-green-600 text-xs">Healthy</Badge>;
case "degraded":
return <Badge className="bg-yellow-500 hover:bg-yellow-600 text-xs">Degraded</Badge>;
case "unhealthy":
return <Badge className="bg-red-500 hover:bg-red-600 text-xs">Unhealthy</Badge>;
default:
return (
<Badge variant="secondary" className="text-xs">
Unknown
</Badge>
);
}
};
const { k8sNodes, connectionInfo, metrics, pods } = metadata;
const restartCount = pods.reduce((sum, pod) => sum + (pod.restarts || 0), 0);
return (
<div
className={cn(
"rounded-lg border bg-card p-3 shadow-md hover:shadow-lg transition-all w-[300px]",
selected && "ring-2 ring-primary ring-offset-2 shadow-xl"
)}
<TopologyNodeCard
selected={selected}
icon={<Globe className="h-4 w-4 text-blue-500" />}
iconClassName="bg-blue-500/10"
title={proxy.id}
description={proxy.description}
health={health}
>
<Handle type="target" position={Position.Top} className="w-3 h-3 !bg-gray-400" />
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="rounded-md bg-blue-500/10 p-1.5">
<Globe className="h-4 w-4 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{proxy.id}</p>
{proxy.description && (
<p className="text-xs text-muted-foreground truncate">{proxy.description}</p>
)}
</div>
</div>
{getStatusBadge()}
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] h-5">
{proxy.type}
@@ -80,52 +32,29 @@ export function ProxyNode({ data, selected }: NodeProps) {
)}
</div>
<div className="flex items-center justify-between text-xs bg-muted/50 rounded px-2 py-1.5">
<span className="text-muted-foreground">Pods</span>
<span
className={cn(
<CompactRow
label="Pods"
className="text-xs bg-muted/50 rounded px-2 py-1.5"
valueClassName={cn(
"font-semibold",
readyPods === podCount ? "text-green-600" : "text-yellow-600"
)}
>
{readyPods}/{podCount}
</span>
</div>
</CompactRow>
{proxy.node_port && (
<div className="space-y-1 text-[11px]">
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Network className="h-3 w-3" /> NodePort
</span>
<span className="font-medium">{proxy.node_port}</span>
</div>
<CompactRow label="NodePort" icon={<Network className="h-3 w-3" />}>
{proxy.node_port}
</CompactRow>
</div>
)}
{connectionInfo?.connectionString && (
<div className="space-y-1 text-[11px] pt-1 border-t">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Address</span>
</div>
<div className="flex items-center gap-1">
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded font-mono flex-1 truncate">
{connectionInfo.connectionString}
</code>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy address"}
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<CompactRow label="Address" />
<CopyableCode value={connectionInfo.connectionString} />
{connectionInfo.note && (
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
)}
@@ -133,74 +62,44 @@ export function ProxyNode({ data, selected }: NodeProps) {
)}
<div className="space-y-1 text-[11px] pt-1 border-t">
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<HardDrive className="h-3 w-3" /> Memory
</span>
<span className="font-medium text-xs">
{metrics?.memoryUsage && (
<span className="text-blue-600">{metrics.memoryUsage} / </span>
)}
<span className="text-muted-foreground">{proxy.memory}MB</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</span>
<span className="font-medium text-xs">
{metrics?.cpuUsage && <span className="text-blue-600">{metrics.cpuUsage} / </span>}
<span className="text-muted-foreground">
{proxy.cpu_request || "N/A"}/{proxy.cpu_limit || "N/A"}
</span>
</span>
</div>
<MetricRow
label="Memory"
icon={<HardDrive className="h-3 w-3" />}
usage={metrics?.memoryUsage}
limit={`${proxy.memory}MB`}
/>
<MetricRow
label="CPU"
icon={<Cpu className="h-3 w-3" />}
usage={metrics?.cpuUsage}
limit={`${proxy.cpu_request || "N/A"}/${proxy.cpu_limit || "N/A"}`}
/>
</div>
{"pods" in metadata && metadata.pods && metadata.pods.length > 0 && (
{pods.length > 0 && (
<div className="space-y-1 text-[11px] pt-1 border-t">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Restarts</span>
<span
className={cn(
"font-medium",
metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0) > 0
? "text-yellow-600"
: "text-green-600"
)}
<CompactRow
label="Restarts"
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
>
{metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0)}
</span>
</div>
{metadata.pods[0]?.age && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Age</span>
<span className="font-medium">{metadata.pods[0].age}</span>
</div>
{restartCount}
</CompactRow>
{pods[0]?.age && <CompactRow label="Age">{pods[0].age}</CompactRow>}
{pods[0]?.ip && (
<CompactRow label="Pod IP" valueClassName="font-mono text-[10px]">
{pods[0].ip}
</CompactRow>
)}
{metadata.pods[0]?.ip && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Pod IP</span>
<span className="font-medium font-mono text-[10px]">{metadata.pods[0].ip}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Routing To</span>
<span className="font-semibold text-blue-600">{connectedServers.length} servers</span>
</div>
<CompactRow label="Routing To" valueClassName="font-semibold text-blue-600">
{connectedServers.length} servers
</CompactRow>
{k8sNodes.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Box className="h-3 w-3" /> K8s Node
</span>
<span className="font-medium">{k8sNodes[0]}</span>
</div>
<CompactRow label="K8s Node" icon={<Box className="h-3 w-3" />}>
{k8sNodes[0]}
</CompactRow>
)}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
</div>
</TopologyNodeCard>
);
}
@@ -1,75 +1,26 @@
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 <Badge className="bg-green-500 hover:bg-green-600 text-xs">Healthy</Badge>;
case "degraded":
return <Badge className="bg-yellow-500 hover:bg-yellow-600 text-xs">Degraded</Badge>;
case "unhealthy":
return <Badge className="bg-red-500 hover:bg-red-600 text-xs">Unhealthy</Badge>;
default:
return (
<Badge variant="secondary" className="text-xs">
Unknown
</Badge>
);
}
};
const { k8sNodes, connectedProxies, connectionInfo, metrics, pods } = metadata;
const restartCount = pods.reduce((sum, pod) => sum + (pod.restarts || 0), 0);
return (
<div
className={cn(
"rounded-lg border bg-card p-3 shadow-md hover:shadow-lg transition-all w-[300px]",
selected && "ring-2 ring-primary ring-offset-2 shadow-xl"
)}
<TopologyNodeCard
selected={selected}
icon={<Server className="h-4 w-4 text-primary" />}
iconClassName="bg-primary/10"
title={server.id}
description={server.description}
health={health}
>
<Handle type="target" position={Position.Top} className="w-3 h-3 !bg-gray-400" />
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="rounded-md bg-primary/10 p-1.5">
<Server className="h-4 w-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{server.id}</p>
{server.description && (
<p className="text-xs text-muted-foreground truncate">{server.description}</p>
)}
</div>
</div>
{getStatusBadge()}
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] h-5">
{server.jar_type}
@@ -87,97 +38,54 @@ export function ServerNode({ data, selected }: NodeProps) {
)}
</div>
<div className="flex items-center justify-between text-xs bg-muted/50 rounded px-2 py-1.5">
<span className="text-muted-foreground">Pods</span>
<span
className={cn(
<CompactRow
label="Pods"
className="text-xs bg-muted/50 rounded px-2 py-1.5"
valueClassName={cn(
"font-semibold",
readyPods === podCount ? "text-green-600" : "text-yellow-600"
)}
>
{readyPods}/{podCount}
</span>
</div>
</CompactRow>
<div className="space-y-1 text-[11px]">
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<HardDrive className="h-3 w-3" /> Memory
</span>
<span className="font-medium text-xs">
{metrics?.memoryUsage && (
<span className="text-blue-600">{metrics.memoryUsage} / </span>
)}
<span className="text-muted-foreground">
{server.memory_request}/{server.memory}MB
</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</span>
<span className="font-medium text-xs">
{metrics?.cpuUsage && <span className="text-blue-600">{metrics.cpuUsage} / </span>}
<span className="text-muted-foreground">
{server.cpu_request || "N/A"}/{server.cpu_limit || "N/A"}
</span>
</span>
</div>
<MetricRow
label="Memory"
icon={<HardDrive className="h-3 w-3" />}
usage={metrics?.memoryUsage}
limit={`${server.memory_request}/${server.memory}MB`}
/>
<MetricRow
label="CPU"
icon={<Cpu className="h-3 w-3" />}
usage={metrics?.cpuUsage}
limit={`${server.cpu_request || "N/A"}/${server.cpu_limit || "N/A"}`}
/>
</div>
{connectionInfo?.connectionString && (
<div className="space-y-1 text-[11px] pt-1 border-t">
<div className="flex items-center gap-1">
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded font-mono flex-1 truncate">
{connectionInfo.connectionString}
</code>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy address"}
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<CopyableCode value={connectionInfo.connectionString} />
{connectionInfo.note && (
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
)}
</div>
)}
{"pods" in metadata && metadata.pods && metadata.pods.length > 0 && (
{pods.length > 0 && (
<div className="space-y-1 text-[11px] pt-1 border-t">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Restarts</span>
<span
className={cn(
"font-medium",
metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0) > 0
? "text-yellow-600"
: "text-green-600"
)}
<CompactRow
label="Restarts"
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
>
{metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0)}
</span>
</div>
{metadata.pods[0]?.age && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Age</span>
<span className="font-medium">{metadata.pods[0].age}</span>
</div>
)}
{metadata.pods[0]?.ip && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Pod IP</span>
<span className="font-medium font-mono text-[10px]">{metadata.pods[0].ip}</span>
</div>
{restartCount}
</CompactRow>
{pods[0]?.age && <CompactRow label="Age">{pods[0].age}</CompactRow>}
{pods[0]?.ip && (
<CompactRow label="Pod IP" valueClassName="font-mono text-[10px]">
{pods[0].ip}
</CompactRow>
)}
</div>
)}
@@ -185,26 +93,17 @@ export function ServerNode({ data, selected }: NodeProps) {
{(k8sNodes.length > 0 || connectedProxies.length > 0) && (
<div className="space-y-1 text-[11px] pt-1 border-t">
{connectedProxies.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Exposed By</span>
<span className="font-semibold text-blue-600">
<CompactRow label="Exposed By" valueClassName="font-semibold text-blue-600">
{connectedProxies.length} proxies
</span>
</div>
</CompactRow>
)}
{k8sNodes.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Box className="h-3 w-3" /> K8s Node
</span>
<span className="font-medium">{k8sNodes[0]}</span>
</div>
<CompactRow label="K8s Node" icon={<Box className="h-3 w-3" />}>
{k8sNodes[0]}
</CompactRow>
)}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
</div>
</TopologyNodeCard>
);
}
@@ -52,7 +52,7 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) {
}, []);
return (
<div className="h-[calc(100vh-250px)] w-full rounded-lg border bg-background shadow-xl">
<div className="h-[calc(100vh-250px)] min-h-[520px] w-full overflow-hidden rounded-sm border-2 border-foreground bg-background shadow-[6px_6px_0_color-mix(in_oklch,var(--foreground)_14%,transparent)]">
<ReactFlow
nodes={nodes}
edges={edges}
@@ -72,25 +72,25 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) {
animated: false,
type: "smoothstep",
style: {
stroke: "#9ca3af",
stroke: "#6f7565",
strokeWidth: 2,
strokeDasharray: "5 5",
},
markerEnd: {
type: "arrowclosed",
color: "#9ca3af",
color: "#6f7565",
width: 20,
height: 20,
},
}}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} color="#cbd5e1" gap={24} size={1} />
<Background variant={BackgroundVariant.Lines} color="#d2d0c5" gap={32} size={1} />
<Controls
showZoom
showFitView
showInteractive
className="bg-white/80 backdrop-blur-sm border shadow-lg"
className="border bg-card/95 shadow-md backdrop-blur-sm"
/>
<MiniMap
nodeColor={(node: any) => {
@@ -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"
/>
<Panel position="top-left">
@@ -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<HealthStatus, string> = {
healthy: "Healthy",
degraded: "Degraded",
unhealthy: "Unhealthy",
unknown: "Unknown",
};
const solidHealthClasses: Record<Exclude<HealthStatus, "unknown">, 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 (
<Badge variant="outline" className={cn("flex items-center gap-1", className)}>
<span className={cn("h-2 w-2 rounded-full", indicatorClasses)} />
{content}
</Badge>
);
}
if (appearance === "detail") {
return (
<Badge
variant={
status === "healthy" ? "default" : status === "degraded" ? "secondary" : "destructive"
}
className={className}
>
{content}
</Badge>
);
}
if (status === "unknown") {
return (
<Badge variant="secondary" className={cn("text-xs", className)}>
{content}
</Badge>
);
}
return <Badge className={cn(solidHealthClasses[status], "text-xs", className)}>{content}</Badge>;
}
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 (
<div
className={cn(
"rounded-lg border bg-card p-3 shadow-md hover:shadow-lg transition-all w-[300px]",
selected && "ring-2 ring-primary ring-offset-2 shadow-xl"
)}
>
<Handle type="target" position={Position.Top} className="w-3 h-3 !bg-gray-400" />
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn("rounded-md p-1.5", iconClassName)}>{icon}</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{title}</p>
{description && (
<p className="text-xs text-muted-foreground truncate">{description}</p>
)}
</div>
</div>
<HealthBadge status={health} />
</div>
{children}
</div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
</div>
);
}
interface CompactRowProps {
label: ReactNode;
icon?: ReactNode;
children?: ReactNode;
className?: string;
valueClassName?: string;
}
export function CompactRow({ label, icon, children, className, valueClassName }: CompactRowProps) {
return (
<div className={cn("flex items-center justify-between", className)}>
<span className={cn("text-muted-foreground", icon && "flex items-center gap-1")}>
{icon}
{label}
</span>
{children !== undefined && (
<span className={cn("font-medium", valueClassName)}>{children}</span>
)}
</div>
);
}
interface MetricRowProps {
label: string;
icon: ReactNode;
usage?: string;
limit: ReactNode;
}
export function MetricRow({ label, icon, usage, limit }: MetricRowProps) {
return (
<CompactRow label={label} icon={icon} valueClassName="text-xs">
{usage && <span className="text-blue-600">{usage} / </span>}
<span className="text-muted-foreground">{limit}</span>
</CompactRow>
);
}
export function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="space-y-2">
<h3 className="font-semibold text-sm">{title}</h3>
<div className="space-y-2">{children}</div>
</div>
);
}
export function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex justify-between items-start text-sm gap-4">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="font-medium text-right break-words">{value}</span>
</div>
);
}
interface CopyableCodeProps {
value: string;
title?: string;
}
export function CopyableCode({ value, title = "Copy address" }: CopyableCodeProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex items-center gap-1">
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded font-mono flex-1 truncate">
{value}
</code>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={handleCopy}
title={copied ? "Copied!" : title}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
);
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function useGraphLayout({ nodes, edges }: LayoutInput) {
const k8sNodePositions = new Map<string, { x: number; width: number }>();
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 });
-6
View File
@@ -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,
+6 -6
View File
@@ -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;
}