Bundle analyzer, task run-now/toggle/weekly, error reporter, OG image, a11y

- Bundle analyzer: @next/bundle-analyzer wired through next.config.ts,
  new `bun run analyze` script (ANALYZE=true next build).
- Scheduled tasks gain:
  - enabled flag round-tripped via a #DISABLED prefix on the crontab line
    (preserves the mc-task marker + payload for re-enable).
  - PATCH /api/schedule/tasks to toggle enabled.
  - POST /api/schedule/tasks/run to execute a task immediately via
    the same buildCommand used for the cron line (60s timeout, kills
    child on client abort).
  - Weekly preset in the UI (day-of-week selector), broader aria-labels
    on all form selects. Human-readable cron renders "Tue at 04:30"
    and next-run calc accounts for weekly.
  - Hover-reveal Run now / Enable / Disable / Remove actions; disabled
    tasks render at 60% opacity with a "disabled" badge and no next-run.
- /api/errors: minimal append-only JSONL reporter (200ms throttle, UA
  and IP captured, fields length-bounded). Falls back to a stable
  path under /home/minecraft/logs/ and never fails the client on
  logging errors.
- ErrorReporter client (production-only) listens to window.error and
  unhandledrejection, fingerprint-dedups via a bounded in-memory set,
  sends with keepalive so unloads still flush.
- app/opengraph-image.tsx: 1200x630 dynamic PNG with live status dot
  (green/red), player count, address. 60s memo on the status probe.
- A11y: aria-labels on log-line count select, scheduled-restart hour
  and minute selects, copy-server-address button (plus focus-visible
  ring). ModManager selected-mod pill upgraded to role=button with a
  real accessible name and a proper × glyph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 06:08:48 -06:00
parent cf467b26c7
commit 7ada9ec7d9
14 changed files with 578 additions and 56 deletions

View file

@ -7,6 +7,7 @@ import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const TASK_MARKER = "# mc-task:";
const DISABLED_PREFIX = "#DISABLED ";
const RUN_SCRIPT = "/home/minecraft/dashboard/scripts/run-task.sh";
const TASK_TYPES = ["say", "backup", "snapshot-prune"] as const;
@ -17,10 +18,8 @@ type Task = {
type: TaskType;
label: string;
cron: string; // "M H D M W"
params: {
message?: string;
keep?: number;
};
enabled: boolean;
params: { message?: string; keep?: number };
};
const MSG_RE = /^[^\r\n$`\\'"]{1,120}$/;
@ -36,7 +35,7 @@ function escapeSingle(s: string): string {
return s.replace(/'/g, "'\\''");
}
function buildCommand(task: Task): string {
export function buildCommand(task: Task): string {
const base = `bash ${RUN_SCRIPT}`;
if (task.type === "say") {
const msg = (task.params.message || "").trim();
@ -67,7 +66,14 @@ function decodeTask(b64: string): Task | null {
) {
return null;
}
return obj as Task;
return {
id: obj.id,
type: obj.type,
label: obj.label,
cron: obj.cron,
enabled: obj.enabled !== false,
params: obj.params || {},
} as Task;
} catch {
return null;
}
@ -93,15 +99,27 @@ function writeCrontab(contents: string): void {
}
}
function parseTasks(): Task[] {
function renderLine(task: Task): string {
const cmd = buildCommand(task);
const body = `${task.cron} ${cmd} ${TASK_MARKER}${encodeTask(task)}`;
return task.enabled ? body : `${DISABLED_PREFIX}${body}`;
}
export function parseTasks(): Task[] {
const lines = readCrontab().split("\n");
const tasks: Task[] = [];
for (const line of lines) {
for (const rawLine of lines) {
let line = rawLine;
let enabled = true;
if (line.startsWith(DISABLED_PREFIX)) {
enabled = false;
line = line.slice(DISABLED_PREFIX.length);
}
const idx = line.indexOf(TASK_MARKER);
if (idx < 0) continue;
const b64 = line.slice(idx + TASK_MARKER.length).trim();
const task = decodeTask(b64);
if (task) tasks.push(task);
if (task) tasks.push({ ...task, enabled });
}
return tasks;
}
@ -155,6 +173,7 @@ export async function POST(req: NextRequest) {
type,
label: label || defaultLabel(type, params),
cron,
enabled: true,
params: {
message: typeof params.message === "string" ? params.message : undefined,
keep: Number.isInteger(Number(params.keep)) ? Number(params.keep) : undefined,
@ -162,10 +181,11 @@ export async function POST(req: NextRequest) {
};
try {
const cmd = buildCommand(task);
const line = `${cron} ${cmd} ${TASK_MARKER}${encodeTask(task)}`;
const crontab = readCrontab();
const updated = (crontab ? crontab.replace(/\n+$/, "") + "\n" : "") + line + "\n";
const updated =
(crontab ? crontab.replace(/\n+$/, "") + "\n" : "") +
renderLine(task) +
"\n";
writeCrontab(updated);
return NextResponse.json(task);
} catch (e) {
@ -173,6 +193,49 @@ export async function POST(req: NextRequest) {
}
}
export async function PATCH(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { id, enabled } = await req.json();
if (typeof id !== "string" || !/^[a-f0-9]{16}$/.test(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
if (typeof enabled !== "boolean") {
return NextResponse.json({ error: "enabled must be boolean" }, { status: 400 });
}
try {
const lines = readCrontab().split("\n");
let found: Task | null = null;
const updated = lines.map((rawLine) => {
let line = rawLine;
let wasDisabled = false;
if (line.startsWith(DISABLED_PREFIX)) {
wasDisabled = true;
line = line.slice(DISABLED_PREFIX.length);
}
const idx = line.indexOf(TASK_MARKER);
if (idx < 0) return rawLine;
const task = decodeTask(line.slice(idx + TASK_MARKER.length).trim());
if (!task || task.id !== id) return rawLine;
task.enabled = enabled;
found = task;
return renderLine(task);
void wasDisabled;
});
if (!found) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
writeCrontab(updated.filter(Boolean).join("\n"));
return NextResponse.json(found);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session) {
@ -185,7 +248,10 @@ export async function DELETE(req: NextRequest) {
try {
const lines = readCrontab().split("\n");
const kept = lines.filter((line) => {
const kept = lines.filter((rawLine) => {
const line = rawLine.startsWith(DISABLED_PREFIX)
? rawLine.slice(DISABLED_PREFIX.length)
: rawLine;
const idx = line.indexOf(TASK_MARKER);
if (idx < 0) return true;
const task = decodeTask(line.slice(idx + TASK_MARKER.length).trim());