import { MessageSquare, Shield, Terminal, Users } from "lucide-react"; import { redirect } from "next/navigation"; import { getLocale, getTranslations } from "next-intl/server"; 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"; import { redisCache } from "@/lib/redis-cache"; type DayRow = { label: string; chats: number; commands: number; bans: number; registrations: number; }; type ActivityData = { dailyData: DayRow[]; weekChats: number; weekCommands: number; weekBans: number; weekRegistrations: number; topChatters: { userId: number; username: string; messages: number }[]; }; async function safeCount( fn: () => Promise, ): Promise<{ value: number; ok: boolean }> { try { return { value: await fn(), ok: true }; } catch { return { value: 0, ok: false }; } } async function safeRawCount( sql: Promise<{ count: bigint }[]>, ): Promise<{ value: number; ok: boolean }> { try { const rows = await sql; return { value: Number(rows[0]?.count ?? 0), ok: true }; } catch { return { value: 0, ok: false }; } } async function getActivityData( locale: string, ): Promise { const now = Math.floor(Date.now() / 1000); const todayStart = now - (now % 86400); const weekAgo = now - 7 * 86400; const rangeStart = todayStart - 6 * 86400; let degraded = false; const emptyDays: DayRow[] = []; for (let i = 6; i >= 0; i--) { const dayStart = todayStart - i * 86400; const date = new Date(dayStart * 1000); emptyDays.push({ label: date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric", }), chats: 0, commands: 0, bans: 0, registrations: 0, }); } const toMap = (rows: { day_bucket: bigint; count: bigint }[]) => new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)])); let chatMap = new Map(); let cmdMap = new Map(); let banMap = new Map(); let regMap = new Map(); try { 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`.catch( () => [], ), 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`.catch( () => [], ), 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`.catch( () => [], ), 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`.catch( () => [], ), ]); chatMap = toMap(chatRows); cmdMap = toMap(cmdRows); banMap = toMap(banRows); regMap = toMap(regRows); } catch { degraded = true; } const dailyData = emptyDays.map((day, idx) => { const dayStart = todayStart - (6 - idx) * 86400; const bucket = Math.floor(dayStart / 86400); return { ...day, chats: chatMap.get(bucket) ?? 0, commands: cmdMap.get(bucket) ?? 0, bans: banMap.get(bucket) ?? 0, registrations: regMap.get(bucket) ?? 0, }; }); const [weekChats, weekCommands, weekBans, weekRegistrations] = await Promise.all([ safeRawCount( prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`, ), safeRawCount( prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`, ), safeCount(() => prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }), ), safeCount(() => prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }), ), ]); for (const r of [weekChats, weekCommands, weekBans, weekRegistrations]) { if (!r.ok) degraded = true; } let topChatters: ActivityData["topChatters"] = []; try { const rows = 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 = rows.map((c) => Number(c.userId)).filter((n) => n > 0); 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])); topChatters = rows.map((c) => { const userId = Number(c.userId); return { userId, username: userMap.get(userId) ?? "", messages: Number(c.messages), }; }); } catch { topChatters = []; degraded = true; } return { dailyData, weekChats: weekChats.value, weekCommands: weekCommands.value, weekBans: weekBans.value, weekRegistrations: weekRegistrations.value, topChatters, degraded, }; } export default async function ActivityPage() { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) { redirect("/admin"); } const locale = await getLocale(); const t = await getTranslations("pages.admin.analytics.activity"); const tRoot = await getTranslations("pages.admin.analytics"); const data = await redisCache(`admin:analytics:activity:${locale}`, 120, () => getActivityData(locale), ); return (
{data.degraded ? (
{tRoot("loadError")}
) : null}
} /> } /> } /> } />
{t("dailyBreakdownTitle")}
{data.dailyData.map((day) => ( ))}
{t("colDay")} {t("colChats")} {t("colCmds")} {t("colBans")} {t("colRegs")}
{day.label} {day.chats.toLocaleString(locale)} {day.commands.toLocaleString(locale)} {day.bans} {day.registrations}
{t("mostActiveUsersTitle")} {data.topChatters.length === 0 ? (
{t("noChatActivity")}
) : (
{data.topChatters.map((user, i) => (
{i + 1} {user.username || t("userFallback", { id: user.userId })} {t("msgCount", { count: user.messages.toLocaleString(locale), })}
))}
)}
); }