Minecraft 26.2 Fabric server stack with CraftDeck management panel

- docker-compose: itzg/minecraft-server (Fabric, offline mode, whitelist
  enforced, Modrinth-managed mods), itzg/mc-backup sidecar, custom dashboard
- Gated auth for offline mode: approve username -> whitelisted with a short
  registration window -> EasyAuth /register + /login; auto-revoke on expiry
- CraftDeck panel (Next.js 16, zero native deps): live console (SSE), players
  & stats, lag diagnostics (server TPS vs player ping via spark), performance
  graphs, Modrinth mod search/manager, backups & restore, sandboxed file
  browser/editor, .env config editor with compose apply, cron schedules,
  Discord alerts, scrypt-hashed panel login
- scripts/setup.sh generates secrets; docs/ covers auth, mods, updating,
  backups, networking (Tailscale), troubleshooting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Fable 5 committed 2026-07-10 11:07:07 -05:00
commit 279d137ce5
98 files changed
+9925

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
import cron, { type ScheduledTask } from "node-cron";
import { db } from "./db";
import { notify } from "./notify";
import { powerContainer, execInContainer } from "./docker";
import { rcon } from "./rcon";
import { say } from "./mc";
import { env } from "./env";
/**
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
* console commands. Definitions live in the panel DB; jobs are (re)loaded
* whenever they change.
*/
export type Schedule = {
id: number;
name: string;
cron: string;
action: "restart" | "backup" | "command" | "announce";
payload: string | null;
enabled: number;
last_run_at: number | null;
last_result: string | null;
};
const g = globalThis as unknown as { __craftdeckJobs?: Map<number, ScheduledTask> };
function jobs(): Map<number, ScheduledTask> {
if (!g.__craftdeckJobs) g.__craftdeckJobs = new Map();
return g.__craftdeckJobs;
}
export function validateCron(expr: string): boolean {
return cron.validate(expr);
}
export async function runScheduleAction(action: Schedule["action"], payload: string | null): Promise<string> {
switch (action) {
case "restart": {
try {
await say("Server restarting in 30 seconds!");
await new Promise((r) => setTimeout(r, 30_000));
} catch {
// server not reachable over RCON — restart anyway
}
await powerContainer(env.mcContainer, "restart");
return "Restarted";
}
case "backup": {
const res = await execInContainer(env.backupContainer, ["backup", "now"]);
if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
return "Backup completed";
}
case "command": {
if (!payload) throw new Error("No command configured");
return (await rcon().exec(payload)).slice(0, 500);
}
case "announce": {
if (!payload) throw new Error("No message configured");
await say(payload);
return "Announced";
}
}
}
async function execute(schedule: Schedule) {
let result: string;
try {
result = await runScheduleAction(schedule.action, schedule.payload);
if (schedule.action !== "announce") {
notify("success", `Schedule "${schedule.name}" ran`, result, { discord: false });
}
} catch (err) {
result = `ERROR: ${(err as Error).message}`;
notify("error", `Schedule "${schedule.name}" failed`, (err as Error).message);
}
db()
.prepare("UPDATE schedules SET last_run_at = ?, last_result = ? WHERE id = ?")
.run(Date.now(), result.slice(0, 1000), schedule.id);
}
export function reloadSchedules() {
for (const task of jobs().values()) task.stop();
jobs().clear();
const rows = db().prepare("SELECT * FROM schedules WHERE enabled = 1").all() as unknown as Schedule[];
for (const row of rows) {
if (!cron.validate(row.cron)) continue;
jobs().set(
row.id,
cron.schedule(row.cron, () => void execute(row))
);
}
}
export function startScheduler() {
reloadSchedules();
}