feat: add admin operations suite
Local Build and Deploy / deploy (push) Successful in 50s

This commit is contained in:
Simo committed 2026-07-12 20:11:28 +02:00
1 parent 21a61068fb
commit 60278b9e71
38 files changed
+3452

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+83
View File
@@ -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<Record<string, string>>
}) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Emulator Errors</h1>
<p className="text-muted-foreground">
{total} total errors — showing page {page} of {lastPage}
</p>
</div>
<Card>
<CardContent className="p-0">
{errors.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">No emulator errors found</div>
) : (
<div className="divide-y">
{errors.map((error) => (
<div key={error.id} className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-xs text-muted-foreground">#{error.id}</span>
<span className="text-xs text-muted-foreground">
{new Date(error.timestamp * 1000).toLocaleString()}
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
{Buffer.from(error.stacktrace).toString('utf-8')}
</pre>
</div>
))}
</div>
)}
</CardContent>
</Card>
{lastPage > 1 && (
<div className="flex justify-center gap-2">
{page > 1 && (
<a href={`?page=${page - 1}`} className="text-sm text-primary hover:underline">
Previous
</a>
)}
<span className="text-sm text-muted-foreground">
Page {page} of {lastPage}
</span>
{page < lastPage && (
<a href={`?page=${page + 1}`} className="text-sm text-primary hover:underline">
Next
</a>
)}
</div>
)}
</div>
)
}
@@ -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 (
<div className="flex items-center gap-2">
<Badge
variant={
status === 'online' ? 'default' : status === 'offline' ? 'destructive' : 'secondary'
}
>
{status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={checkEmulator}
disabled={checking}
>
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
</Button>
</div>
)
}
File diff suppressed because it is too large. Load diff
+5
View File
@@ -89,6 +89,8 @@ function getNavGroups(t: (key: string) => string) {
{ href: "/admin/applications", label: t("applications"), icon: <ClipboardList size={16} /> }, { href: "/admin/applications", label: t("applications"), icon: <ClipboardList size={16} /> },
{ href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> }, { href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> },
{ href: "/admin/permissions", label: t("permissions"), icon: <Key size={16} /> }, { href: "/admin/permissions", label: t("permissions"), icon: <Key size={16} /> },
{ href: "/admin/online", label: "Online Users", icon: <Users size={16} /> },
{ href: "/admin/moderation", label: "Moderation", icon: <Shield size={16} /> },
], ],
}, },
{ {
@@ -131,6 +133,9 @@ function getNavGroups(t: (key: string) => string) {
{ href: "/admin/emulator", label: t("emulator"), icon: <Monitor size={16} /> }, { href: "/admin/emulator", label: t("emulator"), icon: <Monitor size={16} /> },
{ href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> }, { href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> },
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> }, { href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
{ href: "/admin/logs/audit", label: "Audit Log", icon: <FileText size={16} /> },
{ href: "/admin/analytics", label: "Analytics", icon: <LayoutDashboard size={16} /> },
{ href: "/admin/devops", label: "DevOps", icon: <Monitor size={16} /> },
{ href: "/admin/wordfilter", label: t("wordFilter"), icon: <Filter size={16} /> }, { href: "/admin/wordfilter", label: t("wordFilter"), icon: <Filter size={16} /> },
{ href: "/admin/maintenance", label: t("maintenance"), icon: <Wrench size={16} /> }, { href: "/admin/maintenance", label: t("maintenance"), icon: <Wrench size={16} /> },
{ href: "/admin/housekeeping", label: t("housekeeping"), icon: <Settings size={16} /> }, { href: "/admin/housekeeping", label: t("housekeeping"), icon: <Settings size={16} /> },
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function AuditLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
)
}
+48
View File
@@ -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<Record<string, string>>
}) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">Track admin actions and changes</p>
</div>
<AuditTable data={{ rows, ...pagination }} />
</div>
)
}
+45
View File
@@ -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<ChatLogRow>[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'username', label: 'User' },
{
key: 'message',
label: 'Message',
render: (value) => <span className="max-w-[300px] truncate block">{String(value)}</span>,
},
{
key: 'timestamp',
label: 'Time',
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
{ key: 'roomId', label: 'Room ID', sortable: true },
]
interface ChatTableProps {
data: PaginatedResult<ChatLogRow>
}
export function ChatTable({ data }: ChatTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username or message..."
exportUrl="/api/admin/export?type=chatlogs"
/>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function ChatLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
)
}
+49
View File
@@ -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<Record<string, string>>
}) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Chat Logs</h1>
<p className="text-muted-foreground">View in-game chat messages</p>
</div>
<ChatTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
)
}
@@ -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<CommandLogRow>[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'username', label: 'User' },
{ key: 'command', label: 'Command', sortable: true },
{
key: 'params',
label: 'Params',
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">{String(value) || '-'}</span>
),
},
{
key: 'timestamp',
label: 'Time',
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
]
interface CommandsTableProps {
data: PaginatedResult<CommandLogRow>
}
export function CommandsTable({ data }: CommandsTableProps) {
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by username, command, or params..."
exportUrl="/api/admin/export?type=commands"
/>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function CommandLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
)
}
+49
View File
@@ -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<Record<string, string>>
}) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Command Logs</h1>
<p className="text-muted-foreground">View in-game command usage</p>
</div>
<CommandsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function TradeLogsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
)
}
+52
View File
@@ -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<Record<string, string>>
}) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Trade Logs</h1>
<p className="text-muted-foreground">View in-game trade history</p>
</div>
<TradesTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
)
}
@@ -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<TradeLogRow>[] = [
{ 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 <Badge variant={variant}>{v || 'unknown'}</Badge>
},
},
{
key: 'timestamp',
label: 'Time',
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
]
interface TradesTableProps {
data: PaginatedResult<TradeLogRow>
}
export function TradesTable({ data }: TradesTableProps) {
return <DataTable data={data} columns={columns} searchPlaceholder="Search by username..." />
}
File diff suppressed because it is too large. Load diff
+20
View File
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Quick Actions</h1>
<p className="text-muted-foreground">
Fast moderation tools — kick, mute, alert, broadcast
</p>
</div>
<ModActionsClient />
</div>
)
}
File diff suppressed because it is too large. Load diff
@@ -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 (
<CfhDetail
ticket={{
id: ticket.id,
state: ticket.state,
type: ticket.type,
timestamp: ticket.timestamp,
issue: ticket.issue,
roomId: ticket.roomId,
category: ticket.category,
score: ticket.score,
}}
sender={userMap[ticket.senderId] ?? null}
reported={userMap[ticket.reportedId] ?? null}
moderator={userMap[ticket.modId] ?? null}
banHistory={{
count: banCount,
recent: recentBans.map((b) => ({
id: b.id,
reason: b.banReason,
type: b.type,
staff: `Staff #${b.userStaffId}`,
timestamp: b.timestamp,
})),
}}
canEdit={canEdit}
currentUserId={session.user.id}
/>
)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function OnlineLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
+48
View File
@@ -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<Record<string, string>>
}) {
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Online Users</h1>
<p className="text-muted-foreground">Currently online users ({result.total} connected)</p>
</div>
<OnlineTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
)
}
File diff suppressed because it is too large. Load diff
+59
View File
@@ -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<boolean> {
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(),
})
})
+15
View File
@@ -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 (
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm font-medium">{title}</CardTitle>{description && <p className="text-xs text-muted-foreground">{description}</p>}</CardHeader>
<CardContent><div className="flex h-[200px] items-end gap-1" aria-label={title}>{data.map((point) => <div key={point.label} className="flex min-w-0 flex-1 flex-col items-center gap-1"><span className="text-[10px] text-muted-foreground">{point.value}</span><div className="w-full rounded-t bg-[var(--admin-accent)]" style={{ height: `${Math.max(2, (point.value / max) * 150)}px` }} title={`${point.label}: ${point.value}`} /><span className="max-w-full truncate text-[9px] text-muted-foreground">{point.label}</span></div>)}</div></CardContent>
</Card>
)
}
@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Peak Hours</CardTitle>
<p className="text-xs text-muted-foreground">
User activity by day &amp; hour (last 7 days)
</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="min-w-[500px]">
{/* Hour labels */}
<div className="flex gap-px ml-10 mb-1">
{HOURS.map((h) => (
<div key={h} className="flex-1 text-center text-[10px] text-muted-foreground">
{h % 3 === 0 ? `${h}` : ''}
</div>
))}
</div>
{/* Rows */}
{DAYS.map((day, dayIdx) => (
<div key={day} className="flex items-center gap-px mb-px">
<span className="w-10 text-xs text-muted-foreground text-right pr-2 shrink-0">
{day}
</span>
<TooltipProvider delayDuration={0}>
{HOURS.map((hour) => {
const count = data.grid[dayIdx]?.[hour] ?? 0
return (
<Tooltip key={hour}>
<TooltipTrigger asChild>
<div
className={`flex-1 aspect-square rounded-sm ${getColor(count, data.max)} transition-colors min-w-[14px]`}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{day} {hour}:00 — {count} users
</TooltipContent>
</Tooltip>
)
})}
</TooltipProvider>
</div>
))}
{/* Legend */}
<div className="flex items-center gap-2 mt-3 ml-10">
<span className="text-[10px] text-muted-foreground">Less</span>
<div className="h-3 w-3 rounded-sm bg-muted" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/20" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/55" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)] dark:bg-[var(--admin-success)]" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success-strong,var(--admin-success))]" />
<span className="text-[10px] text-muted-foreground">More</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
+10
View File
@@ -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 <Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium">Catalog Spending</CardTitle></CardHeader><CardContent><div className="space-y-3">{data.map((point) => <div key={point.label} className="grid grid-cols-[3rem_1fr] items-center gap-2 text-xs"><span>{point.label}</span><div className="space-y-1"><div className="h-2 rounded bg-[var(--admin-warning)]" style={{ width: `${Math.max(2, point.credits / max * 100)}%` }} title={`${point.credits} credits`} /><div className="h-2 rounded bg-[var(--admin-accent)]" style={{ width: `${Math.max(2, point.points / max * 100)}%` }} title={`${point.points} points`} /></div></div>)}</div></CardContent></Card>
}
+67
View File
@@ -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 = (
<Card
className={
href
? 'transition-all duration-200 hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer'
: ''
}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{value}</div>
{trend !== undefined && <TrendBadge value={trend} />}
</div>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</CardContent>
</Card>
)
if (href) {
return <Link href={href}>{content}</Link>
}
return content
}
function TrendBadge({ value }: { value: number }) {
if (value === 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
0%
</span>
)
}
const isUp = value > 0
return (
<span
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
isUp ? 'text-[var(--admin-success)]' : 'text-[var(--admin-error)]'
}`}
>
{isUp ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{isUp ? '+' : ''}
{value}%
</span>
)
}
+39
View File
@@ -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
<img
src={getAvatarUrl(figure, { ...options, size: autoSize })}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
style={{ maxWidth: width, maxHeight: height }}
className={`image-pixelated object-contain ${className}`}
/>
)
}
+9
View File
@@ -101,6 +101,15 @@ export class RconClient {
forwardUser(userId: number, roomId: number) { forwardUser(userId: number, roomId: number) {
return this.send("forwarduser", { user_id: userId, room_id: roomId }); 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) { executeCommand(userId: number, command: string) {
return this.send("executecommand", { user_id: userId, command }); return this.send("executecommand", { user_id: userId, command });
} }