feat(dashboard): refine activity telemetry and maps
CI / validate (push) Successful in 5m24s
Release / release (push) Successful in 11m6s

This commit is contained in:
dmg
2026-08-01 20:28:32 -04:00
parent 9116107917
commit ebc7c7df17
32 changed files with 695 additions and 72 deletions
+62 -32
View File
@@ -1,9 +1,10 @@
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, isNull, sql } from "drizzle-orm";
import Link from "next/link";
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
import { db } from "@/lib/database";
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
import { parseUserLocation } from "@/lib/user-location-map";
export const dynamic = "force-dynamic";
@@ -15,29 +16,33 @@ export default async function AdminDashboardPage() {
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
const [dailyActiveRows, [totals], [monthlyActive], [monthlyAccounts], locationRows, riskyLatestRows, riskySummaryRows, [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(),
day: sql<string>`to_char(date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
count: countDistinct(ipObservations.userId),
})
.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')`),
.from(ipObservations)
.where(and(gte(ipObservations.observedAt, fourteenDaysAgo), isNotNull(ipObservations.userId)))
.groupBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`)
.orderBy(sql`date_trunc('day', ${ipObservations.observedAt} 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({ accounts: countDistinct(events.subject) }).from(events).where(and(
eq(events.type, "games.minecraft.account-manager.game.player.connected"),
gte(events.time, thirtyDaysAgo),
)),
db
.selectDistinctOn([ipObservations.userId], {
userId: ipObservations.userId,
name: users.firstName,
discordUsername: users.discordUsername,
primaryUsername: minecraftAccounts.username,
classification: ipObservations.classification,
source: ipObservations.source,
observedAt: ipObservations.observedAt,
@@ -46,6 +51,11 @@ export default async function AdminDashboardPage() {
.from(ipObservations)
.innerJoin(users, eq(users.id, ipObservations.userId))
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.leftJoin(minecraftAccounts, and(
eq(minecraftAccounts.userId, users.id),
eq(minecraftAccounts.isPrimary, true),
isNull(minecraftAccounts.deletedAt),
))
.where(and(
isNotNull(ipObservations.userId),
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
@@ -53,9 +63,9 @@ export default async function AdminDashboardPage() {
))
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
db
.select({
.selectDistinctOn([ipObservations.userId], {
id: ipObservations.id,
classification: ipObservations.classification,
classification: ipIntelligence.classification,
observedAt: ipObservations.observedAt,
source: ipObservations.source,
userId: users.id,
@@ -64,17 +74,37 @@ export default async function AdminDashboardPage() {
accountUsername: minecraftAccounts.username,
})
.from(ipObservations)
.leftJoin(users, eq(users.id, ipObservations.userId))
.innerJoin(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),
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(and(
isNotNull(ipObservations.userId),
gte(ipObservations.observedAt, thirtyDaysAgo),
inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]),
))
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
db
.select({
userId: ipObservations.userId,
count: count(),
classifications: sql<string[]>`array_agg(distinct ${ipIntelligence.classification}::text order by ${ipIntelligence.classification}::text)`,
sources: sql<string[]>`array_agg(distinct ${ipObservations.source}::text order by ${ipObservations.source}::text)`,
})
.from(ipObservations)
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(and(
isNotNull(ipObservations.userId),
gte(ipObservations.observedAt, thirtyDaysAgo),
inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]),
))
.groupBy(ipObservations.userId),
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);
const dailyActive = fillDailySeries(dailyActiveRows as DailyCount[], now, 14);
const riskyActivity = mergeRiskActivity(riskyLatestRows, riskySummaryRows).slice(0, 10);
const locations = locationRows.flatMap((row): UserMapLocation[] => {
const parsed = parseUserLocation(row.intelligence);
if (!parsed || !row.userId) return [];
@@ -82,6 +112,7 @@ export default async function AdminDashboardPage() {
userId: row.userId,
name: row.name ?? row.discordUsername,
discordUsername: row.discordUsername,
nickname: formatManagedDiscordNickname(row.name ?? row.discordUsername, row.primaryUsername ?? null),
latitude: parsed.latitude,
longitude: parsed.longitude,
location: parsed.label,
@@ -104,15 +135,15 @@ export default async function AdminDashboardPage() {
<section aria-label="Key metrics" className="mt-10 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="Active Minecraft accounts" value={monthlyAccounts?.accounts ?? 0} detail="Confirmed connections · 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} />
<DailyActiveChart data={dailyActive} />
<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>
<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 risky network activity</h2><p className="mt-2 text-xs text-muted">Collapsed per user across VPN, proxy, and Tor observations from the past 30 days.</p></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">
@@ -121,9 +152,9 @@ export default async function AdminDashboardPage() {
<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>
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.sources.join(" + ")} · {activity.count} {activity.count === 1 ? "observation" : "observations"}</p>
</div>
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classifications.join(" + ")}</span>
</div>
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
</article>
@@ -146,7 +177,7 @@ function Metric({ label, value, detail, accent = false }: { label: string; value
);
}
function RegistrationChart({ data }: { data: DailyCount[] }) {
function DailyActiveChart({ data }: { data: DailyCount[] }) {
const width = 720;
const height = 260;
const padding = 32;
@@ -159,22 +190,21 @@ function RegistrationChart({ data }: { data: DailyCount[] }) {
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>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Activity signal</p>
<h2 className="mt-2 font-display text-2xl font-black uppercase">Daily active users</h2>
<svg aria-labelledby="daily-active-chart-title daily-active-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
<title id="daily-active-chart-title">Daily active users over the last 14 days</title>
<desc id="daily-active-chart-description">Distinct daily users range from zero to {maximum}. Date-labelled values follow 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>;
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} active 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 className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center sm:grid-cols-[repeat(14,minmax(0,1fr))]">
{data.map((entry) => <div key={entry.day}><dt className="font-mono text-[8px] text-muted"><time dateTime={entry.day}>{entry.day.slice(5)}</time></dt><dd className="mt-1 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>
);
}