From 60278b9e714ee05f6dc21fd42368914876073fc6 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 12 Jul 2026 20:11:28 +0200 Subject: [PATCH] feat: add admin operations suite --- src/actions/moderation.ts | 206 +++++++++ src/app/admin/analytics/activity/page.tsx | 197 +++++++++ src/app/admin/analytics/economy/page.tsx | 200 +++++++++ src/app/admin/analytics/page.tsx | 191 ++++++++ src/app/admin/devops/errors/page.tsx | 83 ++++ src/app/admin/devops/health-check-client.tsx | 55 +++ src/app/admin/devops/page.tsx | 147 +++++++ src/app/admin/layout.tsx | 5 + src/app/admin/logs/audit/audit-table.tsx | 111 +++++ src/app/admin/logs/audit/loading.tsx | 28 ++ src/app/admin/logs/audit/page.tsx | 48 +++ src/app/admin/logs/chat/chat-table.tsx | 45 ++ src/app/admin/logs/chat/loading.tsx | 28 ++ src/app/admin/logs/chat/page.tsx | 49 +++ .../admin/logs/commands/commands-table.tsx | 47 ++ src/app/admin/logs/commands/loading.tsx | 28 ++ src/app/admin/logs/commands/page.tsx | 49 +++ src/app/admin/logs/trades/loading.tsx | 28 ++ src/app/admin/logs/trades/page.tsx | 52 +++ src/app/admin/logs/trades/trades-table.tsx | 45 ++ .../moderation/actions/mod-actions-client.tsx | 228 ++++++++++ src/app/admin/moderation/actions/page.tsx | 20 + .../admin/moderation/cfh/[id]/cfh-detail.tsx | 408 ++++++++++++++++++ src/app/admin/moderation/cfh/[id]/page.tsx | 80 ++++ src/app/admin/moderation/cfh/page.tsx | 161 +++++++ src/app/admin/moderation/page.tsx | 184 ++++++++ src/app/admin/moderation/team/page.tsx | 149 +++++++ src/app/admin/online/loading.tsx | 28 ++ src/app/admin/online/online-table.tsx | 120 ++++++ src/app/admin/online/page.tsx | 48 +++ src/app/api/admin/analytics/export/route.ts | 101 +++++ src/app/api/admin/devops/health/route.ts | 59 +++ src/components/admin/dashboard-chart.tsx | 15 + src/components/admin/peak-hours-heatmap.tsx | 84 ++++ src/components/admin/revenue-chart.tsx | 10 + src/components/admin/stats-card.tsx | 67 +++ src/components/shared/avatar-image.tsx | 39 ++ src/lib/services/rcon.ts | 9 + 38 files changed, 3452 insertions(+) create mode 100644 src/actions/moderation.ts create mode 100644 src/app/admin/analytics/activity/page.tsx create mode 100644 src/app/admin/analytics/economy/page.tsx create mode 100644 src/app/admin/analytics/page.tsx create mode 100644 src/app/admin/devops/errors/page.tsx create mode 100644 src/app/admin/devops/health-check-client.tsx create mode 100644 src/app/admin/devops/page.tsx create mode 100644 src/app/admin/logs/audit/audit-table.tsx create mode 100644 src/app/admin/logs/audit/loading.tsx create mode 100644 src/app/admin/logs/audit/page.tsx create mode 100644 src/app/admin/logs/chat/chat-table.tsx create mode 100644 src/app/admin/logs/chat/loading.tsx create mode 100644 src/app/admin/logs/chat/page.tsx create mode 100644 src/app/admin/logs/commands/commands-table.tsx create mode 100644 src/app/admin/logs/commands/loading.tsx create mode 100644 src/app/admin/logs/commands/page.tsx create mode 100644 src/app/admin/logs/trades/loading.tsx create mode 100644 src/app/admin/logs/trades/page.tsx create mode 100644 src/app/admin/logs/trades/trades-table.tsx create mode 100644 src/app/admin/moderation/actions/mod-actions-client.tsx create mode 100644 src/app/admin/moderation/actions/page.tsx create mode 100644 src/app/admin/moderation/cfh/[id]/cfh-detail.tsx create mode 100644 src/app/admin/moderation/cfh/[id]/page.tsx create mode 100644 src/app/admin/moderation/cfh/page.tsx create mode 100644 src/app/admin/moderation/page.tsx create mode 100644 src/app/admin/moderation/team/page.tsx create mode 100644 src/app/admin/online/loading.tsx create mode 100644 src/app/admin/online/online-table.tsx create mode 100644 src/app/admin/online/page.tsx create mode 100644 src/app/api/admin/analytics/export/route.ts create mode 100644 src/app/api/admin/devops/health/route.ts create mode 100644 src/components/admin/dashboard-chart.tsx create mode 100644 src/components/admin/peak-hours-heatmap.tsx create mode 100644 src/components/admin/revenue-chart.tsx create mode 100644 src/components/admin/stats-card.tsx create mode 100644 src/components/shared/avatar-image.tsx 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) + + +
+ + + + + + + + + + + + {data.dailyData.map((day) => ( + + + + + + + + ))} + +
DayChatsCmdsBansRegs
{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" + /> + +
+ +
+