diff --git a/src/app/admin/commandocentrum/page.tsx b/src/app/admin/commandocentrum/page.tsx index 56abc2c3..80acaa1f 100644 --- a/src/app/admin/commandocentrum/page.tsx +++ b/src/app/admin/commandocentrum/page.tsx @@ -1,10 +1,14 @@ +import os from "node:os"; import { hotelAlert, updateCatalog, updateNavigator, updateWordFilter, } from "@/actions/commandocentrum"; +import { DiagnosticRow, InfoItem, StatusCard } 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"; @@ -13,6 +17,10 @@ function formatTimestamp(ts: number | null | undefined): string { 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", " ") : "—"; +} + // stacktrace is a MySQL BLOB (Prisma `Bytes`), so it arrives as a Buffer/ // Uint8Array. Decode to UTF-8 and trim to a single readable preview line. function decodeStacktrace(raw: unknown): string { @@ -25,23 +33,144 @@ function decodeStacktrace(raw: unknown): string { } } +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 errors = await prisma.emulatorErrors - .findMany({ - orderBy: { timestamp: "desc" }, - take: 50, - }) - .catch(() => []); + // Gather everything in parallel; any failure degrades to a safe default so the + // panel still renders (and the DB-down state is itself a diagnostic). + 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>), + ]); + + // Resolve staff usernames for the activity feed. + const staffIds = Array.from(new Set(activity.map((a) => Number(a.userId)))).filter((n) => n > 0); + let nameById = new Map(); + 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 (

Commandocentrum

- Emulator control center — push live reloads and broadcast alerts over RCON. + Live hotel status, diagnostics and emulator control over RCON.

+ {/* ── Live status ────────────────────────────────────────── */} +
+ = 0 ? `${totalUsers} accounts total` : "DB unavailable"} + state="neutral" + icon="👤" + /> + + + +
+ + {/* ── Server info + diagnostics ──────────────────────────── */} +
+
+

Server info

+
+ + + + + + + + +
+
+ +
+

System diagnostics

+
+ + + + + + +
+
+
+ {/* ── RCON controls ──────────────────────────────────────── */} -
+

Emulator controls

@@ -95,8 +224,41 @@ export default async function CommandoCentrum() {
+ {/* ── Staff activity ─────────────────────────────────────── */} +
+

Recent staff activity

+ {activity.length === 0 ? ( +

No staff activity logged yet.

+ ) : ( + + + + + + + + + + + {activity.map((a) => ( + + + + + + + ))} + +
WhenStaffActionDescription
+ {fromDate(a.createdAt)} + {nameById.get(Number(a.userId)) ?? `#${a.userId}`} + {a.action} + {a.description}
+ )} +
+ {/* ── Emulator errors ────────────────────────────────────── */} -
+

Recent emulator errors

{errors.length === 0 ? (

No emulator errors logged.

diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5eeb1a81..7c35a2be 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,40 +1,105 @@ +import Link from "next/link"; +import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; +function fromDate(d: Date | null | undefined): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—"; +} + export default async function AdminDashboard() { - let users = 0; + let users = -1; let online = 0; let articles = 0; + let bans = 0; try { - [users, online, articles] = await Promise.all([ + [users, online, articles, bans] = await Promise.all([ prisma.user.count(), prisma.user.count({ where: { online: "1" } }), prisma.websiteArticles.count(), + prisma.ban.count({ where: { banExpire: { gt: Math.floor(Date.now() / 1000) } } }), ]); } catch { // DB unavailable — show zeroed stats instead of a 500. } + const dbOk = users >= 0; - const stats = [ - { label: "Users", value: users }, - { label: "Online now", value: online }, - { label: "Articles", value: articles }, - ]; + const activity = await prisma.staffActivities + .findMany({ orderBy: { createdAt: "desc" }, take: 8 }) + .catch(() => [] as Awaited>); + const staffIds = Array.from(new Set(activity.map((a) => Number(a.userId)))).filter((n) => n > 0); + let nameById = new Map(); + if (staffIds.length) { + const u = await prisma.user + .findMany({ where: { id: { in: staffIds } }, select: { id: true, username: true } }) + .catch(() => []); + nameById = new Map(u.map((x) => [x.id, x.username])); + } return (

Dashboard

-
- {stats.map((s) => ( -
-

{s.value}

-

- {s.label} -

-
- ))} + +
+ + + 0 ? "warn" : "neutral"} + icon="🔨" + /> +
+ +
+
+

Recent staff activity

+ + View all → + +
+ {activity.length === 0 ? ( +

No staff activity logged yet.

+ ) : ( + + + + + + + + + + + {activity.map((a) => ( + + + + + + + ))} + +
WhenStaffActionDescription
+ {fromDate(a.createdAt)} + {nameById.get(Number(a.userId)) ?? `#${a.userId}`} + {a.action} + {a.description}
+ )} +
); } diff --git a/src/app/globals.css b/src/app/globals.css index 5f85d79c..a214fedc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -564,3 +564,118 @@ td { .admin-badge.danger { background: color-mix(in srgb, #ef4444 22%, transparent); } + +/* Dashboard: live-status tiles, server info, diagnostics */ +.admin-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.9rem; + margin-bottom: 1.4rem; +} +.admin-stat-card { + background: var(--color-surface); + border: 1px solid var(--border); + border-left: 4px solid var(--color-text-muted); + border-radius: 12px; + padding: 0.85rem 1rem; + box-shadow: var(--shadow-card); +} +.admin-stat-card.ok { + border-left-color: #16a34a; +} +.admin-stat-card.warn { + border-left-color: var(--color-primary, #eeb425); +} +.admin-stat-card.danger { + border-left-color: #ef4444; +} +.admin-stat-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.admin-stat-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); +} +.admin-stat-icon { + font-size: 1.1rem; + line-height: 1; +} +.admin-stat-value { + font-size: 1.7rem; + font-weight: 800; + line-height: 1.1; + margin-top: 0.3rem; +} +.admin-stat-hint { + font-size: 0.78rem; + color: var(--color-text-muted); + margin-top: 0.15rem; +} + +.admin-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.6rem 1.2rem; +} +.admin-info-item { + display: flex; + flex-direction: column; + gap: 0.1rem; + font-size: 0.88rem; +} + +.admin-diag { + display: flex; + flex-direction: column; +} +.admin-diag-row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.1rem; + border-top: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent); +} +.admin-diag-row:first-child { + border-top: none; +} +.admin-diag-label { + font-weight: 700; + font-size: 0.88rem; +} +.admin-diag-detail { + flex: 1; + font-size: 0.82rem; +} +.admin-dot { + flex: none; + width: 9px; + height: 9px; + border-radius: 999px; + background: var(--color-text-muted); +} +.admin-dot.ok { + background: #16a34a; + box-shadow: 0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent); +} +.admin-dot.warn { + background: var(--color-primary, #eeb425); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 22%, transparent); +} +.admin-dot.danger { + background: #ef4444; + box-shadow: 0 0 0 3px color-mix(in srgb, #ef4444 22%, transparent); +} + +.admin-section { + margin-top: 1.6rem; +} +.admin-section > h2 { + margin: 0 0 0.7rem; + font-size: 1.15rem; +} diff --git a/src/components/admin/dashboard.tsx b/src/components/admin/dashboard.tsx new file mode 100644 index 00000000..aeeed261 --- /dev/null +++ b/src/components/admin/dashboard.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +type State = "ok" | "warn" | "danger" | "neutral"; + +/** A single live-status tile (online users, emulator, DB, …). */ +export function StatusCard({ + label, + value, + hint, + state = "neutral", + icon, +}: { + label: string; + value: ReactNode; + hint?: string; + state?: State; + icon?: string; +}) { + return ( +
+
+ {label} + {icon ? ( + + {icon} + + ) : null} +
+
{value}
+ {hint ?
{hint}
: null} +
+ ); +} + +/** A row in the system-diagnostics list with an ok/warn/error pill. */ +export function DiagnosticRow({ + label, + detail, + state, +}: { + label: string; + detail: string; + state: Exclude; +}) { + const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR"; + return ( +
+ + {label} + {detail} + + {text} + +
+ ); +} + +/** Small labelled key/value used in the server-info card. */ +export function InfoItem({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +}