feat: refine server management workflows

This commit is contained in:
2026-08-13 02:19:26 +07:00
parent 3a09f0b571
commit bd473863f3
22 changed files with 2069 additions and 2208 deletions
@@ -0,0 +1,97 @@
"use client";
import type { ConnectionInfo } from "@minikura/api";
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api-client";
import { getReverseProxyApi } from "@/lib/api-helpers";
type ConnectionInfoCellProps = {
serverId: string;
type: "normal" | "proxy";
};
export function ConnectionInfoCell({ serverId, type }: ConnectionInfoCellProps) {
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
const fetchConnectionInfo = async () => {
try {
const reverseProxyApi = getReverseProxyApi();
const endpoint =
type === "normal"
? api.api.servers({ id: serverId })["connection-info"]
: reverseProxyApi({ id: serverId })["connection-info"];
const res = await endpoint.get();
if (res.data) {
setConnectionInfo(res.data as ConnectionInfo);
}
} catch (_error) {
} finally {
setLoading(false);
}
};
fetchConnectionInfo();
}, [serverId, type]);
const handleCopy = async () => {
if (connectionInfo?.connectionString) {
await navigator.clipboard.writeText(connectionInfo.connectionString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (loading) {
return <span className="text-muted-foreground text-xs">Loading...</span>;
}
if (!connectionInfo) {
return <span className="text-muted-foreground text-xs">N/A</span>;
}
return (
<TooltipProvider>
<div className="flex flex-col gap-1">
<Badge variant="secondary" className="w-fit text-xs">
{connectionInfo.type}
</Badge>
{connectionInfo.connectionString && (
<div className="flex items-center gap-1">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{connectionInfo.connectionString}
</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={handleCopy}
aria-label="Copy connection string"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
</TooltipContent>
</Tooltip>
</div>
)}
{connectionInfo.note && (
<p className="text-xs text-muted-foreground">{connectionInfo.note}</p>
)}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,52 @@
"use client";
import type { PodInfo } from "@minikura/api";
import { useEffect, useState } from "react";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api-client";
type ServerStatusCellProps = {
serverId: string;
type: "normal" | "proxy";
};
export function ServerStatusCell({ serverId, type }: ServerStatusCellProps) {
const [pods, setPods] = useState<PodInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPods = async () => {
try {
const endpoint =
type === "normal"
? 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) {
} finally {
setLoading(false);
}
};
fetchPods();
}, [serverId, type]);
if (loading) {
return <StatusBadge pulse>Checking</StatusBadge>;
}
if (pods.length === 0) {
return <StatusBadge>No pods</StatusBadge>;
}
const allRunning = pods.every((pod) => pod.status === "Running");
const readyCount = pods.filter((pod) => pod.ready === "1/1").length;
return (
<StatusBadge tone={allRunning ? "success" : "warning"}>
{readyCount}/{pods.length} ready
</StatusBadge>
);
}
@@ -0,0 +1,155 @@
import type { NormalServer, ReverseProxyServer } from "@minikura/api";
import { Pencil, Trash2 } from "lucide-react";
import { DataTable, type DataTableColumn } from "@/components/data-table";
import { TableActions } from "@/components/section-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConnectionInfoCell } from "./connection-info-cell";
import { ServerStatusCell } from "./server-status-cell";
type ServerTableProps =
| {
type: "normal";
servers: NormalServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
| {
type: "proxy";
servers: ReverseProxyServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
};
function RowActions({
id,
kind,
onEdit,
onDelete,
}: {
id: string;
kind: string;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<TableActions>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
aria-label={`Edit ${kind} ${id}`}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
aria-label={`Delete ${kind} ${id}`}
>
<Trash2 />
</Button>
</TableActions>
);
}
export function ServerTable(props: ServerTableProps) {
if (props.type === "normal") {
const columns: readonly DataTableColumn<NormalServer>[] = [
{ id: "id", header: "ID", cell: (server) => server.id, className: "font-bold" },
{
id: "status",
header: "Status",
cell: (server) => <ServerStatusCell serverId={server.id} type="normal" />,
},
{
id: "storage",
header: "Storage",
cell: (server) => <Badge variant="outline">{server.type}</Badge>,
},
{
id: "software",
header: "Software",
cell: (server) => <Badge variant="secondary">{server.jar_type || "VANILLA"}</Badge>,
},
{
id: "version",
header: "Version",
cell: (server) => server.minecraft_version || "LATEST",
className: "text-muted-foreground",
},
{ id: "memory", header: "Memory", cell: (server) => `${server.memory || 1024} MB` },
{
id: "network",
header: "Network",
cell: (server) => <ConnectionInfoCell serverId={server.id} type="normal" />,
},
{
id: "description",
header: "Description",
cell: (server) => server.description || "-",
className: "max-w-64 truncate text-muted-foreground",
},
{
id: "actions",
header: "Actions",
headerClassName: "text-right",
className: "text-right",
cell: (server) => (
<RowActions
id={server.id}
kind="server"
onEdit={props.onEdit}
onDelete={props.onDelete}
/>
),
},
];
return <DataTable data={props.servers} columns={columns} getRowKey={(server) => server.id} />;
}
const columns: readonly DataTableColumn<ReverseProxyServer>[] = [
{ id: "id", header: "ID", cell: (server) => server.id, className: "font-bold" },
{
id: "status",
header: "Status",
cell: (server) => <ServerStatusCell serverId={server.id} type="proxy" />,
},
{
id: "type",
header: "Type",
cell: (server) => <Badge variant="outline">{server.type}</Badge>,
},
{
id: "external",
header: "External",
cell: (server) => `${server.external_address}:${server.external_port}`,
},
{ id: "port", header: "Listen port", cell: (server) => server.listen_port },
{ id: "memory", header: "Memory", cell: (server) => `${server.memory} MB` },
{
id: "network",
header: "Network",
cell: (server) => <ConnectionInfoCell serverId={server.id} type="proxy" />,
},
{
id: "description",
header: "Description",
cell: (server) => server.description || "-",
className: "max-w-64 truncate text-muted-foreground",
},
{
id: "actions",
header: "Actions",
headerClassName: "text-right",
className: "text-right",
cell: (server) => (
<RowActions id={server.id} kind="proxy" onEdit={props.onEdit} onDelete={props.onDelete} />
),
},
];
return <DataTable data={props.servers} columns={columns} getRowKey={(server) => server.id} />;
}