feat: topology, and improves handling

This commit is contained in:
2026-02-17 18:12:02 +07:00
parent e8dbefde43
commit d14f043e7c
145 changed files with 4213 additions and 2861 deletions

View File

@@ -1,8 +1,10 @@
import { api } from "@/lib/api";
import { api } from "@/lib/api-client";
type ReverseProxyApi = {
get: () => Promise<{ data?: unknown }>;
(params: { id: string }): {
(params: {
id: string;
}): {
delete: () => Promise<{ data?: unknown; error?: unknown }>;
"connection-info": { get: () => Promise<{ data?: unknown }> };
};
@@ -10,7 +12,10 @@ type ReverseProxyApi = {
type UserSuspensionApi = {
suspension: {
patch: (body: { isSuspended: boolean; suspendedUntil: string | null }) => Promise<{ error?: unknown }>;
patch: (body: {
isSuspended: boolean;
suspendedUntil: string | null;
}) => Promise<{ error?: unknown }>;
};
};

View File

@@ -1,5 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { adminClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000",

View File

@@ -1,4 +1,4 @@
import { clsx, type ClassValue } from "clsx";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {

View File

@@ -0,0 +1,72 @@
export interface ResourceMetrics {
cpuUsage?: string;
cpuUsagePercent?: number;
memoryUsage?: string;
memoryUsagePercent?: number;
}
/** Convert CPU nanoseconds (e.g. "123456789n") to millicores (e.g. "123m") */
export function parseCpuUsage(cpuNano: string): string | undefined {
const usageNano = Number.parseInt(cpuNano.replace("n", ""), 10);
if (Number.isNaN(usageNano)) return undefined;
const usageMilli = Math.round(usageNano / 1_000_000);
return `${usageMilli}m`;
}
export function calculateCpuPercent(cpuNano: string, capacityNano: string): number | undefined {
const usageNano = Number.parseInt(cpuNano.replace("n", ""), 10);
const capNano = Number.parseInt(capacityNano.replace("n", ""), 10);
if (Number.isNaN(usageNano) || Number.isNaN(capNano) || capNano === 0) {
return undefined;
}
return Math.round((usageNano / capNano) * 100);
}
/** Convert memory kibibytes (e.g. "1024Ki") to mebibytes (e.g. "1Mi") */
export function parseMemoryUsage(memoryKi: string): string | undefined {
const usageKi = Number.parseInt(memoryKi.replace("Ki", ""), 10);
if (Number.isNaN(usageKi)) return undefined;
const usageMi = Math.round(usageKi / 1024);
return `${usageMi}Mi`;
}
export function calculateMemoryPercent(memoryKi: string, capacityKi: string): number | undefined {
const usageKi = Number.parseInt(memoryKi.replace("Ki", ""), 10);
const capKi = Number.parseInt(capacityKi.replace("Ki", ""), 10);
if (Number.isNaN(usageKi) || Number.isNaN(capKi) || capKi === 0) {
return undefined;
}
return Math.round((usageKi / capKi) * 100);
}
/** Parse raw K8s metrics into a standardized format with optional percentages */
export function parseK8sMetrics(
cpuUsage?: string,
memoryUsage?: string,
cpuCapacity?: string,
memoryCapacity?: string
): ResourceMetrics {
const result: ResourceMetrics = {};
if (cpuUsage) {
result.cpuUsage = parseCpuUsage(cpuUsage);
if (cpuCapacity) {
result.cpuUsagePercent = calculateCpuPercent(cpuUsage, cpuCapacity);
}
}
if (memoryUsage) {
result.memoryUsage = parseMemoryUsage(memoryUsage);
if (memoryCapacity) {
result.memoryUsagePercent = calculateMemoryPercent(memoryUsage, memoryCapacity);
}
}
return result;
}

View File

@@ -0,0 +1,100 @@
import type {
ConnectionInfo,
K8sNodeSummary,
NormalServer,
PodInfo,
ReverseProxyServer,
} from "@minikura/api";
import type { Edge, Node } from "@xyflow/react";
export type HealthStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
export type NodeType = "server" | "proxy" | "k8s-node";
export type EdgeType = "proxy-to-server" | "pod-to-node";
export interface ResourceMetrics {
cpuUsage?: string;
memoryUsage?: string;
cpuUsagePercent?: number;
memoryUsagePercent?: number;
}
export interface K8sNodeMetadata {
node: K8sNodeSummary;
podCount: number;
serverPods: string[]; // Pod names of servers
proxyPods: string[]; // Pod names of proxies
health: HealthStatus;
metrics?: ResourceMetrics;
}
export interface ServerMetadata {
server: NormalServer;
podCount: number;
readyPods: number;
pods: PodInfo[];
health: HealthStatus;
connectedProxies: string[]; // IDs of reverse proxies pointing to this server
k8sNodes: string[]; // Names of K8s nodes running this server's pods
connectionInfo?: ConnectionInfo | null;
metrics?: ResourceMetrics;
}
export interface ProxyMetadata {
proxy: ReverseProxyServer;
podCount: number;
readyPods: number;
pods: PodInfo[];
health: HealthStatus;
connectedServers: string[]; // IDs of servers this proxy routes to
k8sNodes: string[]; // Names of K8s nodes running this proxy's pods
connectionInfo?: ConnectionInfo | null;
metrics?: ResourceMetrics;
}
export type NodeMetadata = ServerMetadata | ProxyMetadata | K8sNodeMetadata;
export interface TopologyNodeData extends Record<string, unknown> {
id: string;
type: NodeType;
label: string;
status: HealthStatus;
metadata: NodeMetadata;
}
export interface TopologyEdgeData extends Record<string, unknown> {
id: string;
source: string;
target: string;
type: EdgeType;
label?: string;
animated?: boolean;
}
export type TopologyNode = Node<TopologyNodeData, string>;
export type TopologyEdge = Edge<TopologyEdgeData, string>;
export interface TopologyGraph {
nodes: TopologyNode[];
edges: TopologyEdge[];
metadata: {
totalServers: number;
totalProxies: number;
totalK8sNodes: number;
totalConnections: number;
healthySystems: number;
degradedSystems: number;
unhealthySystems: number;
};
}
export interface TopologyFilters {
showServers: boolean;
showProxies: boolean;
showK8sNodes: boolean;
showConnections: boolean;
searchQuery: string;
filterByStatus?: HealthStatus;
filterByType?: string;
}

View File

@@ -0,0 +1,365 @@
import type {
ConnectionInfo,
K8sNodeSummary,
NormalServer,
PodInfo,
ReverseProxyServer,
} from "@minikura/api";
import { parseK8sMetrics } from "./k8s-metrics";
import type {
HealthStatus,
K8sNodeMetadata,
ProxyMetadata,
ResourceMetrics,
ServerMetadata,
TopologyEdge,
TopologyGraph,
TopologyNode,
} from "./topology-types";
function parsePodReady(ready: string): { ready: number; total: number } {
const [readyStr, totalStr] = ready.split("/");
return {
ready: parseInt(readyStr, 10) || 0,
total: parseInt(totalStr, 10) || 0,
};
}
function calculateHealthStatus(readyPods: number, totalPods: number): HealthStatus {
if (totalPods === 0) return "unknown";
if (readyPods === totalPods) return "healthy";
if (readyPods > 0) return "degraded";
return "unhealthy";
}
function getPodMetrics(podName: string, podMetrics: any): ResourceMetrics | undefined {
if (!podMetrics?.items) return undefined;
const metric = podMetrics.items.find((m: any) => m.metadata?.name === podName);
if (!metric?.containers?.[0]?.usage) return undefined;
const usage = metric.containers[0].usage;
return parseK8sMetrics(usage.cpu, usage.memory);
}
function getNodeMetrics(nodeName: string, nodeMetrics: any): ResourceMetrics | undefined {
if (!nodeMetrics?.items) return undefined;
const metric = nodeMetrics.items.find((m: any) => m.metadata?.name === nodeName);
if (!metric?.usage) return undefined;
return parseK8sMetrics(metric.usage.cpu, metric.usage.memory);
}
interface BuildEnhancedGraphInput {
servers: NormalServer[];
proxies: ReverseProxyServer[];
serverPods: Map<string, PodInfo[]>;
proxyPods: Map<string, PodInfo[]>;
k8sNodes: K8sNodeSummary[];
serverConnections?: Map<string, ConnectionInfo | null>;
proxyConnections?: Map<string, ConnectionInfo | null>;
podMetrics?: any;
nodeMetrics?: any;
}
function parseProxyServerConnections(
_proxy: ReverseProxyServer,
allServers: NormalServer[]
): string[] {
return allServers.map((s) => s.id);
}
export function buildTopologyGraph(input: BuildEnhancedGraphInput): TopologyGraph {
const {
servers,
proxies,
serverPods,
proxyPods,
k8sNodes,
serverConnections,
proxyConnections,
podMetrics,
nodeMetrics,
} = input;
const nodes: TopologyNode[] = [];
const edges: TopologyEdge[] = [];
let healthySystems = 0;
let degradedSystems = 0;
let unhealthySystems = 0;
const serverToProxies = new Map<string, string[]>();
const proxyToServers = new Map<string, string[]>();
const serverToK8sNodes = new Map<string, string[]>();
const proxyToK8sNodes = new Map<string, string[]>();
const k8sNodeToPods = new Map<string, { servers: string[]; proxies: string[] }>();
for (const node of k8sNodes) {
if (node.name) {
k8sNodeToPods.set(node.name, { servers: [], proxies: [] });
}
}
for (const proxy of proxies) {
const connectedServerIds = parseProxyServerConnections(proxy, servers);
proxyToServers.set(proxy.id, connectedServerIds);
for (const serverId of connectedServerIds) {
const existing = serverToProxies.get(serverId) || [];
existing.push(proxy.id);
serverToProxies.set(serverId, existing);
}
}
for (const [serverId, pods] of serverPods.entries()) {
const nodeNames = new Set<string>();
for (const pod of pods) {
if (pod.nodeName) {
nodeNames.add(pod.nodeName);
const nodeData = k8sNodeToPods.get(pod.nodeName);
if (nodeData) {
nodeData.servers.push(pod.name);
}
}
}
serverToK8sNodes.set(serverId, Array.from(nodeNames));
}
for (const [proxyId, pods] of proxyPods.entries()) {
const nodeNames = new Set<string>();
for (const pod of pods) {
if (pod.nodeName) {
nodeNames.add(pod.nodeName);
const nodeData = k8sNodeToPods.get(pod.nodeName);
if (nodeData) {
nodeData.proxies.push(pod.name);
}
}
}
proxyToK8sNodes.set(proxyId, Array.from(nodeNames));
}
for (const server of servers) {
const pods = serverPods.get(server.id) || [];
const readyPods = pods.filter((p) => {
const { ready, total } = parsePodReady(p.ready);
return ready === total && p.status.toLowerCase() === "running";
}).length;
const health = calculateHealthStatus(readyPods, pods.length);
if (health === "healthy") healthySystems++;
else if (health === "degraded") degradedSystems++;
else if (health === "unhealthy") unhealthySystems++;
const podMetric = pods[0] ? getPodMetrics(pods[0].name, podMetrics) : undefined;
const metadata: ServerMetadata = {
server,
podCount: pods.length,
readyPods,
pods,
health,
connectedProxies: serverToProxies.get(server.id) || [],
k8sNodes: serverToK8sNodes.get(server.id) || [],
connectionInfo: serverConnections?.get(server.id) || null,
metrics: podMetric,
};
nodes.push({
id: `server-${server.id}`,
type: "server",
position: { x: 0, y: 0 },
data: {
id: server.id,
type: "server",
label: server.id,
status: health,
metadata,
},
});
}
for (const proxy of proxies) {
const pods = proxyPods.get(proxy.id) || [];
const readyPods = pods.filter((p) => {
const { ready, total } = parsePodReady(p.ready);
return ready === total && p.status.toLowerCase() === "running";
}).length;
const health = calculateHealthStatus(readyPods, pods.length);
if (health === "healthy") healthySystems++;
else if (health === "degraded") degradedSystems++;
else if (health === "unhealthy") unhealthySystems++;
const connectedServers = proxyToServers.get(proxy.id) || [];
const proxyPodMetric = pods[0] ? getPodMetrics(pods[0].name, podMetrics) : undefined;
const metadata: ProxyMetadata = {
proxy,
podCount: pods.length,
readyPods,
pods,
health,
connectedServers,
k8sNodes: proxyToK8sNodes.get(proxy.id) || [],
connectionInfo: proxyConnections?.get(proxy.id) || null,
metrics: proxyPodMetric,
};
nodes.push({
id: `proxy-${proxy.id}`,
type: "proxy",
position: { x: 0, y: 0 },
data: {
id: proxy.id,
type: "proxy",
label: proxy.id,
status: health,
metadata,
},
});
for (const serverId of connectedServers) {
edges.push({
id: `edge-proxy-${proxy.id}-server-${serverId}`,
source: `proxy-${proxy.id}`,
target: `server-${serverId}`,
type: "smoothstep",
animated: false,
data: {
id: `edge-proxy-${proxy.id}-server-${serverId}`,
source: `proxy-${proxy.id}`,
target: `server-${serverId}`,
type: "proxy-to-server",
label: "routes to",
animated: false,
},
});
}
}
for (const k8sNode of k8sNodes) {
if (!k8sNode.name) continue;
const nodePods = k8sNodeToPods.get(k8sNode.name);
const podCount = (nodePods?.servers.length || 0) + (nodePods?.proxies.length || 0);
let nodeHealth: HealthStatus = "healthy";
if (k8sNode.status.toLowerCase() !== "ready") {
nodeHealth = "unhealthy";
}
const k8sNodeMetric = getNodeMetrics(k8sNode.name, nodeMetrics);
const metadata: K8sNodeMetadata = {
node: k8sNode,
podCount,
serverPods: nodePods?.servers || [],
proxyPods: nodePods?.proxies || [],
health: nodeHealth,
metrics: k8sNodeMetric,
};
nodes.push({
id: `k8s-node-${k8sNode.name}`,
type: "k8s-node",
position: { x: 0, y: 0 },
data: {
id: k8sNode.name,
type: "k8s-node",
label: k8sNode.name,
status: nodeHealth,
metadata,
},
});
for (const [serverId, k8sNodeNames] of serverToK8sNodes.entries()) {
if (k8sNodeNames.includes(k8sNode.name)) {
edges.push({
id: `edge-k8s-${k8sNode.name}-server-${serverId}`,
source: `k8s-node-${k8sNode.name}`,
target: `server-${serverId}`,
type: "smoothstep",
animated: false,
data: {
id: `edge-k8s-${k8sNode.name}-server-${serverId}`,
source: `k8s-node-${k8sNode.name}`,
target: `server-${serverId}`,
type: "pod-to-node",
label: "hosts",
animated: false,
},
});
}
}
for (const [proxyId, k8sNodeNames] of proxyToK8sNodes.entries()) {
if (k8sNodeNames.includes(k8sNode.name)) {
edges.push({
id: `edge-k8s-${k8sNode.name}-proxy-${proxyId}`,
source: `k8s-node-${k8sNode.name}`,
target: `proxy-${proxyId}`,
type: "smoothstep",
animated: false,
data: {
id: `edge-k8s-${k8sNode.name}-proxy-${proxyId}`,
source: `k8s-node-${k8sNode.name}`,
target: `proxy-${proxyId}`,
type: "pod-to-node",
label: "hosts",
animated: false,
},
});
}
}
}
return {
nodes,
edges,
metadata: {
totalServers: servers.length,
totalProxies: proxies.length,
totalK8sNodes: k8sNodes.length,
totalConnections: edges.length,
healthySystems,
degradedSystems,
unhealthySystems,
},
};
}
export function filterNodes(
nodes: TopologyNode[],
filters: {
showServers: boolean;
showProxies: boolean;
showK8sNodes: boolean;
searchQuery: string;
filterByStatus?: HealthStatus;
}
): TopologyNode[] {
return nodes.filter((node) => {
const { type, label, status } = node.data;
if (type === "server" && !filters.showServers) return false;
if (type === "proxy" && !filters.showProxies) return false;
if (type === "k8s-node" && !filters.showK8sNodes) return false;
if (filters.searchQuery && !label.toLowerCase().includes(filters.searchQuery.toLowerCase())) {
return false;
}
if (filters.filterByStatus && status !== filters.filterByStatus) {
return false;
}
return true;
});
}
export function filterEdges(edges: TopologyEdge[], visibleNodeIds: Set<string>): TopologyEdge[] {
return edges.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
}