source-gamepanel/apps/web/src/pages/nodes/detail.tsx

249 lines
8.0 KiB
TypeScript

import { useParams, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import {
ArrowLeft,
Network,
Wifi,
WifiOff,
Cpu,
MemoryStick,
HardDrive,
Server,
Activity,
} from 'lucide-react';
import { api } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
interface NodeDetail {
id: string;
name: string;
fqdn: string;
daemonPort: number;
grpcPort: number;
memoryTotal: number;
diskTotal: number;
isOnline: boolean;
daemonVersion: string | null;
createdAt: string;
}
interface NodeStats {
cpuPercent: number;
memoryUsed: number;
memoryTotal: number;
diskUsed: number;
diskTotal: number;
activeServers: number;
totalServers: number;
uptime: number;
}
interface ServerSummary {
id: string;
name: string;
status: string;
memoryLimit: number;
cpuLimit: number;
gameName: string;
}
export function NodeDetailPage() {
const { orgId, nodeId } = useParams();
const { data: node } = useQuery({
queryKey: ['node', orgId, nodeId],
queryFn: () => api.get<NodeDetail>(`/organizations/${orgId}/nodes/${nodeId}`),
});
const { data: stats } = useQuery({
queryKey: ['node-stats', orgId, nodeId],
queryFn: () => api.get<NodeStats>(`/organizations/${orgId}/nodes/${nodeId}/stats`),
refetchInterval: 10_000,
});
const { data: serversData } = useQuery({
queryKey: ['node-servers', orgId, nodeId],
queryFn: () =>
api.get<{ data: ServerSummary[] }>(
`/organizations/${orgId}/nodes/${nodeId}/servers`,
),
});
const servers = serversData?.data ?? [];
if (!node) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const memPercent = stats
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
: 0;
const diskPercent = stats
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
: 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to={`/org/${orgId}/nodes`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-bold">{node.name}</h1>
<p className="text-sm text-muted-foreground">
{node.fqdn}:{node.daemonPort}
</p>
</div>
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
{node.isOnline ? (
<><Wifi className="mr-1 h-3 w-3" /> Online</>
) : (
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
)}
</Badge>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'}
</div>
<Progress value={stats?.cpuPercent ?? 0} className="mt-2 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Memory</CardTitle>
<MemoryStick className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats
? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}`
: '—'}
</div>
<Progress value={memPercent} className="mt-2 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Disk</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats
? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}`
: '—'}
</div>
<Progress value={diskPercent} className="mt-2 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Servers</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats ? `${stats.activeServers} / ${stats.totalServers}` : servers.length.toString()}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{stats ? 'active / total' : 'total servers'}
</p>
</CardContent>
</Card>
</div>
{/* Node Info */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Node Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow label="FQDN" value={node.fqdn} />
<InfoRow label="Daemon Port" value={String(node.daemonPort)} />
<InfoRow label="gRPC Port" value={String(node.grpcPort)} />
<InfoRow label="Total Memory" value={formatBytes(node.memoryTotal)} />
<InfoRow label="Total Disk" value={formatBytes(node.diskTotal)} />
{node.daemonVersion && (
<InfoRow label="Daemon Version" value={node.daemonVersion} />
)}
<InfoRow label="Created" value={new Date(node.createdAt).toLocaleDateString()} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Servers on this Node</CardTitle>
</CardHeader>
<CardContent>
{servers.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No servers on this node
</p>
) : (
<div className="space-y-2">
{servers.map((srv) => (
<Link
key={srv.id}
to={`/org/${orgId}/servers/${srv.id}/console`}
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div>
<p className="font-medium">{srv.name}</p>
<p className="text-xs text-muted-foreground">{srv.gameName}</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant={srv.status === 'running' ? 'default' : 'outline'}
>
{srv.status}
</Badge>
<span className="text-xs text-muted-foreground">
{formatBytes(srv.memoryLimit)} RAM
</span>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{value}</span>
</div>
);
}