feat: add server details
This commit is contained in:
79
frontend/src/models/cityflags.ts
Normal file
79
frontend/src/models/cityflags.ts
Normal 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: "🇿🇦",
|
||||
};
|
||||
89
frontend/src/models/server.ts
Normal file
89
frontend/src/models/server.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
721
frontend/src/routes/server.$serverId.tsx
Normal file
721
frontend/src/routes/server.$serverId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn main() -> std::io::Result<()> {
|
||||
.service(auth::start_flow)
|
||||
.service(auth::get_user)
|
||||
.service(servers::list_servers)
|
||||
.service(servers::get_server)
|
||||
.service(
|
||||
Files::new("/", "./frontend/dist")
|
||||
.index_file("index.html")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use actix_web::{Responder, get};
|
||||
use actix_web::{Responder, get, web};
|
||||
|
||||
use crate::helper;
|
||||
|
||||
@@ -12,3 +12,17 @@ pub async fn list_servers() -> impl Responder {
|
||||
|
||||
serde_json::to_string(&response).unwrap()
|
||||
}
|
||||
|
||||
#[get("/api/servers/{id}")]
|
||||
pub async fn get_server(id: web::Path<i32>) -> impl Responder {
|
||||
let config = helper::get_authed_api_config().await.unwrap();
|
||||
let response = scp_core::apis::default_api::api_v1_servers_server_id_get(
|
||||
&config,
|
||||
id.into_inner(),
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
serde_json::to_string(&response).unwrap()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user