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,6 +1,5 @@
"use client";
import { AlertCircle, CheckCircle2, XCircle } from "lucide-react";
import type {
CustomResourceSummary,
DeploymentInfo,
@@ -10,6 +9,7 @@ import type {
PodInfo,
StatefulSetInfo,
} from "@minikura/api";
import { AlertCircle, CheckCircle2, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -23,7 +23,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import { api } from "@/lib/api-client";
export default function K8sResourcesPage() {
const [status, setStatus] = useState<K8sStatus | null>(null);
@@ -64,50 +64,34 @@ export default function K8sResourcesPage() {
if (statusRes.status === "fulfilled" && statusRes.value.data) {
setStatus(statusRes.value.data as K8sStatus);
} else if (statusRes.status === "rejected") {
console.error("Failed to fetch status:", statusRes.reason);
}
if (podsRes.status === "fulfilled" && podsRes.value.data) {
setPods(podsRes.value.data as PodInfo[]);
} else if (podsRes.status === "rejected") {
console.error("Failed to fetch pods:", podsRes.reason);
}
if (deploymentsRes.status === "fulfilled" && deploymentsRes.value.data) {
setDeployments(deploymentsRes.value.data as DeploymentInfo[]);
} else if (deploymentsRes.status === "rejected") {
console.error("Failed to fetch deployments:", deploymentsRes.reason);
}
if (statefulSetsRes.status === "fulfilled" && statefulSetsRes.value.data) {
setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]);
} else if (statefulSetsRes.status === "rejected") {
console.error("Failed to fetch statefulsets:", statefulSetsRes.reason);
}
if (servicesRes.status === "fulfilled" && servicesRes.value.data) {
setServices(servicesRes.value.data as K8sServiceSummary[]);
} else if (servicesRes.status === "rejected") {
console.error("Failed to fetch services:", servicesRes.reason);
}
if (configMapsRes.status === "fulfilled" && configMapsRes.value.data) {
setConfigMaps(configMapsRes.value.data as K8sConfigMapSummary[]);
} else if (configMapsRes.status === "rejected") {
console.error("Failed to fetch configmaps:", configMapsRes.reason);
}
if (minecraftServersRes.status === "fulfilled" && minecraftServersRes.value.data) {
setMinecraftServers(minecraftServersRes.value.data as CustomResourceSummary[]);
} else if (minecraftServersRes.status === "rejected") {
console.error("Failed to fetch minecraft servers:", minecraftServersRes.reason);
}
if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) {
setReverseProxyServers(reverseProxyServersRes.value.data as CustomResourceSummary[]);
} else if (reverseProxyServersRes.status === "rejected") {
console.error("Failed to fetch reverse proxy servers:", reverseProxyServersRes.reason);
}
} catch (err: unknown) {
const errorMessage =

View File

@@ -6,7 +6,15 @@ import { useRouter } from "next/navigation";
import { ServerForm, type ServerFormData } from "@/components/server-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
import { api } from "@/lib/api-client";
const toDifficultyUppercase = (value: string): "PEACEFUL" | "EASY" | "NORMAL" | "HARD" => {
return value.toUpperCase() as "PEACEFUL" | "EASY" | "NORMAL" | "HARD";
};
const toModeUppercase = (value: string): "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR" => {
return value.toUpperCase() as "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR";
};
export default function CreateServerPage() {
const router = useRouter();
@@ -121,8 +129,8 @@ export default function CreateServerPage() {
jvm_opts: data.jvmOpts || undefined,
use_aikar_flags: data.useAikarFlags || undefined,
use_meowice_flags: data.useMeowiceFlags || undefined,
difficulty: data.difficulty,
game_mode: data.mode,
difficulty: toDifficultyUppercase(data.difficulty),
game_mode: toModeUppercase(data.mode),
max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined,
pvp: data.pvp,
online_mode: data.onlineMode,

View File

@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
import { ServerForm, type ServerFormData, type ServerType } from "@/components/server-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
import { api } from "@/lib/api-client";
import { getReverseProxyApi } from "@/lib/api-helpers";
export default function EditServerPage() {
@@ -48,8 +48,7 @@ export default function EditServerPage() {
setError("Server not found");
setLoading(false);
} catch (err) {
console.error("Failed to fetch server:", err);
} catch (_err) {
setError("Failed to load server data");
setLoading(false);
}
@@ -81,11 +80,12 @@ export default function EditServerPage() {
return "survival";
};
const toServerType = (value?: string | null): ServerType => {
if (value === "VANILLA" || value === "CUSTOM") {
return value;
}
return "PAPER";
const toDifficultyUppercase = (value: string): "PEACEFUL" | "EASY" | "NORMAL" | "HARD" => {
return value.toUpperCase() as "PEACEFUL" | "EASY" | "NORMAL" | "HARD";
};
const toModeUppercase = (value: string): "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR" => {
return value.toUpperCase() as "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR";
};
const parseEnvVariables = (envVars?: Array<{ key: string; value: string }>) => {
@@ -205,8 +205,8 @@ export default function EditServerPage() {
jvm_opts: data.jvmOpts || undefined,
use_aikar_flags: data.useAikarFlags || undefined,
use_meowice_flags: data.useMeowiceFlags || undefined,
difficulty: data.difficulty,
game_mode: data.mode,
difficulty: toDifficultyUppercase(data.difficulty),
game_mode: toModeUppercase(data.mode),
max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined,
pvp: data.pvp,
online_mode: data.onlineMode,

View File

@@ -1,5 +1,6 @@
"use client";
import type { ConnectionInfo, NormalServer, PodInfo, ReverseProxyServer } from "@minikura/api";
import {
AlertCircle,
Check,
@@ -25,15 +26,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -42,14 +34,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { ConnectionInfo, NormalServer, PodInfo, ReverseProxyServer } from "@minikura/api";
import { api } from "@/lib/api-client";
import { getReverseProxyApi } from "@/lib/api-helpers";
import { api } from "@/lib/api";
// Server status component
function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) {
const [pods, setPods] = useState<PodInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -59,14 +47,13 @@ function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal"
try {
const endpoint =
type === "normal"
? api.api.k8s["servers"]({ serverId }).pods.get
? api.api.k8s.servers({ serverId }).pods.get
: api.api.k8s["reverse-proxy"]({ serverId }).pods.get;
const res = await endpoint();
if (res.data) {
setPods(res.data as PodInfo[]);
}
} catch (error) {
console.error("Failed to fetch pods:", error);
} catch (_error) {
} finally {
setLoading(false);
}
@@ -93,9 +80,7 @@ function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal"
}
const allRunning = pods.every((pod) => pod.status === "Running");
const readyCount = pods.filter(
(pod) => pod.ready === "1/1" || pod.ready === "1/1" || pod.ready === "1/1"
).length;
const readyCount = pods.filter((pod) => pod.ready === "1/1").length;
return (
<div className="flex items-center gap-2">
@@ -111,7 +96,6 @@ function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal"
);
}
// Connection info component
function ConnectionInfoCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) {
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo | null>(null);
const [loading, setLoading] = useState(true);
@@ -129,8 +113,7 @@ function ConnectionInfoCell({ serverId, type }: { serverId: string; type: "norma
if (res.data) {
setConnectionInfo(res.data as ConnectionInfo);
}
} catch (error) {
console.error("Failed to fetch connection info:", error);
} catch (_error) {
} finally {
setLoading(false);
}
@@ -213,8 +196,7 @@ export default function ServersPage() {
if (proxyRes.data) {
setReverseProxies(proxyRes.data as unknown as ReverseProxyServer[]);
}
} catch (error) {
console.error("Failed to fetch servers:", error);
} catch (_error) {
} finally {
setLoading(false);
}
@@ -236,9 +218,7 @@ export default function ServersPage() {
}
await fetchServers();
setDeleteTarget(null);
} catch (error) {
console.error("Failed to delete server:", error);
}
} catch (_error) {}
};
if (loading) {
@@ -270,7 +250,6 @@ export default function ServersPage() {
</Button>
</div>
{/* Normal Servers */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
@@ -360,7 +339,6 @@ export default function ServersPage() {
</CardContent>
</Card>
{/* Reverse Proxy Servers */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
@@ -448,7 +426,6 @@ export default function ServersPage() {
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>

View File

@@ -0,0 +1,89 @@
"use client";
import { Network, RefreshCw } from "lucide-react";
import { TopologyCanvas } from "@/components/topology/topology-canvas";
import { useTopologyData } from "@/hooks/use-topology-data";
export default function TopologyPage() {
const { graph, loading, error } = useTopologyData();
if (loading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
</div>
<div className="flex items-center justify-center h-[calc(100vh-250px)]">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading topology...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
</div>
<div className="flex items-center justify-center h-[calc(100vh-250px)] border-2 border-dashed rounded-lg">
<div className="flex flex-col items-center gap-2 p-6 text-center">
<p className="text-destructive font-semibold">Error loading topology</p>
<p className="text-muted-foreground text-sm">{error}</p>
<p className="text-muted-foreground text-xs mt-2">Auto-retrying...</p>
</div>
</div>
</div>
);
}
if (!graph || graph.nodes.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
</div>
<div className="flex items-center justify-center h-[calc(100vh-250px)] border-2 border-dashed rounded-lg">
<div className="flex flex-col items-center gap-2 p-6 text-center">
<p className="text-muted-foreground">No servers or infrastructure found</p>
<p className="text-sm text-muted-foreground">
Create a server to see it appear in the topology
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Network className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Network Topology</h1>
<p className="text-muted-foreground mt-1">
Real-time server infrastructure, proxy connections, and Kubernetes nodes
</p>
</div>
</div>
</div>
<TopologyCanvas graph={graph} />
</div>
);
}

View File

@@ -2,7 +2,6 @@
import { Ban, CheckCircle, Edit, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { getUserApi } from "@/lib/api-helpers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -31,7 +30,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/lib/api";
import { api } from "@/lib/api-client";
import { getUserApi } from "@/lib/api-helpers";
import { useSession } from "@/lib/auth-client";
type User = {
@@ -59,8 +59,7 @@ export default function UsersPage() {
if (data && typeof data === "object" && "users" in data) {
setUsers(data.users as User[]);
}
} catch (error) {
console.error("Failed to fetch users:", error);
} catch (_error) {
} finally {
setLoading(false);
}
@@ -88,9 +87,7 @@ export default function UsersPage() {
await fetchUsers();
setEditingUser(null);
}
} catch (error) {
console.error("Failed to update user:", error);
}
} catch (_error) {}
};
const handleSuspend = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -110,9 +107,7 @@ export default function UsersPage() {
await fetchUsers();
setSuspendingUser(null);
}
} catch (error) {
console.error("Failed to suspend user:", error);
}
} catch (_error) {}
};
const handleUnsuspend = async (userId: string) => {
@@ -125,9 +120,7 @@ export default function UsersPage() {
if (!error) {
await fetchUsers();
}
} catch (error) {
console.error("Failed to unsuspend user:", error);
}
} catch (_error) {}
};
const handleDelete = async () => {
@@ -140,9 +133,7 @@ export default function UsersPage() {
await fetchUsers();
setDeleteUser(null);
}
} catch (error) {
console.error("Failed to delete user:", error);
}
} catch (_error) {}
};
const isUserSuspended = (user: User): boolean => {
@@ -252,7 +243,6 @@ export default function UsersPage() {
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingUser} onOpenChange={() => setEditingUser(null)}>
<DialogContent>
<DialogHeader>
@@ -288,7 +278,6 @@ export default function UsersPage() {
</DialogContent>
</Dialog>
{/* Suspend Dialog */}
<Dialog open={!!suspendingUser} onOpenChange={() => setSuspendingUser(null)}>
<DialogContent>
<DialogHeader>
@@ -324,7 +313,6 @@ export default function UsersPage() {
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog open={!!deleteUser} onOpenChange={() => setDeleteUser(null)}>
<DialogContent>
<DialogHeader>