feat(dashboard): map latest user locations
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UserWorldMap } from "./user-world-map";
|
||||
|
||||
describe("UserWorldMap", () => {
|
||||
it("renders an accessible linked marker, text fallback, and open-data attribution", () => {
|
||||
const markup = renderToStaticMarkup(<UserWorldMap locations={[{
|
||||
userId: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Dani",
|
||||
discordUsername: "dani",
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
location: "Mountain View, California, US",
|
||||
classification: "clear",
|
||||
source: "game",
|
||||
observedAt: new Date("2026-08-01T12:00:00Z"),
|
||||
}]} unavailableCount={2} />);
|
||||
|
||||
expect(markup).toContain('role="group"');
|
||||
expect(markup).toContain('class="map-marker-target"');
|
||||
expect(markup).toContain("Latest approximate location for registered users");
|
||||
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
|
||||
expect(markup).toContain("Mountain View, California, US");
|
||||
expect(markup).toContain("Natural Earth, public domain");
|
||||
expect(markup).toContain("2 without coordinates");
|
||||
|
||||
const countryPaths = [...markup.matchAll(/<path d="([^"]+)"/g)].map((match) => match[1] ?? "");
|
||||
expect(countryPaths.length).toBeGreaterThan(100);
|
||||
for (const path of countryPaths) {
|
||||
const subpaths = path.split("M").slice(1);
|
||||
for (const subpath of subpaths) {
|
||||
const xCoordinates = [...subpath.matchAll(/(?:^|L)(-?\d+(?:\.\d+)?),/g)].map((match) => Number(match[1]));
|
||||
for (let index = 1; index < xCoordinates.length; index += 1) {
|
||||
expect(Math.abs(xCoordinates[index]! - xCoordinates[index - 1]!)).toBeLessThan(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { GeometryCollection, Topology } from "topojson-specification";
|
||||
import { geoEquirectangular, geoPath } from "d3-geo";
|
||||
import { feature } from "topojson-client";
|
||||
import countriesTopologyJson from "world-atlas/countries-110m.json";
|
||||
import Link from "next/link";
|
||||
|
||||
const WIDTH = 1_000;
|
||||
const HEIGHT = 500;
|
||||
const topology = countriesTopologyJson as unknown as Topology<{ countries: GeometryCollection }>;
|
||||
const countries = feature(topology, topology.objects.countries) as FeatureCollection;
|
||||
const projection = geoEquirectangular().fitExtent([[1, 1], [WIDTH - 1, HEIGHT - 1]], { type: "Sphere" });
|
||||
const countryPath = geoPath(projection);
|
||||
|
||||
export interface UserMapLocation {
|
||||
userId: string;
|
||||
name: string;
|
||||
discordUsername: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
location: string;
|
||||
classification: string;
|
||||
source: string;
|
||||
observedAt: Date;
|
||||
}
|
||||
|
||||
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
||||
return (
|
||||
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Latest known location</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Community world</h2>
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-5 text-muted">{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-hidden border border-line bg-[#b9d4d1]">
|
||||
<svg aria-labelledby="user-world-map-title user-world-map-description" className="h-auto w-full" role="group" viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
|
||||
<title id="user-world-map-title">Latest approximate location for registered users</title>
|
||||
<desc id="user-world-map-description">An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows.</desc>
|
||||
<rect fill="#b9d4d1" height={HEIGHT} width={WIDTH} />
|
||||
<g aria-hidden="true" fill="var(--canvas)" stroke="var(--line)" strokeWidth="0.7">
|
||||
{countries.features.map((country, index) => {
|
||||
const path = countryPath(country);
|
||||
return path ? <path d={path} key={country.id ?? index} /> : null;
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{locations.map((user) => {
|
||||
const projected = projection([user.longitude, user.latitude]);
|
||||
if (!projected) return null;
|
||||
const x = Math.min(WIDTH - 14, Math.max(14, projected[0]));
|
||||
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1]));
|
||||
return (
|
||||
<a aria-label={`${user.name}, ${user.location}, last seen ${user.observedAt.toISOString()}`} href={`/admin/users/${user.userId}`} key={user.userId}>
|
||||
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r="7" stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke" />
|
||||
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3">
|
||||
<title>{user.name} · {user.location} · {user.classification}</title>
|
||||
</circle>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
|
||||
|
||||
<details className="mt-5 border-t border-line pt-4">
|
||||
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] border-collapse text-left text-xs">
|
||||
<caption className="sr-only">Latest approximate registered-user locations</caption>
|
||||
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.name}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
|
||||
{!locations.length && <tr><td className="py-6 text-muted" colSpan={5}>No user observations currently include valid coordinates.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseUserLocation, projectWorldPoint } from "./user-location-map";
|
||||
|
||||
describe("user location map", () => {
|
||||
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||
expect(parseUserLocation({
|
||||
classification: "clear",
|
||||
location: {
|
||||
city: "Mountain View",
|
||||
region: "California",
|
||||
countryCode: "US",
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
},
|
||||
})).toEqual({
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
label: "Mountain View, California, US",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing and out-of-range coordinates", () => {
|
||||
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("projects longitude and latitude into an equirectangular SVG", () => {
|
||||
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
|
||||
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
type UnknownMap = Record<string, unknown>;
|
||||
|
||||
function objectValue(value: unknown): UnknownMap | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as UnknownMap
|
||||
: null;
|
||||
}
|
||||
|
||||
function coordinate(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ParsedUserLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||
const intelligence = objectValue(value);
|
||||
const location = objectValue(intelligence?.location);
|
||||
if (!location) return null;
|
||||
const latitude = coordinate(location.latitude);
|
||||
const longitude = coordinate(location.longitude);
|
||||
if (latitude === null || longitude === null || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
return null;
|
||||
}
|
||||
const label = [location.city, location.region, location.countryCode ?? location.country]
|
||||
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
|
||||
.join(", ");
|
||||
return { latitude, longitude, label: label || "Approximate location unavailable" };
|
||||
}
|
||||
|
||||
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
|
||||
return {
|
||||
x: ((longitude + 180) / 360) * width,
|
||||
y: ((90 - latitude) / 180) * height,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user