diff --git a/frontend/src/models/cityflags.ts b/frontend/src/models/cityflags.ts new file mode 100644 index 0000000..be402ff --- /dev/null +++ b/frontend/src/models/cityflags.ts @@ -0,0 +1,79 @@ +export const cityToFlag: Record = { + 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: "๐Ÿ‡ฟ๐Ÿ‡ฆ", +}; diff --git a/frontend/src/models/server.ts b/frontend/src/models/server.ts new file mode 100644 index 0000000..61d6a0d --- /dev/null +++ b/frontend/src/models/server.ts @@ -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; +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 4bd283a..57311f4 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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) diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index f6daba5..55acd11 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -6,32 +6,5 @@ export const Route = createFileRoute("/")({ }); function App() { - return ( -
-
- logo -

Hehe

- - Learn React - - - Learn TanStack - -
-
- ); + return
Dashboard coming soon!
; } diff --git a/frontend/src/routes/server.$serverId.tsx b/frontend/src/routes/server.$serverId.tsx new file mode 100644 index 0000000..73ef01e --- /dev/null +++ b/frontend/src/routes/server.$serverId.tsx @@ -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 ; + } + + if (query.isError) { + return ( +
+ + + + + + + + Failed to load server + + + +

+ {(query.error as Error).message} +

+
+
+
+ ); + } + + if (!query.data) return null; + + return ; +} + +function ServerDetails({ server }: { server: Server }) { + const state = server.serverLiveInfo?.state ?? "unknown"; + + return ( +
+
+ + + + Overview + Live + Network + Storage + + + + + + Summary + + +
+ + + + +
+ + + +
+ + + + + Snapshots: {server.snapshotCount} + + Max CPU: {server.maxCpuCount} +
+
+
+
+ + + + + + Live status + + + + +
+ + + + + + +
+ + + +
+ + + CPU + + + + + + + + + + + + Memory + + + + + + +
+ + + +
+ + + + +
+ +
+ + Nested guest: {formatBool(server.serverLiveInfo?.nestedGuest)} + + + Boot order:{" "} + {(server.serverLiveInfo?.bootorder ?? []).join(", ") || "โ€”"} + +
+
+
+
+ + + + + IP addresses + + +
+
+
IPv4
+
+ + + + IP + + Netmask + + + Gateway + + + + + {(server.ipv4Addresses ?? []).length === 0 ? ( + + + No IPv4 addresses + + + ) : ( + server.ipv4Addresses.map((a) => ( + + + {a.ip} + + + {a.netmask} + + + {a.gateway} + + + )) + )} + +
+
+
+ +
+
IPv6
+
+ + + + Prefix + + Length + + + Gateway + + + + + {(server.ipv6Addresses ?? []).length === 0 ? ( + + + No IPv6 addresses + + + ) : ( + server.ipv6Addresses.map((a) => ( + + + {a.networkPrefix} + + + /{a.networkPrefixLength} + + + {a.gateway} + + + )) + )} + +
+
+
+
+ + + +
+
Interfaces
+
+ + + + MAC + + Driver + + + MTU + + + Speed + + Flags + + + + {(server.serverLiveInfo?.interfaces ?? []).length === + 0 ? ( + + + No interfaces + + + ) : ( + server.serverLiveInfo.interfaces.map((i) => ( + + + {i.mac} + + + {i.driver} + + + {i.mtu} + + + {i.speedInMBits} Mbit/s + + +
+ {i.vlanInterface && ( + VLAN + )} + {i.trafficThrottled && ( + Throttled + )} + + RX {formatMiB(i.rxMonthlyInMiB)} + + + TX {formatMiB(i.txMonthlyInMiB)} + +
+
+
+ )) + )} +
+
+
+ + {(server.serverLiveInfo?.interfaces ?? []).map((i) => ( + + + + {i.mac} addresses + + + +
+
+ IPv4 +
+
+ {(i.ipv4Addresses ?? []).length === 0 ? ( + + โ€” + + ) : ( + i.ipv4Addresses.map((ip) => ( + + {ip} + + )) + )} +
+
+ +
+
+ IPv6 link-local +
+
+ {(i.ipv6LinkLocalAddresses ?? []).length === 0 ? ( + + โ€” + + ) : ( + i.ipv6LinkLocalAddresses.map((ip) => ( + + {ip} + + )) + )} +
+
+ +
+
+ IPv6 prefixes +
+
+ {(i.ipv6NetworkPrefixes ?? []).length === 0 ? ( + + โ€” + + ) : ( + i.ipv6NetworkPrefixes.map((p) => ( + + {p} + + )) + )} +
+
+
+
+ ))} +
+
+
+
+ + + + + Storage + + +
+ + + +
+ + + +
+
Disks
+
+ + + + Device + + Driver + + Capacity + + Allocation + + + + + {(server.serverLiveInfo?.disks ?? []).length === 0 ? ( + + + No disks + + + ) : ( + server.serverLiveInfo.disks.map((d) => ( + + + {d.dev} + + + {d.driver} + + {formatMiB(d.capacityInMiB)} + + {formatMiB(d.allocationInMiB)} + + + )) + )} + +
+
+
+
+
+
+
+
+ ); +} + +function Header({ server }: { server: Server }) { + const state = server.serverLiveInfo?.state ?? "unknown"; + + return ( + + +
+
+
+

+ {server.name} +

+ {server.nickname ? ( + + {server.nickname} + + ) : null} +
+
+ {server.hostname} + โ€ข + ID {server.id} +
+
+ +
+ + {server.disabled ? ( + + + Disabled + + ) : ( + + + Enabled + + )} +
+
+
+
+ ); +} + +function StateBadge({ state }: { state: string }) { + const normalized = (state || "unknown").toLowerCase(); + + if (normalized === "running" || normalized === "online") { + return ( + Running + ); + } + + if (normalized === "stopped" || normalized === "offline") { + return Stopped; + } + + return {state || "unknown"}; +} + +function DetailItem({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) { + const v = value === null || value === undefined || value === "" ? "โ€”" : value; + + return ( +
+
{label}
+
{v}
+
+ ); +} + +function FeatureBadge({ + label, + enabled, + enabledVariant = "secondary", + disabledText = "No", +}: { + label: string; + enabled: boolean; + enabledVariant?: "default" | "secondary" | "destructive" | "outline"; + disabledText?: string; +}) { + if (enabled) { + return {label}: Yes; + } + return ( + + {label}: {disabledText} + + ); +} + +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 ( +
+ + + + +
+ + +
+
+
+ + + + + + +
+ + + + +
+ +
+
+
+ ); +} diff --git a/frontend/src/routes/server_list.tsx b/frontend/src/routes/server_list.tsx index c33337d..8219016 100644 --- a/frontend/src/routes/server_list.tsx +++ b/frontend/src/routes/server_list.tsx @@ -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 (
@@ -38,7 +40,13 @@ function RouteComponent() { {servers?.map((server) => ( - + { + navigate({ to: `/server/${server.id}` }); + }} + > {server.name} {server.hostname} {server.nickname} diff --git a/src/main.rs b/src/main.rs index 64b3462..d013465 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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") diff --git a/src/servers.rs b/src/servers.rs index c98a9bf..3c9835b 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -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) -> 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() +}