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>
41
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
5
AGENTS.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
1
CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@AGENTS.md
|
||||||
36
README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
53
app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
import { ClientOnly } from "@/components/ClientOnly";
|
||||||
|
import { ServerControls } from "@/components/ServerControls";
|
||||||
|
import { Analytics } from "@/components/Analytics";
|
||||||
|
import { PlayerManager } from "@/components/PlayerManager";
|
||||||
|
import { ChatBridge } from "@/components/ChatBridge";
|
||||||
|
import { ModManager } from "@/components/ModManager";
|
||||||
|
import { BackupManager } from "@/components/BackupManager";
|
||||||
|
import { LogViewer } from "@/components/LogViewer";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div className="border-b border-border bg-card py-4 sm:py-6">
|
||||||
|
<div className="max-w-5xl mx-auto px-3 sm:px-6">
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">Admin Panel</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage your Minecraft server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 space-y-4 sm:space-y-6 w-full overflow-x-hidden">
|
||||||
|
<ServerControls />
|
||||||
|
<Analytics />
|
||||||
|
<PlayerManager />
|
||||||
|
<ChatBridge />
|
||||||
|
<ModManager />
|
||||||
|
<BackupManager />
|
||||||
|
<LogViewer />
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition"
|
||||||
|
>
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
app/api/analytics/route.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const ANALYTICS_FILE = "/home/minecraft/server/analytics.jsonl";
|
||||||
|
|
||||||
|
type MetricEntry = {
|
||||||
|
ts: string;
|
||||||
|
tps: number;
|
||||||
|
ramUsedMB: number;
|
||||||
|
ramTotalMB: number;
|
||||||
|
cpuPercent: number;
|
||||||
|
playersOnline: number;
|
||||||
|
players: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.min(
|
||||||
|
parseInt(req.nextUrl.searchParams.get("hours") || "6"),
|
||||||
|
48
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existsSync(ANALYTICS_FILE)) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = readFileSync(ANALYTICS_FILE, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const entries: MetricEntry[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as MetricEntry;
|
||||||
|
if (entry.ts >= cutoff) entries.push(entry);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(entries);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
38
app/api/backups/download/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { existsSync, createReadStream, statSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const BACKUP_DIR = "/home/minecraft/server/backups";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = req.nextUrl.searchParams.get("name");
|
||||||
|
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
|
||||||
|
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = join(BACKUP_DIR, name);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
const webStream = Readable.toWeb(stream) as ReadableStream;
|
||||||
|
|
||||||
|
return new Response(webStream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/gzip",
|
||||||
|
"Content-Disposition": `attachment; filename="${name}"`,
|
||||||
|
"Content-Length": stat.size.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
66
app/api/backups/restore/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { execSync, exec } from "child_process";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { waitForServer } from "@/lib/mods";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const BACKUP_DIR = "/home/minecraft/server/backups";
|
||||||
|
const WORLD_DIR = "/home/minecraft/server/world";
|
||||||
|
const SERVER_DIR = "/home/minecraft/server";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await req.json();
|
||||||
|
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
|
||||||
|
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = join(BACKUP_DIR, name);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stop server
|
||||||
|
execSync("sudo systemctl stop minecraft.service", { timeout: 30000 });
|
||||||
|
|
||||||
|
// Wait for it to stop
|
||||||
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
|
||||||
|
// Remove current world
|
||||||
|
execSync(`rm -rf ${WORLD_DIR}`);
|
||||||
|
|
||||||
|
// Extract backup
|
||||||
|
execSync(`tar xzf ${filePath} -C ${SERVER_DIR}`);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
exec("sudo systemctl start minecraft.service", (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const online = await waitForServer(90000);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
online,
|
||||||
|
message: online
|
||||||
|
? `World restored from "${name}". Server is online.`
|
||||||
|
: `World restored from "${name}". Server is starting...`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/api/backups/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { readdirSync, statSync, unlinkSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { execSync, exec } from "child_process";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const BACKUP_DIR = "/home/minecraft/server/backups";
|
||||||
|
const BACKUP_SCRIPT = "/home/minecraft/dashboard/scripts/backup-world.sh";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(BACKUP_DIR)) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = readdirSync(BACKUP_DIR)
|
||||||
|
.filter((f) => f.endsWith(".tar.gz"))
|
||||||
|
.map((f) => {
|
||||||
|
const stat = statSync(join(BACKUP_DIR, f));
|
||||||
|
return {
|
||||||
|
name: f,
|
||||||
|
size: (stat.size / 1024 / 1024).toFixed(1) + " MB",
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
createdAt: stat.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return NextResponse.json(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup now
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`bash ${BACKUP_SCRIPT}`, { encoding: "utf8", timeout: 60000 });
|
||||||
|
return NextResponse.json({ ok: true, message: "Backup created" });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete backup
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await req.json();
|
||||||
|
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
|
||||||
|
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = join(BACKUP_DIR, name);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkSync(filePath);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
124
app/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { sendCommand } from "@/lib/rcon";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const LOG_FILE = "/home/minecraft/server/logs/latest.log";
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
time: string;
|
||||||
|
type: "chat" | "join" | "leave" | "death" | "server";
|
||||||
|
player: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseLogLine(line: string): ChatMessage | null {
|
||||||
|
// [HH:MM:SS] [Server thread/INFO] [minecraft/DedicatedServer]: <Player> message
|
||||||
|
const chatMatch = line.match(
|
||||||
|
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*<(\w+)>\s*(.*)/
|
||||||
|
);
|
||||||
|
if (chatMatch) {
|
||||||
|
return { time: chatMatch[1], type: "chat", player: chatMatch[2], message: chatMatch[3] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player joins
|
||||||
|
const joinMatch = line.match(
|
||||||
|
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+joined the game/
|
||||||
|
);
|
||||||
|
if (joinMatch) {
|
||||||
|
return { time: joinMatch[1], type: "join", player: joinMatch[2], message: "joined the game" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player leaves
|
||||||
|
const leaveMatch = line.match(
|
||||||
|
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+left the game/
|
||||||
|
);
|
||||||
|
if (leaveMatch) {
|
||||||
|
return { time: leaveMatch[1], type: "leave", player: leaveMatch[2], message: "left the game" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deaths
|
||||||
|
const deathMatch = line.match(
|
||||||
|
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*(\w+)\s+(was |died|drowned|burned|fell|starved|suffocated|hit|blew|withered|tried|experienced|went|walked|froze|was prick|was stung|was impaled|was squashed|was skewered|was squished|was pummeled|discovered)(.*)/
|
||||||
|
);
|
||||||
|
if (deathMatch) {
|
||||||
|
return {
|
||||||
|
time: deathMatch[1],
|
||||||
|
type: "death",
|
||||||
|
player: deathMatch[2],
|
||||||
|
message: deathMatch[3] + (deathMatch[4] || ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server say command
|
||||||
|
const sayMatch = line.match(
|
||||||
|
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*\[Server\]\s*(.*)/
|
||||||
|
);
|
||||||
|
if (sayMatch) {
|
||||||
|
return { time: sayMatch[1], type: "server", player: "Server", message: sayMatch[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(LOG_FILE)) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLines = parseInt(req.nextUrl.searchParams.get("lines") || "100");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(LOG_FILE, "utf8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
|
||||||
|
// Parse from the end, collect up to maxLines relevant messages
|
||||||
|
for (let i = lines.length - 1; i >= 0 && messages.length < maxLines; i--) {
|
||||||
|
const msg = parseLogLine(lines[i]);
|
||||||
|
if (msg) messages.unshift(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(messages);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = await req.json();
|
||||||
|
if (!message || typeof message !== "string" || message.length > 256) {
|
||||||
|
return NextResponse.json({ error: "Invalid message" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize: strip newlines/carriage returns to prevent RCON command injection
|
||||||
|
const sanitized = message.replace(/[\r\n]/g, "").trim();
|
||||||
|
if (!sanitized) {
|
||||||
|
return NextResponse.json({ error: "Empty message" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendCommand(`say ${sanitized}`);
|
||||||
|
return NextResponse.json({ ok: true, response });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/logs/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = Math.min(
|
||||||
|
parseInt(req.nextUrl.searchParams.get("lines") || "50"),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logs = execSync(
|
||||||
|
`sudo journalctl -u minecraft.service --no-pager -n ${lines}`,
|
||||||
|
{ encoding: "utf8", timeout: 5000 }
|
||||||
|
);
|
||||||
|
return NextResponse.json({ logs });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/api/players/route.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { sendCommand } from "@/lib/rcon";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const OPS_FILE = "/home/minecraft/server/ops.json";
|
||||||
|
const WHITELIST_FILE = "/home/minecraft/server/whitelist.json";
|
||||||
|
const BANNED_FILE = "/home/minecraft/server/banned-players.json";
|
||||||
|
|
||||||
|
type OpsEntry = { uuid: string; name: string; level: number };
|
||||||
|
type WhitelistEntry = { uuid: string; name: string };
|
||||||
|
type BannedEntry = { uuid: string; name: string; reason: string; created: string; expires: string };
|
||||||
|
|
||||||
|
function readJson<T>(path: string): T[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET — list ops, whitelist, banned players
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops = readJson<OpsEntry>(OPS_FILE).map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
uuid: e.uuid,
|
||||||
|
level: e.level,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const whitelist = readJson<WhitelistEntry>(WHITELIST_FILE).map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
uuid: e.uuid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const banned = readJson<BannedEntry>(BANNED_FILE).map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
uuid: e.uuid,
|
||||||
|
reason: e.reason,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ ops, whitelist, banned });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST — execute a player management command
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, player, reason } = await req.json();
|
||||||
|
|
||||||
|
if (!player || !/^[a-zA-Z0-9_]{1,16}$/.test(player)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid player name" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedActions = ["op", "deop", "whitelist add", "whitelist remove", "ban", "pardon"];
|
||||||
|
if (!allowedActions.includes(action)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid action" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = `${action} ${player}`;
|
||||||
|
if (action === "ban" && reason) {
|
||||||
|
command += ` ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendCommand(command);
|
||||||
|
|
||||||
|
// Force server to sync JSON files
|
||||||
|
if (action.startsWith("whitelist")) {
|
||||||
|
await sendCommand("whitelist reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for server to write JSON files to disk
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, response });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `RCON failed: ${(e as Error).message}. Is the server online?` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/api/schedule/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const SCRIPT = "/home/minecraft/dashboard/scripts/scheduled-restart.sh";
|
||||||
|
const CRON_MARKER = "# mc-scheduled-restart";
|
||||||
|
|
||||||
|
function getCurrentSchedule(): { enabled: boolean; hour: number; minute: number } | null {
|
||||||
|
try {
|
||||||
|
const crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
|
||||||
|
const match = crontab.match(
|
||||||
|
new RegExp(`^(\\d+)\\s+(\\d+)\\s+\\*\\s+\\*\\s+\\*\\s+.*${CRON_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, "m")
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
return { enabled: true, minute: parseInt(match[1]), hour: parseInt(match[2]) };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = getCurrentSchedule();
|
||||||
|
return NextResponse.json(schedule || { enabled: false, hour: 4, minute: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { enabled, hour, minute } = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get existing crontab without our entry
|
||||||
|
let crontab = "";
|
||||||
|
try {
|
||||||
|
crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Remove old entry
|
||||||
|
const lines = crontab.split("\n").filter((l) => !l.includes(CRON_MARKER));
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
const h = Math.min(Math.max(parseInt(hour) || 4, 0), 23);
|
||||||
|
const m = Math.min(Math.max(parseInt(minute) || 0, 0), 59);
|
||||||
|
lines.push(`${m} ${h} * * * bash ${SCRIPT} ${CRON_MARKER}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCrontab = lines.filter(Boolean).join("\n") + "\n";
|
||||||
|
const { writeFileSync, unlinkSync } = require("fs");
|
||||||
|
const tmpFile = `/tmp/crontab-${Date.now()}.tmp`;
|
||||||
|
writeFileSync(tmpFile, newCrontab, { mode: 0o600 });
|
||||||
|
execSync(`crontab ${tmpFile}`, { encoding: "utf8" });
|
||||||
|
unlinkSync(tmpFile);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
enabled,
|
||||||
|
hour: parseInt(hour) || 4,
|
||||||
|
minute: parseInt(minute) || 0,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/server/[action]/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
const ALLOWED_ACTIONS = ["start", "stop", "restart"];
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ action: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action } = await params;
|
||||||
|
if (!ALLOWED_ACTIONS.includes(action)) {
|
||||||
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<NextResponse>((resolve) => {
|
||||||
|
exec(`sudo systemctl ${action} minecraft.service`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
resolve(
|
||||||
|
NextResponse.json({ error: err.message }, { status: 500 })
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resolve(NextResponse.json({ ok: true, action }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
47
app/api/snapshots/restore/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { restoreSnapshot } from "@/lib/snapshots";
|
||||||
|
import { rebuildModpack, waitForServer } from "@/lib/mods";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dirName } = await req.json();
|
||||||
|
if (!dirName || dirName.includes("/") || dirName.includes("..")) {
|
||||||
|
return NextResponse.json({ error: "Invalid snapshot" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
restoreSnapshot(dirName);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
exec("sudo systemctl restart minecraft.service", (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const online = await waitForServer(90000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
rebuildModpack();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
online,
|
||||||
|
message: online
|
||||||
|
? "Snapshot restored. Server is online."
|
||||||
|
: "Snapshot restored. Server is starting...",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/snapshots/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { listSnapshots, deleteSnapshot } from "@/lib/snapshots";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return NextResponse.json(listSnapshots());
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dirName } = await req.json();
|
||||||
|
if (!dirName) {
|
||||||
|
return NextResponse.json({ error: "Missing dirName" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent path traversal
|
||||||
|
if (dirName.includes("/") || dirName.includes("\\") || dirName.includes("..")) {
|
||||||
|
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deleteSnapshot(dirName);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/status/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { status } from "minecraft-server-util";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { MC_SERVER_IP, MC_SERVER_PORT } from "@/lib/constants";
|
||||||
|
import { sendCommand } from "@/lib/rcon";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
// Tier 1: MC protocol ping (fastest, gives player/version info)
|
||||||
|
try {
|
||||||
|
const result = await status(MC_SERVER_IP, MC_SERVER_PORT, {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
online: true,
|
||||||
|
players: { online: result.players.online, max: result.players.max },
|
||||||
|
version: result.version.name,
|
||||||
|
motd: result.motd.clean,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Tier 2: RCON (server is online but protocol ping failed)
|
||||||
|
try {
|
||||||
|
const response = await sendCommand("list");
|
||||||
|
const match = response.match(/There are (\d+) of a max of (\d+) players/);
|
||||||
|
return NextResponse.json({
|
||||||
|
online: true,
|
||||||
|
players: {
|
||||||
|
online: match ? parseInt(match[1], 10) : 0,
|
||||||
|
max: match ? parseInt(match[2], 10) : 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Tier 3: systemctl (process alive but not yet accepting connections)
|
||||||
|
let starting = false;
|
||||||
|
try {
|
||||||
|
const out = execSync("systemctl is-active minecraft.service", {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
|
starting = out === "active" || out === "activating";
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
online: false,
|
||||||
|
starting,
|
||||||
|
players: { online: 0, max: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
106
app/globals.css
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.13 0.005 285);
|
||||||
|
--foreground: oklch(0.93 0.005 285);
|
||||||
|
--card: oklch(0.18 0.008 285);
|
||||||
|
--card-foreground: oklch(0.93 0.005 285);
|
||||||
|
--popover: oklch(0.18 0.008 285);
|
||||||
|
--popover-foreground: oklch(0.93 0.005 285);
|
||||||
|
--primary: oklch(0.65 0.18 280);
|
||||||
|
--primary-foreground: oklch(0.98 0 0);
|
||||||
|
--secondary: oklch(0.24 0.01 285);
|
||||||
|
--secondary-foreground: oklch(0.88 0.005 285);
|
||||||
|
--muted: oklch(0.22 0.008 285);
|
||||||
|
--muted-foreground: oklch(0.65 0.01 285);
|
||||||
|
--accent: oklch(0.24 0.015 285);
|
||||||
|
--accent-foreground: oklch(0.93 0.005 285);
|
||||||
|
--destructive: oklch(0.65 0.2 25);
|
||||||
|
--border: oklch(0.28 0.01 285);
|
||||||
|
--input: oklch(0.28 0.01 285);
|
||||||
|
--ring: oklch(0.65 0.18 280);
|
||||||
|
--chart-1: oklch(0.65 0.18 280);
|
||||||
|
--chart-2: oklch(0.7 0.15 160);
|
||||||
|
--chart-3: oklch(0.75 0.15 60);
|
||||||
|
--chart-4: oklch(0.7 0.18 330);
|
||||||
|
--chart-5: oklch(0.65 0.2 25);
|
||||||
|
--sidebar: oklch(0.16 0.008 285);
|
||||||
|
--sidebar-foreground: oklch(0.93 0.005 285);
|
||||||
|
--sidebar-primary: oklch(0.65 0.18 280);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||||
|
--sidebar-accent: oklch(0.24 0.015 285);
|
||||||
|
--sidebar-accent-foreground: oklch(0.93 0.005 285);
|
||||||
|
--sidebar-border: oklch(0.28 0.01 285);
|
||||||
|
--sidebar-ring: oklch(0.65 0.18 280);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: oklch(0.15 0.005 285); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: oklch(0.35 0.01 285); border-radius: 3px; }
|
||||||
57
app/layout.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "HurkiCorgi MC",
|
||||||
|
description: "Create & Engineering | Raids | Survival - Minecraft Server",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: "HurkiCorgi MC",
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: "/icon.svg",
|
||||||
|
apple: "/icon.svg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
themeColor: "#1a1a2e",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`dark ${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col overflow-x-hidden">
|
||||||
|
<Providers>
|
||||||
|
<div className="flex flex-col flex-1 w-full overflow-x-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
app/login/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
username: formData.get("username"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
setError("Invalid credentials");
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
router.push("/admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-3 sm:p-6">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl">Admin Login</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input id="username" name="username" type="text" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input id="password" name="password" type="password" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive text-sm text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition"
|
||||||
|
>
|
||||||
|
Back to dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
app/page.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
import { StatusCard } from "@/components/StatusCard";
|
||||||
|
import { DownloadCard } from "@/components/DownloadCard";
|
||||||
|
import { ModList } from "@/components/ModList";
|
||||||
|
import { ClientOnly } from "@/components/ClientOnly";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-border bg-card py-8 sm:py-12 text-center px-4">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-primary">
|
||||||
|
HurkiCorgi MC
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm sm:text-base">
|
||||||
|
Create & Engineering | Raids | Survival
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ClientOnly>
|
||||||
|
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<StatusCard />
|
||||||
|
<DownloadCard />
|
||||||
|
</div>
|
||||||
|
<ModList />
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<footer className="text-center py-6 sm:py-8 text-muted-foreground text-xs">
|
||||||
|
HurkiCorgi MC — Forge 1.20.1
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/providers.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
177
components/Analytics.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
components/BackupManager.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Backup = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BackupManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
const { data: backups = [] } = useQuery<Backup[]>({
|
||||||
|
queryKey: ["backups"],
|
||||||
|
queryFn: () => fetch("/api/backups").then((r) => r.json()),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createBackup = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await fetch("/api/backups", { method: "POST" });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setResult({ ok: true, message: data.message || "Backup created" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["backups"] });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setResult({ ok: false, message: err.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const restore = useMutation({
|
||||||
|
mutationFn: async (name: string) => {
|
||||||
|
setConfirmRestore(null);
|
||||||
|
const res = await fetch("/api/backups/restore", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setResult({ ok: data.success, message: data.message });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setResult({ ok: false, message: err.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBackup = useMutation({
|
||||||
|
mutationFn: async (name: string) => {
|
||||||
|
await fetch("/api/backups", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["backups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBusy = createBackup.isPending || restore.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>World Backups</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Auto-backup every 6 hours. {backups.length} backup(s) stored.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => createBackup.mutate()}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{createBackup.isPending ? "Creating..." : "Backup Now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{result && (
|
||||||
|
<Alert className={result.ok ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}>
|
||||||
|
<AlertDescription className={result.ok ? "text-emerald-300" : "text-red-300"}>
|
||||||
|
{result.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restore.isPending && (
|
||||||
|
<Alert className="border-blue-500/20 bg-blue-500/5">
|
||||||
|
<AlertDescription className="text-blue-300 flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
Restoring world backup... Server will restart. This may take a minute.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backups.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No backups yet. Click "Backup Now" to create one.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 max-h-[300px] overflow-y-auto">
|
||||||
|
{backups.map((b) => (
|
||||||
|
<li
|
||||||
|
key={b.name}
|
||||||
|
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{b.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(b.createdAt).toLocaleString()} — {b.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
|
||||||
|
{confirmRestore === b.name ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => restore.mutate(b.name)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9"
|
||||||
|
>
|
||||||
|
Confirm Restore
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmRestore(null)}
|
||||||
|
className="text-xs h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={`/api/backups/download?name=${encodeURIComponent(b.name)}`}
|
||||||
|
className="sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="ghost" className="text-xs h-9">
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmRestore(b.name)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => deleteBackup.mutate(b.name)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
components/ChatBridge.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
time: string;
|
||||||
|
type: "chat" | "join" | "leave" | "death" | "server";
|
||||||
|
player: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatBridge() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const autoScrollRef = useRef(true);
|
||||||
|
|
||||||
|
const { data: messages = [] } = useQuery<ChatMessage[]>({
|
||||||
|
queryKey: ["chat"],
|
||||||
|
queryFn: () => fetch("/api/chat?lines=50").then((r) => r.json()),
|
||||||
|
refetchInterval: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = useMutation({
|
||||||
|
mutationFn: async (msg: string) => {
|
||||||
|
const res = await fetch("/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: msg }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to send");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setMessage("");
|
||||||
|
autoScrollRef.current = true;
|
||||||
|
setTimeout(() => queryClient.invalidateQueries({ queryKey: ["chat"] }), 500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatRef.current && autoScrollRef.current) {
|
||||||
|
chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!chatRef.current) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = chatRef.current;
|
||||||
|
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
const msg = message.trim();
|
||||||
|
if (!msg) return;
|
||||||
|
send.mutate(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
chat: "text-foreground",
|
||||||
|
join: "text-emerald-400",
|
||||||
|
leave: "text-amber-400",
|
||||||
|
death: "text-red-400",
|
||||||
|
server: "text-blue-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Chat Bridge</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Live in-game chat — auto-refreshes every 3 seconds
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Chat window */}
|
||||||
|
<div
|
||||||
|
ref={chatRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="h-[250px] sm:h-[300px] overflow-y-auto rounded-lg border border-border bg-background p-3 space-y-0.5"
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No chat messages yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
messages.map((msg, i) => (
|
||||||
|
<div key={`${msg.time}-${i}`} className="flex gap-2 text-sm leading-relaxed min-w-0">
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0 mt-0.5 font-mono">
|
||||||
|
{msg.time}
|
||||||
|
</span>
|
||||||
|
{msg.type === "chat" ? (
|
||||||
|
<span className={`${typeColors.chat} break-words min-w-0`}>
|
||||||
|
<strong><{msg.player}></strong> {msg.message}
|
||||||
|
</span>
|
||||||
|
) : msg.type === "join" ? (
|
||||||
|
<span className={`${typeColors.join} break-words`}>
|
||||||
|
{msg.player} joined the game
|
||||||
|
</span>
|
||||||
|
) : msg.type === "leave" ? (
|
||||||
|
<span className={`${typeColors.leave} break-words`}>
|
||||||
|
{msg.player} left the game
|
||||||
|
</span>
|
||||||
|
) : msg.type === "death" ? (
|
||||||
|
<span className={`${typeColors.death} break-words`}>
|
||||||
|
{msg.player} {msg.message}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`${typeColors.server} break-words`}>
|
||||||
|
[Server] {msg.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send message */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Send a message as [Server]..."
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSend();
|
||||||
|
}}
|
||||||
|
maxLength={256}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSend} disabled={!message.trim() || send.isPending}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/ClientOnly.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function ClientOnly({ children }: { children: React.ReactNode }) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
75
components/DownloadCard.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function DownloadCard() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="text-base">Join the Server</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<a
|
||||||
|
href="/download/installer"
|
||||||
|
className={cn(buttonVariants({ size: "lg" }), "w-full min-h-[44px]")}
|
||||||
|
>
|
||||||
|
Windows (.bat)
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/download/installer-linux"
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full min-h-[44px]")}
|
||||||
|
>
|
||||||
|
Linux (.sh)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/download/modpack"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "secondary", size: "lg" }),
|
||||||
|
"w-full min-h-[44px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Download Mods Only (.zip)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">How to install</h3>
|
||||||
|
<ol className="list-decimal pl-5 space-y-1.5 text-muted-foreground text-sm">
|
||||||
|
<li>
|
||||||
|
Make sure you have{" "}
|
||||||
|
<strong className="text-foreground">Minecraft Java Edition</strong>{" "}
|
||||||
|
installed
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Download the{" "}
|
||||||
|
<strong className="text-foreground">Installer</strong> for your OS
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Windows:</strong> double-click
|
||||||
|
the .bat file.{" "}
|
||||||
|
<strong className="text-foreground">Linux:</strong>{" "}
|
||||||
|
run <code className="text-xs bg-muted px-1 py-0.5 rounded">bash install-modpack.sh</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Open Minecraft, select{" "}
|
||||||
|
<strong className="text-foreground">Forge 1.20.1</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Go to <strong className="text-foreground">Multiplayer</strong> and
|
||||||
|
add{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
minecraft.hurkicorgi.com
|
||||||
|
</strong>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/LogViewer.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function LogViewer() {
|
||||||
|
const logRef = useRef<HTMLPreElement>(null);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const [lines, setLines] = useState(100);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
const autoScrollRef = useRef(true);
|
||||||
|
|
||||||
|
const { data, refetch, isFetching, isError, error } = useQuery<{ logs: string }>({
|
||||||
|
queryKey: ["logs", lines],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/logs?lines=${lines}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load logs (${res.status})`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: hasLoaded,
|
||||||
|
refetchInterval: autoRefresh ? 3000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll only if user is near the bottom
|
||||||
|
useEffect(() => {
|
||||||
|
if (logRef.current && autoScrollRef.current) {
|
||||||
|
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!logRef.current) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = logRef.current;
|
||||||
|
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 80;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setHasLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Console Logs</CardTitle>
|
||||||
|
<CardDescription>Server output from journalctl</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={lines}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLines(Number(e.target.value));
|
||||||
|
setTimeout(() => refetch(), 50);
|
||||||
|
}}
|
||||||
|
className="h-10 rounded-md border border-input bg-muted px-2 text-sm text-foreground focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={50}>50 lines</option>
|
||||||
|
<option value={100}>100 lines</option>
|
||||||
|
<option value={200}>200 lines</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={autoRefresh ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setAutoRefresh(!autoRefresh);
|
||||||
|
if (!autoRefresh) autoScrollRef.current = true;
|
||||||
|
}}
|
||||||
|
className={`text-xs ${autoRefresh ? "animate-pulse" : ""}`}
|
||||||
|
>
|
||||||
|
{autoRefresh ? "Live" : "Auto"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{isFetching ? "..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre
|
||||||
|
ref={logRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className={`rounded-lg border bg-background p-3 sm:p-4 h-[300px] sm:h-[450px] overflow-y-auto font-mono text-xs leading-relaxed whitespace-pre-wrap break-all ${
|
||||||
|
isError ? "border-red-500/30 text-red-300" : "border-border text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isError
|
||||||
|
? `Failed to load logs: ${error instanceof Error ? error.message : "unknown error"}`
|
||||||
|
: data?.logs || (isFetching ? "Loading logs..." : "No logs available.")}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
components/ModList.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Mod = {
|
||||||
|
modId: string;
|
||||||
|
displayName: string;
|
||||||
|
version: string;
|
||||||
|
filename: string;
|
||||||
|
size: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModList() {
|
||||||
|
const { data: mods = [] } = useQuery<Mod[]>({
|
||||||
|
queryKey: ["mods"],
|
||||||
|
queryFn: () => fetch("/api/mods").then((r) => r.json()),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Installed Mods ({mods.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="">
|
||||||
|
<ul className="max-h-[350px] sm:max-h-[400px] overflow-y-auto -mx-1">
|
||||||
|
{mods.map((mod, i) => (
|
||||||
|
<li
|
||||||
|
key={mod.filename}
|
||||||
|
className={`flex justify-between items-center px-3 py-2.5 rounded-md ${
|
||||||
|
i % 2 === 1 ? "bg-muted/50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{mod.displayName}
|
||||||
|
</span>
|
||||||
|
{mod.version && (
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0 hidden sm:inline-flex">
|
||||||
|
{mod.version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap ml-3 tabular-nums">
|
||||||
|
{mod.size}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
844
components/ModManager.tsx
Normal file
|
|
@ -0,0 +1,844 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { CheckCircle2, XCircle, Circle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ModSide = "client" | "server" | "both";
|
||||||
|
|
||||||
|
type ModMeta = {
|
||||||
|
modId: string;
|
||||||
|
displayName: string;
|
||||||
|
version: string;
|
||||||
|
filename: string;
|
||||||
|
size: string;
|
||||||
|
side: ModSide;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon_url: string;
|
||||||
|
downloads: number;
|
||||||
|
project_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModDownload = {
|
||||||
|
projectId: string;
|
||||||
|
versionId: string;
|
||||||
|
title: string;
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
isDependency: boolean;
|
||||||
|
alreadyInstalled: boolean;
|
||||||
|
side: ModSide;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveResult = {
|
||||||
|
toInstall: ModDownload[];
|
||||||
|
skipped: ModDownload[];
|
||||||
|
conflicts: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SnapshotInfo = {
|
||||||
|
name: string;
|
||||||
|
dirName: string;
|
||||||
|
createdAt: string;
|
||||||
|
modCount: number;
|
||||||
|
mods: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type WizardStep = "idle" | "searching" | "reviewing" | "installing";
|
||||||
|
|
||||||
|
type TimelineStep = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: "pending" | "active" | "done" | "error";
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INSTALL_STEPS_SERVER: Pick<TimelineStep, "id" | "label">[] = [
|
||||||
|
{ id: "snapshot", label: "Create snapshot" },
|
||||||
|
{ id: "download", label: "Download mods" },
|
||||||
|
{ id: "restart", label: "Restart server" },
|
||||||
|
{ id: "health", label: "Verify server" },
|
||||||
|
{ id: "modpack", label: "Update modpack" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INSTALL_STEPS_CLIENT: Pick<TimelineStep, "id" | "label">[] = [
|
||||||
|
{ id: "snapshot", label: "Create snapshot" },
|
||||||
|
{ id: "download", label: "Download mods" },
|
||||||
|
{ id: "modpack", label: "Update modpack" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Side Badge ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const sideConfig = {
|
||||||
|
client: { label: "Client", className: "border-purple-500/30 text-purple-400" },
|
||||||
|
server: { label: "Server", className: "border-orange-500/30 text-orange-400" },
|
||||||
|
both: { label: "Both", className: "border-green-500/30 text-green-400" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function SideBadge({ side }: { side: ModSide }) {
|
||||||
|
const config = sideConfig[side];
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={`text-xs px-1.5 py-0 ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ModManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [step, setStep] = useState<WizardStep>("idle");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selected, setSelected] = useState<Map<string, SearchResult>>(new Map());
|
||||||
|
const [resolved, setResolved] = useState<ResolveResult | null>(null);
|
||||||
|
const [installStatus, setInstallStatus] = useState("");
|
||||||
|
const [timelineSteps, setTimelineSteps] = useState<TimelineStep[]>([]);
|
||||||
|
const [newlyInstalled, setNewlyInstalled] = useState<Set<string>>(new Set());
|
||||||
|
const [installResult, setInstallResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
rolledBack?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
||||||
|
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
|
|
||||||
|
// Installed mods
|
||||||
|
const { data: mods = [] } = useQuery<ModMeta[]>({
|
||||||
|
queryKey: ["mods"],
|
||||||
|
queryFn: () => fetch("/api/mods").then((r) => r.json()),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search (debounced)
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setDebouncedQuery(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const { data: searchResults = [], isFetching: isSearching } = useQuery<SearchResult[]>({
|
||||||
|
queryKey: ["mod-search", debouncedQuery],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(`/api/mods/search?q=${encodeURIComponent(debouncedQuery)}`).then((r) =>
|
||||||
|
r.json()
|
||||||
|
),
|
||||||
|
enabled: debouncedQuery.length >= 2 && step === "searching",
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Snapshots
|
||||||
|
const { data: snapshots = [] } = useQuery<SnapshotInfo[]>({
|
||||||
|
queryKey: ["snapshots"],
|
||||||
|
queryFn: () => fetch("/api/snapshots").then((r) => r.json()),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve dependencies
|
||||||
|
const resolve = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const projectIds = Array.from(selected.keys());
|
||||||
|
const titles: Record<string, string> = {};
|
||||||
|
selected.forEach((v, k) => (titles[k] = v.title));
|
||||||
|
|
||||||
|
const res = await fetch("/api/mods/resolve", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ projectIds, titles }),
|
||||||
|
});
|
||||||
|
return (await res.json()) as ResolveResult;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setResolved(data);
|
||||||
|
setStep("reviewing");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch install (SSE streaming)
|
||||||
|
const install = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!resolved) throw new Error("No resolved mods");
|
||||||
|
|
||||||
|
const modsToInstall = resolved.toInstall.filter((m) => !m.alreadyInstalled);
|
||||||
|
const snapshotName = `before-${modsToInstall.map((m) => m.title).join("-").slice(0, 40)}`;
|
||||||
|
const hasServerMods = modsToInstall.some((m) => m.side !== "client");
|
||||||
|
|
||||||
|
// Initialize timeline
|
||||||
|
const steps = (hasServerMods ? INSTALL_STEPS_SERVER : INSTALL_STEPS_CLIENT)
|
||||||
|
.map((s) => ({ ...s, status: "pending" as const }));
|
||||||
|
setTimelineSteps(steps);
|
||||||
|
setInstallStatus("Starting installation...");
|
||||||
|
|
||||||
|
const res = await fetch("/api/mods/batch-install", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mods: modsToInstall, snapshotName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.body) throw new Error("No response stream");
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let finalResult: { success: boolean; message: string; installed?: string[]; rolledBack?: boolean } | null = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const frames = buffer.split("\n\n");
|
||||||
|
buffer = frames.pop()!;
|
||||||
|
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (!frame.trim()) continue;
|
||||||
|
const eventMatch = frame.match(/^event:\s*(\w+)/m);
|
||||||
|
const dataMatch = frame.match(/^data:\s*(.+)/m);
|
||||||
|
if (!eventMatch || !dataMatch) continue;
|
||||||
|
|
||||||
|
const event = eventMatch[1];
|
||||||
|
const data = JSON.parse(dataMatch[1]);
|
||||||
|
|
||||||
|
if (event === "step") {
|
||||||
|
setTimelineSteps((prev) => {
|
||||||
|
const exists = prev.some((s) => s.id === data.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map((s) =>
|
||||||
|
s.id === data.id ? { ...s, status: data.status, message: data.message } : s
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Dynamically add rollback step
|
||||||
|
return [...prev, { id: data.id, label: "Rollback", status: data.status, message: data.message }];
|
||||||
|
});
|
||||||
|
setInstallStatus(data.message);
|
||||||
|
} else if (event === "done") {
|
||||||
|
finalResult = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalResult) throw new Error("Stream ended without result");
|
||||||
|
return finalResult;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setInstallStatus("");
|
||||||
|
setInstallResult(data);
|
||||||
|
if (data.success) {
|
||||||
|
setStep("idle");
|
||||||
|
setSelected(new Map());
|
||||||
|
setResolved(null);
|
||||||
|
setTimelineSteps([]);
|
||||||
|
if (data.installed?.length) {
|
||||||
|
setNewlyInstalled(new Set(data.installed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setInstallStatus("");
|
||||||
|
setTimelineSteps([]);
|
||||||
|
setInstallResult({ success: false, message: err.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove mod
|
||||||
|
const removeMod = useMutation({
|
||||||
|
mutationFn: async (filename: string) => {
|
||||||
|
setConfirmRemove(null);
|
||||||
|
const res = await fetch("/api/mods/remove", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setInstallResult(data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore snapshot
|
||||||
|
const restoreSnap = useMutation({
|
||||||
|
mutationFn: async (dirName: string) => {
|
||||||
|
setConfirmRestore(null);
|
||||||
|
const res = await fetch("/api/snapshots/restore", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ dirName }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setInstallResult(data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete snapshot
|
||||||
|
const deleteSnap = useMutation({
|
||||||
|
mutationFn: async (dirName: string) => {
|
||||||
|
await fetch("/api/snapshots", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ dirName }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((result: SearchResult) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
if (next.has(result.project_id)) {
|
||||||
|
next.delete(result.project_id);
|
||||||
|
} else {
|
||||||
|
next.set(result.project_id, result);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isBusy = install.isPending || removeMod.isPending || restoreSnap.isPending;
|
||||||
|
|
||||||
|
const formatDownloads = (n: number) => {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||||
|
return n.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Mod Manager Card ──────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Mod Manager</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search Modrinth, auto-resolve dependencies, install with rollback safety
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{step === "idle" && (
|
||||||
|
<Button onClick={() => setStep("searching")} disabled={isBusy}>
|
||||||
|
Add Mods
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step !== "idle" && step !== "installing" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("idle");
|
||||||
|
setSelected(new Map());
|
||||||
|
setResolved(null);
|
||||||
|
setSearchQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Step indicator */}
|
||||||
|
{step !== "idle" && (
|
||||||
|
<ol className="flex items-center gap-2 text-xs">
|
||||||
|
{[
|
||||||
|
{ key: "searching", label: "Search" },
|
||||||
|
{ key: "reviewing", label: "Review" },
|
||||||
|
{ key: "installing", label: "Install" },
|
||||||
|
].map((s, i, arr) => {
|
||||||
|
const currentIdx = arr.findIndex((x) => x.key === step);
|
||||||
|
const thisIdx = i;
|
||||||
|
const state =
|
||||||
|
thisIdx < currentIdx ? "done" : thisIdx === currentIdx ? "active" : "pending";
|
||||||
|
return (
|
||||||
|
<li key={s.key} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded-full border text-[10px] font-semibold ${
|
||||||
|
state === "active"
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: state === "done"
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
||||||
|
: "border-border text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{thisIdx + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
state === "active"
|
||||||
|
? "font-medium text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{i < arr.length - 1 && (
|
||||||
|
<span className="w-6 h-px bg-border mx-1" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Result feedback */}
|
||||||
|
{installResult && !isBusy && step === "idle" && (
|
||||||
|
<Alert
|
||||||
|
className={
|
||||||
|
installResult.success
|
||||||
|
? "border-emerald-500/20 bg-emerald-500/5"
|
||||||
|
: "border-red-500/20 bg-red-500/5"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertDescription
|
||||||
|
className={installResult.success ? "text-emerald-300" : "text-red-300"}
|
||||||
|
>
|
||||||
|
{installResult.message}
|
||||||
|
{installResult.rolledBack && (
|
||||||
|
<span className="block text-amber-300 mt-1 text-xs">
|
||||||
|
Changes were automatically rolled back.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 1: Search & Select ─────────────────── */}
|
||||||
|
{step === "searching" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search mods on Modrinth..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Selected queue */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Array.from(selected.values()).map((s) => (
|
||||||
|
<Badge
|
||||||
|
key={s.project_id}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 cursor-pointer hover:bg-destructive/20"
|
||||||
|
onClick={() => toggleSelect(s)}
|
||||||
|
>
|
||||||
|
{s.title}
|
||||||
|
<span className="text-muted-foreground">x</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
{isSearching && (
|
||||||
|
<p className="text-sm text-muted-foreground">Searching...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{debouncedQuery.length >= 2 && (
|
||||||
|
<ul className="space-y-1 max-h-[350px] overflow-y-auto">
|
||||||
|
{searchResults.map((result) => {
|
||||||
|
const isSelected = selected.has(result.project_id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={result.project_id}
|
||||||
|
onClick={() => toggleSelect(result)}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition ${
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 border border-primary/30"
|
||||||
|
: "bg-muted/50 hover:bg-muted border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.icon_url ? (
|
||||||
|
<img
|
||||||
|
src={result.icon_url}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-md shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-md bg-muted shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{result.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{formatDownloads(result.downloads)} downloads
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{result.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<Badge variant="default" className="shrink-0 text-xs">
|
||||||
|
Selected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next button */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={() => resolve.mutate()}
|
||||||
|
disabled={resolve.isPending}
|
||||||
|
>
|
||||||
|
{resolve.isPending
|
||||||
|
? "Resolving dependencies..."
|
||||||
|
: `Next (${selected.size} selected)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Review & Validate ───────────────── */}
|
||||||
|
{step === "reviewing" && resolved && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Conflicts */}
|
||||||
|
{resolved.conflicts.length > 0 && (
|
||||||
|
<Alert className="border-red-500/20 bg-red-500/5">
|
||||||
|
<AlertDescription className="text-red-300">
|
||||||
|
<strong>Issues found:</strong>
|
||||||
|
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||||
|
{resolved.conflicts.map((c, i) => (
|
||||||
|
<li key={i}>{c}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mods to install */}
|
||||||
|
{resolved.toInstall.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">
|
||||||
|
Will be installed ({resolved.toInstall.length})
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{resolved.toInstall.map((mod) => (
|
||||||
|
<li
|
||||||
|
key={mod.projectId}
|
||||||
|
className="px-3 py-2 rounded-md bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{mod.title}
|
||||||
|
</span>
|
||||||
|
<SideBadge side={mod.side} />
|
||||||
|
{mod.isDependency && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-1.5 py-0 border-blue-500/30 text-blue-300"
|
||||||
|
>
|
||||||
|
dependency
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{mod.filename}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skipped (already installed) */}
|
||||||
|
{resolved.skipped.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">
|
||||||
|
Already installed (skipped)
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{resolved.skipped.map((mod) => (
|
||||||
|
<li
|
||||||
|
key={mod.projectId}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/30 opacity-60"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{mod.title}</span>
|
||||||
|
<SideBadge side={mod.side} />
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||||
|
installed
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("searching");
|
||||||
|
setResolved(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setStep("installing");
|
||||||
|
install.mutate();
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
resolved.toInstall.length === 0 ||
|
||||||
|
resolved.conflicts.length > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Install {resolved.toInstall.length} mod(s)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Installing (Timeline) ─────────── */}
|
||||||
|
{step === "installing" && timelineSteps.length > 0 && (
|
||||||
|
<div className="space-y-1.5 py-2">
|
||||||
|
{timelineSteps.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-start gap-3 px-1">
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
{s.status === "done" && (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
||||||
|
)}
|
||||||
|
{s.status === "active" && (
|
||||||
|
<span className="flex h-4 w-4 items-center justify-center">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.status === "error" && (
|
||||||
|
<XCircle className="h-4 w-4 text-red-300" />
|
||||||
|
)}
|
||||||
|
{s.status === "pending" && (
|
||||||
|
<Circle className="h-4 w-4 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
s.status === "done" ? "text-emerald-300" :
|
||||||
|
s.status === "active" ? "text-blue-300" :
|
||||||
|
s.status === "error" ? "text-red-300" :
|
||||||
|
"text-muted-foreground/60"
|
||||||
|
}`}>
|
||||||
|
{s.label}
|
||||||
|
</p>
|
||||||
|
{s.message && s.status === "active" && (
|
||||||
|
<p className="text-xs text-muted-foreground">{s.message}</p>
|
||||||
|
)}
|
||||||
|
{s.message && s.status === "error" && (
|
||||||
|
<p className="text-xs text-red-300/80">{s.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-muted-foreground px-1 pt-1">
|
||||||
|
Do not close this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Installed mods list ─────────────────────── */}
|
||||||
|
{step === "idle" && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-3">
|
||||||
|
Installed Mods ({mods.length})
|
||||||
|
</h3>
|
||||||
|
<ul className="max-h-[400px] overflow-y-auto space-y-1">
|
||||||
|
{mods.map((mod) => (
|
||||||
|
<li
|
||||||
|
key={mod.filename}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{mod.displayName}
|
||||||
|
</span>
|
||||||
|
{mod.version && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{mod.version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<SideBadge side={mod.side} />
|
||||||
|
{newlyInstalled.has(mod.filename) && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1.5 py-0 border-emerald-500/30 text-emerald-300">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{mod.filename} — {mod.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmRemove === mod.filename ? (
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => removeMod.mutate(mod.filename)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmRemove(null)}
|
||||||
|
className="text-xs h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmRemove(mod.filename)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition shrink-0"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Snapshots Card ────────────────────────────────── */}
|
||||||
|
{step === "idle" && snapshots.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Snapshots</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Restore a previous mod configuration
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{snapshots.map((snap) => (
|
||||||
|
<li
|
||||||
|
key={snap.dirName}
|
||||||
|
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{snap.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{snap.modCount} mods
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(snap.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
|
||||||
|
{confirmRestore === snap.dirName ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => restoreSnap.mutate(snap.dirName)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmRestore(null)}
|
||||||
|
className="text-xs h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setConfirmRestore(snap.dirName)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => deleteSnap.mutate(snap.dirName)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b border-border bg-card">
|
||||||
|
<div className="max-w-5xl mx-auto flex items-center justify-between px-3 sm:px-6 py-2.5 sm:py-3">
|
||||||
|
<Link href="/" className="font-bold text-primary text-base sm:text-lg tracking-tight">
|
||||||
|
HurkiCorgi MC
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
{session ? (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" render={<Link href="/admin" />}>
|
||||||
|
Admin
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" render={<Link href="/login" />}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
components/PlayerManager.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type PlayerData = {
|
||||||
|
ops: { name: string; uuid: string; level: number }[];
|
||||||
|
whitelist: { name: string; uuid: string }[];
|
||||||
|
banned: { name: string; uuid: string; reason: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = "ops" | "whitelist" | "banned";
|
||||||
|
|
||||||
|
export function PlayerManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tab, setTab] = useState<Tab>("ops");
|
||||||
|
const [playerName, setPlayerName] = useState("");
|
||||||
|
const [banReason, setBanReason] = useState("");
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
const { data = { ops: [], whitelist: [], banned: [] } } = useQuery<PlayerData>({
|
||||||
|
queryKey: ["players"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/players");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch players");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: async (params: { action: string; player: string; reason?: string }) => {
|
||||||
|
const res = await fetch("/api/players", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setResult({ ok: true, message: data.response || "Done" });
|
||||||
|
setPlayerName("");
|
||||||
|
setBanReason("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["players"] });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setResult({ ok: false, message: err.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; count: number }[] = [
|
||||||
|
{ key: "ops", label: "Operators", count: data.ops.length },
|
||||||
|
{ key: "whitelist", label: "Whitelist", count: data.whitelist.length },
|
||||||
|
{ key: "banned", label: "Banned", count: data.banned.length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
|
||||||
|
const trimmed = playerName.trim();
|
||||||
|
const nameValid = MC_NAME_RE.test(trimmed);
|
||||||
|
const nameError =
|
||||||
|
trimmed.length === 0
|
||||||
|
? null
|
||||||
|
: !nameValid
|
||||||
|
? "3–16 chars, letters/numbers/underscores only"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleAction = (act: string, player?: string) => {
|
||||||
|
const name = player || trimmed;
|
||||||
|
if (!name) return;
|
||||||
|
if (!player && !nameValid) return;
|
||||||
|
action.mutate({ action: act, player: name, reason: banReason || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Player Management</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage operators, whitelist, and bans via RCON
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-muted p-1 rounded-lg">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => { setTab(t.key); setResult(null); }}
|
||||||
|
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition min-h-[40px] ${
|
||||||
|
tab === t.key
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.count > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1.5 text-xs px-1.5 py-0">
|
||||||
|
{t.count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add player input */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Player name"
|
||||||
|
value={playerName}
|
||||||
|
onChange={(e) => setPlayerName(e.target.value)}
|
||||||
|
aria-invalid={!!nameError}
|
||||||
|
maxLength={16}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (tab === "ops") handleAction("op");
|
||||||
|
else if (tab === "whitelist") handleAction("whitelist add");
|
||||||
|
else if (tab === "banned") handleAction("ban");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{nameError && (
|
||||||
|
<p className="text-xs text-red-300 mt-1">{nameError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tab === "banned" && (
|
||||||
|
<Input
|
||||||
|
placeholder="Reason (optional)"
|
||||||
|
value={banReason}
|
||||||
|
onChange={(e) => setBanReason(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (tab === "ops") handleAction("op");
|
||||||
|
else if (tab === "whitelist") handleAction("whitelist add");
|
||||||
|
else if (tab === "banned") handleAction("ban");
|
||||||
|
}}
|
||||||
|
disabled={!nameValid || action.isPending}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{tab === "ops" ? "Add OP" : tab === "whitelist" ? "Add" : "Ban"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
{result && (
|
||||||
|
<Alert className={result.ok ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}>
|
||||||
|
<AlertDescription className={result.ok ? "text-emerald-300" : "text-red-300"}>
|
||||||
|
{result.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Player lists */}
|
||||||
|
{tab === "ops" && (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{data.ops.length === 0 && (
|
||||||
|
<li className="text-sm text-muted-foreground py-2 text-center">No operators</li>
|
||||||
|
)}
|
||||||
|
{data.ops.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.uuid || p.name}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<img
|
||||||
|
src={`https://mc-heads.net/avatar/${p.name}/24`}
|
||||||
|
alt=""
|
||||||
|
className="w-6 h-6 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0">
|
||||||
|
Lv{p.level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleAction("deop", p.name)}
|
||||||
|
disabled={action.isPending}
|
||||||
|
className="text-xs h-9 shrink-0 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
Deop
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "whitelist" && (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{data.whitelist.length === 0 && (
|
||||||
|
<li className="text-sm text-muted-foreground py-2 text-center">Whitelist is empty</li>
|
||||||
|
)}
|
||||||
|
{data.whitelist.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.uuid || p.name}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<img
|
||||||
|
src={`https://mc-heads.net/avatar/${p.name}/24`}
|
||||||
|
alt=""
|
||||||
|
className="w-6 h-6 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleAction("whitelist remove", p.name)}
|
||||||
|
disabled={action.isPending}
|
||||||
|
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "banned" && (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{data.banned.length === 0 && (
|
||||||
|
<li className="text-sm text-muted-foreground py-2 text-center">No banned players</li>
|
||||||
|
)}
|
||||||
|
{data.banned.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.uuid || p.name}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={`https://mc-heads.net/avatar/${p.name}/24`}
|
||||||
|
alt=""
|
||||||
|
className="w-6 h-6 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
{p.reason && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate ml-8">{p.reason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleAction("pardon", p.name)}
|
||||||
|
disabled={action.isPending}
|
||||||
|
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
Unban
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
components/ServerControls.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type ServerStatus = {
|
||||||
|
online: boolean;
|
||||||
|
starting?: boolean;
|
||||||
|
players: { online: number; max: number };
|
||||||
|
version?: string;
|
||||||
|
motd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action = "start" | "stop" | "restart";
|
||||||
|
|
||||||
|
const ACTION_LABEL: Record<Action, string> = {
|
||||||
|
start: "Start",
|
||||||
|
stop: "Stop",
|
||||||
|
restart: "Restart",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServerControls() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [confirm, setConfirm] = useState<Action | null>(null);
|
||||||
|
|
||||||
|
const { data: status, isLoading } = useQuery<ServerStatus>({
|
||||||
|
queryKey: ["status"],
|
||||||
|
queryFn: () => fetch("/api/status").then((r) => r.json()),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: async (act: Action) => {
|
||||||
|
const res = await fetch(`/api/server/${act}`, { method: "POST" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || "Request failed");
|
||||||
|
}
|
||||||
|
return { ...(await res.json()), act };
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setTimeout(
|
||||||
|
() => queryClient.invalidateQueries({ queryKey: ["status"] }),
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOnline = status?.online ?? false;
|
||||||
|
const lastAction = action.data?.act as Action | undefined;
|
||||||
|
|
||||||
|
const trigger = (act: Action) => {
|
||||||
|
if (act === "start") {
|
||||||
|
action.mutate(act);
|
||||||
|
} else {
|
||||||
|
setConfirm(act);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRun = () => {
|
||||||
|
if (confirm) {
|
||||||
|
action.mutate(confirm);
|
||||||
|
setConfirm(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle>Server Controls</CardTitle>
|
||||||
|
<CardDescription className="hidden sm:block">Manage the Minecraft server process</CardDescription>
|
||||||
|
</div>
|
||||||
|
{isLoading || !status ? (
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status={statusFromServer(status)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3">
|
||||||
|
<div className="rounded-lg bg-muted p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground mb-0.5">Players</p>
|
||||||
|
{isLoading || !status ? (
|
||||||
|
<Skeleton className="h-7 w-12 mx-auto" />
|
||||||
|
) : (
|
||||||
|
<p className="text-xl font-bold tabular-nums">
|
||||||
|
{isOnline ? `${status.players.online}/${status.players.max}` : "-"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground mb-0.5">Version</p>
|
||||||
|
{isLoading || !status ? (
|
||||||
|
<Skeleton className="h-5 w-16 mx-auto mt-1" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-semibold mt-1">{status.version || "-"}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted p-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground mb-0.5">Address</p>
|
||||||
|
<p className="text-xs font-mono font-semibold mt-1">
|
||||||
|
minecraft.hurkicorgi.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation */}
|
||||||
|
{confirm && (
|
||||||
|
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||||
|
<AlertDescription className="text-amber-300 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<span>
|
||||||
|
{confirm === "stop"
|
||||||
|
? "Stop the server? Players will be disconnected."
|
||||||
|
: "Restart the server? Players will be disconnected briefly."}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1">
|
||||||
|
<Button size="sm" variant="destructive" onClick={confirmRun}>
|
||||||
|
Confirm {ACTION_LABEL[confirm]}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => trigger("start")}
|
||||||
|
disabled={action.isPending || isOnline || !!confirm}
|
||||||
|
className="w-full border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => trigger("stop")}
|
||||||
|
disabled={action.isPending || !isOnline || !!confirm}
|
||||||
|
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => trigger("restart")}
|
||||||
|
disabled={action.isPending || !isOnline || !!confirm}
|
||||||
|
className="w-full border-amber-500/30 text-amber-300 hover:bg-amber-500/10 hover:text-amber-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
{action.isPending && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
Sending {action.variables ? ACTION_LABEL[action.variables] : ""} command...
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{action.isSuccess && !action.isPending && (
|
||||||
|
<Alert className="border-emerald-500/20 bg-emerald-500/5">
|
||||||
|
<AlertDescription className="text-emerald-300">
|
||||||
|
{lastAction ? `${ACTION_LABEL[lastAction]} command sent.` : "Command sent."} Status updates in a few seconds.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{action.isError && (
|
||||||
|
<Alert className="border-red-500/20 bg-red-500/5">
|
||||||
|
<AlertDescription className="text-red-300">
|
||||||
|
{action.error.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Scheduled restart */}
|
||||||
|
<ScheduledRestart />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduledRestart() {
|
||||||
|
const { data: schedule } = useQuery<{ enabled: boolean; hour: number; minute: number }>({
|
||||||
|
queryKey: ["schedule"],
|
||||||
|
queryFn: () => fetch("/api/schedule").then((r) => r.json()),
|
||||||
|
staleTime: 30_000,
|
||||||
|
initialData: { enabled: false, hour: 4, minute: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [hour, setHour] = useState<number | null>(null);
|
||||||
|
const [minute, setMinute] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const h = hour ?? schedule.hour;
|
||||||
|
const m = minute ?? schedule.minute;
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: async (params: { enabled: boolean; hour: number; minute: number }) => {
|
||||||
|
const res = await fetch("/api/schedule", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Scheduled Restart</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{schedule.enabled
|
||||||
|
? `Daily at ${pad(schedule.hour)}:${pad(schedule.minute)} server time — warns players before restarting`
|
||||||
|
: "Set a time for daily auto-restart with player warnings (uses server's local time)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={h}
|
||||||
|
onChange={(e) => setHour(parseInt(e.target.value))}
|
||||||
|
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, i) => (
|
||||||
|
<option key={i} value={i}>
|
||||||
|
{pad(i)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-muted-foreground font-bold">:</span>
|
||||||
|
<select
|
||||||
|
value={m}
|
||||||
|
onChange={(e) => setMinute(parseInt(e.target.value))}
|
||||||
|
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{[0, 15, 30, 45].map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{pad(v)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-muted-foreground">server time</span>
|
||||||
|
{schedule.enabled ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => update.mutate({ enabled: false, hour: h, minute: m })}
|
||||||
|
disabled={update.isPending}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => update.mutate({ enabled: true, hour: h, minute: m })}
|
||||||
|
disabled={update.isPending}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Status = "online" | "starting" | "offline";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
online: {
|
||||||
|
label: "Online",
|
||||||
|
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300",
|
||||||
|
dot: "bg-emerald-400 shadow-[0_0_6px_var(--color-emerald-400)]",
|
||||||
|
pulse: false,
|
||||||
|
},
|
||||||
|
starting: {
|
||||||
|
label: "Starting",
|
||||||
|
className: "border-amber-500/30 bg-amber-500/10 text-amber-300",
|
||||||
|
dot: "bg-amber-400",
|
||||||
|
pulse: true,
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
label: "Offline",
|
||||||
|
className: "border-red-500/30 bg-red-500/10 text-red-300",
|
||||||
|
dot: "bg-red-400",
|
||||||
|
pulse: false,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: Status }) {
|
||||||
|
const c = config[status];
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={`gap-1.5 ${c.className}`}>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${c.dot} ${c.pulse ? "animate-pulse" : ""}`} />
|
||||||
|
{c.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusFromServer(s: { online: boolean; starting?: boolean }): Status {
|
||||||
|
if (s.online) return "online";
|
||||||
|
if (s.starting) return "starting";
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
102
components/StatusCard.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
|
||||||
|
|
||||||
|
type ServerStatus = {
|
||||||
|
online: boolean;
|
||||||
|
starting?: boolean;
|
||||||
|
players: { online: number; max: number };
|
||||||
|
version?: string;
|
||||||
|
motd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusCard() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ServerStatus>({
|
||||||
|
queryKey: ["status"],
|
||||||
|
queryFn: () => fetch("/api/status").then((r) => r.json()),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyIP = () => {
|
||||||
|
navigator.clipboard.writeText("minecraft.hurkicorgi.com");
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="text-base">Server Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{/* Status */}
|
||||||
|
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Skeleton className="h-5 w-20 mx-auto" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status={statusFromServer(data)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Players */}
|
||||||
|
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||||
|
Players
|
||||||
|
</p>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Skeleton className="h-8 w-16 mx-auto" />
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
|
{data.online ? `${data.players.online}/${data.players.max}` : "0"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||||
|
Version
|
||||||
|
</p>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Skeleton className="h-5 w-20 mx-auto" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-semibold">{data.version || "-"}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connect */}
|
||||||
|
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||||
|
Connect
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={copyIP}
|
||||||
|
className="w-full rounded-md border border-dashed border-border px-2 py-2 hover:border-primary/50 transition cursor-pointer min-h-[44px]"
|
||||||
|
>
|
||||||
|
<p className="font-mono text-xs font-semibold text-foreground">
|
||||||
|
minecraft.hurkicorgi.com
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{copied ? (
|
||||||
|
<span className="text-emerald-300">Copied!</span>
|
||||||
|
) : (
|
||||||
|
"tap to copy"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
52
components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
58
components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
103
components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex w-full min-w-0 overflow-x-clip flex-col gap-4 rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-3 sm:px-4 min-w-0 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-3 sm:px-4 min-w-0 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
20
components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
20
components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
25
components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
10
components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted-foreground/10", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
18
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
30
lib/auth.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
|
||||||
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "Admin Login",
|
||||||
|
credentials: {
|
||||||
|
username: { label: "Username", type: "text" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (
|
||||||
|
credentials?.username === process.env.ADMIN_USERNAME &&
|
||||||
|
credentials?.password === process.env.ADMIN_PASSWORD
|
||||||
|
) {
|
||||||
|
return { id: "1", name: "Admin" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
maxAge: 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
});
|
||||||
9
lib/constants.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const MC_SERVER_IP = "127.0.0.1";
|
||||||
|
export const MC_SERVER_PORT = 25565;
|
||||||
|
export const MODS_DIR = "/home/minecraft/server/mods";
|
||||||
|
export const CLIENT_MODS_DIR = "/home/minecraft/server/client-mods";
|
||||||
|
export const MOD_METADATA_FILE = "/home/minecraft/server/mod-metadata.json";
|
||||||
|
export const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
|
||||||
|
export const INSTALLER_BAT = "/var/www/minecraft/install-modpack.bat";
|
||||||
|
export const RCON_PORT = 25575;
|
||||||
|
export const RCON_PASSWORD = process.env.RCON_PASSWORD || "";
|
||||||
229
lib/modrinth.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { MODS_DIR, CLIENT_MODS_DIR } from "./constants";
|
||||||
|
import { getModDetails } from "./mods";
|
||||||
|
|
||||||
|
const API = "https://api.modrinth.com/v2";
|
||||||
|
const GAME_VERSION = "1.20.1";
|
||||||
|
const LOADER = "forge";
|
||||||
|
|
||||||
|
export type ModSide = "client" | "server" | "both";
|
||||||
|
|
||||||
|
export type SearchResult = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon_url: string;
|
||||||
|
downloads: number;
|
||||||
|
project_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModDownload = {
|
||||||
|
projectId: string;
|
||||||
|
versionId: string;
|
||||||
|
title: string;
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
isDependency: boolean;
|
||||||
|
alreadyInstalled: boolean;
|
||||||
|
side: ModSide;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveResult = {
|
||||||
|
toInstall: ModDownload[];
|
||||||
|
skipped: ModDownload[];
|
||||||
|
conflicts: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Search ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function searchMods(query: string): Promise<SearchResult[]> {
|
||||||
|
const facets = JSON.stringify([
|
||||||
|
[`categories:${LOADER}`],
|
||||||
|
[`versions:${GAME_VERSION}`],
|
||||||
|
["project_type:mod"],
|
||||||
|
]);
|
||||||
|
const url = `${API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=12`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Modrinth search failed: ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.hits.map((h: Record<string, unknown>) => ({
|
||||||
|
slug: h.slug,
|
||||||
|
title: h.title,
|
||||||
|
description: h.description,
|
||||||
|
icon_url: h.icon_url || "",
|
||||||
|
downloads: h.downloads,
|
||||||
|
project_id: h.project_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get project details (title + side info) ─────────────────
|
||||||
|
|
||||||
|
type ProjectDetails = {
|
||||||
|
title: string;
|
||||||
|
client_side: string;
|
||||||
|
server_side: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getProjectDetails(
|
||||||
|
projectId: string
|
||||||
|
): Promise<ProjectDetails | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/project/${projectId}`, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
title: data.title || projectId,
|
||||||
|
client_side: data.client_side || "optional",
|
||||||
|
server_side: data.server_side || "optional",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifySide(
|
||||||
|
client_side: string,
|
||||||
|
server_side: string
|
||||||
|
): ModSide {
|
||||||
|
if (server_side === "unsupported") return "client";
|
||||||
|
if (client_side === "unsupported") return "server";
|
||||||
|
return "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get latest version for a project ────────────────────────
|
||||||
|
|
||||||
|
type VersionFile = { url: string; filename: string };
|
||||||
|
type VersionDep = {
|
||||||
|
project_id: string | null;
|
||||||
|
dependency_type: string;
|
||||||
|
};
|
||||||
|
type VersionData = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
files: VersionFile[];
|
||||||
|
dependencies: VersionDep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getLatestVersion(
|
||||||
|
projectId: string
|
||||||
|
): Promise<VersionData | null> {
|
||||||
|
const url = `${API}/project/${projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const versions: VersionData[] = await res.json();
|
||||||
|
return versions.length > 0 ? versions[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve dependencies ────────────────────────────────────
|
||||||
|
|
||||||
|
export async function resolveDependencies(
|
||||||
|
projectIds: string[],
|
||||||
|
titles: Record<string, string>
|
||||||
|
): Promise<ResolveResult> {
|
||||||
|
// Cache installed mods once for the entire resolution
|
||||||
|
const installed = getModDetails();
|
||||||
|
const toInstall: ModDownload[] = [];
|
||||||
|
const skipped: ModDownload[] = [];
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const queue = projectIds.map((id) => ({ id, isDep: false }));
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift()!;
|
||||||
|
if (visited.has(item.id)) continue;
|
||||||
|
visited.add(item.id);
|
||||||
|
|
||||||
|
const [version, details] = await Promise.all([
|
||||||
|
getLatestVersion(item.id),
|
||||||
|
getProjectDetails(item.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!version || version.files.length === 0) {
|
||||||
|
if (!item.isDep) {
|
||||||
|
conflicts.push(
|
||||||
|
`"${titles[item.id] || item.id}" has no Forge 1.20.1 version available`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = titles[item.id] || details?.title || item.id;
|
||||||
|
const side = details
|
||||||
|
? classifySide(details.client_side, details.server_side)
|
||||||
|
: "both";
|
||||||
|
const file = version.files[0];
|
||||||
|
|
||||||
|
const mod: ModDownload = {
|
||||||
|
projectId: item.id,
|
||||||
|
versionId: version.id,
|
||||||
|
title,
|
||||||
|
filename: file.filename,
|
||||||
|
url: file.url,
|
||||||
|
isDependency: item.isDep,
|
||||||
|
alreadyInstalled: false,
|
||||||
|
side,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if already installed (by filename as rough proxy)
|
||||||
|
const filenameBase = file.filename.replace(/-[\d.]+.*\.jar$/, "").toLowerCase();
|
||||||
|
const isInstalled = installed.some(
|
||||||
|
(m) =>
|
||||||
|
m.filename === file.filename ||
|
||||||
|
m.filename.toLowerCase().startsWith(filenameBase)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
mod.alreadyInstalled = true;
|
||||||
|
skipped.push(mod);
|
||||||
|
} else {
|
||||||
|
toInstall.push(mod);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue required dependencies
|
||||||
|
for (const dep of version.dependencies) {
|
||||||
|
if (
|
||||||
|
dep.dependency_type === "required" &&
|
||||||
|
dep.project_id &&
|
||||||
|
!visited.has(dep.project_id)
|
||||||
|
) {
|
||||||
|
queue.push({ id: dep.project_id, isDep: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toInstall, skipped, conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Download a mod ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function downloadMod(
|
||||||
|
url: string,
|
||||||
|
filename: string,
|
||||||
|
side: ModSide = "both"
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to download ${filename}: ${res.status}`);
|
||||||
|
|
||||||
|
const targetDir = side === "client" ? CLIENT_MODS_DIR : MODS_DIR;
|
||||||
|
if (side === "client" && !existsSync(CLIENT_MODS_DIR)) {
|
||||||
|
mkdirSync(CLIENT_MODS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
writeFileSync(join(targetDir, filename), buffer);
|
||||||
|
}
|
||||||
277
lib/mods.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { status } from "minecraft-server-util";
|
||||||
|
import {
|
||||||
|
MODS_DIR,
|
||||||
|
CLIENT_MODS_DIR,
|
||||||
|
MOD_METADATA_FILE,
|
||||||
|
MC_SERVER_IP,
|
||||||
|
MC_SERVER_PORT,
|
||||||
|
} from "./constants";
|
||||||
|
import { sendCommand } from "./rcon";
|
||||||
|
import type { ModSide } from "./modrinth";
|
||||||
|
|
||||||
|
const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
|
||||||
|
const MODPACK_MODS = "/var/www/minecraft/mods";
|
||||||
|
|
||||||
|
export type ModMeta = {
|
||||||
|
modId: string;
|
||||||
|
displayName: string;
|
||||||
|
version: string;
|
||||||
|
filename: string;
|
||||||
|
size: string;
|
||||||
|
side: ModSide;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Mod metadata persistence ───────────────────────────────
|
||||||
|
|
||||||
|
type ModMetadataEntry = { projectId: string; side: ModSide };
|
||||||
|
type ModMetadataMap = Record<string, ModMetadataEntry>;
|
||||||
|
|
||||||
|
function loadModMetadata(): ModMetadataMap {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveModMetadata(metadata: ModMetadataMap): void {
|
||||||
|
writeFileSync(MOD_METADATA_FILE, JSON.stringify(metadata, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addModMetadata(
|
||||||
|
filename: string,
|
||||||
|
entry: ModMetadataEntry
|
||||||
|
): void {
|
||||||
|
const metadata = loadModMetadata();
|
||||||
|
metadata[filename] = entry;
|
||||||
|
saveModMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeModMetadata(filename: string): void {
|
||||||
|
const metadata = loadModMetadata();
|
||||||
|
delete metadata[filename];
|
||||||
|
saveModMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read mod metadata from JAR files ────────────────────────
|
||||||
|
|
||||||
|
function extractToml(content: string, key: string): string | null {
|
||||||
|
const regex = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
|
||||||
|
const match = content.match(regex);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractModMeta(
|
||||||
|
dir: string,
|
||||||
|
filename: string
|
||||||
|
): Omit<ModMeta, "side"> {
|
||||||
|
const filePath = join(dir, filename);
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
let modId = "unknown";
|
||||||
|
let displayName = filename
|
||||||
|
.replace(/-(\d)/, " $1")
|
||||||
|
.replace(".jar", "")
|
||||||
|
.replace(/-/g, " ");
|
||||||
|
let version = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zip = new AdmZip(filePath);
|
||||||
|
const toml = zip.getEntry("META-INF/mods.toml");
|
||||||
|
if (toml) {
|
||||||
|
const content = toml.getData().toString("utf8");
|
||||||
|
modId = extractToml(content, "modId") || modId;
|
||||||
|
displayName = extractToml(content, "displayName") || displayName;
|
||||||
|
version = extractToml(content, "version") || "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use filename-based defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
modId,
|
||||||
|
displayName,
|
||||||
|
version,
|
||||||
|
filename,
|
||||||
|
size: (stat.size / 1024 / 1024).toFixed(1) + " MB",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModDetails(): ModMeta[] {
|
||||||
|
const metadata = loadModMetadata();
|
||||||
|
|
||||||
|
// Server mods
|
||||||
|
const serverFiles = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
||||||
|
const serverMods: ModMeta[] = serverFiles.map((f) => {
|
||||||
|
const meta = extractModMeta(MODS_DIR, f);
|
||||||
|
const stored = metadata[f];
|
||||||
|
const side: ModSide = stored?.side === "server" ? "server" : stored?.side || "both";
|
||||||
|
return { ...meta, side };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client mods
|
||||||
|
let clientMods: ModMeta[] = [];
|
||||||
|
if (existsSync(CLIENT_MODS_DIR)) {
|
||||||
|
const clientFiles = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
||||||
|
f.endsWith(".jar")
|
||||||
|
);
|
||||||
|
clientMods = clientFiles.map((f) => ({
|
||||||
|
...extractModMeta(CLIENT_MODS_DIR, f),
|
||||||
|
side: "client" as ModSide,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...serverMods, ...clientMods];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove a single mod ─────────────────────────────────────
|
||||||
|
|
||||||
|
export function removeMod(filename: string): void {
|
||||||
|
const serverPath = join(MODS_DIR, filename);
|
||||||
|
const clientPath = join(CLIENT_MODS_DIR, filename);
|
||||||
|
|
||||||
|
if (existsSync(serverPath)) {
|
||||||
|
unlinkSync(serverPath);
|
||||||
|
} else if (existsSync(clientPath)) {
|
||||||
|
unlinkSync(clientPath);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Mod "${filename}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeModMetadata(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClientOnlyMod(filename: string): boolean {
|
||||||
|
return (
|
||||||
|
!existsSync(join(MODS_DIR, filename)) &&
|
||||||
|
existsSync(join(CLIENT_MODS_DIR, filename))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server health check ─────────────────────────────────────
|
||||||
|
|
||||||
|
export type HealthProgress = {
|
||||||
|
phase: "waiting" | "process-alive" | "port-open" | "rcon-ready" | "failed" | "process-died";
|
||||||
|
elapsed: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function waitForServerAdaptive(
|
||||||
|
onProgress?: (p: HealthProgress) => void | Promise<void>,
|
||||||
|
options?: { initialDelayMs?: number; maxWaitMs?: number }
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { initialDelayMs = 8000, maxWaitMs = 600000 } = options ?? {};
|
||||||
|
const start = Date.now();
|
||||||
|
const elapsed = () => Date.now() - start;
|
||||||
|
const elapsedSec = () => Math.round(elapsed() / 1000);
|
||||||
|
|
||||||
|
const report = async (phase: HealthProgress["phase"], message: string) => {
|
||||||
|
await onProgress?.({ phase, elapsed: elapsed(), message });
|
||||||
|
};
|
||||||
|
|
||||||
|
await report("waiting", "Waiting for server process...");
|
||||||
|
await sleep(initialDelayMs);
|
||||||
|
|
||||||
|
let rconFailsWhileActive = 0;
|
||||||
|
|
||||||
|
while (elapsed() < maxWaitMs) {
|
||||||
|
// Tier 1: is the process alive?
|
||||||
|
let serviceState: string;
|
||||||
|
try {
|
||||||
|
serviceState = execSync("systemctl is-active minecraft.service", {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
await report("process-died", "Server process is not running");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceState !== "active" && serviceState !== "activating") {
|
||||||
|
await report("process-died", `Server process state: ${serviceState}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await report("process-alive", `Server process alive (${elapsedSec()}s)`);
|
||||||
|
|
||||||
|
// Tier 2: try RCON directly — this is the definitive "server is ready" signal
|
||||||
|
// RCON only starts after the server is fully loaded ("Done!")
|
||||||
|
try {
|
||||||
|
await sendCommand("list");
|
||||||
|
await report("rcon-ready", "Server is fully online");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
rconFailsWhileActive++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: if RCON keeps failing, try MC protocol ping as fallback
|
||||||
|
// (handles case where RCON is misconfigured but server is actually ready)
|
||||||
|
if (rconFailsWhileActive >= 6) {
|
||||||
|
try {
|
||||||
|
await status(MC_SERVER_IP, MC_SERVER_PORT, { timeout: 3000 });
|
||||||
|
await report("port-open", "Server is online (port verified, RCON unavailable)");
|
||||||
|
return true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await report("failed", `Server did not respond within ${Math.round(maxWaitMs / 1000)}s`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForServer(timeoutMs = 600000): Promise<boolean> {
|
||||||
|
return waitForServerAdaptive(undefined, { maxWaitMs: timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modpack rebuild ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function rebuildModpack(): void {
|
||||||
|
// Zip server mods
|
||||||
|
execSync(
|
||||||
|
`cd ${MODS_DIR} && rm -f /tmp/modpack-rebuild.zip && zip -j /tmp/modpack-rebuild.zip *.jar`,
|
||||||
|
{ encoding: "utf8" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add client mods to the same zip
|
||||||
|
if (existsSync(CLIENT_MODS_DIR)) {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`cd ${CLIENT_MODS_DIR} && zip -j /tmp/modpack-rebuild.zip *.jar 2>/dev/null || true`,
|
||||||
|
{ encoding: "utf8" }
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to web root
|
||||||
|
execSync(`sudo cp /tmp/modpack-rebuild.zip ${MODPACK_ZIP}`);
|
||||||
|
execSync(`sudo rm -f ${MODPACK_MODS}/*.jar`);
|
||||||
|
execSync(`sudo cp ${MODS_DIR}/*.jar ${MODPACK_MODS}/`);
|
||||||
|
|
||||||
|
if (existsSync(CLIENT_MODS_DIR)) {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`sudo cp ${CLIENT_MODS_DIR}/*.jar ${MODPACK_MODS}/ 2>/dev/null || true`
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate modlist from both directories
|
||||||
|
execSync(
|
||||||
|
`(ls ${MODS_DIR}/*.jar -1 2>/dev/null; ls ${CLIENT_MODS_DIR}/*.jar -1 2>/dev/null) | xargs -I{} basename {} | sort | sudo tee /var/www/minecraft/modlist.txt > /dev/null`
|
||||||
|
);
|
||||||
|
}
|
||||||
18
lib/rcon.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Rcon } from "rcon-client";
|
||||||
|
import { MC_SERVER_IP, RCON_PORT, RCON_PASSWORD } from "./constants";
|
||||||
|
|
||||||
|
export async function sendCommand(command: string): Promise<string> {
|
||||||
|
const rcon = await Rcon.connect({
|
||||||
|
host: MC_SERVER_IP,
|
||||||
|
port: RCON_PORT,
|
||||||
|
password: RCON_PASSWORD,
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rcon.send(command);
|
||||||
|
return response;
|
||||||
|
} finally {
|
||||||
|
rcon.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
160
lib/snapshots.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
copyFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
unlinkSync,
|
||||||
|
} from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "./constants";
|
||||||
|
|
||||||
|
const SNAPSHOTS_DIR = "/home/minecraft/server/snapshots";
|
||||||
|
const MAX_SNAPSHOTS = 10;
|
||||||
|
|
||||||
|
export type SnapshotMeta = {
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
modCount: number;
|
||||||
|
mods: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureDir(dir: string) {
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSnapshot(name: string): SnapshotMeta {
|
||||||
|
ensureDir(SNAPSHOTS_DIR);
|
||||||
|
|
||||||
|
// Sanitize name
|
||||||
|
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
|
||||||
|
const dirName = `${safeName}_${Date.now()}`;
|
||||||
|
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
||||||
|
mkdirSync(snapDir);
|
||||||
|
|
||||||
|
// Back up server mods
|
||||||
|
const mods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
||||||
|
for (const file of mods) {
|
||||||
|
copyFileSync(join(MODS_DIR, file), join(snapDir, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back up client mods
|
||||||
|
if (existsSync(CLIENT_MODS_DIR)) {
|
||||||
|
const clientMods = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
||||||
|
f.endsWith(".jar")
|
||||||
|
);
|
||||||
|
if (clientMods.length > 0) {
|
||||||
|
const clientSnapDir = join(snapDir, "client-mods");
|
||||||
|
mkdirSync(clientSnapDir);
|
||||||
|
for (const file of clientMods) {
|
||||||
|
copyFileSync(join(CLIENT_MODS_DIR, file), join(clientSnapDir, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back up mod metadata
|
||||||
|
if (existsSync(MOD_METADATA_FILE)) {
|
||||||
|
copyFileSync(MOD_METADATA_FILE, join(snapDir, "mod-metadata.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: SnapshotMeta = {
|
||||||
|
name: safeName,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
modCount: mods.length,
|
||||||
|
mods,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(join(snapDir, "meta.json"), JSON.stringify(meta, null, 2));
|
||||||
|
|
||||||
|
// Enforce max snapshots
|
||||||
|
pruneOldSnapshots();
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreSnapshot(dirName: string): void {
|
||||||
|
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
||||||
|
if (!existsSync(snapDir)) {
|
||||||
|
throw new Error(`Snapshot "${dirName}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current server mods
|
||||||
|
const currentMods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
||||||
|
for (const file of currentMods) {
|
||||||
|
unlinkSync(join(MODS_DIR, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy snapshot server mods back
|
||||||
|
const snapFiles = readdirSync(snapDir).filter((f) => f.endsWith(".jar"));
|
||||||
|
for (const file of snapFiles) {
|
||||||
|
copyFileSync(join(snapDir, file), join(MODS_DIR, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore client mods
|
||||||
|
if (existsSync(CLIENT_MODS_DIR)) {
|
||||||
|
const currentClient = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
||||||
|
f.endsWith(".jar")
|
||||||
|
);
|
||||||
|
for (const file of currentClient) {
|
||||||
|
unlinkSync(join(CLIENT_MODS_DIR, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSnapDir = join(snapDir, "client-mods");
|
||||||
|
if (existsSync(clientSnapDir)) {
|
||||||
|
ensureDir(CLIENT_MODS_DIR);
|
||||||
|
const snapClientFiles = readdirSync(clientSnapDir).filter((f) =>
|
||||||
|
f.endsWith(".jar")
|
||||||
|
);
|
||||||
|
for (const file of snapClientFiles) {
|
||||||
|
copyFileSync(join(clientSnapDir, file), join(CLIENT_MODS_DIR, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore mod metadata
|
||||||
|
const metaBackup = join(snapDir, "mod-metadata.json");
|
||||||
|
if (existsSync(metaBackup)) {
|
||||||
|
copyFileSync(metaBackup, MOD_METADATA_FILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSnapshots(): (SnapshotMeta & { dirName: string })[] {
|
||||||
|
ensureDir(SNAPSHOTS_DIR);
|
||||||
|
|
||||||
|
const dirs = readdirSync(SNAPSHOTS_DIR).filter((d) => {
|
||||||
|
const metaPath = join(SNAPSHOTS_DIR, d, "meta.json");
|
||||||
|
return existsSync(metaPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
.map((dirName) => {
|
||||||
|
const meta = JSON.parse(
|
||||||
|
readFileSync(join(SNAPSHOTS_DIR, dirName, "meta.json"), "utf8")
|
||||||
|
) as SnapshotMeta;
|
||||||
|
return { ...meta, dirName };
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSnapshot(dirName: string): void {
|
||||||
|
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
||||||
|
if (!existsSync(snapDir)) {
|
||||||
|
throw new Error(`Snapshot "${dirName}" not found`);
|
||||||
|
}
|
||||||
|
rmSync(snapDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneOldSnapshots(): void {
|
||||||
|
const snapshots = listSnapshots();
|
||||||
|
if (snapshots.length > MAX_SNAPSHOTS) {
|
||||||
|
const toDelete = snapshots.slice(MAX_SNAPSHOTS);
|
||||||
|
for (const snap of toDelete) {
|
||||||
|
deleteSnapshot(snap.dirName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
5
middleware.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { auth as middleware } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/admin/:path*"],
|
||||||
|
};
|
||||||
12
next.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
serverExternalPackages: ["adm-zip"],
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "100mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
48
package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3001",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.34.3",
|
||||||
|
"@base-ui/react": "^1.3.0",
|
||||||
|
"@tanstack/react-query": "^5.96.2",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"minecraft-server-util": "^5.4.4",
|
||||||
|
"next": "16.2.3",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"rcon-client": "^4.2.5",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"shadcn": "^4.2.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.3",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"ignoreScripts": [
|
||||||
|
"sharp",
|
||||||
|
"unrs-resolver"
|
||||||
|
],
|
||||||
|
"trustedDependencies": [
|
||||||
|
"sharp",
|
||||||
|
"unrs-resolver"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
5
public/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#1a1a2e"/>
|
||||||
|
<rect x="96" y="96" width="320" height="320" rx="32" fill="#2a2a4e"/>
|
||||||
|
<text x="256" y="300" font-family="sans-serif" font-size="200" font-weight="bold" fill="#8b5cf6" text-anchor="middle">MC</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 330 B |
28
public/manifest.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "HurkiCorgi MC Server",
|
||||||
|
"short_name": "HurkiCorgi MC",
|
||||||
|
"description": "Minecraft Server Dashboard — Create & Engineering | Raids | Survival",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f0f13",
|
||||||
|
"theme_color": "#1a1a2e",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
56
scripts/backup-world.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Automated world backup — called by cron every 6 hours
|
||||||
|
|
||||||
|
BACKUP_DIR="/home/minecraft/server/backups"
|
||||||
|
WORLD_DIR="/home/minecraft/server/world"
|
||||||
|
MAX_BACKUPS=20
|
||||||
|
RCON_PASS="23991818cc169249f181436f2a29a013"
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
if systemctl is-active --quiet minecraft.service; then
|
||||||
|
# Disable saving and flush
|
||||||
|
node -e "
|
||||||
|
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
|
||||||
|
await rcon.send('save-off');
|
||||||
|
await rcon.send('save-all flush');
|
||||||
|
rcon.end();
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup
|
||||||
|
tar czf "$BACKUP_DIR/world_${TIMESTAMP}.tar.gz" -C /home/minecraft/server world
|
||||||
|
|
||||||
|
# Validate backup was created and is non-empty
|
||||||
|
if [ ! -s "$BACKUP_DIR/world_${TIMESTAMP}.tar.gz" ]; then
|
||||||
|
echo "ERROR: Backup file is empty or missing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Re-enable saving
|
||||||
|
if systemctl is-active --quiet minecraft.service; then
|
||||||
|
node -e "
|
||||||
|
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
|
||||||
|
await rcon.send('save-on');
|
||||||
|
rcon.end();
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prune old backups
|
||||||
|
cd "$BACKUP_DIR"
|
||||||
|
ls -t world_*.tar.gz 2>/dev/null | tail -n +$((MAX_BACKUPS + 1)) | xargs -r rm -f
|
||||||
|
|
||||||
|
echo "Backup complete: world_${TIMESTAMP}.tar.gz"
|
||||||
103
scripts/collect-metrics.js
Executable file
|
|
@ -0,0 +1,103 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Collect server metrics — called by cron every minute
|
||||||
|
// Appends JSON lines to analytics.jsonl
|
||||||
|
|
||||||
|
const { Rcon } = require("rcon-client");
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
const { appendFileSync, readFileSync, writeFileSync } = require("fs");
|
||||||
|
|
||||||
|
const ANALYTICS_FILE = "/home/minecraft/server/analytics.jsonl";
|
||||||
|
const RCON_HOST = "127.0.0.1";
|
||||||
|
const RCON_PORT = 25575;
|
||||||
|
const RCON_PASS = "23991818cc169249f181436f2a29a013";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Check if server is running
|
||||||
|
try {
|
||||||
|
const active = execSync("systemctl is-active minecraft.service", { encoding: "utf8" }).trim();
|
||||||
|
if (active !== "active") process.exit(0);
|
||||||
|
} catch {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get MC PID
|
||||||
|
let mcPid = "";
|
||||||
|
try {
|
||||||
|
mcPid = execSync("pgrep -f 'java.*forge' | head -1", { encoding: "utf8" }).trim();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let tps = 0;
|
||||||
|
let playersOnline = 0;
|
||||||
|
let players = [];
|
||||||
|
|
||||||
|
// RCON queries
|
||||||
|
try {
|
||||||
|
const rcon = await Rcon.connect({ host: RCON_HOST, port: RCON_PORT, password: RCON_PASS });
|
||||||
|
|
||||||
|
// TPS
|
||||||
|
try {
|
||||||
|
const tpsRaw = await rcon.send("forge tps");
|
||||||
|
const match = tpsRaw.match(/Overall.*Mean TPS:\s*(\d+\.?\d*)/i) || tpsRaw.match(/Mean TPS:\s*(\d+\.?\d*)/);
|
||||||
|
if (match) tps = parseFloat(match[1]);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Players
|
||||||
|
try {
|
||||||
|
const listRaw = await rcon.send("list");
|
||||||
|
const countMatch = listRaw.match(/(\d+)\s+of/);
|
||||||
|
if (countMatch) playersOnline = parseInt(countMatch[1]);
|
||||||
|
if (playersOnline > 0) {
|
||||||
|
const namesStr = listRaw.split(":").pop() || "";
|
||||||
|
players = namesStr.split(",").map(n => n.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
rcon.end();
|
||||||
|
} catch {
|
||||||
|
// RCON not available
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAM
|
||||||
|
let ramUsedMB = 0;
|
||||||
|
const ramTotalMB = 8192;
|
||||||
|
if (mcPid) {
|
||||||
|
try {
|
||||||
|
const status = readFileSync(`/proc/${mcPid}/status`, "utf8");
|
||||||
|
const match = status.match(/VmRSS:\s+(\d+)/);
|
||||||
|
if (match) ramUsedMB = Math.round(parseInt(match[1]) / 1024);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU (normalize to total system CPU — ps reports per-core)
|
||||||
|
let cpuPercent = 0;
|
||||||
|
const numCpus = parseInt(execSync("nproc", { encoding: "utf8" }).trim()) || 1;
|
||||||
|
if (mcPid) {
|
||||||
|
try {
|
||||||
|
const cpu = execSync(`ps -p ${mcPid} -o %cpu --no-headers`, { encoding: "utf8" }).trim();
|
||||||
|
cpuPercent = (parseFloat(cpu) || 0) / numCpus;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write
|
||||||
|
const entry = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
tps: Math.round(tps * 10) / 10,
|
||||||
|
ramUsedMB,
|
||||||
|
ramTotalMB,
|
||||||
|
cpuPercent: Math.round(cpuPercent * 10) / 10,
|
||||||
|
playersOnline,
|
||||||
|
players,
|
||||||
|
};
|
||||||
|
|
||||||
|
appendFileSync(ANALYTICS_FILE, JSON.stringify(entry) + "\n");
|
||||||
|
|
||||||
|
// Prune to last 2880 lines (48h)
|
||||||
|
try {
|
||||||
|
const lines = readFileSync(ANALYTICS_FILE, "utf8").split("\n").filter(Boolean);
|
||||||
|
if (lines.length > 3000) {
|
||||||
|
writeFileSync(ANALYTICS_FILE, lines.slice(-2880).join("\n") + "\n");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(() => process.exit(0));
|
||||||
83
scripts/collect-metrics.sh
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Collect server metrics every minute — called by cron
|
||||||
|
# Appends JSON lines to analytics.jsonl
|
||||||
|
|
||||||
|
ANALYTICS_FILE="/home/minecraft/server/analytics.jsonl"
|
||||||
|
RCON_PASS="23991818cc169249f181436f2a29a013"
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
if ! systemctl is-active --quiet minecraft.service; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get MC server PID
|
||||||
|
MC_PID=$(pgrep -f 'minecraft.*server' | head -1)
|
||||||
|
if [ -z "$MC_PID" ]; then
|
||||||
|
MC_PID=$(pgrep -f 'java.*forge' | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# TPS via RCON
|
||||||
|
TPS="0"
|
||||||
|
TPS_RAW=$(echo -e "\x00" | timeout 3 python3 -c "
|
||||||
|
from rcon.source import Client
|
||||||
|
try:
|
||||||
|
with Client('127.0.0.1', 25575, passwd='${RCON_PASS}') as c:
|
||||||
|
r = c.run('forge tps')
|
||||||
|
print(r)
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
if echo "$TPS_RAW" | grep -q "Overall"; then
|
||||||
|
TPS=$(echo "$TPS_RAW" | grep "Overall" | grep -oP '[\d.]+(?= TPS)' | head -1)
|
||||||
|
elif echo "$TPS_RAW" | grep -q "Dim 0"; then
|
||||||
|
TPS=$(echo "$TPS_RAW" | grep "Dim 0\|overworld" | grep -oP '[\d.]+(?= TPS)' | head -1)
|
||||||
|
fi
|
||||||
|
[ -z "$TPS" ] && TPS="0"
|
||||||
|
|
||||||
|
# RAM from /proc
|
||||||
|
RAM_USED=0
|
||||||
|
RAM_TOTAL=8192
|
||||||
|
if [ -n "$MC_PID" ] && [ -f "/proc/$MC_PID/status" ]; then
|
||||||
|
RAM_KB=$(grep VmRSS "/proc/$MC_PID/status" 2>/dev/null | awk '{print $2}')
|
||||||
|
[ -n "$RAM_KB" ] && RAM_USED=$((RAM_KB / 1024))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CPU
|
||||||
|
CPU="0"
|
||||||
|
if [ -n "$MC_PID" ]; then
|
||||||
|
CPU=$(ps -p "$MC_PID" -o %cpu --no-headers 2>/dev/null | tr -d ' ')
|
||||||
|
fi
|
||||||
|
[ -z "$CPU" ] && CPU="0"
|
||||||
|
|
||||||
|
# Players via RCON
|
||||||
|
PLAYERS_JSON="[]"
|
||||||
|
PLAYERS_ONLINE=0
|
||||||
|
PLAYERS_RAW=$(timeout 3 python3 -c "
|
||||||
|
from rcon.source import Client
|
||||||
|
try:
|
||||||
|
with Client('127.0.0.1', 25575, passwd='${RCON_PASS}') as c:
|
||||||
|
r = c.run('list')
|
||||||
|
print(r)
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
if echo "$PLAYERS_RAW" | grep -q "players online"; then
|
||||||
|
PLAYERS_ONLINE=$(echo "$PLAYERS_RAW" | grep -oP '\d+(?= of)' | head -1)
|
||||||
|
NAMES=$(echo "$PLAYERS_RAW" | sed 's/.*: //')
|
||||||
|
if [ "$PLAYERS_ONLINE" -gt 0 ] 2>/dev/null && [ -n "$NAMES" ]; then
|
||||||
|
PLAYERS_JSON=$(echo "$NAMES" | python3 -c "import sys,json; print(json.dumps([n.strip() for n in sys.stdin.read().split(',') if n.strip()]))")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -z "$PLAYERS_ONLINE" ] && PLAYERS_ONLINE=0
|
||||||
|
|
||||||
|
# Timestamp
|
||||||
|
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
# Write JSON line
|
||||||
|
echo "{\"ts\":\"$TS\",\"tps\":$TPS,\"ramUsedMB\":$RAM_USED,\"ramTotalMB\":$RAM_TOTAL,\"cpuPercent\":$CPU,\"playersOnline\":$PLAYERS_ONLINE,\"players\":$PLAYERS_JSON}" >> "$ANALYTICS_FILE"
|
||||||
|
|
||||||
|
# Keep only last 48 hours of data (2880 lines at 1/min)
|
||||||
|
if [ $(wc -l < "$ANALYTICS_FILE") -gt 3000 ]; then
|
||||||
|
tail -n 2880 "$ANALYTICS_FILE" > "$ANALYTICS_FILE.tmp"
|
||||||
|
mv "$ANALYTICS_FILE.tmp" "$ANALYTICS_FILE"
|
||||||
|
fi
|
||||||
32
scripts/scheduled-restart.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Scheduled server restart with player warnings
|
||||||
|
|
||||||
|
RCON_PASS="23991818cc169249f181436f2a29a013"
|
||||||
|
|
||||||
|
send_rcon() {
|
||||||
|
node -e "
|
||||||
|
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
|
||||||
|
await rcon.send('say $1');
|
||||||
|
rcon.end();
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5 minute warning
|
||||||
|
send_rcon '§c[Server] Restarting in 5 minutes!'
|
||||||
|
sleep 240
|
||||||
|
|
||||||
|
# 1 minute warning
|
||||||
|
send_rcon '§c[Server] Restarting in 1 minute!'
|
||||||
|
sleep 50
|
||||||
|
|
||||||
|
# 10 second warning
|
||||||
|
send_rcon '§c[Server] Restarting in 10 seconds!'
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
sudo systemctl restart minecraft.service
|
||||||
34
tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||