diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts
new file mode 100644
index 00000000..b95f21c6
--- /dev/null
+++ b/src/actions/moderation.ts
@@ -0,0 +1,206 @@
+'use server'
+
+import { z } from 'zod'
+import { PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+import { adminAction } from '@/lib/safe-action'
+import { ActionError, actionOk } from '@/lib/safe-action-shared'
+import { logAudit } from '@/lib/services/audit'
+import { rcon } from '@/lib/services/rcon'
+
+// ── CFH Ticket Actions ──────────────────────────────────────────────
+
+const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() })
+
+export const assignCfhTicket = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
+ async (ctx) => {
+ const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
+ if (!ticket) throw new ActionError('Ticket not found')
+
+ await prisma.supportTickets.update({
+ where: { id: ctx.data.ticketId },
+ data: { modId: ctx.session.user.id, state: 1 },
+ })
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'cfh_assign',
+ target: 'support_tickets',
+ targetId: ctx.data.ticketId,
+ })
+
+ return actionOk()
+ },
+)
+
+const cfhStateSchema = z.object({
+ ticketId: z.coerce.number().int().positive(),
+ state: z.coerce.number().int().min(0).max(3),
+})
+
+export const updateCfhState = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
+ async (ctx) => {
+ const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } })
+ if (!ticket) throw new ActionError('Ticket not found')
+
+ await prisma.supportTickets.update({
+ where: { id: ctx.data.ticketId },
+ data: { state: ctx.data.state, modId: ctx.session.user.id },
+ })
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'cfh_state_change',
+ target: 'support_tickets',
+ targetId: ctx.data.ticketId,
+ before: { state: ticket.state },
+ after: { state: ctx.data.state },
+ })
+
+ return actionOk()
+ },
+)
+
+export const closeCfhTicket = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
+ async (ctx) => {
+ await prisma.supportTickets.update({
+ where: { id: ctx.data.ticketId },
+ data: { state: 2, modId: ctx.session.user.id },
+ })
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'cfh_close',
+ target: 'support_tickets',
+ targetId: ctx.data.ticketId,
+ })
+
+ return actionOk()
+ },
+)
+
+// ── Quick Mod Actions ────────────────────────────────────────────────
+
+const userIdSchema = z.object({ userId: z.coerce.number().int().positive() })
+
+export const quickKick = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
+ async (ctx) => {
+ await rcon.disconnectUser(ctx.data.userId)
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'mod_kick',
+ target: 'User',
+ targetId: ctx.data.userId,
+ })
+
+ return actionOk()
+ },
+)
+
+const muteSchema = z.object({
+ userId: z.coerce.number().int().positive(),
+ duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
+})
+
+export const quickMute = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: muteSchema },
+ async (ctx) => {
+ await rcon.muteUser(ctx.data.userId, ctx.data.duration)
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'mod_mute',
+ target: 'User',
+ targetId: ctx.data.userId,
+ after: { duration: ctx.data.duration },
+ })
+
+ return actionOk()
+ },
+)
+
+export const quickUnmute = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
+ async (ctx) => {
+ await rcon.unmuteUser(ctx.data.userId)
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'mod_unmute',
+ target: 'User',
+ targetId: ctx.data.userId,
+ })
+
+ return actionOk()
+ },
+)
+
+const alertSchema = z.object({
+ userId: z.coerce.number().int().positive(),
+ message: z.string().min(1).max(500),
+})
+
+export const quickAlert = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: alertSchema },
+ async (ctx) => {
+ await rcon.alertUser(ctx.data.userId, ctx.data.message)
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'mod_alert',
+ target: 'User',
+ targetId: ctx.data.userId,
+ after: { message: ctx.data.message },
+ })
+
+ return actionOk()
+ },
+)
+
+const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() })
+
+export const quickRoomKick = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
+ async (ctx) => {
+ await rcon.kickAll(ctx.data.roomId)
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: 'mod_room_kick',
+ target: 'Room',
+ targetId: ctx.data.roomId,
+ })
+
+ return actionOk()
+ },
+)
+
+const broadcastSchema = z.object({
+ message: z.string().min(1).max(500),
+ type: z.enum(['hotel', 'staff']),
+})
+
+export const broadcastAlert = adminAction(
+ { permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
+ async (ctx) => {
+ if (ctx.data.type === 'hotel') {
+ await rcon.hotelAlert(ctx.data.message)
+ } else {
+ await rcon.staffAlert(ctx.data.message)
+ }
+
+ logAudit({
+ userId: ctx.session.user.id,
+ action: `mod_broadcast_${ctx.data.type}`,
+ target: 'broadcast',
+ after: { message: ctx.data.message },
+ })
+
+ return actionOk()
+ },
+)
diff --git a/src/app/admin/analytics/activity/page.tsx b/src/app/admin/analytics/activity/page.tsx
new file mode 100644
index 00000000..32402e67
--- /dev/null
+++ b/src/app/admin/analytics/activity/page.tsx
@@ -0,0 +1,197 @@
+import { MessageSquare, Shield, Terminal, Users } from 'lucide-react'
+import { redirect } from 'next/navigation'
+import { StatsCard } from '@/components/admin/stats-card'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+async function getActivityData() {
+ const now = Math.floor(Date.now() / 1000)
+ const todayStart = now - (now % 86400)
+ const weekAgo = now - 7 * 86400
+
+ // Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
+ const rangeStart = todayStart - 6 * 86400
+ const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
+ prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
+ SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
+ FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
+ prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
+ SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
+ FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
+ prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
+ SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
+ FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
+ prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
+ SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
+ FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
+ ])
+ const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
+ new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]))
+ const chatMap = toMap(chatRows)
+ const cmdMap = toMap(cmdRows)
+ const banMap = toMap(banRows)
+ const regMap = toMap(regRows)
+
+ const dailyData = []
+ for (let i = 6; i >= 0; i--) {
+ const dayStart = todayStart - i * 86400
+ const bucket = Math.floor(dayStart / 86400)
+ const date = new Date(dayStart * 1000)
+ dailyData.push({
+ label: date.toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
+ chats: chatMap.get(bucket) ?? 0,
+ commands: cmdMap.get(bucket) ?? 0,
+ bans: banMap.get(bucket) ?? 0,
+ registrations: regMap.get(bucket) ?? 0,
+ })
+ }
+
+ // Totals
+ const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
+ prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
+ prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
+ prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
+ prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
+ ])
+ const weekChats = Number(weekChatRows[0]?.count ?? 0)
+ const weekCommands = Number(weekCommandRows[0]?.count ?? 0)
+
+ // Most active users (by chat messages)
+ const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
+ SELECT user_from_id AS userId, COUNT(*) AS messages
+ FROM chatlogs_room WHERE timestamp >= ${weekAgo}
+ GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
+ `
+
+ const userIds = topChatters.map((c) => c.userId)
+ const users = await prisma.user.findMany({
+ where: { id: { in: userIds } },
+ select: { id: true, username: true },
+ })
+ const userMap = new Map(users.map((u) => [u.id, u.username]))
+
+ const topChattersWithNames = topChatters.map((c) => ({
+ userId: c.userId,
+ username: userMap.get(c.userId) ?? `User #${c.userId}`,
+ messages: Number(c.messages),
+ }))
+
+ return {
+ dailyData,
+ weekChats,
+ weekCommands,
+ weekBans,
+ weekRegistrations,
+ topChatters: topChattersWithNames,
+ }
+}
+
+export default async function ActivityPage() {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
+ redirect('/admin')
+ }
+
+ const data = await getActivityData()
+
+ return (
+
+
+
Activity
+
User activity breakdown and engagement metrics
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ {/* Daily breakdown table */}
+
+
+ Daily Breakdown (7 days)
+
+
+
+
+
+
+ | Day |
+ Chats |
+ Cmds |
+ Bans |
+ Regs |
+
+
+
+ {data.dailyData.map((day) => (
+
+ | {day.label} |
+ {day.chats.toLocaleString()} |
+ {day.commands.toLocaleString()} |
+ {day.bans} |
+ {day.registrations} |
+
+ ))}
+
+
+
+
+
+
+ {/* Top chatters */}
+
+
+ Most Active Users (7 days)
+
+
+ {data.topChatters.length === 0 ? (
+ No chat activity
+ ) : (
+
+ {data.topChatters.map((user, i) => (
+
+
+ {i + 1}
+
+ {user.username}
+
+ {user.messages.toLocaleString()} msgs
+
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/app/admin/analytics/economy/page.tsx b/src/app/admin/analytics/economy/page.tsx
new file mode 100644
index 00000000..61e45368
--- /dev/null
+++ b/src/app/admin/analytics/economy/page.tsx
@@ -0,0 +1,200 @@
+import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from 'lucide-react'
+import dynamic from 'next/dynamic'
+import { redirect } from 'next/navigation'
+import { StatsCard } from '@/components/admin/stats-card'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Skeleton } from '@/components/ui/skeleton'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+const RevenueChart = dynamic(
+ () => import('@/components/admin/revenue-chart').then((m) => ({ default: m.RevenueChart })),
+ { loading: () => },
+)
+
+async function getEconomyData() {
+ const now = Math.floor(Date.now() / 1000)
+ const todayStart = now - (now % 86400)
+ const weekAgo = now - 7 * 86400
+
+ // Total currency in circulation — parallel instead of sequential
+ const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
+ prisma.user.aggregate({ _sum: { credits: true } }),
+ prisma.user.aggregate({ _sum: { pixels: true } }),
+ prisma.user.aggregate({ _sum: { points: true } }),
+ ])
+ const totalCredits = creditsAgg._sum.credits ?? 0
+ const totalPixels = pixelsAgg._sum.pixels ?? 0
+ const totalPoints = pointsAgg._sum.points ?? 0
+
+ // Shop revenue (7 days)
+ const shopRevenue = await prisma.logsShopPurchases.aggregate({
+ where: { timestamp: { gte: weekAgo } },
+ _sum: { costCredits: true, costPoints: true },
+ _count: true,
+ })
+
+ // Marketplace stats
+ const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } })
+ const marketplaceSold = await prisma.marketplaceItems.count({
+ where: { state: 2, soldTimestamp: { gte: weekAgo } },
+ })
+
+ // Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
+ const rangeStart = todayStart - 6 * 86400
+ const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
+ SELECT FLOOR(timestamp / 86400) AS day_bucket,
+ COALESCE(SUM(cost_credits), 0) AS credits,
+ COALESCE(SUM(cost_points), 0) AS points
+ FROM logs_shop_purchases
+ WHERE timestamp >= ${rangeStart}
+ GROUP BY day_bucket ORDER BY day_bucket
+ `
+ const revMap = new Map(
+ revRows.map((r) => [
+ Number(r.day_bucket),
+ { credits: Number(r.credits), points: Number(r.points) },
+ ]),
+ )
+ const revenueData = []
+ for (let i = 6; i >= 0; i--) {
+ const dayStart = todayStart - i * 86400
+ const bucket = Math.floor(dayStart / 86400)
+ const date = new Date(dayStart * 1000)
+ const entry = revMap.get(bucket)
+ revenueData.push({
+ label: date.toLocaleDateString('en', { weekday: 'short' }),
+ credits: entry?.credits ?? 0,
+ points: entry?.points ?? 0,
+ })
+ }
+
+ // Trades (7 days)
+ const weekTrades = await prisma.roomTradeLog.count({
+ where: { timestamp: { gte: weekAgo } },
+ })
+
+ return {
+ totalCredits,
+ totalPixels,
+ totalPoints,
+ shopPurchases: typeof shopRevenue._count === 'number' ? shopRevenue._count : 0,
+ shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
+ shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
+ marketplaceActive,
+ marketplaceSold,
+ weekTrades,
+ revenueData,
+ }
+}
+
+export default async function EconomyPage() {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
+ redirect('/admin')
+ }
+
+ const data = await getEconomyData()
+
+ return (
+
+
+
Economy
+
+ Currency circulation, shop revenue and marketplace activity
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+
+ Shop Revenue (7d)
+
+
+
+
+ Purchases
+ {data.shopPurchases}
+
+
+ Credits spent
+ {data.shopCreditsSpent.toLocaleString()}
+
+
+ Points spent
+ {data.shopPointsSpent.toLocaleString()}
+
+
+
+
+
+
+ Marketplace
+
+
+
+
+ Active listings
+ {data.marketplaceActive}
+
+
+ Sold (7d)
+ {data.marketplaceSold}
+
+
+
+
+
+
+ Currency Distribution
+
+
+
+
+ Credits
+ {data.totalCredits.toLocaleString()}
+
+
+ Pixels
+ {data.totalPixels.toLocaleString()}
+
+
+ Points
+ {data.totalPoints.toLocaleString()}
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx
new file mode 100644
index 00000000..4b6452eb
--- /dev/null
+++ b/src/app/admin/analytics/page.tsx
@@ -0,0 +1,191 @@
+import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from 'lucide-react'
+import dynamic from 'next/dynamic'
+import { redirect } from 'next/navigation'
+import { StatsCard } from '@/components/admin/stats-card'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Skeleton } from '@/components/ui/skeleton'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+const DashboardChart = dynamic(
+ () => import('@/components/admin/dashboard-chart').then((m) => ({ default: m.DashboardChart })),
+ { loading: () => },
+)
+
+const PeakHoursHeatmap = dynamic(
+ () =>
+ import('@/components/admin/peak-hours-heatmap').then((m) => ({ default: m.PeakHoursHeatmap })),
+ { loading: () => },
+)
+
+async function getAnalyticsData() {
+ const now = Math.floor(Date.now() / 1000)
+ const todayStart = now - (now % 86400)
+ const weekAgo = now - 7 * 86400
+ const monthAgo = now - 30 * 86400
+
+ const [
+ totalUsers,
+ onlineUsers,
+ weekRegistrations,
+ monthRegistrations,
+ totalRooms,
+ activeRooms,
+ weekChatMessages,
+ weekTrades,
+ weekBans,
+ ] = await Promise.all([
+ prisma.user.count(),
+ prisma.user.count({ where: { online: '1' } }),
+ prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
+ prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
+ prisma.rooms.count(),
+ prisma.rooms.count({ where: { users: { gt: 0 } } }),
+ prisma.$queryRaw<{ count: bigint }[]>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
+ prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
+ prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
+ ])
+
+ // Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
+ const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
+ SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
+ FROM users
+ WHERE account_created >= ${monthAgo}
+ GROUP BY day_bucket ORDER BY day_bucket
+ `
+ const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]))
+ const registrationChart = []
+ for (let i = 29; i >= 0; i--) {
+ const dayStart = todayStart - i * 86400
+ const bucket = Math.floor(dayStart / 86400)
+ const date = new Date(dayStart * 1000)
+ registrationChart.push({
+ label: date.toLocaleDateString('en', { month: 'short', day: 'numeric' }),
+ value: regCountMap.get(bucket) ?? 0,
+ })
+ }
+
+ // Peak hours — single GROUP BY query instead of fetching all user rows
+ const peakRows = await prisma.$queryRaw<{ d: number; h: number; c: bigint }[]>`
+ SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d,
+ HOUR(FROM_UNIXTIME(last_online)) AS h,
+ COUNT(*) AS c
+ FROM users
+ WHERE last_online >= ${weekAgo}
+ GROUP BY d, h
+ `
+ const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
+ let max = 0
+ for (const r of peakRows) {
+ const count = Number(r.c)
+ grid[r.d][r.h] = count
+ if (count > max) max = count
+ }
+
+ return {
+ totalUsers,
+ onlineUsers,
+ weekRegistrations,
+ monthRegistrations,
+ totalRooms,
+ activeRooms,
+ weekChatMessages: Array.isArray(weekChatMessages) ? Number(weekChatMessages[0]?.count ?? 0) : 0,
+ weekTrades,
+ weekBans,
+ registrationChart,
+ peakHours: { grid, max },
+ }
+}
+
+export default async function AnalyticsPage() {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
+ redirect('/admin')
+ }
+
+ const data = await getAnalyticsData()
+
+ return (
+
+
+
Analytics
+
Hotel performance overview and trends
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+
+
+ Chat Activity
+
+
+
+ {data.weekChatMessages.toLocaleString()}
+ Messages in the last 7 days
+
+
+
+
+
+ Trades
+
+
+
+ {data.weekTrades.toLocaleString()}
+ Trades in the last 7 days
+
+
+
+
+
+ Retention
+
+
+
+
+ {data.totalUsers > 0 ? Math.round((data.onlineUsers / data.totalUsers) * 100) : 0}%
+
+ Users online / total
+
+
+
+
+ )
+}
diff --git a/src/app/admin/devops/errors/page.tsx b/src/app/admin/devops/errors/page.tsx
new file mode 100644
index 00000000..2dbc2e8b
--- /dev/null
+++ b/src/app/admin/devops/errors/page.tsx
@@ -0,0 +1,83 @@
+import { redirect } from 'next/navigation'
+import { Card, CardContent } from '@/components/ui/card'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+export default async function EmulatorErrorsPage({
+ searchParams,
+}: {
+ searchParams: Promise>
+}) {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
+ redirect('/admin')
+ }
+
+ const params = await searchParams
+ const page = Math.max(1, Number(params.page) || 1)
+ const perPage = 50
+
+ const [errors, total] = await Promise.all([
+ prisma.emulatorErrors.findMany({
+ orderBy: { id: 'desc' },
+ skip: (page - 1) * perPage,
+ take: perPage,
+ }),
+ prisma.emulatorErrors.count(),
+ ])
+
+ const lastPage = Math.ceil(total / perPage)
+
+ return (
+
+
+
Emulator Errors
+
+ {total} total errors — showing page {page} of {lastPage}
+
+
+
+
+
+ {errors.length === 0 ? (
+ No emulator errors found
+ ) : (
+
+ {errors.map((error) => (
+
+
+ #{error.id}
+
+ {new Date(error.timestamp * 1000).toLocaleString()}
+
+
+
+ {Buffer.from(error.stacktrace).toString('utf-8')}
+
+
+ ))}
+
+ )}
+
+
+
+ {lastPage > 1 && (
+
+ {page > 1 && (
+
+ Previous
+
+ )}
+
+ Page {page} of {lastPage}
+
+ {page < lastPage && (
+
+ Next
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/app/admin/devops/health-check-client.tsx b/src/app/admin/devops/health-check-client.tsx
new file mode 100644
index 00000000..5c8062f3
--- /dev/null
+++ b/src/app/admin/devops/health-check-client.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+import { RefreshCw } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+
+export function HealthCheckClient() {
+ const [status, setStatus] = useState<'checking' | 'online' | 'offline'>('checking')
+ const [checking, setChecking] = useState(false)
+
+ async function checkEmulator() {
+ setChecking(true)
+ setStatus('checking')
+ try {
+ const res = await fetch('/api/admin/devops/health')
+ if (res.ok) {
+ const data = await res.json()
+ setStatus(data.emulator ? 'online' : 'offline')
+ } else {
+ setStatus('offline')
+ }
+ } catch {
+ setStatus('offline')
+ } finally {
+ setChecking(false)
+ }
+ }
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
+ useEffect(() => {
+ checkEmulator()
+ }, [])
+
+ return (
+
+
+ {status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
+
+
+
+ )
+}
diff --git a/src/app/admin/devops/page.tsx b/src/app/admin/devops/page.tsx
new file mode 100644
index 00000000..cc19b660
--- /dev/null
+++ b/src/app/admin/devops/page.tsx
@@ -0,0 +1,147 @@
+import { AlertTriangle, Database, HeartPulse, Server } from 'lucide-react'
+import { redirect } from 'next/navigation'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+import { HealthCheckClient } from './health-check-client'
+
+async function getDevOpsData() {
+ const now = Math.floor(Date.now() / 1000)
+ const dayAgo = now - 86400
+
+ // DB health — simple count query
+ let dbStatus: 'healthy' | 'error' = 'healthy'
+ let dbLatency = 0
+ try {
+ const start = Date.now()
+ await prisma.user.count({ take: 1 })
+ dbLatency = Date.now() - start
+ } catch {
+ dbStatus = 'error'
+ }
+
+ // Recent emulator errors
+ const recentErrors = await prisma.emulatorErrors.findMany({
+ orderBy: { id: 'desc' },
+ take: 20,
+ })
+
+ const errorsToday = await prisma.emulatorErrors.count({
+ where: { timestamp: { gte: dayAgo } },
+ })
+
+ // Emulator settings (version info)
+ const emuVersion = await prisma.emulatorSettings.findFirst({
+ where: { key: 'version' },
+ })
+
+ // Online user count as proxy for emulator health
+ const onlineUsers = await prisma.user.count({ where: { online: '1' } })
+
+ return {
+ dbStatus,
+ dbLatency,
+ recentErrors,
+ errorsToday,
+ emuVersion: emuVersion?.value ?? 'Unknown',
+ onlineUsers,
+ }
+}
+
+export default async function DevOpsPage() {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
+ redirect('/admin')
+ }
+
+ const data = await getDevOpsData()
+
+ return (
+
+
+
Health Status
+
System health, emulator status and error monitoring
+
+
+ {/* Status cards */}
+
+
+
+ Database
+
+
+
+
+ {data.dbStatus === 'healthy' ? 'Healthy' : 'Error'}
+
+ Latency: {data.dbLatency}ms
+
+
+
+
+
+ Emulator
+
+
+
+
+ Version: {data.emuVersion}
+
+
+
+
+
+ Online Users
+
+
+
+ {data.onlineUsers}
+ Current active connections
+
+
+
+
+
+ Errors (24h)
+
+
+
+ {data.errorsToday}
+ Emulator errors today
+
+
+
+
+ {/* Recent errors */}
+
+
+
+ Recent Emulator Errors
+
+
+
+ {data.recentErrors.length === 0 ? (
+ No recent errors
+ ) : (
+
+ {data.recentErrors.map((error) => (
+
+
+ #{error.id}
+
+ {new Date(error.timestamp * 1000).toLocaleString()}
+
+
+
+ {Buffer.from(error.stacktrace).toString('utf-8')}
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 865d4498..24567585 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -89,6 +89,8 @@ function getNavGroups(t: (key: string) => string) {
{ href: "/admin/applications", label: t("applications"), icon: },
{ href: "/admin/teams", label: t("teams"), icon: },
{ href: "/admin/permissions", label: t("permissions"), icon: },
+ { href: "/admin/online", label: "Online Users", icon: },
+ { href: "/admin/moderation", label: "Moderation", icon: },
],
},
{
@@ -131,6 +133,9 @@ function getNavGroups(t: (key: string) => string) {
{ href: "/admin/emulator", label: t("emulator"), icon: },
{ href: "/admin/alerts", label: t("alerts"), icon: },
{ href: "/admin/logs", label: t("logs"), icon: },
+ { href: "/admin/logs/audit", label: "Audit Log", icon: },
+ { href: "/admin/analytics", label: "Analytics", icon: },
+ { href: "/admin/devops", label: "DevOps", icon: },
{ href: "/admin/wordfilter", label: t("wordFilter"), icon: },
{ href: "/admin/maintenance", label: t("maintenance"), icon: },
{ href: "/admin/housekeeping", label: t("housekeeping"), icon: },
diff --git a/src/app/admin/logs/audit/audit-table.tsx b/src/app/admin/logs/audit/audit-table.tsx
new file mode 100644
index 00000000..17116c31
--- /dev/null
+++ b/src/app/admin/logs/audit/audit-table.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+import { useState } from 'react'
+import { DataTable } from '@/components/admin/data-table'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import type { DataTableColumn, PaginatedResult } from '@/types'
+
+interface AuditRow {
+ id: number
+ username: string
+ action: string
+ target: string
+ targetId: number | null
+ diff: string | null
+ createdAt: string
+}
+
+function DiffCell({ diff }: { diff: string | null }) {
+ const [open, setOpen] = useState(false)
+ if (!diff) return -
+
+ let parsed: Record
+ try {
+ parsed = JSON.parse(diff)
+ } catch {
+ return -
+ }
+
+ const keys = Object.keys(parsed)
+ if (keys.length === 0) return -
+
+ const summary =
+ keys.length === 1
+ ? `${keys[0]}: ${String(parsed[keys[0]].from ?? '∅')} → ${String(parsed[keys[0]].to ?? '∅')}`
+ : `${keys.length} changes`
+
+ return (
+
+
+ {open && (
+
+ {keys.map((k) => (
+
+ {k}:
+ {String(parsed[k].from ?? '∅')}
+ →
+ {String(parsed[k].to ?? '∅')}
+
+ ))}
+
+ )}
+
+ )
+}
+
+const columns: DataTableColumn[] = [
+ { key: 'id', label: 'ID', sortable: true },
+ { key: 'username', label: 'User', sortable: true },
+ {
+ key: 'action',
+ label: 'Action',
+ sortable: true,
+ render: (value) => {
+ const v = String(value)
+ let variant: 'default' | 'secondary' | 'destructive' = 'secondary'
+ if (v.includes('create') || v.includes('add')) variant = 'default'
+ if (v.includes('delete') || v.includes('remove') || v.includes('ban')) variant = 'destructive'
+ return {v}
+ },
+ },
+ { key: 'target', label: 'Target', sortable: true },
+ {
+ key: 'targetId',
+ label: 'Target ID',
+ render: (value) => {value != null ? String(value) : '-'},
+ },
+ {
+ key: 'diff',
+ label: 'Details',
+ render: (_value, row) => ,
+ },
+ {
+ key: 'createdAt',
+ label: 'Date',
+ sortable: true,
+ render: (value) => {String(value) || 'N/A'},
+ },
+]
+
+interface AuditTableProps {
+ data: PaginatedResult
+}
+
+export function AuditTable({ data }: AuditTableProps) {
+ return (
+
+ )
+}
diff --git a/src/app/admin/logs/audit/loading.tsx b/src/app/admin/logs/audit/loading.tsx
new file mode 100644
index 00000000..9c600589
--- /dev/null
+++ b/src/app/admin/logs/audit/loading.tsx
@@ -0,0 +1,28 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function AuditLogsLoading() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/admin/logs/audit/page.tsx b/src/app/admin/logs/audit/page.tsx
new file mode 100644
index 00000000..a8e3f965
--- /dev/null
+++ b/src/app/admin/logs/audit/page.tsx
@@ -0,0 +1,48 @@
+import { redirect } from 'next/navigation'
+import { calcPagination, parseListParams } from '@/lib/admin-helpers'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { getAuditLogs } from '@/lib/services/audit'
+import { AuditTable } from './audit-table'
+
+export default async function AuditLogsPage({
+ params: paramsPromise,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams: Promise>
+}) {
+ await paramsPromise
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
+ redirect('/admin')
+ }
+
+ const params = await searchParams
+ const sp = new URLSearchParams(params)
+ const { search, perPage, page } = parseListParams(sp)
+
+ const result = await getAuditLogs({ search, page, perPage })
+
+ const pagination = calcPagination(result.total, result.page, result.perPage)
+
+ const rows = result.rows.map((r) => ({
+ id: r.id,
+ username: r.username,
+ action: r.action,
+ target: r.target,
+ targetId: r.targetId,
+ diff: r.diff,
+ createdAt: r.createdAt,
+ }))
+
+ return (
+
+
+
Audit Logs
+
Track admin actions and changes
+
+
+
+
+ )
+}
diff --git a/src/app/admin/logs/chat/chat-table.tsx b/src/app/admin/logs/chat/chat-table.tsx
new file mode 100644
index 00000000..a6c4f04e
--- /dev/null
+++ b/src/app/admin/logs/chat/chat-table.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import { DataTable } from '@/components/admin/data-table'
+import { formatTimestamp } from '@/lib/admin-helpers'
+import type { DataTableColumn, PaginatedResult } from '@/types'
+
+interface ChatLogRow {
+ id: number
+ username: string
+ message: string
+ timestamp: number
+ roomId: number
+}
+
+const columns: DataTableColumn[] = [
+ { key: 'id', label: 'ID', sortable: true },
+ { key: 'username', label: 'User' },
+ {
+ key: 'message',
+ label: 'Message',
+ render: (value) => {String(value)},
+ },
+ {
+ key: 'timestamp',
+ label: 'Time',
+ sortable: true,
+ render: (value) => {formatTimestamp(Number(value))},
+ },
+ { key: 'roomId', label: 'Room ID', sortable: true },
+]
+
+interface ChatTableProps {
+ data: PaginatedResult
+}
+
+export function ChatTable({ data }: ChatTableProps) {
+ return (
+
+ )
+}
diff --git a/src/app/admin/logs/chat/loading.tsx b/src/app/admin/logs/chat/loading.tsx
new file mode 100644
index 00000000..14be6f24
--- /dev/null
+++ b/src/app/admin/logs/chat/loading.tsx
@@ -0,0 +1,28 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function ChatLogsLoading() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/admin/logs/chat/page.tsx b/src/app/admin/logs/chat/page.tsx
new file mode 100644
index 00000000..f61c2dc7
--- /dev/null
+++ b/src/app/admin/logs/chat/page.tsx
@@ -0,0 +1,49 @@
+import { fetchAdminList } from '@/lib/admin-list'
+import { PERMS } from '@/lib/permissions'
+import { ChatTable } from './chat-table'
+
+export default async function ChatLogsPage({
+ params: paramsPromise,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams: Promise>
+}) {
+ const result = await fetchAdminList(
+ {
+ permission: PERMS.LOGS_VIEW,
+ model: 'chatLog',
+ searchFields: ['message', 'user.username'],
+ defaultPerPage: 50,
+ include: { user: { select: { username: true } } },
+ mapRow: (l) => ({
+ id: l.id,
+ username: l.user.username,
+ message: l.message,
+ timestamp: l.timestamp,
+ roomId: l.roomId,
+ }),
+ },
+ paramsPromise,
+ searchParams,
+ )
+
+ return (
+
+
+
Chat Logs
+
View in-game chat messages
+
+
+
+
+ )
+}
diff --git a/src/app/admin/logs/commands/commands-table.tsx b/src/app/admin/logs/commands/commands-table.tsx
new file mode 100644
index 00000000..adfc088c
--- /dev/null
+++ b/src/app/admin/logs/commands/commands-table.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { DataTable } from '@/components/admin/data-table'
+import { formatTimestamp } from '@/lib/admin-helpers'
+import type { DataTableColumn, PaginatedResult } from '@/types'
+
+interface CommandLogRow {
+ id: number
+ username: string
+ command: string
+ params: string
+ timestamp: number
+}
+
+const columns: DataTableColumn[] = [
+ { key: 'id', label: 'ID', sortable: true },
+ { key: 'username', label: 'User' },
+ { key: 'command', label: 'Command', sortable: true },
+ {
+ key: 'params',
+ label: 'Params',
+ render: (value) => (
+ {String(value) || '-'}
+ ),
+ },
+ {
+ key: 'timestamp',
+ label: 'Time',
+ sortable: true,
+ render: (value) => {formatTimestamp(Number(value))},
+ },
+]
+
+interface CommandsTableProps {
+ data: PaginatedResult
+}
+
+export function CommandsTable({ data }: CommandsTableProps) {
+ return (
+
+ )
+}
diff --git a/src/app/admin/logs/commands/loading.tsx b/src/app/admin/logs/commands/loading.tsx
new file mode 100644
index 00000000..bc423498
--- /dev/null
+++ b/src/app/admin/logs/commands/loading.tsx
@@ -0,0 +1,28 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function CommandLogsLoading() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/admin/logs/commands/page.tsx b/src/app/admin/logs/commands/page.tsx
new file mode 100644
index 00000000..3e6c7317
--- /dev/null
+++ b/src/app/admin/logs/commands/page.tsx
@@ -0,0 +1,49 @@
+import { fetchAdminList } from '@/lib/admin-list'
+import { PERMS } from '@/lib/permissions'
+import { CommandsTable } from './commands-table'
+
+export default async function CommandLogsPage({
+ params: paramsPromise,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams: Promise>
+}) {
+ const result = await fetchAdminList(
+ {
+ permission: PERMS.LOGS_VIEW,
+ model: 'commandLog',
+ searchFields: ['command', 'params', 'user.username'],
+ defaultPerPage: 50,
+ include: { user: { select: { username: true } } },
+ mapRow: (l) => ({
+ id: l.id,
+ username: l.user.username,
+ command: l.command,
+ params: l.params,
+ timestamp: l.timestamp,
+ }),
+ },
+ paramsPromise,
+ searchParams,
+ )
+
+ return (
+
+
+
Command Logs
+
View in-game command usage
+
+
+
+
+ )
+}
diff --git a/src/app/admin/logs/trades/loading.tsx b/src/app/admin/logs/trades/loading.tsx
new file mode 100644
index 00000000..a32864a5
--- /dev/null
+++ b/src/app/admin/logs/trades/loading.tsx
@@ -0,0 +1,28 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function TradeLogsLoading() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/admin/logs/trades/page.tsx b/src/app/admin/logs/trades/page.tsx
new file mode 100644
index 00000000..6c117e52
--- /dev/null
+++ b/src/app/admin/logs/trades/page.tsx
@@ -0,0 +1,52 @@
+import { fetchAdminList } from '@/lib/admin-list'
+import { PERMS } from '@/lib/permissions'
+import { TradesTable } from './trades-table'
+
+export default async function TradeLogsPage({
+ params: paramsPromise,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams: Promise>
+}) {
+ const result = await fetchAdminList(
+ {
+ permission: PERMS.LOGS_VIEW,
+ model: 'trade',
+ searchFields: ['userOne.username', 'userTwo.username'],
+ defaultPerPage: 50,
+ include: {
+ userOne: { select: { username: true } },
+ userTwo: { select: { username: true } },
+ },
+ mapRow: (t) => ({
+ id: t.id,
+ userOneName: t.userOne.username,
+ userTwoName: t.userTwo.username,
+ status: t.status,
+ timestamp: t.timestamp,
+ }),
+ },
+ paramsPromise,
+ searchParams,
+ )
+
+ return (
+
+
+
Trade Logs
+
View in-game trade history
+
+
+
+
+ )
+}
diff --git a/src/app/admin/logs/trades/trades-table.tsx b/src/app/admin/logs/trades/trades-table.tsx
new file mode 100644
index 00000000..67c5ca4c
--- /dev/null
+++ b/src/app/admin/logs/trades/trades-table.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import { DataTable } from '@/components/admin/data-table'
+import { Badge } from '@/components/ui/badge'
+import { formatTimestamp } from '@/lib/admin-helpers'
+import type { DataTableColumn, PaginatedResult } from '@/types'
+
+interface TradeLogRow {
+ id: number
+ userOneName: string
+ userTwoName: string
+ status: string
+ timestamp: number
+}
+
+const columns: DataTableColumn[] = [
+ { key: 'id', label: 'ID', sortable: true },
+ { key: 'userOneName', label: 'User One' },
+ { key: 'userTwoName', label: 'User Two' },
+ {
+ key: 'status',
+ label: 'Status',
+ sortable: true,
+ render: (value) => {
+ const v = String(value)
+ const variant =
+ v === 'completed' ? 'default' : v === 'cancelled' ? 'destructive' : 'secondary'
+ return {v || 'unknown'}
+ },
+ },
+ {
+ key: 'timestamp',
+ label: 'Time',
+ sortable: true,
+ render: (value) => {formatTimestamp(Number(value))},
+ },
+]
+
+interface TradesTableProps {
+ data: PaginatedResult
+}
+
+export function TradesTable({ data }: TradesTableProps) {
+ return
+}
diff --git a/src/app/admin/moderation/actions/mod-actions-client.tsx b/src/app/admin/moderation/actions/mod-actions-client.tsx
new file mode 100644
index 00000000..9393c480
--- /dev/null
+++ b/src/app/admin/moderation/actions/mod-actions-client.tsx
@@ -0,0 +1,228 @@
+'use client'
+
+import {
+ DoorOpen,
+ Loader2,
+ Megaphone,
+ MessageSquare,
+ Shield,
+ Volume2,
+ VolumeX,
+ Zap,
+} from 'lucide-react'
+import { useState } from 'react'
+import {
+ broadcastAlert,
+ quickAlert,
+ quickKick,
+ quickMute,
+ quickRoomKick,
+ quickUnmute,
+} from '@/actions/moderation'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { useServerAction } from '@/hooks/use-server-action'
+
+export function ModActionsClient() {
+ const { run, isPending } = useServerAction()
+
+ // User actions
+ const [userId, setUserId] = useState('')
+ const [muteDuration, setMuteDuration] = useState('10')
+ const [alertMessage, setAlertMessage] = useState('')
+
+ // Room actions
+ const [roomId, setRoomId] = useState('')
+
+ // Broadcast
+ const [broadcastMsg, setBroadcastMsg] = useState('')
+
+ const uid = parseInt(userId, 10)
+ const rid = parseInt(roomId, 10)
+
+ return (
+
+ {/* User Actions */}
+
+
+
+
+ User Actions
+
+
+
+
+ User ID
+ setUserId(e.target.value)}
+ placeholder="Enter user ID"
+ />
+
+
+
+
+
+
+
+
+ setMuteDuration(e.target.value)}
+ className="w-24"
+ placeholder="Min"
+ />
+
+
+
+
+
+
+
+
+ {/* Room Actions */}
+
+
+
+
+
+ Room Actions
+
+
+
+
+ Room ID
+ setRoomId(e.target.value)}
+ placeholder="Enter room ID"
+ />
+
+
+
+
+
+ {/* Broadcast */}
+
+
+
+
+ Broadcast Alert
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/moderation/actions/page.tsx b/src/app/admin/moderation/actions/page.tsx
new file mode 100644
index 00000000..895b4268
--- /dev/null
+++ b/src/app/admin/moderation/actions/page.tsx
@@ -0,0 +1,20 @@
+import { redirect } from 'next/navigation'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { ModActionsClient } from './mod-actions-client'
+
+export default async function QuickActionsPage() {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.MODERATION_EDIT, session.user.rank)) redirect('/admin')
+
+ return (
+
+
+
Quick Actions
+
+ Fast moderation tools — kick, mute, alert, broadcast
+
+
+
+
+ )
+}
diff --git a/src/app/admin/moderation/cfh/[id]/cfh-detail.tsx b/src/app/admin/moderation/cfh/[id]/cfh-detail.tsx
new file mode 100644
index 00000000..7d542bfb
--- /dev/null
+++ b/src/app/admin/moderation/cfh/[id]/cfh-detail.tsx
@@ -0,0 +1,408 @@
+'use client'
+
+import {
+ AlertTriangle,
+ ArrowLeft,
+ Ban,
+ CheckCircle2,
+ MessageSquare,
+ Shield,
+ User,
+ VolumeX,
+ Zap,
+} from 'lucide-react'
+import Link from 'next/link'
+import { useState } from 'react'
+import {
+ assignCfhTicket,
+ closeCfhTicket,
+ quickAlert,
+ quickKick,
+ quickMute,
+ updateCfhState,
+} from '@/actions/moderation'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { useServerAction } from '@/hooks/use-server-action'
+
+const CFH_STATES: Record = {
+ 0: { label: 'Open', class: 'bg-[var(--admin-error-subtle)] text-[var(--admin-error)]' },
+ 1: { label: 'Picked', class: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]' },
+ 2: { label: 'Closed', class: 'bg-muted text-muted-foreground' },
+}
+
+interface UserInfo {
+ id: number
+ username: string
+ look: string
+ rank: number
+ online: string
+ ipRegister: string
+ mail: string | null
+}
+
+interface BanRecord {
+ id: number
+ reason: string
+ type: string
+ staff: string
+ timestamp: number
+}
+
+export function CfhDetail({
+ ticket,
+ sender,
+ reported,
+ moderator,
+ banHistory,
+ canEdit,
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
+ currentUserId,
+}: {
+ ticket: {
+ id: number
+ state: number
+ type: number
+ timestamp: number
+ issue: string
+ roomId: number
+ category: number
+ score: number
+ }
+ sender: UserInfo | null
+ reported: UserInfo | null
+ moderator: UserInfo | null
+ banHistory: { count: number; recent: BanRecord[] }
+ canEdit: boolean
+ currentUserId: number
+}) {
+ const { run, isPending } = useServerAction()
+ const [alertMsg, setAlertMsg] = useState('')
+ const [muteDuration, setMuteDuration] = useState(10)
+
+ const state = CFH_STATES[ticket.state] ?? CFH_STATES[0]
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ CFH #{ticket.id}
+ {state.label}
+
+ Type {ticket.type} · Category {ticket.category}
+
+
+
Call For Help Report
+
+ {new Date(ticket.timestamp * 1000).toLocaleString()} · Room #{ticket.roomId}
+
+
+
+
+
+ {/* Main content */}
+
+ {/* Issue */}
+
+
+
+
+ Report Message
+
+
+
+
+ {ticket.issue || 'No description provided.'}
+
+
+
+
+ {/* Users involved */}
+
+ {/* Sender */}
+
+
+ Reporter
+
+
+ {sender ? (
+
+
+
+
{sender.username}
+
+ Rank {sender.rank} · {sender.online === '1' ? 'Online' : 'Offline'}
+
+
+
+ ) : (
+ Unknown user
+ )}
+
+
+
+ {/* Reported */}
+
+
+ Reported User
+
+
+ {reported ? (
+
+
+
+
+
{reported.username}
+
+ Rank {reported.rank} · {reported.online === '1' ? 'Online' : 'Offline'} ·{' '}
+ {banHistory.count} bans
+
+
+
+
+ {/* Quick actions on reported user */}
+ {canEdit && (
+
+
+
+
+
+
setMuteDuration(Number(e.target.value))}
+ className="h-7 text-xs"
+ placeholder="Mute duration (min)"
+ />
+
+
+
+ )}
+
+ ) : (
+ Unknown user
+ )}
+
+
+
+
+ {/* Ban history */}
+ {banHistory.recent.length > 0 && (
+
+
+
+
+ Ban History ({banHistory.count})
+
+
+
+
+ {banHistory.recent.map((ban) => (
+
+
{ban.reason || 'No reason'}
+
+ {ban.type} · by {ban.staff} ·{' '}
+ {new Date(ban.timestamp * 1000).toLocaleDateString()}
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Sidebar */}
+
+ {/* Actions */}
+ {canEdit && (
+
+
+ Actions
+
+
+
+ Status
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Moderator */}
+
+
+ Assigned Moderator
+
+
+ {moderator ? (
+
+
+ {moderator.username}
+
+ ) : (
+ Unassigned
+ )}
+
+
+
+ {/* Details */}
+
+
+ Details
+
+
+
+ Created
+ {new Date(ticket.timestamp * 1000).toLocaleString()}
+
+
+ Room
+ #{ticket.roomId}
+
+
+ Type
+ {ticket.type}
+
+
+ Category
+ {ticket.category}
+
+
+ Score
+ {ticket.score}
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/moderation/cfh/[id]/page.tsx b/src/app/admin/moderation/cfh/[id]/page.tsx
new file mode 100644
index 00000000..9a3076c0
--- /dev/null
+++ b/src/app/admin/moderation/cfh/[id]/page.tsx
@@ -0,0 +1,80 @@
+import { notFound, redirect } from 'next/navigation'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+import { CfhDetail } from './cfh-detail'
+
+export default async function CfhDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) redirect('/admin')
+
+ const { id } = await params
+ const ticketId = Number(id)
+ if (Number.isNaN(ticketId)) notFound()
+
+ const ticket = await prisma.supportTickets.findUnique({ where: { id: ticketId } })
+ if (!ticket) notFound()
+
+ const canEdit = canAccess(permissions, PERMS.MODERATION_EDIT, session.user.rank)
+
+ // Fetch sender, reported, mod info
+ const userIds = [ticket.senderId, ticket.reportedId, ticket.modId].filter(Boolean)
+ const users =
+ userIds.length > 0
+ ? await prisma.user.findMany({
+ where: { id: { in: userIds } },
+ select: {
+ id: true,
+ username: true,
+ look: true,
+ rank: true,
+ online: true,
+ ipRegister: true,
+ mail: true,
+ },
+ })
+ : []
+ const userMap = Object.fromEntries(users.map((u) => [u.id, u]))
+
+ // Get ban/mute history for reported user
+ const reportedId = ticket.reportedId
+ const [banCount, recentBans] = reportedId
+ ? await Promise.all([
+ prisma.ban.count({ where: { userId: reportedId } }),
+ prisma.ban.findMany({
+ where: { userId: reportedId },
+ orderBy: { id: 'desc' },
+ take: 5,
+ }),
+ ])
+ : [0, []]
+
+ return (
+ ({
+ id: b.id,
+ reason: b.banReason,
+ type: b.type,
+ staff: `Staff #${b.userStaffId}`,
+ timestamp: b.timestamp,
+ })),
+ }}
+ canEdit={canEdit}
+ currentUserId={session.user.id}
+ />
+ )
+}
diff --git a/src/app/admin/moderation/cfh/page.tsx b/src/app/admin/moderation/cfh/page.tsx
new file mode 100644
index 00000000..2ea2449f
--- /dev/null
+++ b/src/app/admin/moderation/cfh/page.tsx
@@ -0,0 +1,161 @@
+import { AlertTriangle, CheckCircle2, Clock, Radio } from 'lucide-react'
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+import { Badge } from '@/components/ui/badge'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+const CFH_STATES: Record = {
+ 0: { label: 'Open', class: 'bg-[var(--admin-error-subtle)] text-[var(--admin-error)]', icon: AlertTriangle },
+ 1: { label: 'Picked', class: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]', icon: Clock },
+ 2: { label: 'Closed', class: 'bg-muted text-muted-foreground', icon: CheckCircle2 },
+}
+
+interface PageProps {
+ searchParams: Promise<{ state?: string; page?: string }>
+}
+
+export default async function CfhListPage({ searchParams }: PageProps) {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) redirect('/admin')
+
+ const sp = await searchParams
+ const stateFilter = sp.state !== undefined ? Number(sp.state) : -1
+ const page = Math.max(1, Number(sp.page) || 1)
+ const perPage = 25
+
+ const where = stateFilter >= 0 ? { state: stateFilter } : {}
+
+ const [tickets, total] = await Promise.all([
+ prisma.supportTickets.findMany({
+ where,
+ orderBy: { id: 'desc' },
+ skip: (page - 1) * perPage,
+ take: perPage,
+ }),
+ prisma.supportTickets.count({ where }),
+ ])
+
+ // Fetch all related users
+ const userIds = [
+ ...new Set([
+ ...tickets.map((t) => t.senderId),
+ ...tickets.map((t) => t.reportedId),
+ ...tickets.map((t) => t.modId),
+ ]),
+ ].filter(Boolean)
+
+ const users =
+ userIds.length > 0
+ ? await prisma.user.findMany({
+ where: { id: { in: userIds } },
+ select: { id: true, username: true },
+ })
+ : []
+ const userMap = new Map(users.map((u) => [u.id, u.username]))
+
+ const totalPages = Math.ceil(total / perPage)
+
+ return (
+
+
+
CFH Tickets
+
In-game Call For Help reports
+
+
+ {/* Filters */}
+
+ {[
+ { key: -1, label: 'All' },
+ { key: 0, label: 'Open' },
+ { key: 1, label: 'Picked' },
+ { key: 2, label: 'Closed' },
+ ].map(({ key, label }) => (
+ = 0 ? `?state=${key}` : ''}`}>
+
+ {label}
+
+
+ ))}
+
+
+ {/* Table */}
+
+
+ #
+ Issue
+ Sender
+ Reported
+ Mod
+ Status
+
+
+ {tickets.length === 0 ? (
+
+
+ No tickets found.
+
+ ) : (
+ tickets.map((ticket) => {
+ const state = CFH_STATES[ticket.state] ?? CFH_STATES[0]
+ const StateIcon = state.icon
+
+ return (
+
+
{ticket.id}
+
+
{ticket.issue || 'No description'}
+
+ Room #{ticket.roomId} · {new Date(ticket.timestamp * 1000).toLocaleString()}
+
+
+
+ {userMap.get(ticket.senderId) ?? '—'}
+
+
+ {userMap.get(ticket.reportedId) ?? '—'}
+
+
+ {userMap.get(ticket.modId) ?? '—'}
+
+
+
+ {state.label}
+
+
+ )
+ })
+ )}
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+ {page > 1 && (
+
+
+ Previous
+
+
+ )}
+
+ Page {page} of {totalPages}
+
+ {page < totalPages && (
+
+
+ Next
+
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/app/admin/moderation/page.tsx b/src/app/admin/moderation/page.tsx
new file mode 100644
index 00000000..c71e0eb3
--- /dev/null
+++ b/src/app/admin/moderation/page.tsx
@@ -0,0 +1,184 @@
+import { AlertTriangle, Ban, Clock, Gavel, Radio, Shield, User } from 'lucide-react'
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+import { StatsCard } from '@/components/admin/stats-card'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+const CFH_STATES: Record = {
+ 0: { label: 'Open', class: 'bg-[var(--admin-error-subtle)] text-[var(--admin-error)]' },
+ 1: { label: 'Picked', class: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]' },
+ 2: { label: 'Closed', class: 'bg-muted text-muted-foreground' },
+}
+
+export default async function ModerationDashboard() {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) redirect('/admin')
+
+ const now = Math.floor(Date.now() / 1000)
+ const todayStart = now - (now % 86400)
+
+ const [openCfh, pickedCfh, todayBans, modsOnline, recentCfh, recentModActions] =
+ await Promise.all([
+ prisma.supportTickets.count({ where: { state: 0 } }),
+ prisma.supportTickets.count({ where: { state: 1 } }),
+ prisma.ban.count({ where: { timestamp: { gte: todayStart } } }),
+ prisma.user.count({ where: { online: '1', rank: { gte: 4 } } }),
+ prisma.supportTickets.findMany({
+ orderBy: { id: 'desc' },
+ take: 10,
+ }),
+ prisma.adminAuditLog.findMany({
+ where: {
+ action: { startsWith: 'mod_' },
+ },
+ orderBy: { id: 'desc' },
+ take: 10,
+ include: { user: { select: { username: true } } },
+ }),
+ ])
+
+ // Fetch usernames for CFH tickets
+ const userIds = [
+ ...new Set([
+ ...recentCfh.map((t) => t.senderId),
+ ...recentCfh.map((t) => t.reportedId),
+ ...recentCfh.map((t) => t.modId),
+ ]),
+ ].filter(Boolean)
+
+ const users =
+ userIds.length > 0
+ ? await prisma.user.findMany({
+ where: { id: { in: userIds } },
+ select: { id: true, username: true },
+ })
+ : []
+ const userMap = new Map(users.map((u) => [u.id, u.username]))
+
+ return (
+
+
+
Moderation
+
In-game moderation dashboard
+
+
+ {/* Stats */}
+
+ }
+ href="/admin/moderation/cfh"
+ />
+ }
+ href="/admin/bans"
+ />
+ }
+ href="/admin/moderation/team"
+ />
+ }
+ href="/admin/moderation/actions"
+ />
+
+
+
+ {/* Recent CFH */}
+
+
+
+
+
+ Recent CFH Tickets
+
+
+ View all
+
+
+
+
+ {recentCfh.length === 0 ? (
+ No CFH tickets
+ ) : (
+
+ {recentCfh.map((ticket) => {
+ const state = CFH_STATES[ticket.state] ?? CFH_STATES[0]
+ return (
+
+
+ #{ticket.id}
+
+
+
{ticket.issue || 'No description'}
+
+ {userMap.get(ticket.senderId) ?? '?'} →{' '}
+ {userMap.get(ticket.reportedId) ?? '?'}
+
+
+
{state.label}
+
+ )
+ })}
+
+ )}
+
+
+
+ {/* Recent Mod Actions */}
+
+
+
+
+ Recent Mod Actions
+
+
+
+ {recentModActions.length === 0 ? (
+ No recent actions
+ ) : (
+
+ {recentModActions.map((log) => (
+
+
+
+
+ {log.action.replace('mod_', '').replace(/_/g, ' ')}
+
+
+ by {log.user?.username ?? '?'} · {log.target}{' '}
+ {log.targetId ? `#${log.targetId}` : ''}
+
+
+
+ {log.createdAt}
+
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/app/admin/moderation/team/page.tsx b/src/app/admin/moderation/team/page.tsx
new file mode 100644
index 00000000..427426bb
--- /dev/null
+++ b/src/app/admin/moderation/team/page.tsx
@@ -0,0 +1,149 @@
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+import { AvatarImage } from '@/components/shared/avatar-image'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent } from '@/components/ui/card'
+import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+export default async function ModTeamPage() {
+ const { session, permissions } = await getAdminContext()
+ if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) redirect('/admin')
+
+ // Get all staff (rank >= 4)
+ const staff = await prisma.user.findMany({
+ where: { rank: { gte: 4 } },
+ select: {
+ id: true,
+ username: true,
+ look: true,
+ rank: true,
+ online: true,
+ lastLogin: true,
+ lastOnline: true,
+ },
+ orderBy: [{ online: 'desc' }, { rank: 'desc' }],
+ })
+
+ // Get CFH ticket counts per mod and web ticket counts
+ const [cfhCounts, webTicketCounts, modActionCounts] = await Promise.all([
+ prisma.supportTickets.groupBy({
+ by: ['modId'],
+ where: { modId: { in: staff.map((s) => s.id) } },
+ _count: { _all: true },
+ }),
+ prisma.websiteTicket.groupBy({
+ by: ['assigneeId'],
+ where: { assigneeId: { in: staff.map((s) => s.id) } },
+ _count: { _all: true },
+ }),
+ prisma.adminAuditLog.groupBy({
+ by: ['userId'],
+ where: {
+ userId: { in: staff.map((s) => s.id) },
+ action: { startsWith: 'mod_' },
+ },
+ _count: { _all: true },
+ }),
+ ])
+
+ const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c._count._all]))
+ const webMap = new Map(webTicketCounts.map((c) => [c.assigneeId, c._count._all]))
+ const actionMap = new Map(modActionCounts.map((c) => [c.userId, c._count._all]))
+
+ return (
+
+
+
Mod Team
+
Staff performance and status
+
+
+ {/* Summary */}
+
+
+
+
+ {staff.filter((s) => s.online === '1').length}
+
+ Online Now
+
+
+
+
+ {staff.length}
+ Total Staff
+
+
+
+
+
+ {staff.filter((s) => s.rank >= 7).length}
+
+ Super Admins
+
+
+
+
+ {/* Staff list */}
+
+
+
+ Name
+ Rank
+ CFH
+ Tickets
+ Actions
+ Last Seen
+
+
+ {staff.map((member) => (
+
+
+
+
+ Rank {member.rank}
+
+
+
{cfhMap.get(member.id) ?? 0}
+
CFH
+
+
+
{webMap.get(member.id) ?? 0}
+
Web
+
+
+
{actionMap.get(member.id) ?? 0}
+
Mod
+
+
+ {member.online === '1'
+ ? 'Online'
+ : member.lastOnline
+ ? new Date(member.lastOnline * 1000).toLocaleDateString()
+ : '—'}
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/admin/online/loading.tsx b/src/app/admin/online/loading.tsx
new file mode 100644
index 00000000..ba99340c
--- /dev/null
+++ b/src/app/admin/online/loading.tsx
@@ -0,0 +1,28 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function OnlineLoading() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/admin/online/online-table.tsx b/src/app/admin/online/online-table.tsx
new file mode 100644
index 00000000..3b4d686b
--- /dev/null
+++ b/src/app/admin/online/online-table.tsx
@@ -0,0 +1,120 @@
+'use client'
+
+import { Pause, Play, RefreshCw } from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useCallback, useEffect, useState } from 'react'
+import { DataTable } from '@/components/admin/data-table'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { getAvatarUrl } from '@/lib/imager'
+import type { DataTableColumn, PaginatedResult } from '@/types'
+
+interface OnlineUserRow {
+ id: number
+ username: string
+ motto: string
+ look: string
+}
+
+const columns: DataTableColumn[] = [
+ { key: 'id', label: 'ID', sortable: true },
+ {
+ key: 'username',
+ label: 'Username',
+ sortable: true,
+ render: (_, row) => (
+
+

+
{row.username}
+
+
+
+
+
+ ),
+ },
+ {
+ key: 'motto',
+ label: 'Motto',
+ render: (value) => {String(value) || '-'},
+ },
+ {
+ key: 'look',
+ label: 'Avatar',
+ render: (_, row) =>
,
+ },
+]
+
+interface OnlineTableProps {
+ data: PaginatedResult
+}
+
+const REFRESH_INTERVAL = 15_000 // 15 seconds
+
+export function OnlineTable({ data }: OnlineTableProps) {
+ const router = useRouter()
+ const [autoRefresh, setAutoRefresh] = useState(false)
+ const [lastUpdated, setLastUpdated] = useState(() => new Date())
+ const [isRefreshing, setIsRefreshing] = useState(false)
+
+ const doRefresh = useCallback(() => {
+ setIsRefreshing(true)
+ router.refresh()
+ setLastUpdated(new Date())
+ // Small delay to show spinner
+ setTimeout(() => setIsRefreshing(false), 500)
+ }, [router])
+
+ useEffect(() => {
+ if (!autoRefresh) return
+ const timer = setInterval(doRefresh, REFRESH_INTERVAL)
+ return () => clearInterval(timer)
+ }, [autoRefresh, doRefresh])
+
+ // Update lastUpdated when data changes
+ // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
+ useEffect(() => {
+ setLastUpdated(new Date())
+ }, [data.total])
+
+ return (
+
+ {/* Auto-refresh controls */}
+
+
+
+ {autoRefresh && (
+
+ Every {REFRESH_INTERVAL / 1000}s
+
+ )}
+
+ Last updated: {lastUpdated.toLocaleTimeString()}
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/online/page.tsx b/src/app/admin/online/page.tsx
new file mode 100644
index 00000000..23b0146a
--- /dev/null
+++ b/src/app/admin/online/page.tsx
@@ -0,0 +1,48 @@
+import { fetchAdminList } from '@/lib/admin-list'
+import { PERMS } from '@/lib/permissions'
+import { OnlineTable } from './online-table'
+
+export default async function OnlineUsersPage({
+ params: paramsPromise,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams: Promise>
+}) {
+ const result = await fetchAdminList(
+ {
+ permission: PERMS.USERS_VIEW,
+ model: 'user',
+ searchFields: ['username', 'motto'],
+ baseWhere: { online: '1' },
+ defaultPerPage: 50,
+ select: {
+ id: true,
+ username: true,
+ motto: true,
+ look: true,
+ },
+ },
+ paramsPromise,
+ searchParams,
+ )
+
+ return (
+
+
+
Online Users
+
Currently online users ({result.total} connected)
+
+
+
+
+ )
+}
diff --git a/src/app/api/admin/analytics/export/route.ts b/src/app/api/admin/analytics/export/route.ts
new file mode 100644
index 00000000..d4366420
--- /dev/null
+++ b/src/app/api/admin/analytics/export/route.ts
@@ -0,0 +1,101 @@
+import { withAdmin } from '@/lib/api-handler'
+import { apiError } from '@/lib/api-response'
+import { PERMS } from '@/lib/permission-slugs'
+import { prisma } from '@/lib/prisma'
+
+const VALID_REPORTS = new Set(['registrations', 'online-by-hour', 'economy'])
+
+function csvEscape(value: unknown): string {
+ if (value == null) return ''
+ const str = String(value)
+ if (/[",\n\r]/.test(str)) {
+ return `"${str.replace(/"/g, '""')}"`
+ }
+ return str
+}
+
+function csvRow(values: unknown[]): string {
+ return `${values.map(csvEscape).join(',')}\n`
+}
+
+async function streamRegistrations(): Promise> {
+ const rows = await prisma.$queryRaw<{ day: string; cnt: bigint }[]>`
+ SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt
+ FROM users
+ WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY)
+ GROUP BY day
+ ORDER BY day ASC
+ `
+ const encoder = new TextEncoder()
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(csvRow(['day', 'registrations'])))
+ for (const r of rows) {
+ controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])))
+ }
+ controller.close()
+ },
+ })
+}
+
+async function streamOnlineByHour(): Promise> {
+ const rows = await prisma.$queryRaw<{ hour: number; cnt: bigint }[]>`
+ SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt
+ FROM users
+ WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
+ GROUP BY hour
+ ORDER BY hour ASC
+ `
+ const encoder = new TextEncoder()
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(csvRow(['hour', 'active_users'])))
+ for (const r of rows) {
+ controller.enqueue(encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])))
+ }
+ controller.close()
+ },
+ })
+}
+
+async function streamEconomy(): Promise> {
+ const [totals] = await prisma.$queryRaw<{ total_credits: bigint; total_pixels: bigint }[]>`
+ SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
+ `
+ const encoder = new TextEncoder()
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(csvRow(['metric', 'value'])))
+ controller.enqueue(
+ encoder.encode(csvRow(['total_credits', Number(totals?.total_credits ?? 0)])),
+ )
+ controller.enqueue(
+ encoder.encode(csvRow(['total_pixels', Number(totals?.total_pixels ?? 0)])),
+ )
+ controller.close()
+ },
+ })
+}
+
+export const GET = withAdmin({ permission: PERMS.ANALYTICS_EXPORT }, async (request) => {
+ const url = new URL(request.url)
+ const report = url.searchParams.get('report') ?? ''
+ if (!VALID_REPORTS.has(report)) {
+ return apiError(`Unknown report. Use one of: ${[...VALID_REPORTS].join(', ')}`, 400)
+ }
+
+ const stream =
+ report === 'registrations'
+ ? await streamRegistrations()
+ : report === 'online-by-hour'
+ ? await streamOnlineByHour()
+ : await streamEconomy()
+
+ return new Response(stream, {
+ headers: {
+ 'Content-Type': 'text/csv; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${report}.csv"`,
+ 'Cache-Control': 'no-store',
+ },
+ })
+})
diff --git a/src/app/api/admin/devops/health/route.ts b/src/app/api/admin/devops/health/route.ts
new file mode 100644
index 00000000..9b8e1997
--- /dev/null
+++ b/src/app/api/admin/devops/health/route.ts
@@ -0,0 +1,59 @@
+import net from 'node:net'
+import { NextResponse } from 'next/server'
+import { env } from '@/env'
+import { withAdmin } from '@/lib/api-handler'
+import { PERMS } from '@/lib/permissions'
+import { prisma } from '@/lib/prisma'
+
+function checkRcon(): Promise {
+ return new Promise((resolve) => {
+ const host = env.RCON_HOST
+ const port = env.RCON_PORT
+
+ const socket = new net.Socket()
+ const timeout = setTimeout(() => {
+ socket.destroy()
+ resolve(false)
+ }, 3000)
+
+ socket.connect(port, host, () => {
+ clearTimeout(timeout)
+ socket.destroy()
+ resolve(true)
+ })
+
+ socket.on('error', () => {
+ clearTimeout(timeout)
+ socket.destroy()
+ resolve(false)
+ })
+ })
+}
+
+export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
+ // DB health
+ let dbOk = false
+ let dbLatency = 0
+ try {
+ const start = Date.now()
+ await prisma.$queryRaw`SELECT 1`
+ dbLatency = Date.now() - start
+ dbOk = true
+ } catch {
+ dbOk = false
+ }
+
+ // Emulator health (RCON TCP check)
+ const emulator = await checkRcon()
+
+ // Online users
+ const onlineUsers = await prisma.user.count({ where: { online: '1' } })
+
+ return NextResponse.json({
+ database: dbOk,
+ dbLatency,
+ emulator,
+ onlineUsers,
+ timestamp: new Date().toISOString(),
+ })
+})
diff --git a/src/components/admin/dashboard-chart.tsx b/src/components/admin/dashboard-chart.tsx
new file mode 100644
index 00000000..0c8ea1fa
--- /dev/null
+++ b/src/components/admin/dashboard-chart.tsx
@@ -0,0 +1,15 @@
+'use client'
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+
+interface ChartDataPoint { label: string; value: number }
+
+export function DashboardChart({ title, data, description }: { title: string; data: ChartDataPoint[]; color?: string; description?: string }) {
+ const max = Math.max(1, ...data.map((point) => point.value))
+ return (
+
+ {title}{description && {description}
}
+ {data.map((point) =>
{point.value}{point.label} )}
+
+ )
+}
diff --git a/src/components/admin/peak-hours-heatmap.tsx b/src/components/admin/peak-hours-heatmap.tsx
new file mode 100644
index 00000000..3f9bdb3d
--- /dev/null
+++ b/src/components/admin/peak-hours-heatmap.tsx
@@ -0,0 +1,84 @@
+'use client'
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+
+interface PeakHoursData {
+ /** [dayOfWeek (0=Mon..6=Sun)][hour (0..23)] = count */
+ grid: number[][]
+ max: number
+}
+
+const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
+const HOURS = Array.from({ length: 24 }, (_, i) => i)
+
+function getColor(value: number, max: number) {
+ if (max === 0 || value === 0) return 'bg-muted'
+ const ratio = value / max
+ if (ratio < 0.25) return 'bg-[var(--admin-success)]/20'
+ if (ratio < 0.5) return 'bg-[var(--admin-success)]/55'
+ if (ratio < 0.75) return 'bg-[var(--admin-success)] dark:bg-[var(--admin-success)]'
+ return 'bg-[var(--admin-success-strong,var(--admin-success))]'
+}
+
+export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
+ return (
+
+
+ Peak Hours
+
+ User activity by day & hour (last 7 days)
+
+
+
+
+
+ {/* Hour labels */}
+
+ {HOURS.map((h) => (
+
+ {h % 3 === 0 ? `${h}` : ''}
+
+ ))}
+
+ {/* Rows */}
+ {DAYS.map((day, dayIdx) => (
+
+
+ {day}
+
+
+ {HOURS.map((hour) => {
+ const count = data.grid[dayIdx]?.[hour] ?? 0
+ return (
+
+
+
+
+
+ {day} {hour}:00 — {count} users
+
+
+ )
+ })}
+
+
+ ))}
+ {/* Legend */}
+
+
Less
+
+
+
+
+
+
More
+
+
+
+
+
+ )
+}
diff --git a/src/components/admin/revenue-chart.tsx b/src/components/admin/revenue-chart.tsx
new file mode 100644
index 00000000..cc8019da
--- /dev/null
+++ b/src/components/admin/revenue-chart.tsx
@@ -0,0 +1,10 @@
+'use client'
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+
+interface RevenueDataPoint { label: string; credits: number; points: number }
+
+export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
+ const max = Math.max(1, ...data.flatMap((point) => [point.credits, point.points]))
+ return Catalog Spending
+}
diff --git a/src/components/admin/stats-card.tsx b/src/components/admin/stats-card.tsx
new file mode 100644
index 00000000..dc1f3f0c
--- /dev/null
+++ b/src/components/admin/stats-card.tsx
@@ -0,0 +1,67 @@
+import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
+import Link from 'next/link'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+
+interface StatsCardProps {
+ title: string
+ value: string | number
+ description?: string
+ icon?: React.ReactNode
+ href?: string
+ /** Percentage change vs previous period. Positive = up, negative = down, undefined = no trend */
+ trend?: number
+}
+
+export function StatsCard({ title, value, description, icon, href, trend }: StatsCardProps) {
+ const content = (
+
+
+ {title}
+ {icon && {icon}
}
+
+
+
+
{value}
+ {trend !== undefined &&
}
+
+ {description && {description}
}
+
+
+ )
+
+ if (href) {
+ return {content}
+ }
+
+ return content
+}
+
+function TrendBadge({ value }: { value: number }) {
+ if (value === 0) {
+ return (
+
+
+ 0%
+
+ )
+ }
+
+ const isUp = value > 0
+ return (
+
+ {isUp ? : }
+ {isUp ? '+' : ''}
+ {value}%
+
+ )
+}
diff --git a/src/components/shared/avatar-image.tsx b/src/components/shared/avatar-image.tsx
new file mode 100644
index 00000000..e22ca6e7
--- /dev/null
+++ b/src/components/shared/avatar-image.tsx
@@ -0,0 +1,39 @@
+import { type AvatarOptions, getAvatarUrl } from '@/lib/imager'
+
+interface AvatarImageProps {
+ figure: string
+ options?: AvatarOptions
+ width: number
+ height: number
+ alt: string
+ className?: string
+}
+
+export function AvatarImage({
+ figure,
+ options,
+ width,
+ height,
+ alt,
+ className = '',
+}: AvatarImageProps) {
+ // Auto-pick the smallest size that still looks crisp at the requested
+ // dimensions, unless the caller passed an explicit size.
+ const max = Math.max(width, height)
+ const autoSize: 's' | 'm' | 'l' =
+ options?.size ?? (options?.headOnly ? 'l' : max <= 40 ? 's' : max <= 80 ? 'm' : 'l')
+
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )
+}
diff --git a/src/lib/services/rcon.ts b/src/lib/services/rcon.ts
index 1754ea00..1bb082bc 100644
--- a/src/lib/services/rcon.ts
+++ b/src/lib/services/rcon.ts
@@ -101,6 +101,15 @@ export class RconClient {
forwardUser(userId: number, roomId: number) {
return this.send("forwarduser", { user_id: userId, room_id: roomId });
}
+ kickAll(roomId: number) {
+ return this.send("kickall", { room_id: roomId });
+ }
+ hotelAlert(message: string) {
+ return this.send("hotelalert", { message });
+ }
+ staffAlert(message: string) {
+ return this.send("staffalert", { message });
+ }
executeCommand(userId: number, command: string) {
return this.send("executecommand", { user_id: userId, command });
}