diff --git a/apps/frontend/app/api/server/[ip]/[port]/snapshot/[snapshotId]/route.ts b/apps/frontend/app/api/server/[ip]/[port]/snapshot/[snapshotId]/route.ts new file mode 100644 index 0000000..9b83cb4 --- /dev/null +++ b/apps/frontend/app/api/server/[ip]/[port]/snapshot/[snapshotId]/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { decodeIp } from '../../../../../../../utils/encoding'; +import prisma from '../../../../../../../utils/prisma'; + +const paramsSchema = z.object({ + ip: z.string().transform(decodeIp), + port: z.coerce.number().int().positive().max(65535), + snapshotId: z.coerce.number().int().positive(), +}); + +export async function GET( + _request: Request, + { params }: { params: { ip: string; port: string; snapshotId: string } } +) { + const parsedParams = paramsSchema.safeParse(params); + + if (!parsedParams.success) { + return NextResponse.json({ error: 'Bad request' }, { status: 400 }); + } + + const { ip, port, snapshotId } = parsedParams.data; + + const snapshot = await prisma.gameServerSnapshot.findFirst({ + where: { + id: snapshotId, + gameServer: { + ip, + port, + }, + }, + select: { + id: true, + createdAt: true, + name: true, + numClients: true, + maxClients: true, + map: { + select: { + name: true, + gameTypeName: true, + }, + }, + clients: { + orderBy: { + score: 'desc', + }, + select: { + playerName: true, + clanName: true, + score: true, + }, + }, + }, + }); + + if (snapshot === null) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(snapshot, { + headers: { + 'Cache-Control': 'public, max-age=86400, immutable', + }, + }); +} diff --git a/apps/frontend/app/server/[ip]/[port]/page.tsx b/apps/frontend/app/server/[ip]/[port]/page.tsx index eae057d..b4da274 100644 --- a/apps/frontend/app/server/[ip]/[port]/page.tsx +++ b/apps/frontend/app/server/[ip]/[port]/page.tsx @@ -6,7 +6,8 @@ import Link from 'next/link'; import { List, ListCell } from '../../../../components/List'; import { searchParamPageSchema } from '../../../../utils/page'; import prisma from '../../../../utils/prisma'; -import { encodeString } from '../../../../utils/encoding'; +import { encodeIp, encodeString } from '../../../../utils/encoding'; +import { SnapshotTimeline } from '../../../../components/SnapshotTimeline'; import { formatPlayTime } from '../../../../utils/format'; import { GameServer } from '@prisma/client'; import { formatDuration, intervalToDuration } from 'date-fns'; @@ -78,33 +79,51 @@ export default async function Index({ const { ip, port } = parsedParams.data; - const gameServer = await prisma.gameServer.findUnique({ - where: { - ip_port: { - ip, - port, + const [gameServer, snapshots] = await Promise.all([ + prisma.gameServer.findUnique({ + where: { + ip_port: { + ip, + port, + }, }, - }, - include: { - gameServerState: { - include: { - clients: { - orderBy: { - score: 'desc', + include: { + gameServerState: { + include: { + clients: { + orderBy: { + score: 'desc', + }, + skip: (page - 1) * 100, + take: 100, }, - skip: (page - 1) * 100, - take: 100, - }, - _count: { - select: { - clients: true, + _count: { + select: { + clients: true, + }, }, + map: true, }, - map: true, }, }, - }, - }); + }), + prisma.gameServerSnapshot.findMany({ + where: { + gameServer: { + ip, + port, + }, + }, + orderBy: { + createdAt: 'asc', + }, + select: { + id: true, + createdAt: true, + numClients: true, + }, + }), + ]); if (gameServer === null) { return notFound(); @@ -162,50 +181,61 @@ export default async function Index({ - ({ + id: snapshot.id, + createdAt: snapshot.createdAt.toISOString(), + numClients: snapshot.numClients, + }))} + apiPath={`/api/server/${encodeIp(gameServer.ip)}/${ + gameServer.port + }/snapshot`} > - {gameServer.gameServerState.clients.map((client, index) => ( - <> - - - - - - ))} - + + {gameServer.gameServerState.clients.map((client, index) => ( + <> + + + + + + ))} + + ); } diff --git a/apps/frontend/components/SnapshotTimeline.tsx b/apps/frontend/components/SnapshotTimeline.tsx new file mode 100644 index 0000000..ca5c274 --- /dev/null +++ b/apps/frontend/components/SnapshotTimeline.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { Fragment, useEffect, useRef, useState } from 'react'; +import { format } from 'date-fns'; +import { List, ListCell } from './List'; +import { encodeString } from '../utils/encoding'; +import { useDebounce } from '../utils/hooks'; + +export type TimelinePoint = { + id: number; + createdAt: string; + numClients: number; +}; + +type Snapshot = { + id: number; + createdAt: string; + name: string; + numClients: number; + maxClients: number; + map: { + name: string; + gameTypeName: string; + }; + clients: { + playerName: string; + clanName: string | null; + score: number; + }[]; +}; + +function sparklinePath(snapshots: TimelinePoint[]) { + const maxClients = Math.max(1, ...snapshots.map(({ numClients }) => numClients)); + + const points = snapshots.map(({ numClients }, index) => { + const x = + snapshots.length === 1 + ? 1000 + : (index / (snapshots.length - 1)) * 1000; + const y = 40 - (numClients / maxClients) * 36; + return `L${x},${y}`; + }); + + return `M0,40 ${points.join(' ')} L1000,40 Z`; +} + +function SnapshotList({ snapshot }: { snapshot: Snapshot }) { + return ( + + {snapshot.clients.map((client, index) => ( + + + + + + + ))} + + ); +} + +export function SnapshotTimeline({ + snapshots, + apiPath, + children, +}: { + snapshots: TimelinePoint[]; + apiPath: string; + children: React.ReactNode; +}) { + const [selectedIndex, setSelectedIndex] = useState(null); + const [lastLoaded, setLastLoaded] = useState(null); + const cacheRef = useRef>(); + + if (cacheRef.current === undefined) { + cacheRef.current = new Map(); + } + + const cache = cacheRef.current; + const selected = selectedIndex === null ? null : snapshots[selectedIndex]; + const debouncedSelected = useDebounce(selected, 150); + const snapshot = + selected === null ? null : cache.get(selected.id) ?? lastLoaded; + + useEffect(() => { + if (debouncedSelected === null || cache.has(debouncedSelected.id)) { + return; + } + + const controller = new AbortController(); + + (async () => { + try { + const response = await fetch(`${apiPath}/${debouncedSelected.id}`, { + signal: controller.signal, + }); + + if (!response.ok) { + return; + } + + const data: Snapshot = await response.json(); + cache.set(data.id, data); + setLastLoaded(data); + } catch { + return; + } + })(); + + return () => { + controller.abort(); + }; + }, [debouncedSelected, apiPath, cache]); + + if (snapshots.length === 0) { + return <>{children}; + } + + const lastIndex = snapshots.length - 1; + const cursorFraction = + selectedIndex === null || lastIndex === 0 ? 1 : selectedIndex / lastIndex; + const stale = selected !== null && snapshot?.id !== selected.id; + + return ( + <> +
+
+ {selected === null ? ( + + Live + — drag the bar to rewind + + ) : ( + + + {format(new Date(selected.createdAt), 'MMM d, HH:mm')} + + + {' '} + — {selected.numClients} clients + {!stale && snapshot !== null && ` on ${snapshot.map.name}`} + + + )} + + {selected !== null && ( + + )} +
+ +
+ + + + +
+ + setSelectedIndex(Number(event.target.value))} + aria-label="Rewind to a past snapshot" + className="absolute inset-0 w-full h-full opacity-0 cursor-ew-resize" + /> +
+ +
+ + {format(new Date(snapshots[0].createdAt), 'MMM d, HH:mm')} + + now +
+
+ + {selected === null ? ( + children + ) : snapshot === null ? ( +

Loading snapshot…

+ ) : ( +
+ +
+ )} + + ); +} diff --git a/apps/frontend/utils/hooks.ts b/apps/frontend/utils/hooks.ts index 1a94897..f2d0567 100644 --- a/apps/frontend/utils/hooks.ts +++ b/apps/frontend/utils/hooks.ts @@ -1,6 +1,21 @@ import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { debounce } from "lodash"; export function useSearchParamsObject() { const searchParams = useSearchParams(); return Object.fromEntries(searchParams.entries()); } + +export function useDebounce(value: T, wait: number): T { + const [debounced, setDebounced] = useState(value); + const setDebouncedSlowly = useMemo(() => debounce(setDebounced, wait), [wait]); + + useEffect(() => { + setDebouncedSlowly(value); + }, [value, setDebouncedSlowly]); + + useEffect(() => () => setDebouncedSlowly.cancel(), [setDebouncedSlowly]); + + return debounced; +}