Files
Epicnabbo-Catalogus-Updated…/src/app/admin/commandocentrum/page.tsx
T

452 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os from "node:os";
import { Activity } from 'lucide-react';
import {
hotelAlert,
updateCatalog,
updateNavigator,
updateWordFilter,
disconnectUser,
alertUser,
forwardUser,
giveCredits,
giveDuckets,
giveDiamonds,
giveBadge,
setMotto,
setRank,
executeCommand,
sendGift,
} from "@/actions/commandocentrum";
import { DiagnosticRow, InfoItem, StatusCard, OnlineUsersWidget } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
function formatTimestamp(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 19).replace("T", " ");
}
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "—";
}
function decodeStacktrace(raw: unknown): string {
if (raw == null) return "";
try {
if (typeof raw === "string") return raw;
return Buffer.from(raw as Uint8Array).toString("utf8");
} catch {
return "";
}
}
function mb(bytes: number): string {
return `${Math.round(bytes / 1024 / 1024)} MB`;
}
function uptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`;
}
export default async function CommandoCentrum() {
const [onlineCount, totalUsers, errors, dbOk, emulatorOnline, maintenance, activity] =
await Promise.all([
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
prisma.user.count().catch(() => -1),
prisma.emulatorErrors.findMany({ orderBy: { timestamp: "desc" }, take: 50 }).catch(() => []),
prisma.websiteSetting
.count()
.then(() => true)
.catch(() => false),
rcon.send("ping", null).catch(() => false),
siteSettings.getBool("maintenance_enabled", false),
prisma.staffActivities
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
.catch(() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>),
]);
const staffIds = Array.from(new Set(activity.map((a) => Number(a.userId)))).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({ where: { id: { in: staffIds } }, select: { id: true, username: true } })
.catch(() => []);
nameById = new Map(users.map((u) => [u.id, u.username]));
}
const appKeySet = !!process.env.APP_KEY;
const smtpSet = !!process.env.SMTP_HOST;
const rconSet = !!process.env.RCON_HOST;
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
<Activity size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Commandocentrum</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">Live hotel status, diagnostics and emulator control over RCON.</p>
</div>
</div>
{/* ── Live status ────────────────────────────────────────── */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6" style={{ marginTop: "1.1rem" }}>
<StatusCard
label="Players online"
value={onlineCount}
hint={totalUsers >= 0 ? `${totalUsers} accounts total` : "DB unavailable"}
state="neutral"
icon="👤"
/>
<StatusCard
label="Emulator"
value={emulatorOnline ? "Online" : "Offline"}
hint={emulatorOnline ? "RCON reachable" : "RCON not responding"}
state={emulatorOnline ? "ok" : "danger"}
icon="🎮"
/>
<StatusCard
label="Database"
value={dbOk ? "Connected" : "Down"}
hint={dbOk ? "Queries responding" : "Cannot reach DB"}
state={dbOk ? "ok" : "danger"}
icon="🗄️"
/>
<StatusCard
label="Emulator errors"
value={errors.length}
hint="last 50 logged"
state={errors.length === 0 ? "ok" : "warn"}
icon="⚠️"
/>
</div>
{/* ── Online users widget ───────────────────────────────── */}
<section className="admin-card mb-6">
<h2 className="admin-section-title">Online Users</h2>
<OnlineUsersWidget
users={[]}
isLoading={false}
/>
</section>
{/* ── Server info + diagnostics ──────────────────────────── */}
<div className="grid cols-2">
<section className="admin-card p-5">
<h2 className="admin-section-title">Server info</h2>
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
<InfoItem label="Node" value={process.version} />
<InfoItem label="Platform" value={`${process.platform} ${process.arch}`} />
<InfoItem label="App uptime" value={uptime(process.uptime())} />
<InfoItem label="Heap used" value={mb(process.memoryUsage().heapUsed)} />
<InfoItem label="RSS" value={mb(process.memoryUsage().rss)} />
<InfoItem label="Host uptime" value={uptime(os.uptime())} />
<InfoItem label="Load (1m)" value={os.loadavg()[0]?.toFixed(2) ?? "—"} />
<InfoItem label="CPUs" value={os.cpus().length} />
</div>
</section>
<section className="admin-card p-5">
<h2 className="admin-section-title">System diagnostics</h2>
<div className="flex flex-col">
<DiagnosticRow
label="Database"
detail={dbOk ? "Connection healthy" : "Unreachable"}
state={dbOk ? "ok" : "danger"}
/>
<DiagnosticRow
label="Emulator (RCON)"
detail={emulatorOnline ? "Responding to ping" : "No response — is it running?"}
state={emulatorOnline ? "ok" : "warn"}
/>
<DiagnosticRow
label="RCON config"
detail={rconSet ? "RCON_HOST set" : "RCON_HOST not configured"}
state={rconSet ? "ok" : "warn"}
/>
<DiagnosticRow
label="2FA encryption key"
detail={appKeySet ? "APP_KEY configured" : "APP_KEY missing — 2FA secrets can't decrypt"}
state={appKeySet ? "ok" : "warn"}
/>
<DiagnosticRow
label="Email (SMTP)"
detail={smtpSet ? "SMTP_HOST set" : "Not configured — emails disabled"}
state={smtpSet ? "ok" : "warn"}
/>
<DiagnosticRow
label="Maintenance mode"
detail={maintenance ? "Site is in maintenance" : "Site is live"}
state={maintenance ? "warn" : "ok"}
/>
</div>
</section>
</div>
<section className="mt-6">
<h2 className="admin-section-title">Emulator controls</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={updateCatalog} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update catalog</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload catalog pages and items on the live server.
</p>
<button type="submit" className="btn btn-primary">
Update catalog
</button>
</form>
<form action={updateWordFilter} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update word filter</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload the chat word filter from the database.
</p>
<button type="submit" className="btn btn-primary">
Update word filter
</button>
</form>
<form action={updateNavigator} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Update navigator</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Reload navigator categories and room listings.
</p>
<button type="submit" className="btn btn-primary">
Update navigator
</button>
</form>
</div>
<form action={hotelAlert} className="admin-card mt-6">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Hotel alert</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">
Broadcast a message to every connected user.
</p>
<div className="flex gap-2">
<input
name="message"
required
maxLength={512}
placeholder="Message to broadcast…"
className="flex-1 min-w-[220px]"
/>
<button type="submit" className="btn btn-danger">
Send alert
</button>
</div>
</form>
</section>
{/* ── User RCON actions ──────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">User actions (RCON)</h2>
<p className="text-xs text-[var(--color-text-muted)] mb-4">
Enter a User ID to perform actions on a specific user. Username required for disconnect.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={disconnectUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Disconnect / Kick</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Force disconnect a user.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="username" type="text" placeholder="Username (required)" required className="w-full" />
<button type="submit" className="btn btn-danger w-full">Disconnect user</button>
</div>
</form>
<form action={alertUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Alert user</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Send a popup alert to a user.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="message" type="text" placeholder="Alert message" required maxLength={512} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Send alert</button>
</div>
</form>
<form action={forwardUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Forward to room</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Teleport user to a room ID.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="roomId" type="number" min={1} placeholder="Room ID" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Forward user</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveCredits} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give credits</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Add credits to a user.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="credits" type="number" min={1} placeholder="Amount" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give credits</button>
</div>
</form>
<form action={giveDuckets} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give duckets</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Add duckets to a user.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="amount" type="number" min={1} placeholder="Amount" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give duckets</button>
</div>
</form>
<form action={giveDiamonds} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give diamonds</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Add diamonds to a user.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="amount" type="number" min={1} placeholder="Amount" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give diamonds</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveBadge} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Give badge</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Give a badge code (e.g. HOF1).</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="badge" type="text" placeholder="Badge code" required maxLength={32} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Give badge</button>
</div>
</form>
<form action={setMotto} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Set motto</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Change user's motto (max 127 chars).</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="motto" type="text" placeholder="New motto" required maxLength={127} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Set motto</button>
</div>
</form>
<form action={setRank} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Set rank</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Change user rank (0–10).</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="rank" type="number" min={0} max={10} placeholder="Rank (0–10)" required className="w-full" />
<button type="submit" className="btn btn-primary w-full">Set rank</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={executeCommand} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Execute command</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Run a chat command as the user.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="command" type="text" placeholder="Command (e.g. :pickall)" required maxLength={100} className="w-full" />
<button type="submit" className="btn btn-primary w-full">Execute</button>
</div>
</form>
<form action={sendGift} className="admin-card">
<h3 className="text-sm font-bold text-[var(--color-text)] mb-2">Send gift</h3>
<p className="text-xs text-[var(--color-text-muted)] mb-3">Send a furniture item as gift.</p>
<div className="space-y-2">
<input name="userId" type="number" min={1} placeholder="User ID" required className="w-full" />
<input name="itemId" type="number" min={1} placeholder="Furni ID" required className="w-full" />
<input name="message" type="text" placeholder="Gift message" maxLength={255} defaultValue="Here is a gift." className="w-full" />
<button type="submit" className="btn btn-primary w-full">Send gift</button>
</div>
</form>
</div>
</section>
{/* ── Staff activity ─────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Recent staff activity</h2>
{activity.length === 0 ? (
<div className="admin-empty">No staff activity logged yet.</div>
) : (
<div className="admin-card overflow-x-auto">
<table>
<thead>
<tr>
<th>When</th>
<th>Staff</th>
<th>Action</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{activity.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
<td>
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">{a.action}</span>
</td>
<td className="text-sm text-gray-500 dark:text-gray-400">{a.description}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* ── Emulator errors ────────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">Recent emulator errors</h2>
{errors.length === 0 ? (
<div className="admin-empty">No emulator errors logged.</div>
) : (
<div className="admin-card overflow-x-auto">
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Version</th>
<th>Stacktrace</th>
</tr>
</thead>
<tbody>
{errors.map((e) => {
const trace = decodeStacktrace(e.stacktrace);
return (
<tr key={e.id}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formatTimestamp(e.timestamp)}
</td>
<td>{e.type}</td>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{e.version}
</td>
<td>
<pre className="text-xs p-2 bg-gray-50 dark:bg-black/20 rounded overflow-auto max-h-[160px]">
{trace || "(empty)"}
</pre>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}