mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-03-30 23:29:45 +00:00
✨ feat: topology, and improves handling
This commit is contained in:
@@ -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 }>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]) {
|
||||
72
apps/web/lib/k8s-metrics.ts
Normal file
72
apps/web/lib/k8s-metrics.ts
Normal 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;
|
||||
}
|
||||
100
apps/web/lib/topology-types.ts
Normal file
100
apps/web/lib/topology-types.ts
Normal 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;
|
||||
}
|
||||
365
apps/web/lib/topology-utils.ts
Normal file
365
apps/web/lib/topology-utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user