Minecraft 26.2 Fabric server stack with CraftDeck management panel
- docker-compose: itzg/minecraft-server (Fabric, offline mode, whitelist enforced, Modrinth-managed mods), itzg/mc-backup sidecar, custom dashboard - Gated auth for offline mode: approve username -> whitelisted with a short registration window -> EasyAuth /register + /login; auto-revoke on expiry - CraftDeck panel (Next.js 16, zero native deps): live console (SSE), players & stats, lag diagnostics (server TPS vs player ping via spark), performance graphs, Modrinth mod search/manager, backups & restore, sandboxed file browser/editor, .env config editor with compose apply, cron schedules, Discord alerts, scrypt-hashed panel login - scripts/setup.sh generates secrets; docs/ covers auth, mods, updating, backups, networking (Tailscale), troubleshooting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
279d137ce5
98 files changed
+9925
No files matched your search
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, Square, RotateCw, HardDrive, Globe2, Cuboid } from "lucide-react";
|
||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||
import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui";
|
||||
import { Sparkline, UsageBar, type Point } from "@/components/charts";
|
||||
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
|
||||
|
||||
type ServerData = {
|
||||
dockerAvailable: boolean;
|
||||
container: { exists: boolean; running: boolean; status: string; health?: string };
|
||||
players: { online: number; max: number; names: string[] };
|
||||
version: string | null;
|
||||
serverType: string | null;
|
||||
metrics: Record<string, number | null> | null;
|
||||
pendingApprovals: number;
|
||||
world: { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
|
||||
uptimeMs: number | null;
|
||||
};
|
||||
|
||||
type MetricRow = {
|
||||
bucket: number;
|
||||
cpu: number | null;
|
||||
mem_used: number | null;
|
||||
mem_limit: number | null;
|
||||
tps: number | null;
|
||||
players: number | null;
|
||||
disk_used: number | null;
|
||||
disk_free: number | null;
|
||||
};
|
||||
|
||||
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
|
||||
|
||||
type ApprovedRow = { username: string; status: string; window_expires_at: number | null };
|
||||
|
||||
const ACTIVITY_LABELS: Record<string, { label: string; color: "good" | "warn" | "crit" | "info" | "muted" }> = {
|
||||
join: { label: "joined", color: "good" },
|
||||
leave: { label: "left", color: "muted" },
|
||||
connect: { label: "connecting", color: "info" },
|
||||
register: { label: "registered", color: "good" },
|
||||
approve: { label: "approved", color: "info" },
|
||||
expire: { label: "window expired", color: "warn" },
|
||||
revoke: { label: "revoked", color: "crit" },
|
||||
server_online: { label: "server online", color: "good" },
|
||||
server_stopping: { label: "server stopping", color: "warn" },
|
||||
backup: { label: "backup", color: "info" },
|
||||
restore: { label: "restore", color: "warn" },
|
||||
power: { label: "power", color: "muted" },
|
||||
console: { label: "console", color: "muted" },
|
||||
world_reset: { label: "world reset", color: "crit" },
|
||||
};
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
||||
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
||||
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/api/activity?limit=14", 20000);
|
||||
const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>(
|
||||
"/api/approved",
|
||||
15000
|
||||
);
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
useSSE("/api/stream/events", {
|
||||
"mc-event": () => {
|
||||
void refreshActivity();
|
||||
void refreshApproved();
|
||||
},
|
||||
metrics: () => forceTick((x) => x + 1),
|
||||
});
|
||||
|
||||
const power = async (action: "start" | "stop" | "restart") => {
|
||||
setBusyAction(action);
|
||||
try {
|
||||
await api("/api/server/power", { body: { action } });
|
||||
toast(`Server ${action} requested`, "success");
|
||||
setTimeout(() => void refresh(), 1500);
|
||||
} catch (err) {
|
||||
toast((err as Error).message, "error");
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const running = server?.container.running ?? false;
|
||||
const m = server?.metrics;
|
||||
const points = (key: keyof MetricRow): Point[] =>
|
||||
(metricRows ?? []).map((r) => ({ ts: r.bucket, v: r[key] as number | null }));
|
||||
|
||||
const pending = (approvedData?.approved ?? []).filter((a) => a.status === "pending");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* header row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Overview</h1>
|
||||
<p className="mt-0.5 text-xs text-ink2">
|
||||
{server?.serverType ?? "Fabric"} · Minecraft {server?.version ?? "…"}
|
||||
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => power("start")}
|
||||
busy={busyAction === "start"}
|
||||
disabled={running || !server?.dockerAvailable}
|
||||
>
|
||||
<Play size={13} /> Start
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => power("restart")}
|
||||
busy={busyAction === "restart"}
|
||||
disabled={!running}
|
||||
>
|
||||
<RotateCw size={13} /> Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => power("stop")}
|
||||
busy={busyAction === "stop"}
|
||||
disabled={!running}
|
||||
>
|
||||
<Square size={13} /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!server?.dockerAvailable && server && (
|
||||
<div className="rounded-xl border border-crit/40 bg-crit/10 px-4 py-3 text-sm text-crit">
|
||||
Docker is unreachable from the panel. If you're running the dashboard outside the compose stack,
|
||||
server control and metrics are unavailable.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stat tiles */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile
|
||||
label="TPS"
|
||||
value={m?.tps != null ? Number(m.tps).toFixed(1) : "—"}
|
||||
sub="target 20.0"
|
||||
accent={m?.tps != null ? (Number(m.tps) >= 18 ? "good" : Number(m.tps) >= 15 ? "warn" : "crit") : undefined}
|
||||
>
|
||||
<Sparkline points={points("tps")} yMax={20} color="var(--color-series2)" />
|
||||
</StatTile>
|
||||
<StatTile label="CPU" value={m?.cpu != null ? `${Number(m.cpu).toFixed(0)}%` : "—"} sub="of one core × cores">
|
||||
<Sparkline points={points("cpu")} />
|
||||
</StatTile>
|
||||
<StatTile
|
||||
label="Memory"
|
||||
value={m?.mem_used != null ? fmtBytes(Number(m.mem_used)) : "—"}
|
||||
sub={m?.mem_limit ? `limit ${fmtBytes(Number(m.mem_limit))}` : undefined}
|
||||
>
|
||||
<Sparkline points={points("mem_used")} yMax={m?.mem_limit ? Number(m.mem_limit) : undefined} />
|
||||
</StatTile>
|
||||
<StatTile
|
||||
label="Players"
|
||||
value={`${server?.players.online ?? 0}${server?.players.max ? ` / ${server.players.max}` : ""}`}
|
||||
sub={server?.players.names.join(", ") || "nobody online"}
|
||||
>
|
||||
<Sparkline points={points("players")} color="var(--color-series3)" />
|
||||
</StatTile>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* activity feed */}
|
||||
<Card title="Recent activity">
|
||||
{!activity || activity.length === 0 ? (
|
||||
<EmptyState title="No activity yet" hint="Joins, registrations and server events show up here." />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{activity.map((a) => {
|
||||
const meta = ACTIVITY_LABELS[a.type] ?? { label: a.type, color: "muted" as const };
|
||||
return (
|
||||
<li key={a.id} className="flex items-center gap-2.5 text-sm">
|
||||
<Badge color={meta.color}>{meta.label}</Badge>
|
||||
<span className="truncate text-ink">
|
||||
{a.player && <span className="font-medium">{a.player} </span>}
|
||||
<span className="text-ink2">{a.detail}</span>
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-muted">{timeAgo(a.ts)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* pending registrations */}
|
||||
{pending.length > 0 && (
|
||||
<Card title="Waiting to register">
|
||||
<ul className="space-y-2">
|
||||
{pending.map((p) => (
|
||||
<li key={p.username} className="flex items-center gap-2 text-sm">
|
||||
<Badge color="warn">pending</Badge>
|
||||
<span className="font-medium">{p.username}</span>
|
||||
<span className="ml-auto font-mono text-xs text-warn">
|
||||
{p.window_expires_at ? fmtCountdown(p.window_expires_at) : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-[11px] text-muted">
|
||||
They must join and <code>/register <password> <password></code> before the timer runs
|
||||
out. Manage in{" "}
|
||||
<Link href="/access" className="text-accent underline">
|
||||
Access & Auth
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* world + disk */}
|
||||
<Card title="World & storage">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Cuboid size={15} className="text-muted" />
|
||||
<span className="text-ink2">World</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{server?.world.levelName ?? "world"} · {fmtBytes(server?.world.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe2 size={15} className="text-muted" />
|
||||
<span className="text-ink2">Dimensions</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{server?.world.dimensions.length ? server.world.dimensions.join(" · ") : "not generated yet"}
|
||||
</span>
|
||||
</div>
|
||||
{server?.world.seed && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-ink2">Seed</span>
|
||||
<span className="ml-auto font-mono text-xs">{server.world.seed}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1">
|
||||
{m?.disk_used != null && m?.disk_free != null ? (
|
||||
<UsageBar
|
||||
used={Number(m.disk_used)}
|
||||
total={Number(m.disk_used) + Number(m.disk_free)}
|
||||
label="Host disk"
|
||||
format={fmtBytes}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<HardDrive size={13} /> disk stats unavailable
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user