Initial commit: Minecraft dashboard
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>
This commit is contained in:
commit
dd69c17c3b
77 changed files with 7007 additions and 0 deletions
180
app/api/mods/batch-install/route.ts
Normal file
180
app/api/mods/batch-install/route.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { downloadMod } from "@/lib/modrinth";
|
||||
import type { ModSide } from "@/lib/modrinth";
|
||||
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
|
||||
import { waitForServerAdaptive, rebuildModpack, addModMetadata } from "@/lib/mods";
|
||||
import { exec } from "child_process";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type ModToInstall = {
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
title: string;
|
||||
side: ModSide;
|
||||
};
|
||||
|
||||
function restartServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec("sudo systemctl restart minecraft.service", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 { mods, snapshotName } = (await req.json()) as {
|
||||
mods: ModToInstall[];
|
||||
snapshotName: string;
|
||||
};
|
||||
|
||||
if (!Array.isArray(mods) || mods.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "No mods to install" }), {
|
||||
status: 400,
|
||||
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 {}
|
||||
}
|
||||
|
||||
// Run the pipeline in the background, streaming events as we go
|
||||
(async () => {
|
||||
const name = snapshotName || `before-install-${Date.now()}`;
|
||||
|
||||
try {
|
||||
// 1. Create snapshot
|
||||
await send("step", { id: "snapshot", status: "active", message: "Creating safety snapshot..." });
|
||||
try {
|
||||
createSnapshot(name);
|
||||
} catch (e) {
|
||||
await send("step", { id: "snapshot", status: "error", message: (e as Error).message });
|
||||
await send("done", { success: false, message: `Failed to create snapshot: ${(e as Error).message}` });
|
||||
return;
|
||||
}
|
||||
await send("step", { id: "snapshot", status: "done", message: "Snapshot created" });
|
||||
|
||||
// 2. Download mods
|
||||
await send("step", { id: "download", status: "active", message: `Downloading ${mods.length} mod(s)...` });
|
||||
try {
|
||||
for (const mod of mods) {
|
||||
await downloadMod(mod.url, mod.filename, mod.side);
|
||||
addModMetadata(mod.filename, { projectId: mod.projectId, side: mod.side });
|
||||
}
|
||||
} 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" });
|
||||
|
||||
// 3. Check if server-side mods need a restart
|
||||
const hasServerMods = mods.some((m) => m.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: `Installed ${mods.length} client-only mod(s). No server restart needed.`,
|
||||
installed: mods.map((m) => m.filename),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Restart server
|
||||
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: `Server restart failed: ${(e as Error).message}`, rolledBack: true });
|
||||
return;
|
||||
}
|
||||
await send("step", { id: "restart", status: "done", message: "Restart command sent" });
|
||||
|
||||
// 5. Wait for server with progress reporting
|
||||
await send("step", { id: "health", status: "active", message: "Waiting for server..." });
|
||||
const online = await waitForServerAdaptive(async (progress) => {
|
||||
await send("step", { id: "health", status: "active", message: progress.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: `Installed ${mods.length} mod(s). Server is online.`,
|
||||
installed: mods.map((m) => m.filename),
|
||||
});
|
||||
} 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 (progress) => {
|
||||
await send("step", { id: "rollback", status: "active", message: `Rollback: ${progress.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 to start after installing ${mods.length} mod(s). Rolled back to snapshot.`,
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
101
app/api/mods/remove/route.ts
Normal file
101
app/api/mods/remove/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { exec } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { removeMod, isClientOnlyMod, waitForServer, rebuildModpack } from "@/lib/mods";
|
||||
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
|
||||
import { MODS_DIR, CLIENT_MODS_DIR } from "@/lib/constants";
|
||||
|
||||
function restartServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec("sudo systemctl restart minecraft.service", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { filename } = await req.json();
|
||||
|
||||
if (!filename || !filename.endsWith(".jar")) {
|
||||
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (filename.includes("/") || filename.includes("\\")) {
|
||||
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
|
||||
}
|
||||
|
||||
const inServerDir = existsSync(join(MODS_DIR, filename));
|
||||
const inClientDir = existsSync(join(CLIENT_MODS_DIR, filename));
|
||||
|
||||
if (!inServerDir && !inClientDir) {
|
||||
return NextResponse.json({ error: "Mod not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create snapshot before removing
|
||||
const snapName = `before-remove-${filename.replace(".jar", "")}`;
|
||||
createSnapshot(snapName);
|
||||
|
||||
const clientOnly = !inServerDir && inClientDir;
|
||||
|
||||
// Remove the mod
|
||||
removeMod(filename);
|
||||
|
||||
if (clientOnly) {
|
||||
// Client-only mod — no restart needed
|
||||
try { rebuildModpack(); } catch {}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Removed client-only mod "${filename}". No server restart needed.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Server mod — restart and verify
|
||||
try {
|
||||
await restartServer();
|
||||
} catch (e) {
|
||||
const snaps = listSnapshots();
|
||||
const snap = snaps[0];
|
||||
if (snap) restoreSnapshot(snap.dirName);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `Restart failed: ${(e as Error).message}`,
|
||||
rolledBack: true,
|
||||
});
|
||||
}
|
||||
|
||||
const online = await waitForServer(90000);
|
||||
|
||||
if (online) {
|
||||
try { rebuildModpack(); } catch {}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Removed "${filename}". Server is online.`,
|
||||
});
|
||||
} else {
|
||||
// Rollback
|
||||
const snaps = listSnapshots();
|
||||
const snap = snaps[0];
|
||||
if (snap) {
|
||||
restoreSnapshot(snap.dirName);
|
||||
try {
|
||||
await restartServer();
|
||||
await waitForServer(90000);
|
||||
try { rebuildModpack(); } catch {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `Server failed after removing "${filename}". Rolled back.`,
|
||||
rolledBack: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
28
app/api/mods/resolve/route.ts
Normal file
28
app/api/mods/resolve/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveDependencies } from "@/lib/modrinth";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { projectIds, titles } = await req.json();
|
||||
if (!Array.isArray(projectIds) || projectIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No mods selected" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveDependencies(projectIds, titles || {});
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
16
app/api/mods/route.ts
Normal file
16
app/api/mods/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getModDetails } from "@/lib/mods";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const mods = getModDetails();
|
||||
return NextResponse.json(mods);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/api/mods/search/route.ts
Normal file
21
app/api/mods/search/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { searchMods } from "@/lib/modrinth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const query = req.nextUrl.searchParams.get("q");
|
||||
if (!query || query.length < 2) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await searchMods(query);
|
||||
return NextResponse.json(results);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue