Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded Forge 1.20.1 server. Includes server controls, player management, mod manager with Modrinth search and dependency resolution, world backups, snapshots, analytics, logs, and chat bridge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
177 lines
5 KiB
TypeScript
177 lines
5 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useState } from "react";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
|
|
type MetricEntry = {
|
|
ts: string;
|
|
tps: number;
|
|
ramUsedMB: number;
|
|
ramTotalMB: number;
|
|
cpuPercent: number;
|
|
playersOnline: number;
|
|
};
|
|
|
|
function Sparkline({
|
|
data,
|
|
color,
|
|
max,
|
|
height = 80,
|
|
label,
|
|
unit,
|
|
currentValue,
|
|
}: {
|
|
data: number[];
|
|
color: string;
|
|
max?: number;
|
|
height?: number;
|
|
label: string;
|
|
unit: string;
|
|
currentValue: string;
|
|
}) {
|
|
if (data.length < 2) {
|
|
return (
|
|
<div className="rounded-lg bg-muted p-4">
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="text-lg font-bold">
|
|
{currentValue}
|
|
<span className="text-xs text-muted-foreground ml-1">{unit}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Skeleton className="w-full" style={{ height }} />
|
|
<p className="text-xs text-muted-foreground mt-2">Collecting data...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const dataMax = max || Math.max(...data, 1);
|
|
const w = 300;
|
|
const points = data.map((v, i) => {
|
|
const x = (i / (data.length - 1)) * w;
|
|
const y = height - (v / dataMax) * (height - 10) - 5;
|
|
return `${x},${y}`;
|
|
});
|
|
|
|
const pathD = `M${points.join(" L")}`;
|
|
const areaD = `${pathD} L${w},${height} L0,${height} Z`;
|
|
|
|
return (
|
|
<div className="rounded-lg bg-muted p-4">
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="text-lg font-bold">
|
|
{currentValue}
|
|
<span className="text-xs text-muted-foreground ml-1">{unit}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<svg viewBox={`0 0 ${w} ${height}`} className="w-full" preserveAspectRatio="none">
|
|
<defs>
|
|
<linearGradient id={`grad-${label}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
|
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path d={areaD} fill={`url(#grad-${label})`} />
|
|
<path d={pathD} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Analytics() {
|
|
const [hours, setHours] = useState(6);
|
|
|
|
const { data: metrics = [] } = useQuery<MetricEntry[]>({
|
|
queryKey: ["analytics", hours],
|
|
queryFn: () =>
|
|
fetch(`/api/analytics?hours=${hours}`).then((r) => r.json()),
|
|
refetchInterval: 60_000,
|
|
});
|
|
|
|
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
|
|
|
|
const ranges = [
|
|
{ label: "1h", value: 1 },
|
|
{ label: "6h", value: 6 },
|
|
{ label: "24h", value: 24 },
|
|
];
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
|
<div>
|
|
<CardTitle>Server Analytics</CardTitle>
|
|
<CardDescription>
|
|
{metrics.length} data points
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex gap-1 bg-muted p-1 rounded-lg w-fit shrink-0">
|
|
{ranges.map((r) => (
|
|
<button
|
|
key={r.value}
|
|
onClick={() => setHours(r.value)}
|
|
className={`px-3 py-1 rounded-md text-xs font-medium transition ${
|
|
hours === r.value
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{r.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<Sparkline
|
|
data={metrics.map((m) => m.tps)}
|
|
color="#4ade80"
|
|
max={22}
|
|
label="TPS"
|
|
unit=""
|
|
currentValue={latest ? latest.tps.toFixed(1) : "-"}
|
|
/>
|
|
<Sparkline
|
|
data={metrics.map((m) => m.ramUsedMB)}
|
|
color="#60a5fa"
|
|
label="RAM"
|
|
unit="MB"
|
|
currentValue={
|
|
latest ? `${(latest.ramUsedMB / 1024).toFixed(1)} GB` : "-"
|
|
}
|
|
/>
|
|
<Sparkline
|
|
data={metrics.map((m) => m.cpuPercent)}
|
|
color="#f59e0b"
|
|
max={100}
|
|
label="CPU"
|
|
unit="%"
|
|
currentValue={latest ? latest.cpuPercent.toFixed(0) : "-"}
|
|
/>
|
|
<Sparkline
|
|
data={metrics.map((m) => m.playersOnline)}
|
|
color="#a78bfa"
|
|
label="Players"
|
|
unit=""
|
|
currentValue={latest ? latest.playersOnline.toString() : "0"}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|