wireguard-ui/src/pages/StatusPage.tsx

201 lines
7.4 KiB
TypeScript

import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api-client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowDown, ArrowUp, ArrowUpDown, CircleCheck, CircleX } from "lucide-react";
import type { DeviceStatus, PeerStatus } from "@/lib/types";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatHandshake(nanos: number): string {
if (!nanos || nanos <= 0) return "Never";
const seconds = Math.floor(nanos / 1_000_000_000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
type SortKey =
| "name"
| "connected"
| "last_handshake_rel"
| "received_bytes"
| "transmit_bytes"
| "endpoint";
type SortDir = "asc" | "desc";
function getSortValue(peer: PeerStatus, key: SortKey): string | number | boolean {
switch (key) {
case "name":
return (peer.name || "").toLowerCase();
case "connected":
return peer.connected ? 1 : 0;
case "last_handshake_rel":
return peer.last_handshake_rel || Number.MAX_SAFE_INTEGER;
case "received_bytes":
return peer.received_bytes;
case "transmit_bytes":
return peer.transmit_bytes;
case "endpoint":
return peer.endpoint || "";
}
}
function SortIcon({ column, sortKey, sortDir }: { column: SortKey; sortKey: SortKey; sortDir: SortDir }) {
if (column !== sortKey) return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return sortDir === "asc"
? <ArrowUp className="ml-1 inline h-3 w-3" />
: <ArrowDown className="ml-1 inline h-3 w-3" />;
}
export function StatusPage() {
const { data: devices, isLoading } = useQuery({
queryKey: ["status"],
queryFn: () => apiGet<DeviceStatus[]>("/status"),
refetchInterval: 5000,
});
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const toggleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
};
const sortedDevices = useMemo(() => {
if (!devices) return [];
return devices.map((device) => ({
...device,
peers: [...(device.peers || [])].sort((a, b) => {
const va = getSortValue(a, sortKey);
const vb = getSortValue(b, sortKey);
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
return sortDir === "asc" ? cmp : -cmp;
}),
}));
}, [devices, sortKey, sortDir]);
if (isLoading) {
return <Skeleton className="h-64 w-full" />;
}
const headerClass = "cursor-pointer select-none hover:text-foreground";
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold tracking-tight">Server Status</h2>
{sortedDevices.map((device) => (
<Card key={device.name}>
<CardHeader>
<CardTitle>{device.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className={headerClass} onClick={() => toggleSort("name")}>
Name <SortIcon column="name" sortKey={sortKey} sortDir={sortDir} />
</TableHead>
<TableHead className={`${headerClass} w-10`} onClick={() => toggleSort("connected")}>
<SortIcon column="connected" sortKey={sortKey} sortDir={sortDir} />
</TableHead>
<TableHead className={headerClass} onClick={() => toggleSort("endpoint")}>
Endpoint <SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
</TableHead>
<TableHead className={headerClass} onClick={() => toggleSort("last_handshake_rel")}>
Handshake <SortIcon column="last_handshake_rel" sortKey={sortKey} sortDir={sortDir} />
</TableHead>
<TableHead className={headerClass} onClick={() => toggleSort("received_bytes")}>
Rx <SortIcon column="received_bytes" sortKey={sortKey} sortDir={sortDir} />
</TableHead>
<TableHead className={headerClass} onClick={() => toggleSort("transmit_bytes")}>
Tx <SortIcon column="transmit_bytes" sortKey={sortKey} sortDir={sortDir} />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{device.peers?.map((peer) => (
<TableRow key={peer.public_key}>
<TableCell>
<div className="font-medium">{peer.name || "Unknown"}</div>
<code className="text-xs text-muted-foreground">
{peer.public_key.substring(0, 16)}...
</code>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger>
{peer.connected ? (
<CircleCheck className="h-5 w-5 text-green-500" />
) : (
<CircleX className="h-5 w-5 text-muted-foreground" />
)}
</TooltipTrigger>
<TooltipContent>
{peer.connected ? "Connected" : "Disconnected"}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="font-mono">{peer.endpoint || "-"}</TableCell>
<TableCell>{formatHandshake(peer.last_handshake_rel)}</TableCell>
<TableCell>{formatBytes(peer.received_bytes)}</TableCell>
<TableCell>{formatBytes(peer.transmit_bytes)}</TableCell>
</TableRow>
))}
{(!device.peers || device.peers.length === 0) && (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
No peers connected
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
))}
{(!devices || devices.length === 0) && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No WireGuard interfaces found
</CardContent>
</Card>
)}
</div>
);
}