216 lines
12 KiB
TypeScript
216 lines
12 KiB
TypeScript
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
|
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, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
|
import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
|
|
|
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 [dailyActiveRows, [totals], [monthlyActive], [monthlyAccounts], locationRows, riskyLatestRows, riskySummaryRows, [recentDenials]] = await Promise.all([
|
|
db
|
|
.select({
|
|
day: sql<string>`to_char(date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
|
count: countDistinct(ipObservations.userId),
|
|
})
|
|
.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),
|
|
}).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: ipIntelligence.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))
|
|
.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`,
|
|
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
|
|
.selectDistinctOn([ipObservations.userId], {
|
|
id: ipObservations.id,
|
|
classification: ipIntelligence.classification,
|
|
observedAt: ipObservations.observedAt,
|
|
source: ipObservations.source,
|
|
userId: users.id,
|
|
firstName: users.firstName,
|
|
discordUsername: users.discordUsername,
|
|
accountUsername: minecraftAccounts.username,
|
|
})
|
|
.from(ipObservations)
|
|
.innerJoin(users, eq(users.id, ipObservations.userId))
|
|
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
|
.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 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 [];
|
|
const network = parseUserNetwork(row.intelligence);
|
|
return [{
|
|
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,
|
|
classification: row.classification,
|
|
networkProvider: network.provider,
|
|
networkAsn: network.asn,
|
|
connectionType: network.connectionType,
|
|
proxy: network.proxy,
|
|
source: row.source,
|
|
observedAt: row.observedAt,
|
|
}];
|
|
});
|
|
|
|
return (
|
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
|
<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>
|
|
|
|
<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={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]">
|
|
<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 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">
|
|
{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.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.classifications.join(" + ")}</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>
|
|
</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 DailyActiveChart({ 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">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} active users</title></circle>;
|
|
})}
|
|
</svg>
|
|
<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>
|
|
</section>
|
|
);
|
|
}
|