🎨 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
+39 -76
View File
@@ -1,89 +1,52 @@
"use client"; "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 { TopologyCanvas } from "@/components/topology/topology-canvas";
import { useTopologyData } from "@/hooks/use-topology-data"; import { useTopologyData } from "@/hooks/use-topology-data";
export default function TopologyPage() { export default function TopologyPage() {
const { graph, loading, error } = useTopologyData(); const { graph, loading, error } = useTopologyData();
if (loading) { const header = (
return ( <PageHeader
<div className="space-y-6"> eyebrow="Network / Live Graph"
<div> title="Topology"
<h1 className="text-3xl font-bold">Network Topology</h1> description="Real-time server infrastructure, proxy connections, and Kubernetes nodes"
<p className="text-muted-foreground mt-1"> leading={
Real-time server infrastructure, proxy connections, and Kubernetes nodes <div className="border border-foreground bg-primary p-3 shadow-[3px_3px_0_var(--foreground)]">
</p> <Network className="size-6 text-primary-foreground" />
</div> </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 ( return (
<div className="space-y-6"> <PageShell>
<div> {header}
<div className="flex items-center gap-3"> {loading ? (
<div className="p-2 bg-primary/10 rounded-lg"> <StatePanel loading title="Loading topology..." className="h-[calc(100vh-250px)]" />
<Network className="h-6 w-6 text-primary" /> ) : error ? (
</div> <StatePanel
<div> title="Error loading topology"
<h1 className="text-3xl font-bold">Network Topology</h1> description={
<p className="text-muted-foreground mt-1"> <>
Real-time server infrastructure, proxy connections, and Kubernetes nodes <p>{error}</p>
</p> <p className="mt-2 text-xs">Auto-retrying...</p>
</div> </>
</div> }
</div> tone="error"
className="h-[calc(100vh-250px)]"
<TopologyCanvas graph={graph} /> />
</div> ) : !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} />
)}
</PageShell>
); );
} }
@@ -3,18 +3,14 @@
import { Box } from "lucide-react"; import { Box } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { import type {
K8sNodeMetadata, K8sNodeMetadata,
ProxyMetadata, ProxyMetadata,
ServerMetadata, ServerMetadata,
TopologyNodeData, TopologyNodeData,
} from "@/lib/topology-types"; } from "@/lib/topology-types";
import { DetailRow, DetailSection, HealthBadge } from "../topology-primitives";
interface NodeDetailsPanelProps { interface NodeDetailsPanelProps {
node: TopologyNodeData | null; node: TopologyNodeData | null;
@@ -22,11 +18,7 @@ interface NodeDetailsPanelProps {
onClose: () => void; onClose: () => void;
} }
export function NodeDetailsPanel({ export function NodeDetailsPanel({ node, open, onClose }: NodeDetailsPanelProps) {
node,
open,
onClose,
}: NodeDetailsPanelProps) {
if (!node) return null; if (!node) return null;
return ( return (
@@ -37,7 +29,6 @@ export function NodeDetailsPanel({
</SheetHeader> </SheetHeader>
<div className="space-y-4 pr-2"> <div className="space-y-4 pr-2">
{/* Type and Status */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline"> <Badge variant="outline">
@@ -47,28 +38,16 @@ export function NodeDetailsPanel({
? "Reverse Proxy" ? "Reverse Proxy"
: "Kubernetes Node"} : "Kubernetes Node"}
</Badge> </Badge>
<Badge <HealthBadge status={node.status} appearance="detail">
variant={
node.status === "healthy"
? "default"
: node.status === "degraded"
? "secondary"
: "destructive"
}
>
{node.status} {node.status}
</Badge> </HealthBadge>
</div> </div>
</div> </div>
<Separator /> <Separator />
{node.type === "server" && ( {node.type === "server" && <ServerDetails metadata={node.metadata as ServerMetadata} />}
<ServerDetails metadata={node.metadata as ServerMetadata} /> {node.type === "proxy" && <ProxyDetails metadata={node.metadata as ProxyMetadata} />}
)}
{node.type === "proxy" && (
<ProxyDetails metadata={node.metadata as ProxyMetadata} />
)}
{node.type === "k8s-node" && ( {node.type === "k8s-node" && (
<K8sNodeDetails metadata={node.metadata as K8sNodeMetadata} /> <K8sNodeDetails metadata={node.metadata as K8sNodeMetadata} />
)} )}
@@ -79,173 +58,79 @@ export function NodeDetailsPanel({
} }
function ServerDetails({ metadata }: { metadata: ServerMetadata }) { function ServerDetails({ metadata }: { metadata: ServerMetadata }) {
const { server, podCount, readyPods, pods, k8sNodes, connectedProxies } = const { server, podCount, readyPods, pods, k8sNodes, connectedProxies } = metadata;
metadata;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<DetailSection title="Server Configuration"> <DetailSection title="Server Configuration">
<DetailItem label="ID" value={server.id} /> <DetailRow label="ID" value={server.id} />
{server.description && ( {server.description && <DetailRow label="Description" value={server.description} />}
<DetailItem label="Description" value={server.description} /> <DetailRow label="Type" value={server.type} />
)} <DetailRow label="Jar Type" value={server.jar_type} />
<DetailItem label="Type" value={server.type} /> <DetailRow label="Minecraft Version" value={server.minecraft_version} />
<DetailItem label="Jar Type" value={server.jar_type} /> <DetailRow label="Port" value={server.listen_port.toString()} />
<DetailItem
label="Minecraft Version"
value={server.minecraft_version}
/>
<DetailItem label="Port" value={server.listen_port.toString()} />
</DetailSection> </DetailSection>
<DetailSection title="Kubernetes Info"> <DetailSection title="Kubernetes Info">
<DetailItem label="Pods" value={`${readyPods}/${podCount} Ready`} /> <KubernetesWorkloadDetails
{k8sNodes.length > 0 && ( podCount={podCount}
<div className="space-y-1"> readyPods={readyPods}
<div className="flex items-center gap-2 text-sm mb-2"> pods={pods}
<Box className="h-4 w-4 text-muted-foreground" /> k8sNodes={k8sNodes}
<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>
))}
</DetailSection> </DetailSection>
{connectedProxies.length > 0 && ( {connectedProxies.length > 0 && (
<DetailSection title="Proxy Connections"> <DetailSection title="Proxy Connections">
<DetailItem <DetailRow label="Behind Proxies" value={connectedProxies.length.toString()} />
label="Behind Proxies" <IdentifierList
value={connectedProxies.length.toString()} 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>
)} )}
<DetailSection title="Resources"> <DetailSection title="Resources">
<DetailItem <DetailRow label="Memory" value={`${server.memory_request}MB / ${server.memory}MB`} />
label="Memory" <DetailRow label="CPU Request" value={server.cpu_request || "Not set"} />
value={`${server.memory_request}MB / ${server.memory}MB`} <DetailRow label="CPU Limit" value={server.cpu_limit || "Not set"} />
/>
<DetailItem
label="CPU Request"
value={server.cpu_request || "Not set"}
/>
<DetailItem label="CPU Limit" value={server.cpu_limit || "Not set"} />
</DetailSection> </DetailSection>
</div> </div>
); );
} }
function ProxyDetails({ metadata }: { metadata: ProxyMetadata }) { function ProxyDetails({ metadata }: { metadata: ProxyMetadata }) {
const { proxy, podCount, readyPods, pods, k8sNodes, connectedServers } = const { proxy, podCount, readyPods, pods, k8sNodes, connectedServers } = metadata;
metadata;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<DetailSection title="Proxy Configuration"> <DetailSection title="Proxy Configuration">
<DetailItem label="ID" value={proxy.id} /> <DetailRow label="ID" value={proxy.id} />
{proxy.description && ( {proxy.description && <DetailRow label="Description" value={proxy.description} />}
<DetailItem label="Description" value={proxy.description} /> <DetailRow label="Type" value={proxy.type} />
)} <DetailRow
<DetailItem label="Type" value={proxy.type} />
<DetailItem
label="External Address" label="External Address"
value={`${proxy.external_address}:${proxy.external_port}`} 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>
<DetailSection title="Kubernetes Info"> <DetailSection title="Kubernetes Info">
<DetailItem label="Pods" value={`${readyPods}/${podCount} Ready`} /> <KubernetesWorkloadDetails
{k8sNodes.length > 0 && ( podCount={podCount}
<div className="space-y-1"> readyPods={readyPods}
<div className="flex items-center gap-2 text-sm mb-2"> pods={pods}
<Box className="h-4 w-4 text-muted-foreground" /> k8sNodes={k8sNodes}
<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>
))}
</DetailSection> </DetailSection>
<DetailSection title="Connected Servers"> <DetailSection title="Connected Servers">
<DetailItem label="Total" value={connectedServers.length.toString()} /> <DetailRow label="Total" value={connectedServers.length.toString()} />
{connectedServers.length > 0 && ( {connectedServers.length > 0 && (
<div className="mt-2 space-y-1"> <IdentifierList
{connectedServers.map((serverId) => ( items={connectedServers}
<div className="text-sm bg-green-50 border border-green-200"
key={serverId} />
className="text-sm p-2 bg-green-50 border border-green-200 rounded font-mono break-all"
>
{serverId}
</div>
))}
</div>
)} )}
</DetailSection> </DetailSection>
</div> </div>
@@ -258,57 +143,43 @@ function K8sNodeDetails({ metadata }: { metadata: K8sNodeMetadata }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<DetailSection title="Node Information"> <DetailSection title="Node Information">
<DetailItem label="Name" value={node.name || "Unknown"} /> <DetailRow label="Name" value={node.name || "Unknown"} />
<DetailItem label="Status" value={node.status} /> <DetailRow label="Status" value={node.status} />
{node.version && <DetailItem label="Version" value={node.version} />} {node.version && <DetailRow label="Version" value={node.version} />}
{node.internalIP && ( {node.internalIP && <DetailRow label="Internal IP" value={node.internalIP} />}
<DetailItem label="Internal IP" value={node.internalIP} /> {node.externalIP && <DetailRow label="External IP" value={node.externalIP} />}
)}
{node.externalIP && (
<DetailItem label="External IP" value={node.externalIP} />
)}
</DetailSection> </DetailSection>
<DetailSection title="Node Details"> <DetailSection title="Node Details">
<DetailItem label="Roles" value={node.roles} /> <DetailRow label="Roles" value={node.roles} />
<DetailItem label="Age" value={node.age} /> <DetailRow label="Age" value={node.age} />
{node.hostname && <DetailItem label="Hostname" value={node.hostname} />} {node.hostname && <DetailRow label="Hostname" value={node.hostname} />}
</DetailSection> </DetailSection>
<DetailSection title="Running Pods"> <DetailSection title="Running Pods">
<DetailItem label="Total Pods" value={podCount.toString()} /> <DetailRow label="Total Pods" value={podCount.toString()} />
<DetailItem label="Server Pods" value={serverPods.length.toString()} /> <DetailRow label="Server Pods" value={serverPods.length.toString()} />
<DetailItem label="Proxy Pods" value={proxyPods.length.toString()} /> <DetailRow label="Proxy Pods" value={proxyPods.length.toString()} />
{serverPods.length > 0 && ( {serverPods.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<p className="text-sm font-medium mb-2">Server Pods:</p> <p className="text-sm font-medium mb-2">Server Pods:</p>
<div className="space-y-1"> <IdentifierList
{serverPods.map((podName) => ( items={serverPods}
<div className="text-xs bg-green-50 border border-green-200"
key={podName} withMargin={false}
className="text-xs p-2 bg-green-50 border border-green-200 rounded font-mono break-all" />
>
{podName}
</div>
))}
</div>
</div> </div>
)} )}
{proxyPods.length > 0 && ( {proxyPods.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<p className="text-sm font-medium mb-2">Proxy Pods:</p> <p className="text-sm font-medium mb-2">Proxy Pods:</p>
<div className="space-y-1"> <IdentifierList
{proxyPods.map((podName) => ( items={proxyPods}
<div className="text-xs bg-blue-50 border border-blue-200"
key={podName} withMargin={false}
className="text-xs p-2 bg-blue-50 border border-blue-200 rounded font-mono break-all" />
>
{podName}
</div>
))}
</div>
</div> </div>
)} )}
</DetailSection> </DetailSection>
@@ -316,26 +187,65 @@ function K8sNodeDetails({ metadata }: { metadata: K8sNodeMetadata }) {
); );
} }
function DetailSection({ function KubernetesWorkloadDetails({
title, podCount,
children, readyPods,
}: { pods,
title: string; k8sNodes,
children: React.ReactNode; }: Pick<ServerMetadata, "podCount" | "readyPods" | "pods" | "k8sNodes">) {
}) {
return ( return (
<div className="space-y-2"> <>
<h3 className="font-semibold text-sm">{title}</h3> <DetailRow label="Pods" value={`${readyPods}/${podCount} Ready`} />
<div className="space-y-2">{children}</div> {k8sNodes.length > 0 && (
</div> <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 ( return (
<div className="flex justify-between items-start text-sm gap-4"> <div className={withMargin ? "mt-2 space-y-1" : "space-y-1"}>
<span className="text-muted-foreground shrink-0">{label}:</span> {items.map((item) => (
<span className="font-medium text-right break-words">{value}</span> <div key={item} className={`p-2 rounded font-mono break-all ${className}`}>
{item}
</div>
))}
</div> </div>
); );
} }
@@ -1,14 +1,17 @@
"use client"; "use client";
import type { LucideIcon } from "lucide-react";
import { Box, Filter, Globe, Search, Server } from "lucide-react"; import { Box, Filter, Globe, Search, Server } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { TopologyFilters } from "@/lib/topology-types"; import type { TopologyFilters } from "@/lib/topology-types";
import { HealthBadge } from "../topology-primitives";
type ToggleFilterKey = "showServers" | "showProxies" | "showK8sNodes" | "showConnections";
interface TopologyToolbarProps { interface TopologyToolbarProps {
filters: TopologyFilters; filters: TopologyFilters;
@@ -32,54 +35,54 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology
onFiltersChange({ ...filters, searchQuery: value }); onFiltersChange({ ...filters, searchQuery: value });
}; };
const toggleFilter = (key: keyof TopologyFilters) => { const toggleFilter = (key: ToggleFilterKey) => {
onFiltersChange({ ...filters, [key]: !filters[key] }); 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 ( return (
<div className="flex flex-col gap-3 p-4 bg-white/90 backdrop-blur-sm rounded-lg border shadow-lg min-w-[350px]"> <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">
{/* Stats */}
<div className="grid grid-cols-3 gap-2"> <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 className="flex items-center gap-1 text-muted-foreground mb-1"> <div key={label} className="flex flex-col items-center border bg-muted/50 p-2">
<Server className="h-3 w-3" /> <div className="flex items-center gap-1 text-muted-foreground mb-1">
<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">{value}</span>
</div> </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>
</div>
</div> </div>
{/* Health Status */}
<div className="flex gap-2 justify-between text-xs"> <div className="flex gap-2 justify-between text-xs">
<Badge variant="outline" className="flex items-center gap-1"> <HealthBadge status="healthy" appearance="summary">
<div className="w-2 h-2 rounded-full bg-green-500" />
{metadata.healthySystems} Healthy {metadata.healthySystems} Healthy
</Badge> </HealthBadge>
<Badge variant="outline" className="flex items-center gap-1"> <HealthBadge status="degraded" appearance="summary">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
{metadata.degradedSystems} Degraded {metadata.degradedSystems} Degraded
</Badge> </HealthBadge>
<Badge variant="outline" className="flex items-center gap-1"> <HealthBadge status="unhealthy" appearance="summary">
<div className="w-2 h-2 rounded-full bg-red-500" />
{metadata.unhealthySystems} Down {metadata.unhealthySystems} Down
</Badge> </HealthBadge>
</div> </div>
{/* Search */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -90,7 +93,6 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology
/> />
</div> </div>
{/* Filters */}
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full"> <Button variant="outline" size="sm" className="w-full">
@@ -103,52 +105,22 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology
<div> <div>
<h4 className="font-semibold mb-3">Show/Hide</h4> <h4 className="font-semibold mb-3">Show/Hide</h4>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> {filterOptions.map(({ id, label, filter, icon: Icon }) => (
<Label htmlFor="show-servers" className="flex items-center gap-2 cursor-pointer"> <div key={id} className="flex items-center justify-between">
<Server className="h-4 w-4" /> <Label
<span>Servers</span> htmlFor={id}
</Label> className={Icon ? "flex items-center gap-2 cursor-pointer" : "cursor-pointer"}
<Switch >
id="show-servers" {Icon && <Icon className="h-4 w-4" />}
checked={filters.showServers} <span>{label}</span>
onCheckedChange={() => toggleFilter("showServers")} </Label>
/> <Switch
</div> id={id}
<div className="flex items-center justify-between"> checked={filters[filter]}
<Label htmlFor="show-proxies" className="flex items-center gap-2 cursor-pointer"> onCheckedChange={() => toggleFilter(filter)}
<Globe className="h-4 w-4" /> />
<span>Reverse Proxies</span> </div>
</Label> ))}
<Switch
id="show-proxies"
checked={filters.showProxies}
onCheckedChange={() => toggleFilter("showProxies")}
/>
</div>
<div className="flex items-center justify-between">
<Label
htmlFor="show-k8s-nodes"
className="flex items-center gap-2 cursor-pointer"
>
<Box className="h-4 w-4" />
<span>K8s Nodes</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")}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,10 +1,10 @@
import type { TopologyEdge, TopologyNode } from "@/lib/topology-types"; import type { TopologyEdge, TopologyNode } from "@/lib/topology-types";
const LAYOUT_CONFIG = { const LAYOUT_CONFIG = {
TIER_SPACING: 300, // Vertical spacing between proxy and server tiers TIER_SPACING: 300,
NODE_SPACING: 250, // Horizontal spacing between nodes NODE_SPACING: 250,
START_X: 150, // Left padding START_X: 150,
START_Y: 100, // Top padding START_Y: 100,
} as const; } as const;
export function applyHierarchicalLayout( export function applyHierarchicalLayout(
+75 -117
View File
@@ -1,142 +1,100 @@
import type { NodeProps } from "@xyflow/react"; import type { NodeProps } from "@xyflow/react";
import { Handle, Position } from "@xyflow/react";
import { Box, Cpu, HardDrive, Network, Server as ServerIcon } from "lucide-react"; import { Box, Cpu, HardDrive, Network, Server as ServerIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/cn";
import type { K8sNodeMetadata, TopologyNodeData } from "@/lib/topology-types"; import type { K8sNodeMetadata, TopologyNodeData } from "@/lib/topology-types";
import { CompactRow, TopologyNodeCard } from "../topology-primitives";
export function K8sNodeComponent({ data, selected }: NodeProps) { export function K8sNodeComponent({ data, selected }: NodeProps) {
const nodeData = data as TopologyNodeData; const nodeData = data as TopologyNodeData;
const metadata = nodeData.metadata as K8sNodeMetadata; const metadata = nodeData.metadata as K8sNodeMetadata;
const { node, podCount, serverPods, proxyPods, health, metrics } = metadata; 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 ( return (
<div <TopologyNodeCard
className={cn( selected={selected}
"rounded-lg border bg-card p-3 shadow-md hover:shadow-lg transition-all w-[300px]", icon={<Box className="h-4 w-4 text-blue-600" />}
selected && "ring-2 ring-primary ring-offset-2 shadow-xl" 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="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] h-5">
<div className="space-y-2.5"> {node.status}
<div className="flex items-start justify-between gap-2"> </Badge>
<div className="flex items-center gap-2 flex-1 min-w-0"> {node.version && (
<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"> <Badge variant="outline" className="text-[10px] h-5">
{node.status} {node.version}
</Badge> </Badge>
{node.version && (
<Badge variant="outline" className="text-[10px] h-5">
{node.version}
</Badge>
)}
<Badge variant="outline" className="text-[10px] h-5">
{node.roles}
</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>
<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>
</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>
)}
{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>
)}
</div>
)} )}
<Badge variant="outline" className="text-[10px] h-5">
{node.roles}
</Badge>
</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]">
<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"> <div className="space-y-1 text-[11px] pt-1 border-t">
<div className="flex items-center justify-between"> {metrics.cpuUsage && (
<span className="text-muted-foreground">Age</span> <CompactRow
<span className="font-medium">{node.age}</span> label="CPU"
</div> icon={<Cpu className="h-3 w-3" />}
{node.hostname && ( valueClassName="font-semibold text-xs text-blue-600"
<div className="flex items-center justify-between"> >
<span className="text-muted-foreground">Hostname</span> {metrics.cpuUsage}
<span className="font-medium font-mono text-[10px] truncate">{node.hostname}</span> </CompactRow>
</div> )}
{metrics.memoryUsage && (
<CompactRow
label="Memory"
icon={<HardDrive className="h-3 w-3" />}
valueClassName="font-semibold text-xs text-blue-600"
>
{metrics.memoryUsage}
</CompactRow>
)} )}
</div> </div>
)}
{(node.internalIP || node.externalIP) && ( <div className="space-y-1 text-[11px] pt-1 border-t">
<div className="space-y-1 text-[11px] pt-1 border-t"> <CompactRow label="Age">{node.age}</CompactRow>
{node.internalIP && ( {node.hostname && (
<div className="flex items-center justify-between"> <CompactRow label="Hostname" valueClassName="font-mono text-[10px] truncate">
<span className="text-muted-foreground">Internal IP</span> {node.hostname}
<span className="font-mono text-[10px] font-medium">{node.internalIP}</span> </CompactRow>
</div>
)}
{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>
)}
</div>
)} )}
</div> </div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" /> {(node.internalIP || node.externalIP) && (
</div> <div className="space-y-1 text-[11px] pt-1 border-t">
{node.internalIP && (
<CompactRow label="Internal IP" valueClassName="font-mono text-[10px]">
{node.internalIP}
</CompactRow>
)}
{node.externalIP && (
<CompactRow label="External IP" valueClassName="font-mono text-[10px]">
{node.externalIP}
</CompactRow>
)}
</div>
)}
</TopologyNodeCard>
); );
} }
+88 -189
View File
@@ -1,206 +1,105 @@
import type { NodeProps } from "@xyflow/react"; import type { NodeProps } from "@xyflow/react";
import { Handle, Position } from "@xyflow/react"; import { Box, Cpu, Globe, HardDrive, Network } from "lucide-react";
import { Box, Check, Copy, Cpu, Globe, HardDrive, Network } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import type { ProxyMetadata, TopologyNodeData } from "@/lib/topology-types"; import type { ProxyMetadata, TopologyNodeData } from "@/lib/topology-types";
import { CompactRow, CopyableCode, MetricRow, TopologyNodeCard } from "../topology-primitives";
export function ProxyNode({ data, selected }: NodeProps) { export function ProxyNode({ data, selected }: NodeProps) {
const nodeData = data as TopologyNodeData | TopologyNodeData; const nodeData = data as TopologyNodeData;
const metadata = nodeData.metadata as ProxyMetadata | ProxyMetadata; const metadata = nodeData.metadata as ProxyMetadata;
const { proxy, readyPods, podCount, health, connectedServers } = metadata; const { proxy, readyPods, podCount, health, connectedServers } = metadata;
const { k8sNodes, connectionInfo, metrics, pods } = metadata;
const k8sNodes = "k8sNodes" in metadata ? metadata.k8sNodes : []; const restartCount = pods.reduce((sum, pod) => sum + (pod.restarts || 0), 0);
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>
);
}
};
return ( return (
<div <TopologyNodeCard
className={cn( selected={selected}
"rounded-lg border bg-card p-3 shadow-md hover:shadow-lg transition-all w-[300px]", icon={<Globe className="h-4 w-4 text-blue-500" />}
selected && "ring-2 ring-primary ring-offset-2 shadow-xl" 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="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] h-5">
<div className="space-y-2.5"> {proxy.type}
<div className="flex items-start justify-between gap-2"> </Badge>
<div className="flex items-center gap-2 flex-1 min-w-0"> {connectionInfo && (
<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"> <Badge variant="outline" className="text-[10px] h-5">
{proxy.type} {connectionInfo.type}
</Badge> </Badge>
{connectionInfo && (
<Badge variant="outline" className="text-[10px] h-5">
{connectionInfo.type}
</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">Pods</span>
<span
className={cn(
"font-semibold",
readyPods === podCount ? "text-green-600" : "text-yellow-600"
)}
>
{readyPods}/{podCount}
</span>
</div>
{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>
</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>
{connectionInfo.note && (
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
)}
</div>
)}
<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>
</div>
{"pods" in metadata && metadata.pods && metadata.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"
)}
>
{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>
)}
<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>
{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>
)}
</div>
)} )}
</div> </div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" /> <CompactRow
</div> 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}
</CompactRow>
{proxy.node_port && (
<div className="space-y-1 text-[11px]">
<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">
<CompactRow label="Address" />
<CopyableCode value={connectionInfo.connectionString} />
{connectionInfo.note && (
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
)}
</div>
)}
<div className="space-y-1 text-[11px] pt-1 border-t">
<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.length > 0 && (
<div className="space-y-1 text-[11px] pt-1 border-t">
<CompactRow
label="Restarts"
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
>
{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>
)}
<CompactRow label="Routing To" valueClassName="font-semibold text-blue-600">
{connectedServers.length} servers
</CompactRow>
{k8sNodes.length > 0 && (
<CompactRow label="K8s Node" icon={<Box className="h-3 w-3" />}>
{k8sNodes[0]}
</CompactRow>
)}
</div>
)}
</TopologyNodeCard>
); );
} }
@@ -1,210 +1,109 @@
import type { NodeProps } from "@xyflow/react"; import type { NodeProps } from "@xyflow/react";
import { Handle, Position } from "@xyflow/react"; import { Box, Cpu, HardDrive, Server } from "lucide-react";
import { Box, Check, Copy, Cpu, HardDrive, Server } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import type { ServerMetadata, TopologyNodeData } from "@/lib/topology-types"; import type { ServerMetadata, TopologyNodeData } from "@/lib/topology-types";
import { CompactRow, CopyableCode, MetricRow, TopologyNodeCard } from "../topology-primitives";
export function ServerNode({ data, selected }: NodeProps) { export function ServerNode({ data, selected }: NodeProps) {
const nodeData = data as TopologyNodeData | TopologyNodeData; const nodeData = data as TopologyNodeData;
const metadata = nodeData.metadata as ServerMetadata | ServerMetadata; const metadata = nodeData.metadata as ServerMetadata;
const { server, readyPods, podCount, health } = metadata; const { server, readyPods, podCount, health } = metadata;
const { k8sNodes, connectedProxies, connectionInfo, metrics, pods } = metadata;
const k8sNodes = "k8sNodes" in metadata ? metadata.k8sNodes : []; const restartCount = pods.reduce((sum, pod) => sum + (pod.restarts || 0), 0);
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>
);
}
};
return ( return (
<div <TopologyNodeCard
className={cn( selected={selected}
"rounded-lg border bg-card p-3 shadow-md hover:shadow-lg transition-all w-[300px]", icon={<Server className="h-4 w-4 text-primary" />}
selected && "ring-2 ring-primary ring-offset-2 shadow-xl" 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="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] h-5">
<div className="space-y-2.5"> {server.jar_type}
<div className="flex items-start justify-between gap-2"> </Badge>
<div className="flex items-center gap-2 flex-1 min-w-0"> <Badge variant="outline" className="text-[10px] h-5">
<div className="rounded-md bg-primary/10 p-1.5"> MC {server.minecraft_version}
<Server className="h-4 w-4 text-primary" /> </Badge>
</div> <Badge variant="outline" className="text-[10px] h-5">
<div className="flex-1 min-w-0"> {server.type}
<p className="font-bold text-sm truncate">{server.id}</p> </Badge>
{server.description && ( {connectionInfo && (
<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"> <Badge variant="outline" className="text-[10px] h-5">
{server.jar_type} {connectionInfo.type}
</Badge> </Badge>
<Badge variant="outline" className="text-[10px] h-5">
MC {server.minecraft_version}
</Badge>
<Badge variant="outline" className="text-[10px] h-5">
{server.type}
</Badge>
{connectionInfo && (
<Badge variant="outline" className="text-[10px] h-5">
{connectionInfo.type}
</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">Pods</span>
<span
className={cn(
"font-semibold",
readyPods === podCount ? "text-green-600" : "text-yellow-600"
)}
>
{readyPods}/{podCount}
</span>
</div>
<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>
</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>
{connectionInfo.note && (
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
)}
</div>
)}
{"pods" in metadata && metadata.pods && metadata.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"
)}
>
{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>
)}
</div>
)}
{(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">
{connectedProxies.length} proxies
</span>
</div>
)}
{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>
)}
</div>
)} )}
</div> </div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" /> <CompactRow
</div> 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}
</CompactRow>
<div className="space-y-1 text-[11px]">
<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">
<CopyableCode value={connectionInfo.connectionString} />
{connectionInfo.note && (
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
)}
</div>
)}
{pods.length > 0 && (
<div className="space-y-1 text-[11px] pt-1 border-t">
<CompactRow
label="Restarts"
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
>
{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>
)}
{(k8sNodes.length > 0 || connectedProxies.length > 0) && (
<div className="space-y-1 text-[11px] pt-1 border-t">
{connectedProxies.length > 0 && (
<CompactRow label="Exposed By" valueClassName="font-semibold text-blue-600">
{connectedProxies.length} proxies
</CompactRow>
)}
{k8sNodes.length > 0 && (
<CompactRow label="K8s Node" icon={<Box className="h-3 w-3" />}>
{k8sNodes[0]}
</CompactRow>
)}
</div>
)}
</TopologyNodeCard>
); );
} }
@@ -52,7 +52,7 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) {
}, []); }, []);
return ( 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 <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
@@ -72,25 +72,25 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) {
animated: false, animated: false,
type: "smoothstep", type: "smoothstep",
style: { style: {
stroke: "#9ca3af", stroke: "#6f7565",
strokeWidth: 2, strokeWidth: 2,
strokeDasharray: "5 5", strokeDasharray: "5 5",
}, },
markerEnd: { markerEnd: {
type: "arrowclosed", type: "arrowclosed",
color: "#9ca3af", color: "#6f7565",
width: 20, width: 20,
height: 20, height: 20,
}, },
}} }}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} color="#cbd5e1" gap={24} size={1} /> <Background variant={BackgroundVariant.Lines} color="#d2d0c5" gap={32} size={1} />
<Controls <Controls
showZoom showZoom
showFitView showFitView
showInteractive showInteractive
className="bg-white/80 backdrop-blur-sm border shadow-lg" className="border bg-card/95 shadow-md backdrop-blur-sm"
/> />
<MiniMap <MiniMap
nodeColor={(node: any) => { nodeColor={(node: any) => {
@@ -103,8 +103,8 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) {
}; };
return colors[data.status] || "#94a3b8"; return colors[data.status] || "#94a3b8";
}} }}
maskColor="rgba(0, 0, 0, 0.05)" maskColor="rgba(29, 31, 26, 0.08)"
className="bg-white/80 backdrop-blur-sm border shadow-lg" className="border bg-card/95 shadow-md backdrop-blur-sm"
/> />
<Panel position="top-left"> <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 }>(); const k8sNodePositions = new Map<string, { x: number; width: number }>();
k8sWithApps.forEach((k8sNode) => { k8sWithApps.forEach((k8sNode) => {
const width = k8sNodeWidths.get(k8sNode.id)!; const width = k8sNodeWidths.get(k8sNode.id) ?? nodeWidth;
const centerX = currentX + width / 2; const centerX = currentX + width / 2;
k8sNodePositions.set(k8sNode.id, { x: centerX, width }); k8sNodePositions.set(k8sNode.id, { x: centerX, width });
-6
View File
@@ -4,8 +4,6 @@ export interface ResourceMetrics {
memoryUsage?: string; memoryUsage?: string;
memoryUsagePercent?: number; memoryUsagePercent?: number;
} }
/** Convert CPU nanoseconds (e.g. "123456789n") to millicores (e.g. "123m") */
export function parseCpuUsage(cpuNano: string): string | undefined { export function parseCpuUsage(cpuNano: string): string | undefined {
const usageNano = Number.parseInt(cpuNano.replace("n", ""), 10); const usageNano = Number.parseInt(cpuNano.replace("n", ""), 10);
if (Number.isNaN(usageNano)) return undefined; if (Number.isNaN(usageNano)) return undefined;
@@ -24,8 +22,6 @@ export function calculateCpuPercent(cpuNano: string, capacityNano: string): numb
return Math.round((usageNano / capNano) * 100); return Math.round((usageNano / capNano) * 100);
} }
/** Convert memory kibibytes (e.g. "1024Ki") to mebibytes (e.g. "1Mi") */
export function parseMemoryUsage(memoryKi: string): string | undefined { export function parseMemoryUsage(memoryKi: string): string | undefined {
const usageKi = Number.parseInt(memoryKi.replace("Ki", ""), 10); const usageKi = Number.parseInt(memoryKi.replace("Ki", ""), 10);
if (Number.isNaN(usageKi)) return undefined; if (Number.isNaN(usageKi)) return undefined;
@@ -44,8 +40,6 @@ export function calculateMemoryPercent(memoryKi: string, capacityKi: string): nu
return Math.round((usageKi / capKi) * 100); return Math.round((usageKi / capKi) * 100);
} }
/** Parse raw K8s metrics into a standardized format with optional percentages */
export function parseK8sMetrics( export function parseK8sMetrics(
cpuUsage?: string, cpuUsage?: string,
memoryUsage?: string, memoryUsage?: string,
+6 -6
View File
@@ -23,8 +23,8 @@ export interface ResourceMetrics {
export interface K8sNodeMetadata { export interface K8sNodeMetadata {
node: K8sNodeSummary; node: K8sNodeSummary;
podCount: number; podCount: number;
serverPods: string[]; // Pod names of servers serverPods: string[];
proxyPods: string[]; // Pod names of proxies proxyPods: string[];
health: HealthStatus; health: HealthStatus;
metrics?: ResourceMetrics; metrics?: ResourceMetrics;
} }
@@ -35,8 +35,8 @@ export interface ServerMetadata {
readyPods: number; readyPods: number;
pods: PodInfo[]; pods: PodInfo[];
health: HealthStatus; health: HealthStatus;
connectedProxies: string[]; // IDs of reverse proxies pointing to this server connectedProxies: string[];
k8sNodes: string[]; // Names of K8s nodes running this server's pods k8sNodes: string[];
connectionInfo?: ConnectionInfo | null; connectionInfo?: ConnectionInfo | null;
metrics?: ResourceMetrics; metrics?: ResourceMetrics;
} }
@@ -47,8 +47,8 @@ export interface ProxyMetadata {
readyPods: number; readyPods: number;
pods: PodInfo[]; pods: PodInfo[];
health: HealthStatus; health: HealthStatus;
connectedServers: string[]; // IDs of servers this proxy routes to connectedServers: string[];
k8sNodes: string[]; // Names of K8s nodes running this proxy's pods k8sNodes: string[];
connectionInfo?: ConnectionInfo | null; connectionInfo?: ConnectionInfo | null;
metrics?: ResourceMetrics; metrics?: ResourceMetrics;
} }