diff --git a/app/providers.tsx b/app/providers.tsx index c21a354..cbde16b 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -5,6 +5,7 @@ import { SessionProvider } from "next-auth/react"; import { Toaster } from "sonner"; import { useEffect, useState } from "react"; import { CommandPalette } from "@/components/CommandPalette"; +import { PlayerDrawer } from "@/components/PlayerDrawer"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -34,6 +35,7 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} + = 95 ? "#4ade80" : percent >= 80 ? "#facc15" : "#f87171"; + return ( + + + + + {Math.round(percent)}% + + + ); +} + function Sparkline({ data, color, @@ -28,6 +75,8 @@ function Sparkline({ label, unit, currentValue, + peakIndex, + peakLabel, }: { data: number[]; color: string; @@ -36,6 +85,8 @@ function Sparkline({ label: string; unit: string; currentValue: string; + peakIndex?: number; + peakLabel?: string; }) { if (data.length < 2) { return ( @@ -55,17 +106,27 @@ function Sparkline({ ); } - const { pathD, areaD, w } = useMemo(() => { + const { pathD, areaD, w, peakXY } = useMemo(() => { const dataMax = max || Math.max(...data, 1); const width = 300; - const pts = data.map((v, i) => { + const coords = data.map((v, i) => { const x = (i / (data.length - 1)) * width; const y = height - (v / dataMax) * (height - 10) - 5; - return `${x},${y}`; + return { x, y }; }); + const pts = coords.map((c) => `${c.x},${c.y}`); const p = `M${pts.join(" L")}`; - return { pathD: p, areaD: `${p} L${width},${height} L0,${height} Z`, w: width }; - }, [data, max, height]); + const peak = + typeof peakIndex === "number" && peakIndex >= 0 && peakIndex < coords.length + ? coords[peakIndex] + : null; + return { + pathD: p, + areaD: `${p} L${width},${height} L0,${height} Z`, + w: width, + peakXY: peak, + }; + }, [data, max, height, peakIndex]); return (
@@ -87,7 +148,18 @@ function Sparkline({ + {peakXY && ( + <> + + + + )} + {peakLabel && ( +

+ peak {peakLabel} +

+ )}
); } @@ -105,6 +177,25 @@ export function Analytics() { const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null; + const { uptimePct, peakPlayers, peakIndex, onlineNow } = useMemo(() => { + if (metrics.length === 0) { + return { uptimePct: 0, peakPlayers: 0, peakIndex: -1, onlineNow: [] as string[] }; + } + const alive = metrics.filter((m) => m.tps > 0 || m.playersOnline > 0).length; + const pct = (alive / metrics.length) * 100; + let peak = -Infinity; + let idx = -1; + metrics.forEach((m, i) => { + if (m.playersOnline > peak) { + peak = m.playersOnline; + idx = i; + } + }); + const online = + (latest?.players?.filter((p): p is string => typeof p === "string" && p.length > 0)) || []; + return { uptimePct: pct, peakPlayers: peak, peakIndex: idx, onlineNow: online }; + }, [metrics, latest]); + const ranges = [ { label: "1h", value: 1 }, { label: "6h", value: 6 }, @@ -138,7 +229,57 @@ export function Analytics() { - + + {metrics.length > 0 && ( +
+
+ +
+

Uptime

+

+ {uptimePct.toFixed(1)}%{" "} + + over last {hours}h + +

+
+
+
+
+

Peak players

+

+ {peakPlayers >= 0 ? peakPlayers : 0} +

+
+
+
+

+ Online now ({onlineNow.length}) +

+ {onlineNow.length === 0 ? ( +

+ ) : ( +
+ {onlineNow.slice(0, 8).map((name) => ( + + + {name} + + ))} + {onlineNow.length > 8 && ( + + +{onlineNow.length - 8} + + )} +
+ )} +
+
+ )}
m.tps)} @@ -171,6 +312,8 @@ export function Analytics() { label="Players" unit="" currentValue={latest ? latest.playersOnline.toString() : "0"} + peakIndex={peakPlayers > 0 ? peakIndex : undefined} + peakLabel={peakPlayers > 0 ? peakPlayers.toString() : undefined} />
diff --git a/components/ModManager.tsx b/components/ModManager.tsx index 3a4cfd0..f9a3915 100644 --- a/components/ModManager.tsx +++ b/components/ModManager.tsx @@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; -import { timeAgo } from "@/lib/time"; +import { timeAgo, formatBytes } from "@/lib/time"; import { Card, CardContent, @@ -67,6 +67,7 @@ type SnapshotInfo = { createdAt: string; modCount: number; mods: string[]; + sizeBytes?: number; }; type WizardStep = "idle" | "searching" | "reviewing" | "installing"; @@ -170,6 +171,7 @@ export function ModManager() { const [confirmDeleteSnap, setConfirmDeleteSnap] = useState(null); const [installedQuery, setInstalledQuery] = useState(""); const [sideFilter, setSideFilter] = useState<"all" | ModSide>("all"); + const [typedConfirm, setTypedConfirm] = useState(""); const debounceRef = useRef>(null); // Installed mods @@ -209,6 +211,7 @@ export function ModManager() { setConfirmRemove(null); setConfirmRestore(null); setConfirmDeleteSnap(null); + setTypedConfirm(""); return; } if (step === "searching" || step === "reviewing") { @@ -1036,97 +1039,128 @@ export function ModManager() {
    - {snapshots.map((snap) => ( -
  • -
    -
    - {snap.name} - - {snap.modCount} mods - + {snapshots.map((snap) => { + const pending = + confirmRestore === snap.dirName || confirmDeleteSnap === snap.dirName; + const mode = confirmRestore === snap.dirName ? "restore" : "delete"; + const matched = typedConfirm === snap.name; + return ( +
  • +
    +
    +
    + {snap.name} + + {snap.modCount} mods + + {typeof snap.sizeBytes === "number" && snap.sizeBytes > 0 && ( + + {formatBytes(snap.sizeBytes)} + + )} +
    +

    + {timeAgo(snap.createdAt)} +

    +
    +
    + {pending ? ( + + ) : ( + <> + + + + )} +
    -

    - {timeAgo(snap.createdAt)} -

    -
-
- {confirmRestore === snap.dirName ? ( - <> - - - - ) : confirmDeleteSnap === snap.dirName ? ( - <> - - - - ) : ( - <> - - - + {pending && ( +
+

+ Type {snap.name} to{" "} + {mode === "restore" ? "confirm restore" : "confirm delete"} +

+
+ setTypedConfirm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && matched) { + if (mode === "restore") restoreSnap.mutate(snap.dirName); + else { + deleteSnap.mutate(snap.dirName); + setConfirmDeleteSnap(null); + setTypedConfirm(""); + } + } + }} + placeholder={snap.name} + className="h-9 text-sm flex-1 font-mono" + /> + +
+
)} -
- - ))} + + ); + })} diff --git a/components/PlayerAvatar.tsx b/components/PlayerAvatar.tsx index 576c30e..1e4ccc1 100644 --- a/components/PlayerAvatar.tsx +++ b/components/PlayerAvatar.tsx @@ -2,41 +2,59 @@ import Image from "next/image"; import { useState } from "react"; +import { dispatchAppEvent } from "@/lib/events"; export function PlayerAvatar({ name, size = 24, className = "", + interactive = true, }: { name: string; size?: number; className?: string; + interactive?: boolean; }) { const [failed, setFailed] = useState(false); const dim = `${size}px`; const initial = name.slice(0, 1).toUpperCase(); - if (failed || !name) { - return ( + const inner = + failed || !name ? (
{initial}
+ ) : ( + setFailed(true)} + className="rounded shrink-0 bg-muted" + /> ); + + if (!interactive || !name) { + return {inner}; } return ( - setFailed(true)} - className={`rounded shrink-0 bg-muted ${className}`} - /> + ); } diff --git a/components/PlayerDrawer.tsx b/components/PlayerDrawer.tsx new file mode 100644 index 0000000..bf4c665 --- /dev/null +++ b/components/PlayerDrawer.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { PlayerAvatar } from "@/components/PlayerAvatar"; +import { onAppEvent } from "@/lib/events"; + +type PlayerData = { + ops: { name: string; uuid: string; level: number }[]; + whitelist: { name: string; uuid: string }[]; + banned: { name: string; uuid: string; reason: string }[]; +}; + +type MetricEntry = { ts: string; players?: string[] }; + +export function PlayerDrawer() { + const { data: session } = useSession(); + const queryClient = useQueryClient(); + const [name, setName] = useState(null); + const [banReason, setBanReason] = useState(""); + + useEffect(() => onAppEvent("player:open", ({ name }) => setName(name)), []); + + useEffect(() => { + if (!name) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setName(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [name]); + + const authed = !!session; + + const players = useQuery({ + queryKey: ["players"], + queryFn: () => fetch("/api/players").then((r) => r.json()), + enabled: authed && !!name, + staleTime: 10_000, + }); + + // Use existing analytics cache (last entry) to determine online now + const analytics = queryClient.getQueryData(["analytics", 6]); + const onlineNow = + (analytics && analytics.length > 0 + ? analytics[analytics.length - 1].players || [] + : []) as string[]; + + const isOp = !!name && !!players.data?.ops.some((p) => p.name === name); + const isWhitelisted = + !!name && !!players.data?.whitelist.some((p) => p.name === name); + const bannedEntry = + (name && players.data?.banned.find((p) => p.name === name)) || null; + const isBanned = !!bannedEntry; + const isOnline = !!name && onlineNow.includes(name); + + const action = useMutation({ + mutationFn: async (params: { action: string; reason?: string }) => { + if (!name) throw new Error("No player selected"); + const res = await fetch("/api/players", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: params.action, player: name, reason: params.reason }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + onSuccess: (data) => { + toast.success(data.response || "Done"); + setBanReason(""); + queryClient.invalidateQueries({ queryKey: ["players"] }); + }, + onError: (err) => toast.error("Action failed", { description: err.message }), + }); + + if (!name) return null; + + return ( +
{ + if (e.target === e.currentTarget) setName(null); + }} + > + +
+ ); +} diff --git a/lib/events.ts b/lib/events.ts new file mode 100644 index 0000000..1682c25 --- /dev/null +++ b/lib/events.ts @@ -0,0 +1,21 @@ +export type AppEvents = { + "player:open": { name: string }; +}; + +export function dispatchAppEvent( + name: K, + detail: AppEvents[K] +): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(name, { detail })); +} + +export function onAppEvent( + name: K, + handler: (detail: AppEvents[K]) => void +): () => void { + if (typeof window === "undefined") return () => {}; + const listener = (e: Event) => handler((e as CustomEvent).detail); + window.addEventListener(name, listener); + return () => window.removeEventListener(name, listener); +} diff --git a/lib/snapshots.ts b/lib/snapshots.ts index 3c82f4b..d73ee07 100644 --- a/lib/snapshots.ts +++ b/lib/snapshots.ts @@ -4,6 +4,7 @@ import { readdirSync, copyFileSync, rmSync, + statSync, writeFileSync, readFileSync, unlinkSync, @@ -11,6 +12,22 @@ import { import { join } from "path"; import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "./constants"; +function dirSize(dir: string): number { + let total = 0; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) total += dirSize(p); + else if (entry.isFile()) { + try { + total += statSync(p).size; + } catch {} + } + } + } catch {} + return total; +} + const SNAPSHOTS_DIR = "/home/minecraft/server/snapshots"; const MAX_SNAPSHOTS = 10; @@ -19,6 +36,7 @@ export type SnapshotMeta = { createdAt: string; modCount: number; mods: string[]; + sizeBytes?: number; }; function ensureDir(dir: string) { @@ -133,7 +151,8 @@ export function listSnapshots(): (SnapshotMeta & { dirName: string })[] { const meta = JSON.parse( readFileSync(join(SNAPSHOTS_DIR, dirName, "meta.json"), "utf8") ) as SnapshotMeta; - return { ...meta, dirName }; + const sizeBytes = dirSize(join(SNAPSHOTS_DIR, dirName)); + return { ...meta, dirName, sizeBytes }; }) .sort( (a, b) =>