249 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|