feat: add server details

This commit is contained in:
2025-12-26 01:07:13 +01:00
parent 85a02076f6
commit 7294b50edf
8 changed files with 937 additions and 34 deletions

View File

@@ -0,0 +1,79 @@
export const cityToFlag: Record<string, string> = {
Vienna: "🇦🇹",
"Vienna (DATASIX)": "🇦🇹",
Klagenfurt: "🇦🇹",
"Vienna (InterXion)": "🇦🇹",
Sofia: "🇧🇬",
Prague: "🇨🇿",
Copenhagen: "🇩🇰",
Helsinki: "🇫🇮",
Paris: "🇫🇷",
Marseille: "🇫🇷",
Munich: "🇩🇪",
Frankfurt: "🇩🇪",
Nuremberg: "🇩🇪",
Athens: "🇬🇷",
Budapest: "🇭🇺",
Dublin: "🇮🇪",
Milan: "🇮🇹",
Palermo: "🇮🇹",
Luxembourg: "🇱🇺",
Amsterdam: "🇳🇱",
Oslo: "🇳🇴",
Warsaw: "🇵🇱",
Lisbon: "🇵🇹",
Bucharest: "🇷🇴",
"St. Petersburg": "🇷🇺",
Bratislava: "🇸🇰",
Ljubljana: "🇸🇮",
Madrid: "🇪🇸",
Stockholm: "🇸🇪",
Zurich: "🇨🇭",
Ankara: "🇹🇷",
Istanbul: "🇹🇷",
Kyiv: "🇺🇦",
London: "🇬🇧",
Manchester: "🇬🇧",
Belgrade: "🇷🇸",
"New York City": "🇺🇸",
Denver: "🇺🇸",
"Los Angeles": "🇺🇸",
Miami: "🇺🇸",
Seattle: "🇺🇸",
Chicago: "🇺🇸",
Atlanta: "🇺🇸",
Honolulu: "🇺🇸",
Dallas: "🇺🇸",
Manassas: "🇺🇸",
Vancouver: "🇨🇦",
Toronto: "🇨🇦",
"Mexico City": "🇲🇽",
"Buenos Aires": "🇦🇷",
"Rio de Janeiro": "🇧🇷",
"São Paulo": "🇧🇷",
"Santiago de Chile": "🇨🇱",
Medellín: "🇨🇴",
Lima: "🇵🇪",
Sydney: "🇦🇺",
Brisbane: "🇦🇺",
Perth: "🇦🇺",
Tianjin: "🇨🇳",
"Hong Kong": "🇭🇰",
Chennai: "🇮🇳",
Delhi: "🇮🇳",
Mumbai: "🇮🇳",
Jakarta: "🇮🇩",
"Tel Aviv": "🇮🇱",
Osaka: "🇯🇵",
Tokyo: "🇯🇵",
Seoul: "🇰🇷",
"Kuala Lumpur": "🇲🇾",
Singapore: "🇸🇬",
Taipei: "🇹🇼",
Bangkok: "🇹🇭",
Dubai: "🇦🇪",
Hanoi: "🇻🇳",
Johannesburg: "🇿🇦",
};

View File

@@ -0,0 +1,89 @@
export interface Server {
id: number;
name: string;
hostname: string;
nickname: string;
disabled: boolean;
template: Template;
serverLiveInfo: ServerLiveInfo;
ipv4Addresses: Ipv4Address[];
ipv6Addresses: Ipv6Address[];
site: Site;
snapshotCount: number;
maxCpuCount: number;
disksAvailableSpaceInMiB: number;
rescueSystemActive: boolean;
snapshotAllowed: boolean;
architecture: string;
}
export interface Template {
id: number;
name: string;
}
export interface ServerLiveInfo {
state: string;
autostart: boolean;
uefi: boolean;
interfaces: Interface[];
disks: Disk[];
bootorder: string[];
requiredStorageOptimization: string;
template: string;
uptimeInSeconds: number;
currentServerMemoryInMiB: number;
maxServerMemoryInMiB: number;
cpuCount: number;
cpuMaxCount: number;
sockets: number;
coresPerSocket: number;
latestQemu: boolean;
configChanged: boolean;
osOptimization: string;
nestedGuest: boolean;
machineType: string;
keyboardLayout: string;
cloudinitAttached: boolean;
}
export interface Interface {
mac: string;
driver: string;
mtu: number;
speedInMBits: number;
rxMonthlyInMiB: number;
txMonthlyInMiB: number;
ipv4Addresses: string[];
ipv6LinkLocalAddresses: string[];
ipv6NetworkPrefixes: string[];
trafficThrottled: boolean;
vlanInterface: boolean;
}
export interface Disk {
dev: string;
driver: string;
capacityInMiB: number;
allocationInMiB: number;
}
export interface Ipv4Address {
id: number;
ip: string;
netmask: string;
gateway: string;
broadcast: string;
}
export interface Ipv6Address {
id: number;
networkPrefix: string;
networkPrefixLength: number;
gateway: string;
}
export interface Site {
id: number;
city: string;
}

View File

@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as Server_listRouteImport } from './routes/server_list'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ServerServerIdRouteImport } from './routes/server.$serverId'
const Server_listRoute = Server_listRouteImport.update({
id: '/server_list',
@@ -22,31 +23,40 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ServerServerIdRoute = ServerServerIdRouteImport.update({
id: '/server/$serverId',
path: '/server/$serverId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/server_list': typeof Server_listRoute
'/server/$serverId': typeof ServerServerIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/server_list': typeof Server_listRoute
'/server/$serverId': typeof ServerServerIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/server_list': typeof Server_listRoute
'/server/$serverId': typeof ServerServerIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/server_list'
fullPaths: '/' | '/server_list' | '/server/$serverId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/server_list'
id: '__root__' | '/' | '/server_list'
to: '/' | '/server_list' | '/server/$serverId'
id: '__root__' | '/' | '/server_list' | '/server/$serverId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
Server_listRoute: typeof Server_listRoute
ServerServerIdRoute: typeof ServerServerIdRoute
}
declare module '@tanstack/react-router' {
@@ -65,12 +75,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/server/$serverId': {
id: '/server/$serverId'
path: '/server/$serverId'
fullPath: '/server/$serverId'
preLoaderRoute: typeof ServerServerIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
Server_listRoute: Server_listRoute,
ServerServerIdRoute: ServerServerIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -6,32 +6,5 @@ export const Route = createFileRoute("/")({
});
function App() {
return (
<div className="text-center">
<header className="min-h-full flex flex-col items-center justify-center text-white text-[calc(10px+2vmin)]">
<img
src={logo}
className="h-[40vmin] pointer-events-none animate-[spin_20s_linear_infinite]"
alt="logo"
/>
<p>Hehe</p>
<a
className="text-[#61dafb] hover:underline"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
<a
className="text-[#61dafb] hover:underline"
href="https://tanstack.com"
target="_blank"
rel="noopener noreferrer"
>
Learn TanStack
</a>
</header>
</div>
);
return <div className="text-center">Dashboard coming soon!</div>;
}

View File

@@ -0,0 +1,721 @@
import type { Server } from "@/models/server";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
import { AlertTriangle, CheckCircle2, CircleOff } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { cityToFlag } from "@/models/cityflags";
export const Route = createFileRoute("/server/$serverId")({
component: RouteComponent,
});
function RouteComponent() {
const { serverId } = useParams({ strict: false });
const query = useQuery({
queryKey: ["server", serverId],
enabled: Boolean(serverId),
queryFn: async () => {
const response = await fetch(`/api/servers/${serverId}`);
if (!response.ok) {
throw new Error(`Failed to load server (${response.status})`);
}
return (await response.json()) as Server;
},
refetchInterval: 5000,
});
if (query.isLoading) {
return <ServerDetailsSkeleton />;
}
if (query.isError) {
return (
<div className="mx-auto max-w-5xl p-6">
<Link to="/server_list">
<Button variant="outline">Back to list</Button>
</Link>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Failed to load server
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{(query.error as Error).message}
</p>
</CardContent>
</Card>
</div>
);
}
if (!query.data) return null;
return <ServerDetails server={query.data} />;
}
function ServerDetails({ server }: { server: Server }) {
const state = server.serverLiveInfo?.state ?? "unknown";
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<Header server={server} />
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="live">Live</TabsTrigger>
<TabsTrigger value="network">Network</TabsTrigger>
<TabsTrigger value="storage">Storage</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card className="min-w-175">
<CardHeader>
<CardTitle>Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<DetailItem label="Server ID" value={server.id} />
<DetailItem label="Architecture" value={server.architecture} />
<DetailItem label="Template" value={server.template?.name} />
<DetailItem
label="Site"
value={
server.site?.city + " " + cityToFlag[server.site?.city]
}
/>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<FeatureBadge
label="Disabled"
enabled={server.disabled}
enabledVariant="destructive"
disabledText="Active"
/>
<FeatureBadge
label="Rescue system"
enabled={server.rescueSystemActive}
/>
<FeatureBadge
label="Snapshots allowed"
enabled={server.snapshotAllowed}
/>
<Badge variant="secondary">
Snapshots: {server.snapshotCount}
</Badge>
<Badge variant="secondary">Max CPU: {server.maxCpuCount}</Badge>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="live" className="space-y-4">
<Card className="min-w-175">
<CardHeader>
<CardTitle className="flex items-center gap-2">
Live status
<StateBadge state={state} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<DetailItem
label="Uptime"
value={formatUptime(server.serverLiveInfo?.uptimeInSeconds)}
/>
<DetailItem
label="Autostart"
value={formatBool(server.serverLiveInfo?.autostart)}
/>
<DetailItem
label="UEFI"
value={formatBool(server.serverLiveInfo?.uefi)}
/>
<DetailItem
label="Latest QEMU"
value={formatBool(server.serverLiveInfo?.latestQemu)}
/>
<DetailItem
label="Config changed"
value={formatBool(server.serverLiveInfo?.configChanged)}
/>
<DetailItem
label="Cloud-init"
value={formatBool(server.serverLiveInfo?.cloudinitAttached)}
/>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-base">CPU</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<DetailItem
label="CPU count"
value={server.serverLiveInfo?.cpuCount}
/>
<DetailItem
label="CPU max"
value={server.serverLiveInfo?.cpuMaxCount}
/>
<DetailItem
label="Sockets"
value={server.serverLiveInfo?.sockets}
/>
<DetailItem
label="Cores / socket"
value={server.serverLiveInfo?.coresPerSocket}
/>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-base">Memory</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<DetailItem
label="Current"
value={formatMiB(
server.serverLiveInfo?.currentServerMemoryInMiB,
)}
/>
<DetailItem
label="Max"
value={formatMiB(
server.serverLiveInfo?.maxServerMemoryInMiB,
)}
/>
</CardContent>
</Card>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<DetailItem
label="Machine type"
value={server.serverLiveInfo?.machineType}
/>
<DetailItem
label="Keyboard layout"
value={server.serverLiveInfo?.keyboardLayout}
/>
<DetailItem
label="OS optimization"
value={server.serverLiveInfo?.osOptimization}
/>
<DetailItem
label="Required storage optimization"
value={server.serverLiveInfo?.requiredStorageOptimization}
/>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
Nested guest: {formatBool(server.serverLiveInfo?.nestedGuest)}
</Badge>
<Badge variant="outline">
Boot order:{" "}
{(server.serverLiveInfo?.bootorder ?? []).join(", ") || "—"}
</Badge>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="network" className="space-y-4">
<Card className="min-w-175">
<CardHeader>
<CardTitle>IP addresses</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">IPv4</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP</TableHead>
<TableHead className="hidden sm:table-cell">
Netmask
</TableHead>
<TableHead className="hidden md:table-cell">
Gateway
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(server.ipv4Addresses ?? []).length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="text-sm text-muted-foreground"
>
No IPv4 addresses
</TableCell>
</TableRow>
) : (
server.ipv4Addresses.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
{a.ip}
</TableCell>
<TableCell className="hidden sm:table-cell">
{a.netmask}
</TableCell>
<TableCell className="hidden md:table-cell">
{a.gateway}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">IPv6</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Prefix</TableHead>
<TableHead className="hidden sm:table-cell">
Length
</TableHead>
<TableHead className="hidden md:table-cell">
Gateway
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(server.ipv6Addresses ?? []).length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="text-sm text-muted-foreground"
>
No IPv6 addresses
</TableCell>
</TableRow>
) : (
server.ipv6Addresses.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
{a.networkPrefix}
</TableCell>
<TableCell className="hidden sm:table-cell">
/{a.networkPrefixLength}
</TableCell>
<TableCell className="hidden md:table-cell">
{a.gateway}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Interfaces</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>MAC</TableHead>
<TableHead className="hidden sm:table-cell">
Driver
</TableHead>
<TableHead className="hidden md:table-cell">
MTU
</TableHead>
<TableHead className="hidden lg:table-cell">
Speed
</TableHead>
<TableHead>Flags</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(server.serverLiveInfo?.interfaces ?? []).length ===
0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-sm text-muted-foreground"
>
No interfaces
</TableCell>
</TableRow>
) : (
server.serverLiveInfo.interfaces.map((i) => (
<TableRow key={i.mac}>
<TableCell className="font-medium">
{i.mac}
</TableCell>
<TableCell className="hidden sm:table-cell">
{i.driver}
</TableCell>
<TableCell className="hidden md:table-cell">
{i.mtu}
</TableCell>
<TableCell className="hidden lg:table-cell">
{i.speedInMBits} Mbit/s
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{i.vlanInterface && (
<Badge variant="secondary">VLAN</Badge>
)}
{i.trafficThrottled && (
<Badge variant="destructive">Throttled</Badge>
)}
<Badge variant="outline">
RX {formatMiB(i.rxMonthlyInMiB)}
</Badge>
<Badge variant="outline">
TX {formatMiB(i.txMonthlyInMiB)}
</Badge>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{(server.serverLiveInfo?.interfaces ?? []).map((i) => (
<Card key={`${i.mac}-ips`} className="mt-4 shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-base">
{i.mac} addresses
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
IPv4
</div>
<div className="flex flex-wrap gap-2">
{(i.ipv4Addresses ?? []).length === 0 ? (
<span className="text-sm text-muted-foreground">
</span>
) : (
i.ipv4Addresses.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))
)}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
IPv6 link-local
</div>
<div className="flex flex-wrap gap-2">
{(i.ipv6LinkLocalAddresses ?? []).length === 0 ? (
<span className="text-sm text-muted-foreground">
</span>
) : (
i.ipv6LinkLocalAddresses.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))
)}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
IPv6 prefixes
</div>
<div className="flex flex-wrap gap-2">
{(i.ipv6NetworkPrefixes ?? []).length === 0 ? (
<span className="text-sm text-muted-foreground">
</span>
) : (
i.ipv6NetworkPrefixes.map((p) => (
<Badge key={p} variant="outline">
{p}
</Badge>
))
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="storage" className="space-y-4">
<Card className="min-w-175">
<CardHeader>
<CardTitle>Storage</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<DetailItem
label="Disks available"
value={formatMiB(server.disksAvailableSpaceInMiB)}
/>
<DetailItem
label="Required optimization"
value={server.serverLiveInfo?.requiredStorageOptimization}
/>
<DetailItem
label="Template (live)"
value={server.serverLiveInfo?.template}
/>
</div>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Disks</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead className="hidden sm:table-cell">
Driver
</TableHead>
<TableHead>Capacity</TableHead>
<TableHead className="hidden md:table-cell">
Allocation
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(server.serverLiveInfo?.disks ?? []).length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-sm text-muted-foreground"
>
No disks
</TableCell>
</TableRow>
) : (
server.serverLiveInfo.disks.map((d) => (
<TableRow key={d.dev}>
<TableCell className="font-medium">
{d.dev}
</TableCell>
<TableCell className="hidden sm:table-cell">
{d.driver}
</TableCell>
<TableCell>{formatMiB(d.capacityInMiB)}</TableCell>
<TableCell className="hidden md:table-cell">
{formatMiB(d.allocationInMiB)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
function Header({ server }: { server: Server }) {
const state = server.serverLiveInfo?.state ?? "unknown";
return (
<Card>
<CardHeader className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-xl font-semibold leading-tight">
{server.name}
</h1>
{server.nickname ? (
<Badge variant="secondary" className="max-w-full truncate">
{server.nickname}
</Badge>
) : null}
</div>
<div className="mt-1 text-sm text-muted-foreground">
<span className="font-mono">{server.hostname}</span>
<span className="mx-2"></span>
<span>ID {server.id}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<StateBadge state={state} />
{server.disabled ? (
<Badge variant="destructive" className="gap-1">
<CircleOff className="h-3.5 w-3.5" />
Disabled
</Badge>
) : (
<Badge variant="outline" className="gap-1">
<CheckCircle2 className="h-3.5 w-3.5" />
Enabled
</Badge>
)}
</div>
</div>
</CardHeader>
</Card>
);
}
function StateBadge({ state }: { state: string }) {
const normalized = (state || "unknown").toLowerCase();
if (normalized === "running" || normalized === "online") {
return (
<Badge className="bg-emerald-600 hover:bg-emerald-600">Running</Badge>
);
}
if (normalized === "stopped" || normalized === "offline") {
return <Badge variant="secondary">Stopped</Badge>;
}
return <Badge variant="outline">{state || "unknown"}</Badge>;
}
function DetailItem({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
const v = value === null || value === undefined || value === "" ? "—" : value;
return (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-sm font-medium">{v}</div>
</div>
);
}
function FeatureBadge({
label,
enabled,
enabledVariant = "secondary",
disabledText = "No",
}: {
label: string;
enabled: boolean;
enabledVariant?: "default" | "secondary" | "destructive" | "outline";
disabledText?: string;
}) {
if (enabled) {
return <Badge variant={enabledVariant}>{label}: Yes</Badge>;
}
return (
<Badge variant="outline">
{label}: {disabledText}
</Badge>
);
}
function formatBool(v: boolean | undefined) {
if (v === undefined) return "—";
return v ? "Yes" : "No";
}
function formatMiB(mib: number | undefined) {
if (mib === undefined) return "—";
const gib = mib / 1024;
if (gib >= 10) return `${gib.toFixed(0)} GiB`;
if (gib >= 1) return `${gib.toFixed(1)} GiB`;
return `${mib.toLocaleString()} MiB`;
}
function formatUptime(seconds: number | undefined) {
if (seconds === undefined) return "—";
const s = Math.max(0, Math.floor(seconds));
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function ServerDetailsSkeleton() {
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<Card className="min-w-175">
<CardHeader className="space-y-3">
<Skeleton className="h-6 w-72" />
<Skeleton className="h-4 w-96" />
<div className="flex gap-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-6 w-24" />
</div>
</CardHeader>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
<Skeleton className="h-36 w-full" />
</CardContent>
</Card>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import {
} from "@/components/ui/table";
import type { MinimalServers } from "@/models/minimal_servers";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
export const Route = createFileRoute("/server_list")({
component: RouteComponent,
@@ -24,6 +24,8 @@ function RouteComponent() {
return (await res.json()) as MinimalServers;
},
});
const navigate = useNavigate({ from: "/server_list" });
return (
<div className="p-2 flex justify-center items-center w-full">
<Card className="w-full max-w-225 p-2">
@@ -38,7 +40,13 @@ function RouteComponent() {
</TableHeader>
<TableBody>
{servers?.map((server) => (
<TableRow key={server.id}>
<TableRow
key={server.id}
className="cursor-pointer"
onClick={() => {
navigate({ to: `/server/${server.id}` });
}}
>
<TableCell className="font-medium">{server.name}</TableCell>
<TableCell>{server.hostname}</TableCell>
<TableCell>{server.nickname}</TableCell>