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

58
app/api/errors/route.ts Normal file
View file

@ -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<string, unknown>;
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 });
}

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());

View file

@ -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<NextResponse>((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 {}
});
});
}

126
app/opengraph-image.tsx Normal file
View file

@ -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(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 72,
background:
"linear-gradient(135deg, #0b0b18 0%, #1a1a2e 60%, #2a1f45 100%)",
color: "#f4f4f5",
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
<div
style={{
width: 20,
height: 20,
borderRadius: 999,
background: accent,
boxShadow: `0 0 28px ${accent}`,
}}
/>
<span
style={{
fontSize: 28,
fontWeight: 700,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: accent,
}}
>
{statusLabel}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div
style={{
fontSize: 108,
fontWeight: 800,
letterSpacing: "-0.04em",
lineHeight: 1,
display: "flex",
}}
>
HurkiCorgi MC
</div>
<div
style={{
fontSize: 38,
color: "#a1a1aa",
display: "flex",
}}
>
Create & Engineering · Raids · Survival
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
gap: 48,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 22, color: "#71717a" }}>Address</span>
<span style={{ fontSize: 34, fontWeight: 600, fontFamily: "monospace" }}>
minecraft.hurkicorgi.com
</span>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 4,
}}
>
<span style={{ fontSize: 22, color: "#71717a" }}>Players</span>
<span
style={{
fontSize: 64,
fontWeight: 800,
fontVariantNumeric: "tabular-nums",
color: online ? "#f4f4f5" : "#52525b",
display: "flex",
}}
>
{online ? `${players.online}/${players.max}` : "—"}
</span>
</div>
</div>
</div>
),
{ ...size }
);
}

View file

@ -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 }) {
<QueryClientProvider client={queryClient}>
<EventsBridge />
<ServiceWorkerRegister />
<ErrorReporter />
<OfflineBanner />
{children}
<CommandPalette />

View file

@ -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=="],

View file

@ -0,0 +1,67 @@
"use client";
import { useEffect } from "react";
const SEEN = new Set<string>();
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;
}

View file

@ -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"
>
<option value={50}>50 lines</option>

View file

@ -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}
<span className="text-muted-foreground">x</span>
<span aria-hidden className="text-muted-foreground">×</span>
</Badge>
))}
</div>

View file

@ -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<Task[]>({
@ -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<string, unknown> } = {
type: form.type,
cron,
label: "",
params: {},
};
const cron = cronFromForm(form);
const body: {
type: TaskType;
cron: string;
label: string;
params: Record<string, unknown>;
} = { 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() {
<div>
<p className="text-sm font-semibold">Scheduled Tasks</p>
<p className="text-xs text-muted-foreground">
Recurring chat messages, backups, and snapshot pruning (via crontab)
Recurring chat, backups, and snapshot pruning via crontab
</p>
</div>
{!adding && (
@ -224,7 +276,7 @@ export function ScheduledTasks() {
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">
{(["daily", "every-hours"] as const).map((p) => (
{(["daily", "every-hours", "weekly"] as const).map((p) => (
<button
key={p}
onClick={() => setForm((f) => ({ ...f, preset: p }))}
@ -235,12 +287,27 @@ export function ScheduledTasks() {
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{p === "daily" ? "Daily" : "Every N hours"}
{p === "daily" ? "Daily" : p === "weekly" ? "Weekly" : "Every Nh"}
</button>
))}
</div>
{form.preset === "daily" ? (
{form.preset === "weekly" && (
<select
value={form.dow}
onChange={(e) => setForm((f) => ({ ...f, dow: parseInt(e.target.value) }))}
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
aria-label="Day of week"
>
{DOW_LABELS.map((d, i) => (
<option key={d} value={i}>
{d}
</option>
))}
</select>
)}
{(form.preset === "daily" || form.preset === "weekly") && (
<>
<select
value={form.hour}
@ -248,6 +315,7 @@ export function ScheduledTasks() {
setForm((f) => ({ ...f, hour: parseInt(e.target.value) }))
}
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
aria-label="Hour"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>
@ -262,6 +330,7 @@ export function ScheduledTasks() {
setForm((f) => ({ ...f, minute: parseInt(e.target.value) }))
}
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
aria-label="Minute"
>
{[0, 15, 30, 45].map((v) => (
<option key={v} value={v}>
@ -270,13 +339,16 @@ export function ScheduledTasks() {
))}
</select>
</>
) : (
)}
{form.preset === "every-hours" && (
<select
value={form.hours}
onChange={(e) =>
setForm((f) => ({ ...f, hours: parseInt(e.target.value) }))
}
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
aria-label="Hour interval"
>
{[1, 2, 3, 4, 6, 8, 12].map((v) => (
<option key={v} value={v}>
@ -316,28 +388,60 @@ export function ScheduledTasks() {
{tasks.map((t) => (
<li
key={t.id}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
className={`flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group ${
t.enabled ? "" : "opacity-60"
}`}
>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{t.label}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{t.type}
</Badge>
{!t.enabled && (
<Badge
variant="outline"
className="text-xs px-1.5 py-0 border-muted-foreground/40 text-muted-foreground"
>
disabled
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{describeCron(t.cron)} · next {relative(nextRun(t.cron))}
{describeCron(t.cron)}
{t.enabled && <> · next {relative(nextRun(t.cron))}</>}
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(t.id)}
disabled={remove.isPending}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition shrink-0"
>
Remove
</Button>
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
<Button
size="sm"
variant="ghost"
onClick={() => runNow.mutate(t.id)}
disabled={runNow.isPending}
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
title="Run immediately"
>
Run now
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => toggle.mutate({ id: t.id, enabled: !t.enabled })}
disabled={toggle.isPending}
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
>
{t.enabled ? "Disable" : "Enable"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(t.id)}
disabled={remove.isPending}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Remove
</Button>
</div>
</li>
))}
</ul>

View file

@ -243,6 +243,7 @@ function ScheduledRestart() {
<select
value={h}
onChange={(e) => 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() {
<select
value={m}
onChange={(e) => setMinute(parseInt(e.target.value))}
aria-label="Restart minute"
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
>
{[0, 15, 30, 45].map((v) => (

View file

@ -82,7 +82,8 @@ export function StatusCard() {
</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]"
aria-label="Copy server address"
className="w-full rounded-md border border-dashed border-border px-2 py-2 hover:border-primary/50 transition cursor-pointer min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<p className="font-mono text-xs font-semibold text-foreground">
minecraft.hurkicorgi.com

View file

@ -1,4 +1,9 @@
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
const withAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
const nextConfig: NextConfig = {
serverExternalPackages: ["adm-zip"],
@ -16,4 +21,4 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withAnalyzer(nextConfig);

View file

@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"analyze": "ANALYZE=true next build",
"start": "next start -p 3001",
"lint": "eslint"
},
@ -29,6 +30,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",