mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 18:59:25 +00:00
🎨 style: refresh topology visualization
This commit is contained in:
@@ -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)]"
|
||||||
|
/>
|
||||||
|
) : !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} />
|
<TopologyCanvas graph={graph} />
|
||||||
</div>
|
)}
|
||||||
|
</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 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>
|
</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 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">
|
<div className="flex items-center gap-1 text-muted-foreground mb-1">
|
||||||
<Server className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
<span className="text-xs">Servers</span>
|
<span className="text-xs">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold">{metadata.totalServers}</span>
|
<span className="text-lg font-bold">{value}</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>
|
</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" />
|
|
||||||
<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">
|
|
||||||
<Label
|
<Label
|
||||||
htmlFor="show-k8s-nodes"
|
htmlFor={id}
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
className={Icon ? "flex items-center gap-2 cursor-pointer" : "cursor-pointer"}
|
||||||
>
|
>
|
||||||
<Box className="h-4 w-4" />
|
{Icon && <Icon className="h-4 w-4" />}
|
||||||
<span>K8s Nodes</span>
|
<span>{label}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="show-k8s-nodes"
|
id={id}
|
||||||
checked={filters.showK8sNodes}
|
checked={filters[filter]}
|
||||||
onCheckedChange={() => toggleFilter("showK8sNodes")}
|
onCheckedChange={() => toggleFilter(filter)}
|
||||||
/>
|
|
||||||
</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>
|
</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(
|
||||||
|
|||||||
@@ -1,55 +1,23 @@
|
|||||||
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 (
|
return (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<TopologyNodeCard
|
||||||
Unknown
|
selected={selected}
|
||||||
</Badge>
|
icon={<Box className="h-4 w-4 text-blue-600" />}
|
||||||
);
|
iconClassName="bg-blue-500/10"
|
||||||
}
|
title={node.name || "Unknown"}
|
||||||
};
|
description="Kubernetes Node"
|
||||||
|
health={health}
|
||||||
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="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">
|
<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.status}
|
||||||
@@ -64,79 +32,69 @@ export function K8sNodeComponent({ data, selected }: NodeProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs bg-muted/50 rounded px-2 py-1.5">
|
<CompactRow
|
||||||
<span className="text-muted-foreground">Total Pods</span>
|
label="Total Pods"
|
||||||
<span className="font-semibold text-blue-600">{podCount}</span>
|
className="text-xs bg-muted/50 rounded px-2 py-1.5"
|
||||||
</div>
|
valueClassName="font-semibold text-blue-600"
|
||||||
|
>
|
||||||
|
{podCount}
|
||||||
|
</CompactRow>
|
||||||
|
|
||||||
<div className="space-y-1 text-[11px]">
|
<div className="space-y-1 text-[11px]">
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="Server Pods" icon={<ServerIcon className="h-3 w-3" />}>
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
{serverPods.length}
|
||||||
<ServerIcon className="h-3 w-3" /> Server Pods
|
</CompactRow>
|
||||||
</span>
|
<CompactRow label="Proxy Pods" icon={<Network className="h-3 w-3" />}>
|
||||||
<span className="font-medium">{serverPods.length}</span>
|
{proxyPods.length}
|
||||||
</div>
|
</CompactRow>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{metrics && (metrics.cpuUsage || metrics.memoryUsage) && (
|
{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">
|
||||||
{metrics.cpuUsage && (
|
{metrics.cpuUsage && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
label="CPU"
|
||||||
<Cpu className="h-3 w-3" /> CPU
|
icon={<Cpu className="h-3 w-3" />}
|
||||||
</span>
|
valueClassName="font-semibold text-xs text-blue-600"
|
||||||
<span className="font-semibold text-xs text-blue-600">{metrics.cpuUsage}</span>
|
>
|
||||||
</div>
|
{metrics.cpuUsage}
|
||||||
|
</CompactRow>
|
||||||
)}
|
)}
|
||||||
{metrics.memoryUsage && (
|
{metrics.memoryUsage && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
label="Memory"
|
||||||
<HardDrive className="h-3 w-3" /> Memory
|
icon={<HardDrive className="h-3 w-3" />}
|
||||||
</span>
|
valueClassName="font-semibold text-xs text-blue-600"
|
||||||
<span className="font-semibold text-xs text-blue-600">{metrics.memoryUsage}</span>
|
>
|
||||||
</div>
|
{metrics.memoryUsage}
|
||||||
|
</CompactRow>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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">
|
<CompactRow label="Age">{node.age}</CompactRow>
|
||||||
<span className="text-muted-foreground">Age</span>
|
|
||||||
<span className="font-medium">{node.age}</span>
|
|
||||||
</div>
|
|
||||||
{node.hostname && (
|
{node.hostname && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="Hostname" valueClassName="font-mono text-[10px] truncate">
|
||||||
<span className="text-muted-foreground">Hostname</span>
|
{node.hostname}
|
||||||
<span className="font-medium font-mono text-[10px] truncate">{node.hostname}</span>
|
</CompactRow>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(node.internalIP || node.externalIP) && (
|
{(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">
|
||||||
{node.internalIP && (
|
{node.internalIP && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="Internal IP" valueClassName="font-mono text-[10px]">
|
||||||
<span className="text-muted-foreground">Internal IP</span>
|
{node.internalIP}
|
||||||
<span className="font-mono text-[10px] font-medium">{node.internalIP}</span>
|
</CompactRow>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{node.externalIP && (
|
{node.externalIP && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="External IP" valueClassName="font-mono text-[10px]">
|
||||||
<span className="text-muted-foreground">External IP</span>
|
{node.externalIP}
|
||||||
<span className="font-mono text-[10px] font-medium">{node.externalIP}</span>
|
</CompactRow>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TopologyNodeCard>
|
||||||
|
|
||||||
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,26 @@
|
|||||||
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="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">
|
<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}
|
{proxy.type}
|
||||||
@@ -80,52 +32,29 @@ export function ProxyNode({ data, selected }: NodeProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs bg-muted/50 rounded px-2 py-1.5">
|
<CompactRow
|
||||||
<span className="text-muted-foreground">Pods</span>
|
label="Pods"
|
||||||
<span
|
className="text-xs bg-muted/50 rounded px-2 py-1.5"
|
||||||
className={cn(
|
valueClassName={cn(
|
||||||
"font-semibold",
|
"font-semibold",
|
||||||
readyPods === podCount ? "text-green-600" : "text-yellow-600"
|
readyPods === podCount ? "text-green-600" : "text-yellow-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{readyPods}/{podCount}
|
{readyPods}/{podCount}
|
||||||
</span>
|
</CompactRow>
|
||||||
</div>
|
|
||||||
|
|
||||||
{proxy.node_port && (
|
{proxy.node_port && (
|
||||||
<div className="space-y-1 text-[11px]">
|
<div className="space-y-1 text-[11px]">
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="NodePort" icon={<Network className="h-3 w-3" />}>
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
{proxy.node_port}
|
||||||
<Network className="h-3 w-3" /> NodePort
|
</CompactRow>
|
||||||
</span>
|
|
||||||
<span className="font-medium">{proxy.node_port}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{connectionInfo?.connectionString && (
|
{connectionInfo?.connectionString && (
|
||||||
<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">
|
<CompactRow label="Address" />
|
||||||
<span className="text-muted-foreground">Address</span>
|
<CopyableCode value={connectionInfo.connectionString} />
|
||||||
</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 && (
|
{connectionInfo.note && (
|
||||||
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
|
<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="space-y-1 text-[11px] pt-1 border-t">
|
||||||
<div className="flex items-center justify-between">
|
<MetricRow
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
label="Memory"
|
||||||
<HardDrive className="h-3 w-3" /> Memory
|
icon={<HardDrive className="h-3 w-3" />}
|
||||||
</span>
|
usage={metrics?.memoryUsage}
|
||||||
<span className="font-medium text-xs">
|
limit={`${proxy.memory}MB`}
|
||||||
{metrics?.memoryUsage && (
|
/>
|
||||||
<span className="text-blue-600">{metrics.memoryUsage} / </span>
|
<MetricRow
|
||||||
)}
|
label="CPU"
|
||||||
<span className="text-muted-foreground">{proxy.memory}MB</span>
|
icon={<Cpu className="h-3 w-3" />}
|
||||||
</span>
|
usage={metrics?.cpuUsage}
|
||||||
</div>
|
limit={`${proxy.cpu_request || "N/A"}/${proxy.cpu_limit || "N/A"}`}
|
||||||
<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>
|
</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="space-y-1 text-[11px] pt-1 border-t">
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow
|
||||||
<span className="text-muted-foreground">Restarts</span>
|
label="Restarts"
|
||||||
<span
|
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
|
||||||
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)}
|
{restartCount}
|
||||||
</span>
|
</CompactRow>
|
||||||
</div>
|
{pods[0]?.age && <CompactRow label="Age">{pods[0].age}</CompactRow>}
|
||||||
{metadata.pods[0]?.age && (
|
{pods[0]?.ip && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="Pod IP" valueClassName="font-mono text-[10px]">
|
||||||
<span className="text-muted-foreground">Age</span>
|
{pods[0].ip}
|
||||||
<span className="font-medium">{metadata.pods[0].age}</span>
|
</CompactRow>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{metadata.pods[0]?.ip && (
|
<CompactRow label="Routing To" valueClassName="font-semibold text-blue-600">
|
||||||
<div className="flex items-center justify-between">
|
{connectedServers.length} servers
|
||||||
<span className="text-muted-foreground">Pod IP</span>
|
</CompactRow>
|
||||||
<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 && (
|
{k8sNodes.length > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="K8s Node" icon={<Box className="h-3 w-3" />}>
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
{k8sNodes[0]}
|
||||||
<Box className="h-3 w-3" /> K8s Node
|
</CompactRow>
|
||||||
</span>
|
|
||||||
<span className="font-medium">{k8sNodes[0]}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TopologyNodeCard>
|
||||||
|
|
||||||
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,26 @@
|
|||||||
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="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">
|
<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}
|
{server.jar_type}
|
||||||
@@ -87,97 +38,54 @@ export function ServerNode({ data, selected }: NodeProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs bg-muted/50 rounded px-2 py-1.5">
|
<CompactRow
|
||||||
<span className="text-muted-foreground">Pods</span>
|
label="Pods"
|
||||||
<span
|
className="text-xs bg-muted/50 rounded px-2 py-1.5"
|
||||||
className={cn(
|
valueClassName={cn(
|
||||||
"font-semibold",
|
"font-semibold",
|
||||||
readyPods === podCount ? "text-green-600" : "text-yellow-600"
|
readyPods === podCount ? "text-green-600" : "text-yellow-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{readyPods}/{podCount}
|
{readyPods}/{podCount}
|
||||||
</span>
|
</CompactRow>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-[11px]">
|
<div className="space-y-1 text-[11px]">
|
||||||
<div className="flex items-center justify-between">
|
<MetricRow
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
label="Memory"
|
||||||
<HardDrive className="h-3 w-3" /> Memory
|
icon={<HardDrive className="h-3 w-3" />}
|
||||||
</span>
|
usage={metrics?.memoryUsage}
|
||||||
<span className="font-medium text-xs">
|
limit={`${server.memory_request}/${server.memory}MB`}
|
||||||
{metrics?.memoryUsage && (
|
/>
|
||||||
<span className="text-blue-600">{metrics.memoryUsage} / </span>
|
<MetricRow
|
||||||
)}
|
label="CPU"
|
||||||
<span className="text-muted-foreground">
|
icon={<Cpu className="h-3 w-3" />}
|
||||||
{server.memory_request}/{server.memory}MB
|
usage={metrics?.cpuUsage}
|
||||||
</span>
|
limit={`${server.cpu_request || "N/A"}/${server.cpu_limit || "N/A"}`}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{connectionInfo?.connectionString && (
|
{connectionInfo?.connectionString && (
|
||||||
<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 gap-1">
|
<CopyableCode value={connectionInfo.connectionString} />
|
||||||
<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 && (
|
{connectionInfo.note && (
|
||||||
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
|
<p className="text-[10px] text-muted-foreground italic">{connectionInfo.note}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="space-y-1 text-[11px] pt-1 border-t">
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow
|
||||||
<span className="text-muted-foreground">Restarts</span>
|
label="Restarts"
|
||||||
<span
|
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
|
||||||
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)}
|
{restartCount}
|
||||||
</span>
|
</CompactRow>
|
||||||
</div>
|
{pods[0]?.age && <CompactRow label="Age">{pods[0].age}</CompactRow>}
|
||||||
{metadata.pods[0]?.age && (
|
{pods[0]?.ip && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="Pod IP" valueClassName="font-mono text-[10px]">
|
||||||
<span className="text-muted-foreground">Age</span>
|
{pods[0].ip}
|
||||||
<span className="font-medium">{metadata.pods[0].age}</span>
|
</CompactRow>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -185,26 +93,17 @@ export function ServerNode({ data, selected }: NodeProps) {
|
|||||||
{(k8sNodes.length > 0 || connectedProxies.length > 0) && (
|
{(k8sNodes.length > 0 || connectedProxies.length > 0) && (
|
||||||
<div className="space-y-1 text-[11px] pt-1 border-t">
|
<div className="space-y-1 text-[11px] pt-1 border-t">
|
||||||
{connectedProxies.length > 0 && (
|
{connectedProxies.length > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="Exposed By" valueClassName="font-semibold text-blue-600">
|
||||||
<span className="text-muted-foreground">Exposed By</span>
|
|
||||||
<span className="font-semibold text-blue-600">
|
|
||||||
{connectedProxies.length} proxies
|
{connectedProxies.length} proxies
|
||||||
</span>
|
</CompactRow>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{k8sNodes.length > 0 && (
|
{k8sNodes.length > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<CompactRow label="K8s Node" icon={<Box className="h-3 w-3" />}>
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
{k8sNodes[0]}
|
||||||
<Box className="h-3 w-3" /> K8s Node
|
</CompactRow>
|
||||||
</span>
|
|
||||||
<span className="font-medium">{k8sNodes[0]}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TopologyNodeCard>
|
||||||
|
|
||||||
<Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user