feat(portal): add SSR operations and exclusive groups
This commit is contained in:
@@ -1,50 +1,142 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/database";
|
||||
import { saveDiscordSettings } from "./actions";
|
||||
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
|
||||
|
||||
export default async function AdminPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ saved?: string; error?: string }>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminDashboardPage() {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 13 * 24 * 60 * 60 * 1_000);
|
||||
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
||||
|
||||
const [registrationRows, [totals], [monthlyActive], riskyActivity, [recentDenials]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: count(),
|
||||
})
|
||||
.from(users)
|
||||
.where(gte(users.createdAt, fourteenDaysAgo))
|
||||
.groupBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`)
|
||||
.orderBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`),
|
||||
db.select({ users: count(users.id) }).from(users),
|
||||
db.select({
|
||||
users: countDistinct(ipObservations.userId),
|
||||
accounts: countDistinct(ipObservations.minecraftAccountId),
|
||||
}).from(ipObservations).where(and(
|
||||
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||
isNotNull(ipObservations.userId),
|
||||
)),
|
||||
db
|
||||
.select({
|
||||
id: ipObservations.id,
|
||||
classification: ipObservations.classification,
|
||||
observedAt: ipObservations.observedAt,
|
||||
source: ipObservations.source,
|
||||
userId: users.id,
|
||||
firstName: users.firstName,
|
||||
discordUsername: users.discordUsername,
|
||||
accountUsername: minecraftAccounts.username,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.leftJoin(users, eq(users.id, ipObservations.userId))
|
||||
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
||||
.where(inArray(ipObservations.classification, ["vpn", "proxy", "tor"]))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(10),
|
||||
db.select({ count: count() }).from(events).where(and(
|
||||
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
|
||||
gte(events.time, oneDayAgo),
|
||||
)),
|
||||
]);
|
||||
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
|
||||
<section>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
|
||||
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
|
||||
<dl className="mt-7 space-y-4 font-mono text-[10px] uppercase tracking-wider text-muted">
|
||||
<div><dt className="font-bold text-ink">Guild ID</dt><dd className="mt-1 break-all normal-case">{guildId ?? "Missing"}</dd></div>
|
||||
<div><dt className="font-bold text-ink">Invite URL</dt><dd className="mt-1 break-all normal-case">{inviteUrl ? <a className="text-ink underline decoration-accent underline-offset-4" href={inviteUrl} rel="noreferrer" target="_blank">{inviteUrl}</a> : "Missing"}</dd></div>
|
||||
</dl>
|
||||
<header className="border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Operations overview</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Dashboard</h1>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Key metrics" className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
|
||||
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
|
||||
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 30 days" />
|
||||
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
|
||||
</section>
|
||||
|
||||
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
|
||||
<RegistrationChart data={registrations} />
|
||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Network review</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Recent VPN activity</h2></div>
|
||||
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
|
||||
</div>
|
||||
<div className="mt-5 divide-y divide-line">
|
||||
{riskyActivity.map((activity) => (
|
||||
<article className="py-4" key={activity.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
{activity.userId ? <Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/users/${activity.userId}`}>{activity.firstName ?? activity.discordUsername ?? "Unknown user"}</Link> : <span className="font-mono text-xs font-bold">Unknown user</span>}
|
||||
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.source}</p>
|
||||
</div>
|
||||
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
|
||||
</div>
|
||||
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
|
||||
</article>
|
||||
))}
|
||||
{!riskyActivity.length && <p className="py-6 text-sm text-muted">No recent VPN, proxy, or Tor observations.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider">Settings saved</p>}
|
||||
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm">Check the highlighted configuration values and try again.</p>}
|
||||
|
||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
|
||||
<textarea
|
||||
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||
defaultValue={message}
|
||||
id="registrationMessage"
|
||||
maxLength={500}
|
||||
minLength={10}
|
||||
name="registrationMessage"
|
||||
required
|
||||
/>
|
||||
|
||||
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail, accent = false }: { label: string; value: number; detail: string; accent?: boolean }) {
|
||||
return (
|
||||
<article className={`border p-5 ${accent ? "border-accent bg-ink text-canvas" : "border-line bg-panel"}`}>
|
||||
<p className={`font-mono text-[9px] font-bold uppercase tracking-widest ${accent ? "text-signal" : "text-muted"}`}>{label}</p>
|
||||
<p className="mt-3 font-display text-5xl font-black">{value}</p>
|
||||
<p className={`mt-2 text-xs ${accent ? "text-canvas" : "text-muted"}`}>{detail}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrationChart({ data }: { data: DailyCount[] }) {
|
||||
const width = 720;
|
||||
const height = 260;
|
||||
const padding = 32;
|
||||
const maximum = Math.max(1, ...data.map((entry) => entry.count));
|
||||
const points = data.map((entry, index) => {
|
||||
const x = padding + index * ((width - padding * 2) / Math.max(1, data.length - 1));
|
||||
const y = height - padding - (entry.count / maximum) * (height - padding * 2);
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
|
||||
return (
|
||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Growth signal</p>
|
||||
<h2 className="mt-2 font-display text-2xl font-black uppercase">New users by day</h2>
|
||||
<svg aria-labelledby="registration-chart-title registration-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
|
||||
<title id="registration-chart-title">New user registrations over the last 14 days</title>
|
||||
<desc id="registration-chart-description">Daily registrations range from zero to {maximum}. A text summary follows the chart.</desc>
|
||||
<line stroke="var(--line)" strokeWidth="1" x1={padding} x2={width - padding} y1={height - padding} y2={height - padding} />
|
||||
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
|
||||
{data.map((entry, index) => {
|
||||
const [x, y] = points.split(" ")[index]!.split(",");
|
||||
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} new users</title></circle>;
|
||||
})}
|
||||
</svg>
|
||||
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center">
|
||||
{data.map((entry) => <div key={entry.day}><dt className="sr-only">{entry.day}</dt><dd className="font-mono text-xs font-bold">{entry.count}</dd></div>)}
|
||||
</dl>
|
||||
<div aria-hidden="true" className="mt-2 flex justify-between font-mono text-[9px] text-muted"><span>{data[0]?.day}</span><span>{data.at(-1)?.day}</span></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user