feat(dashboard): map latest user locations
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 10m40s

This commit is contained in:
dmg
2026-08-01 19:41:03 -04:00
parent b7c0083647
commit 9116107917
14 changed files with 363 additions and 8 deletions
+41 -3
View File
@@ -1,8 +1,10 @@
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
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 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 { parseUserLocation } from "@/lib/user-location-map";
export const dynamic = "force-dynamic";
@@ -13,7 +15,7 @@ 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], riskyActivity, [recentDenials]] = await Promise.all([
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
db
.select({
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
@@ -31,6 +33,25 @@ export default async function AdminDashboardPage() {
gte(ipObservations.observedAt, thirtyDaysAgo),
isNotNull(ipObservations.userId),
)),
db
.selectDistinctOn([ipObservations.userId], {
userId: ipObservations.userId,
name: users.firstName,
discordUsername: users.discordUsername,
classification: ipObservations.classification,
source: ipObservations.source,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations)
.innerJoin(users, eq(users.id, ipObservations.userId))
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.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`,
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
))
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
db
.select({
id: ipObservations.id,
@@ -54,6 +75,21 @@ export default async function AdminDashboardPage() {
)),
]);
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
const locations = locationRows.flatMap((row): UserMapLocation[] => {
const parsed = parseUserLocation(row.intelligence);
if (!parsed || !row.userId) return [];
return [{
userId: row.userId,
name: row.name ?? row.discordUsername,
discordUsername: row.discordUsername,
latitude: parsed.latitude,
longitude: parsed.longitude,
location: parsed.label,
classification: row.classification,
source: row.source,
observedAt: row.observedAt,
}];
});
return (
<main className="mx-auto max-w-6xl px-6 py-14">
@@ -63,7 +99,9 @@ export default async function AdminDashboardPage() {
<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">
<UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
<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" />
+6
View File
@@ -58,6 +58,12 @@ body {
transform: translateY(0);
}
svg a:hover .map-marker,
svg a:focus .map-marker {
stroke: var(--ink);
stroke-width: 6px;
}
::selection {
background: var(--accent);
color: var(--panel);