diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts new file mode 100644 index 0000000..789a471 --- /dev/null +++ b/app/api/errors/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { appendFile, mkdir } from "fs/promises"; +import { dirname } from "path"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const LOG_PATH = + process.env.MC_DASHBOARD_ERROR_LOG || + "/home/minecraft/logs/mc-dashboard/errors.log"; + +let ensured = false; +async function ensureLog() { + if (ensured) return; + try { + await mkdir(dirname(LOG_PATH), { recursive: true }); + ensured = true; + } catch {} +} + +// Very light throttle: drop any request shorter than THROTTLE_MS after the last +const THROTTLE_MS = 200; +let lastWrite = 0; + +export async function POST(req: NextRequest) { + const now = Date.now(); + if (now - lastWrite < THROTTLE_MS) { + return NextResponse.json({ ok: true, throttled: true }); + } + lastWrite = now; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const b = body as Record; + const entry = { + ts: new Date().toISOString(), + type: String(b.type || "unknown").slice(0, 40), + message: String(b.message || "").slice(0, 500), + stack: typeof b.stack === "string" ? b.stack.slice(0, 4000) : undefined, + url: typeof b.url === "string" ? b.url.slice(0, 500) : undefined, + userAgent: req.headers.get("user-agent")?.slice(0, 200) || undefined, + ip: req.headers.get("x-forwarded-for")?.split(",")[0].trim() || undefined, + }; + + try { + await ensureLog(); + await appendFile(LOG_PATH, JSON.stringify(entry) + "\n", { encoding: "utf8" }); + } catch { + // Never fail the client on logging problems + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/schedule/tasks/route.ts b/app/api/schedule/tasks/route.ts index 573c68c..c9efc39 100644 --- a/app/api/schedule/tasks/route.ts +++ b/app/api/schedule/tasks/route.ts @@ -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()); diff --git a/app/api/schedule/tasks/run/route.ts b/app/api/schedule/tasks/run/route.ts new file mode 100644 index 0000000..76fe75a --- /dev/null +++ b/app/api/schedule/tasks/run/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { exec } from "child_process"; +import { auth } from "@/lib/auth"; +import { parseTasks, buildCommand } from "../route"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const { id } = await req.json(); + if (typeof id !== "string" || !/^[a-f0-9]{16}$/.test(id)) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + + const task = parseTasks().find((t) => t.id === id); + if (!task) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } + + const cmd = buildCommand(task); + + return new Promise((resolve) => { + const child = exec(cmd, { timeout: 60_000 }, (err, stdout, stderr) => { + if (err) { + resolve( + NextResponse.json( + { + success: false, + message: err.message, + stderr: stderr?.toString().slice(-500), + }, + { status: 500 } + ) + ); + return; + } + resolve( + NextResponse.json({ + success: true, + message: "Task executed", + stdout: stdout?.toString().slice(-500), + }) + ); + }); + req.signal.addEventListener("abort", () => { + try { child.kill(); } catch {} + }); + }); +} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx new file mode 100644 index 0000000..e67a037 --- /dev/null +++ b/app/opengraph-image.tsx @@ -0,0 +1,126 @@ +import { ImageResponse } from "next/og"; +import { probeStatus } from "@/lib/server-status"; +import { memoAsync } from "@/lib/cache"; + +export const dynamic = "force-dynamic"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; +export const alt = "HurkiCorgi MC — modded Minecraft server"; + +export default async function OGImage() { + const status = await memoAsync("og-status", 60_000, async () => { + try { + return await probeStatus(); + } catch { + return { online: false, players: { online: 0, max: 0 } }; + } + }); + + const online = status.online; + const players = status.players; + const accent = online ? "#4ade80" : "#f87171"; + const statusLabel = online ? "Online" : "Offline"; + + return new ImageResponse( + ( +
+
+
+ + {statusLabel} + +
+ +
+
+ HurkiCorgi MC +
+
+ Create & Engineering · Raids · Survival +
+
+ +
+
+ Address + + minecraft.hurkicorgi.com + +
+
+ Players + + {online ? `${players.online}/${players.max}` : "—"} + +
+
+
+ ), + { ...size } + ); +} diff --git a/app/providers.tsx b/app/providers.tsx index b9b59ee..ee8e3c0 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -9,6 +9,7 @@ import { PlayerDrawer } from "@/components/PlayerDrawer"; import { EventsBridge } from "@/components/EventsBridge"; import { OfflineBanner } from "@/components/OfflineBanner"; import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister"; +import { ErrorReporter } from "@/components/ErrorReporter"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -38,6 +39,7 @@ export function Providers({ children }: { children: React.ReactNode }) { + {children} diff --git a/bun.lock b/bun.lock index 38faacb..be64091 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "tw-animate-css": "^1.4.0", }, "devDependencies": { + "@next/bundle-analyzer": "^16.2.3", "@tailwindcss/postcss": "^4", "@types/adm-zip": "^0.5.8", "@types/node": "^20", @@ -108,6 +109,8 @@ "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], + "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.61.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], @@ -230,6 +233,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@next/bundle-analyzer": ["@next/bundle-analyzer@16.2.3", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-aDwW4f4SVqbQDWzSBHQJ1KI6H+lx8oX/vS3xGqzLajUu+KQb7uakK88AIMvRIf7TlIonce67g594rzpxvBuJIw=="], + "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.3", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA=="], @@ -272,6 +277,8 @@ "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -436,6 +443,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -566,6 +575,8 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], @@ -598,6 +609,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -764,6 +777,8 @@ "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], + "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -786,6 +801,8 @@ "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -860,6 +877,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -998,6 +1017,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.13.2", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A=="], @@ -1054,6 +1075,8 @@ "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -1210,6 +1233,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sirv": ["sirv@2.0.4", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], @@ -1280,6 +1305,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], @@ -1344,6 +1371,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webpack-bundle-analyzer": ["webpack-bundle-analyzer@4.10.1", "", { "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", "commander": "^7.2.0", "debounce": "^1.2.1", "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", "is-plain-object": "^5.0.0", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", "ws": "^7.3.1" }, "bin": { "webpack-bundle-analyzer": "lib/bin/analyzer.js" } }, "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1360,6 +1389,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1484,6 +1515,8 @@ "string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "webpack-bundle-analyzer/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/components/ErrorReporter.tsx b/components/ErrorReporter.tsx new file mode 100644 index 0000000..e350fcc --- /dev/null +++ b/components/ErrorReporter.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect } from "react"; + +const SEEN = new Set(); +const MAX_SEEN = 50; + +function fingerprint(type: string, message: string, stack?: string): string { + const head = (stack || "").split("\n").slice(0, 3).join("\n"); + return `${type}|${message.slice(0, 80)}|${head.slice(0, 120)}`; +} + +async function report(payload: { + type: string; + message: string; + stack?: string; + url?: string; +}) { + const fp = fingerprint(payload.type, payload.message, payload.stack); + if (SEEN.has(fp)) return; + SEEN.add(fp); + if (SEEN.size > MAX_SEEN) { + const first = SEEN.values().next().value; + if (first) SEEN.delete(first); + } + try { + await fetch("/api/errors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, url: payload.url || location.href }), + keepalive: true, + }); + } catch {} +} + +export function ErrorReporter() { + useEffect(() => { + if (typeof window === "undefined") return; + if (process.env.NODE_ENV !== "production") return; + + const onError = (e: ErrorEvent) => { + report({ + type: "window.error", + message: e.message || String(e.error), + stack: e.error?.stack, + url: e.filename, + }); + }; + + const onRejection = (e: PromiseRejectionEvent) => { + const reason = e.reason; + const message = + reason instanceof Error ? reason.message : String(reason); + const stack = reason instanceof Error ? reason.stack : undefined; + report({ type: "unhandledrejection", message, stack }); + }; + + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); + return () => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); + }; + }, []); + + return null; +} diff --git a/components/LogViewer.tsx b/components/LogViewer.tsx index 45237a9..740c1bd 100644 --- a/components/LogViewer.tsx +++ b/components/LogViewer.tsx @@ -115,6 +115,7 @@ export function LogViewer() { setLines(Number(e.target.value)); setTimeout(() => refetch(), 50); }} + aria-label="Number of log lines to fetch" className="h-9 rounded-md border border-input bg-muted px-2 text-sm text-foreground focus:outline-none" > diff --git a/components/ModManager.tsx b/components/ModManager.tsx index f9a3915..b4859b5 100644 --- a/components/ModManager.tsx +++ b/components/ModManager.tsx @@ -627,9 +627,11 @@ export function ModManager() { variant="secondary" className="gap-1 cursor-pointer hover:bg-destructive/20" onClick={() => toggleSelect(s)} + role="button" + aria-label={`Unselect ${s.title}`} > {s.title} - x + × ))}
diff --git a/components/ScheduledTasks.tsx b/components/ScheduledTasks.tsx index 7c0a070..4e58a8c 100644 --- a/components/ScheduledTasks.tsx +++ b/components/ScheduledTasks.tsx @@ -14,24 +14,34 @@ type Task = { type: TaskType; label: string; cron: string; + enabled: boolean; params: { message?: string; keep?: number }; }; -type Preset = - | { kind: "daily"; hour: number; minute: number } - | { kind: "every-hours"; hours: number }; +type Preset = "daily" | "every-hours" | "weekly"; -function cronFromPreset(p: Preset): string { - if (p.kind === "daily") return `${p.minute} ${p.hour} * * *`; - return `0 */${p.hours} * * *`; +const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function cronFromForm(form: { + preset: Preset; + hour: number; + minute: number; + hours: number; + dow: number; +}): string { + const { preset, hour, minute, hours, dow } = form; + if (preset === "daily") return `${minute} ${hour} * * *`; + if (preset === "weekly") return `${minute} ${hour} * * ${dow}`; + return `0 */${hours} * * *`; } function describeCron(cron: string): string { const parts = cron.trim().split(/\s+/); if (parts.length !== 5) return cron; const [m, h, , , dow] = parts; - if (dow === "*" && h.startsWith("*/")) { - return `every ${h.slice(2)}h`; + if (dow === "*" && h.startsWith("*/")) return `every ${h.slice(2)}h`; + if (/^\d+$/.test(dow) && /^\d+$/.test(h) && /^\d+$/.test(m)) { + return `${DOW_LABELS[parseInt(dow) % 7]} at ${h.padStart(2, "0")}:${m.padStart(2, "0")}`; } if (dow === "*" && /^\d+$/.test(h) && /^\d+$/.test(m)) { return `daily at ${h.padStart(2, "0")}:${m.padStart(2, "0")}`; @@ -42,9 +52,18 @@ function describeCron(cron: string): string { function nextRun(cron: string): Date | null { const parts = cron.trim().split(/\s+/); if (parts.length !== 5) return null; - const [m, h] = parts; + const [m, h, , , dow] = parts; const now = new Date(); + if (/^\d+$/.test(dow) && /^\d+$/.test(h) && /^\d+$/.test(m)) { + const d = new Date(now); + d.setHours(parseInt(h), parseInt(m), 0, 0); + const target = parseInt(dow) % 7; + let diff = (target - d.getDay() + 7) % 7; + if (diff === 0 && d.getTime() <= now.getTime()) diff = 7; + d.setDate(d.getDate() + diff); + return d; + } if (/^\d+$/.test(h) && /^\d+$/.test(m)) { const d = new Date(now); d.setHours(parseInt(h), parseInt(m), 0, 0); @@ -85,10 +104,11 @@ export function ScheduledTasks() { type: "say" as TaskType, message: "", keep: 5, - preset: "daily" as "daily" | "every-hours", + preset: "daily" as Preset, hour: 4, minute: 0, hours: 6, + dow: 0, }); const { data: tasks = [], isLoading } = useQuery({ @@ -97,19 +117,18 @@ export function ScheduledTasks() { staleTime: 30_000, }); + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); + const create = useMutation({ mutationFn: async () => { - const cron = cronFromPreset( - form.preset === "daily" - ? { kind: "daily", hour: form.hour, minute: form.minute } - : { kind: "every-hours", hours: form.hours } - ); - const body: { type: TaskType; cron: string; label: string; params: Record } = { - type: form.type, - cron, - label: "", - params: {}, - }; + const cron = cronFromForm(form); + const body: { + type: TaskType; + cron: string; + label: string; + params: Record; + } = { type: form.type, cron, label: "", params: {} }; if (form.type === "say") body.params.message = form.message.trim(); if (form.type === "snapshot-prune") body.params.keep = form.keep; const res = await fetch("/api/schedule/tasks", { @@ -125,11 +144,44 @@ export function ScheduledTasks() { toast.success("Task scheduled"); setAdding(false); setForm((f) => ({ ...f, message: "" })); - queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); + invalidate(); }, onError: (err) => toast.error("Failed to add task", { description: err.message }), }); + const toggle = useMutation({ + mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => { + const res = await fetch("/api/schedule/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, enabled }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data as Task; + }, + onSuccess: (t) => { + toast.success(t.enabled ? "Task enabled" : "Task disabled"); + invalidate(); + }, + onError: (err) => toast.error("Toggle failed", { description: err.message }), + }); + + const runNow = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch("/api/schedule/tasks/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || data.error); + return data; + }, + onSuccess: () => toast.success("Task ran"), + onError: (err) => toast.error("Run failed", { description: err.message }), + }); + const remove = useMutation({ mutationFn: async (id: string) => { const res = await fetch("/api/schedule/tasks", { @@ -143,7 +195,7 @@ export function ScheduledTasks() { }, onSuccess: () => { toast.success("Task removed"); - queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); + invalidate(); }, onError: (err) => toast.error("Remove failed", { description: err.message }), }); @@ -167,7 +219,7 @@ export function ScheduledTasks() {

Scheduled Tasks

- Recurring chat messages, backups, and snapshot pruning (via crontab) + Recurring chat, backups, and snapshot pruning via crontab

{!adding && ( @@ -224,7 +276,7 @@ export function ScheduledTasks() {
- {(["daily", "every-hours"] as const).map((p) => ( + {(["daily", "every-hours", "weekly"] as const).map((p) => ( ))}
- {form.preset === "daily" ? ( + {form.preset === "weekly" && ( + + )} + + {(form.preset === "daily" || form.preset === "weekly") && ( <> - ) : ( + )} + + {form.preset === "every-hours" && ( setHour(parseInt(e.target.value))} + aria-label="Restart hour" className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground" > {Array.from({ length: 24 }, (_, i) => ( @@ -255,6 +256,7 @@ function ScheduledRestart() {