diff --git a/app/admin/error.tsx b/app/admin/error.tsx new file mode 100644 index 0000000..df045b5 --- /dev/null +++ b/app/admin/error.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function AdminError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Admin error:", error); + }, [error]); + + return ( +
+ + + Admin panel crashed + + A component threw an error. Other tabs may still work. + + + +
+            {error.message}
+            {error.digest ? `\n\nref: ${error.digest}` : ""}
+          
+
+ + +
+
+
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c491749..7499d81 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -21,7 +21,7 @@ export default async function AdminPage() { -
+
@@ -32,7 +32,7 @@ export default async function AdminPage() { Back to dashboard
-
+ ); } diff --git a/app/api/mods/update/route.ts b/app/api/mods/update/route.ts new file mode 100644 index 0000000..e4a37ab --- /dev/null +++ b/app/api/mods/update/route.ts @@ -0,0 +1,400 @@ +import { NextRequest } from "next/server"; +import { existsSync, readFileSync, unlinkSync } from "fs"; +import { join } from "path"; +import { exec } from "child_process"; +import { auth } from "@/lib/auth"; +import { downloadMod, getLatestVersion } from "@/lib/modrinth"; +import type { ModSide } from "@/lib/modrinth"; +import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots"; +import { + waitForServerAdaptive, + rebuildModpack, + addModMetadata, + removeModMetadata, + invalidateModsCache, +} from "@/lib/mods"; +import { invalidate as invalidateCache } from "@/lib/cache"; +import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "@/lib/constants"; + +export const dynamic = "force-dynamic"; + +type ModMetadataEntry = { projectId: string; side: ModSide }; + +function restartServer(): Promise { + return new Promise((resolve, reject) => { + exec("sudo systemctl restart minecraft.service", (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function locateMod(filename: string): { dir: string; side: ModSide } | null { + if (existsSync(join(MODS_DIR, filename))) { + return { dir: MODS_DIR, side: "both" }; + } + if (existsSync(join(CLIENT_MODS_DIR, filename))) { + return { dir: CLIENT_MODS_DIR, side: "client" }; + } + return null; +} + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + const { filenames } = (await req.json()) as { filenames: string[] }; + + if (!Array.isArray(filenames) || filenames.length === 0) { + return new Response(JSON.stringify({ error: "No mods to update" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Validate filenames + for (const f of filenames) { + if (!f || !f.endsWith(".jar") || f.includes("/") || f.includes("\\")) { + return new Response(JSON.stringify({ error: `Invalid filename: ${f}` }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + } + + let metadata: Record; + try { + metadata = JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8")); + } catch { + return new Response( + JSON.stringify({ error: "Mod metadata unavailable" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + const encoder = new TextEncoder(); + + async function send(event: string, data: object) { + try { + await writer.write( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + ); + } catch {} + } + + (async () => { + const snapName = `before-update-${Date.now()}`; + + try { + // 1. Resolve targets + await send("step", { + id: "resolve", + status: "active", + message: `Resolving latest versions for ${filenames.length} mod(s)...`, + }); + + type Target = { + oldFilename: string; + dir: string; + side: ModSide; + projectId: string; + newFilename: string; + url: string; + }; + const targets: Target[] = []; + + for (const f of filenames) { + const entry = metadata[f]; + if (!entry?.projectId) { + await send("step", { + id: "resolve", + status: "error", + message: `${f}: unknown origin (not installed via dashboard)`, + }); + await send("done", { + success: false, + message: `Cannot update ${f} — no Modrinth project linked.`, + }); + return; + } + const loc = locateMod(f); + if (!loc) { + await send("step", { + id: "resolve", + status: "error", + message: `${f}: file missing on disk`, + }); + await send("done", { success: false, message: `Mod file missing: ${f}` }); + return; + } + const side: ModSide = entry.side || loc.side; + const latest = await getLatestVersion(entry.projectId); + if (!latest || !latest.files?.[0]) { + await send("step", { + id: "resolve", + status: "error", + message: `${f}: no compatible Modrinth version`, + }); + await send("done", { + success: false, + message: `No update available for ${f}.`, + }); + return; + } + const file = latest.files[0]; + if (file.filename === f) { + // Already up to date + continue; + } + targets.push({ + oldFilename: f, + dir: side === "client" ? CLIENT_MODS_DIR : MODS_DIR, + side, + projectId: entry.projectId, + newFilename: file.filename, + url: file.url, + }); + } + + if (targets.length === 0) { + await send("step", { + id: "resolve", + status: "done", + message: "All selected mods already current", + }); + await send("done", { + success: true, + message: "No updates needed — everything is up to date.", + installed: [], + }); + return; + } + await send("step", { + id: "resolve", + status: "done", + message: `${targets.length} update(s) to apply`, + }); + + // 2. Snapshot + await send("step", { + id: "snapshot", + status: "active", + message: "Creating safety snapshot...", + }); + try { + createSnapshot(snapName); + } catch (e) { + await send("step", { + id: "snapshot", + status: "error", + message: (e as Error).message, + }); + await send("done", { + success: false, + message: `Snapshot failed: ${(e as Error).message}`, + }); + return; + } + await send("step", { id: "snapshot", status: "done", message: "Snapshot created" }); + + // 3. Download + swap + await send("step", { + id: "download", + status: "active", + message: `Downloading ${targets.length} mod(s)...`, + }); + try { + for (const t of targets) { + await downloadMod(t.url, t.newFilename, t.side); + const oldPath = join(t.dir, t.oldFilename); + if (existsSync(oldPath) && t.oldFilename !== t.newFilename) { + unlinkSync(oldPath); + } + removeModMetadata(t.oldFilename); + addModMetadata(t.newFilename, { projectId: t.projectId, side: t.side }); + } + invalidateModsCache(); + invalidateCache("mods:updates"); + } catch (e) { + await send("step", { + id: "download", + status: "error", + message: (e as Error).message, + }); + await send("step", { + id: "rollback", + status: "active", + message: "Rolling back...", + }); + try { + const snaps = listSnapshots(); + if (snaps.length > 0) restoreSnapshot(snaps[0].dirName); + } catch {} + await send("step", { + id: "rollback", + status: "done", + message: "Rolled back to snapshot", + }); + await send("done", { + success: false, + message: `Download failed: ${(e as Error).message}`, + rolledBack: true, + }); + return; + } + await send("step", { + id: "download", + status: "done", + message: "All mods downloaded", + }); + + // 4. Restart if any server-side updates + const hasServerMods = targets.some((t) => t.side !== "client"); + if (!hasServerMods) { + await send("step", { + id: "modpack", + status: "active", + message: "Rebuilding modpack...", + }); + try { rebuildModpack(); } catch {} + await send("step", { + id: "modpack", + status: "done", + message: "Modpack updated", + }); + await send("done", { + success: true, + message: `Updated ${targets.length} client-only mod(s).`, + installed: targets.map((t) => t.newFilename), + }); + return; + } + + await send("step", { + id: "restart", + status: "active", + message: "Restarting server...", + }); + try { + await restartServer(); + } catch (e) { + await send("step", { + id: "restart", + status: "error", + message: (e as Error).message, + }); + await send("step", { id: "rollback", status: "active", message: "Rolling back..." }); + try { + const snaps = listSnapshots(); + if (snaps.length > 0) restoreSnapshot(snaps[0].dirName); + } catch {} + await send("step", { + id: "rollback", + status: "done", + message: "Rolled back to snapshot", + }); + await send("done", { + success: false, + message: `Restart failed: ${(e as Error).message}`, + rolledBack: true, + }); + return; + } + await send("step", { + id: "restart", + status: "done", + message: "Restart command sent", + }); + + // 5. Verify + await send("step", { + id: "health", + status: "active", + message: "Waiting for server...", + }); + const online = await waitForServerAdaptive(async (p) => { + await send("step", { id: "health", status: "active", message: p.message }); + }); + + if (online) { + await send("step", { + id: "health", + status: "done", + message: "Server is online", + }); + await send("step", { + id: "modpack", + status: "active", + message: "Rebuilding modpack...", + }); + try { rebuildModpack(); } catch {} + await send("step", { + id: "modpack", + status: "done", + message: "Modpack updated", + }); + await send("done", { + success: true, + message: `Updated ${targets.length} mod(s). Server is online.`, + installed: targets.map((t) => t.newFilename), + }); + } else { + await send("step", { + id: "health", + status: "error", + message: "Server failed to start", + }); + await send("step", { + id: "rollback", + status: "active", + message: "Rolling back to snapshot...", + }); + try { + const snaps = listSnapshots(); + if (snaps.length > 0) { + restoreSnapshot(snaps[0].dirName); + await restartServer(); + await waitForServerAdaptive(async (p) => { + await send("step", { + id: "rollback", + status: "active", + message: `Rollback: ${p.message}`, + }); + }); + try { rebuildModpack(); } catch {} + } + } catch {} + await send("step", { + id: "rollback", + status: "done", + message: "Rolled back and server restarted", + }); + await send("done", { + success: false, + message: `Server failed after updating ${targets.length} mod(s). Rolled back.`, + rolledBack: true, + }); + } + } catch (e) { + await send("done", { success: false, message: (e as Error).message }); + } finally { + try { writer.close(); } catch {} + } + })(); + + return new Response(stream.readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..a15894f --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("App error:", error); + }, [error]); + + return ( +
+
+

Something went wrong

+

+ {error.message || "An unexpected error occurred."} +

+ {error.digest && ( +

+ ref: {error.digest} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..db5f3ee --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,70 @@ +"use client"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
+

+ The dashboard hit a fatal error +

+

+ {error.message || "Unexpected error"} +

+ {error.digest && ( +

+ ref: {error.digest} +

+ )} + +
+ + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..d657688 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Navbar } from "@/components/Navbar"; + +export default function NotFound() { + return ( + <> + +
+
+

+ 404 +

+

Page not found

+

+ The page you're looking for doesn't exist or has moved. +

+ +
+
+ + ); +} diff --git a/app/page.tsx b/app/page.tsx index 9e1da8f..4934186 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -21,13 +21,13 @@ export default function Home() { {/* Content */} -
+
-
+