feat(admin): dashboard home, telemetry section, enrollment ops screen
- mission-control dashboard: now strip, tournament quick actions, usage sparklines, error feed, live activity, tools list - /admin/telemetry with functions/events/errors/players/usage child routes, hand-rolled svg chart primitives, decomposed activities table - error browser with grouping and mark-resolved - consolidated per-tournament enrollment screen; hub simplified with run tournament promoted - /admin/activities redirects to /admin/telemetry/functions
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { searchActivities } from "@/features/activities/server";
|
||||
import { activityKeys } from "@/features/activities/queries";
|
||||
import { RelativeTime } from "@/features/telemetry/components/shared";
|
||||
import type { PlayerInfo } from "@/features/players/types";
|
||||
|
||||
const FEED_PARAMS = { perPage: 10 } as const;
|
||||
|
||||
const playerName = (player?: string | PlayerInfo): string | null => {
|
||||
if (!player || typeof player === "string") return null;
|
||||
return [player.first_name, player.last_name].filter(Boolean).join(" ") || null;
|
||||
};
|
||||
|
||||
export const ActivityFeed = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useServerQuery({
|
||||
queryKey: activityKeys.search(FEED_PARAMS),
|
||||
queryFn: () => searchActivities({ data: FEED_PARAMS }),
|
||||
options: {
|
||||
refetchInterval: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Recent activity</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{data?.items.map((activity) => {
|
||||
const name = playerName(activity.player);
|
||||
return (
|
||||
<Group
|
||||
key={activity.id}
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
onClick={() => navigate({ to: "/admin/telemetry/functions" })}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{activity.success ? (
|
||||
<CheckCircleIcon
|
||||
size={14}
|
||||
color="var(--mantine-color-green-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<XCircleIcon
|
||||
size={14}
|
||||
color="var(--mantine-color-red-6)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" truncate style={{ flex: 1, minWidth: 0 }}>
|
||||
{activity.name}
|
||||
{name && (
|
||||
<Text component="span" size="xs" c="dimmed">
|
||||
{" "}
|
||||
· {name}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
<RelativeTime date={activity.created} />
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{data && data.items.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>No activity yet</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityFeed;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Badge, Card, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { useClientErrorGroups } from "@/features/telemetry/queries";
|
||||
import { RelativeTime } from "@/features/telemetry/components/shared";
|
||||
import { lastNDays } from "@/features/telemetry/range";
|
||||
|
||||
export const ErrorsCard = () => {
|
||||
const navigate = useNavigate();
|
||||
const range = lastNDays(30);
|
||||
|
||||
const { data: groups } = useClientErrorGroups({ from: range.from, to: range.to });
|
||||
|
||||
return (
|
||||
<UnstyledButton w="100%" onClick={() => navigate({ to: "/admin/telemetry/errors" })}>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Client errors</Trans>
|
||||
</Text>
|
||||
{groups && groups.length > 0 ? (
|
||||
<Badge color="red" variant="light">
|
||||
{groups.length}
|
||||
</Badge>
|
||||
) : (
|
||||
<CheckCircleIcon size={18} color="var(--mantine-color-green-6)" weight="fill" />
|
||||
)}
|
||||
</Group>
|
||||
{groups && groups.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
{groups.slice(0, 3).map((group) => (
|
||||
<Group key={group.group_hash} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<WarningCircleIcon
|
||||
size={14}
|
||||
color="var(--mantine-color-red-6)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" lineClamp={1}>
|
||||
{group.sample.message}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<RelativeTime date={group.lastSeen} />
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>No unresolved errors</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorsCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Suspense } from "react";
|
||||
import { Box, SimpleGrid, Stack } from "@mantine/core";
|
||||
import { CardSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||
import QuietBoundary from "./quiet-boundary";
|
||||
import NowStrip from "./now-strip";
|
||||
import TournamentCard from "./tournament-card";
|
||||
import UsageCard from "./usage-card";
|
||||
import ErrorsCard from "./errors-card";
|
||||
import ActivityFeed from "./activity-feed";
|
||||
import ToolsList from "./tools-list";
|
||||
|
||||
const NowStripSkeleton = () => (
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<StatTileSkeleton />
|
||||
<StatTileSkeleton />
|
||||
<StatTileSkeleton />
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
export const AdminDashboard = () => (
|
||||
<Stack gap="md" pb="md">
|
||||
<Stack gap="md" px="md" pt="md">
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<NowStripSkeleton />}>
|
||||
<NowStrip />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<TournamentCard />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ErrorsCard />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<QuietBoundary>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageCard />
|
||||
</Suspense>
|
||||
</QuietBoundary>
|
||||
<QuietBoundary>
|
||||
<ActivityFeed />
|
||||
</QuietBoundary>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<Box>
|
||||
<ToolsList />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from "react";
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
useClientErrorGroups,
|
||||
useRollupRange,
|
||||
useTelemetryRuntimeStatus,
|
||||
} from "@/features/telemetry/queries";
|
||||
import { StatTile } from "@/features/telemetry/components/charts";
|
||||
import { isoDay, lastNDays, listDays } from "@/features/telemetry/range";
|
||||
|
||||
export const NowStrip = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const range = useMemo(() => lastNDays(7), []);
|
||||
|
||||
const { data: status } = useTelemetryRuntimeStatus();
|
||||
const { data: errorGroups } = useClientErrorGroups({
|
||||
from: lastNDays(30).from,
|
||||
to: range.to,
|
||||
});
|
||||
const { data: rollups } = useRollupRange({
|
||||
metrics: ["server_fn.count"],
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
const { callsToday, callsSeries } = useMemo(() => {
|
||||
const byDate = new Map<string, number>();
|
||||
for (const row of rollups ?? []) {
|
||||
byDate.set(row.date, (byDate.get(row.date) ?? 0) + row.value);
|
||||
}
|
||||
return {
|
||||
callsToday: byDate.get(isoDay(new Date())) ?? 0,
|
||||
callsSeries: listDays(range).map((day) => byDate.get(day) ?? 0),
|
||||
};
|
||||
}, [rollups, range]);
|
||||
|
||||
const unresolved = errorGroups?.length ?? 0;
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<StatTile label={t`Online now`} value={status?.activeSseConnections ?? 0} />
|
||||
<StatTile
|
||||
label={t`Open errors`}
|
||||
value={unresolved}
|
||||
caption={undefined}
|
||||
onClick={() => navigate({ to: "/admin/telemetry/errors" })}
|
||||
/>
|
||||
<StatTile
|
||||
label={t`Calls today`}
|
||||
value={callsToday.toLocaleString()}
|
||||
spark={callsSeries}
|
||||
onClick={() => navigate({ to: "/admin/telemetry/usage" })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default NowStrip;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, ReactNode } from "react";
|
||||
import { Card, Text } from "@mantine/core";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface QuietBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface QuietBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
// One failing telemetry card must not blank the whole dashboard.
|
||||
export class QuietBoundary extends Component<QuietBoundaryProps, QuietBoundaryState> {
|
||||
state: QuietBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): QuietBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>Unavailable right now</Trans>
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default QuietBoundary;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { List, Text } from "@mantine/core";
|
||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
DatabaseIcon,
|
||||
TreeStructureIcon,
|
||||
TrophyIcon,
|
||||
MedalIcon,
|
||||
CrownIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import ListLink from "@/components/list-link";
|
||||
import ListButton from "@/components/list-button";
|
||||
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import toast from "@/lib/sonner";
|
||||
|
||||
export const ToolsList = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
|
||||
queryKey: ["admin", "badge-migration-status"],
|
||||
queryFn: () => getBadgeMigrationStatus(),
|
||||
options: {
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.state === "running" ? 2000 : false,
|
||||
},
|
||||
});
|
||||
|
||||
const isMigrating = migrationStatus?.state === "running";
|
||||
|
||||
const handleMigrateBadges = async () => {
|
||||
if (isMigrating) return;
|
||||
|
||||
const result = await migrateBadgeProgress();
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
result.data.started
|
||||
? t`Badge recalculation started in the background`
|
||||
: t`Badge recalculation is already running`
|
||||
);
|
||||
} else {
|
||||
toast.error(t`Failed to start badge recalculation`);
|
||||
}
|
||||
await refetchMigrationStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<List p="0">
|
||||
<ListLink
|
||||
label={t`Manage Tournaments`}
|
||||
Icon={TrophyIcon}
|
||||
to="/admin/tournaments"
|
||||
/>
|
||||
<ListLink
|
||||
label={t`Award Badges`}
|
||||
Icon={CrownIcon}
|
||||
to="/admin/badges"
|
||||
/>
|
||||
<ListButton
|
||||
label={t`Migrate Badge Progress`}
|
||||
Icon={MedalIcon}
|
||||
onClick={handleMigrateBadges}
|
||||
loading={isMigrating}
|
||||
/>
|
||||
{migrationStatus?.state === "running" && (
|
||||
<Text size="sm" c="dimmed" px="md" py="xs">
|
||||
<Trans>
|
||||
Recalculating badges in the background ({migrationStatus.playersProcessed}/{migrationStatus.totalPlayers})
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{migrationStatus?.state === "completed" && (
|
||||
<Text size="sm" c="dimmed" px="md" py="xs">
|
||||
<Trans>
|
||||
Last recalculation:{" "}
|
||||
<Plural
|
||||
value={migrationStatus.result.totalBadgesEarned}
|
||||
one="# badge earned"
|
||||
other="# badges earned"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{migrationStatus?.state === "failed" && (
|
||||
<Text size="sm" c="red" px="md" py="xs">
|
||||
<Trans>Badge recalculation failed. Check the server logs.</Trans>
|
||||
</Text>
|
||||
)}
|
||||
<ListButton
|
||||
label={t`Open Pocketbase`}
|
||||
Icon={DatabaseIcon}
|
||||
onClick={() =>
|
||||
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
|
||||
}
|
||||
/>
|
||||
<ListLink
|
||||
label={t`Bracket Preview`}
|
||||
Icon={TreeStructureIcon}
|
||||
to="/admin/preview"
|
||||
/>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolsList;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { PlayIcon, TrophyIcon, UsersThreeIcon, GearIcon } from "@phosphor-icons/react";
|
||||
import { useCurrentTournament } from "@/features/tournaments/queries";
|
||||
import { Countdown } from "@/components/countdown";
|
||||
|
||||
export const TournamentCard = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const { data: tournament } = useCurrentTournament();
|
||||
|
||||
if (!tournament) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans>No tournaments yet</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<TrophyIcon size={16} />}
|
||||
onClick={() => navigate({ to: "/admin/tournaments" })}
|
||||
>
|
||||
<Trans>Manage Tournaments</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const upcoming = !!tournament.start_time && Date.parse(tournament.start_time) > Date.now();
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} truncate>
|
||||
{tournament.name}
|
||||
</Text>
|
||||
{upcoming && (
|
||||
<Countdown date={new Date(tournament.start_time)} label={t`Starts in`} />
|
||||
)}
|
||||
</Stack>
|
||||
<TrophyIcon size={20} />
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<PlayIcon size={16} weight="fill" />}
|
||||
fullWidth
|
||||
onClick={() => navigate({ to: `/admin/tournaments/run/${tournament.id}` })}
|
||||
>
|
||||
<Trans>Run Tournament</Trans>
|
||||
</Button>
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<UsersThreeIcon size={16} />}
|
||||
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}/enrollment` })}
|
||||
>
|
||||
<Trans>Enrollment</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<GearIcon size={16} />}
|
||||
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}` })}
|
||||
>
|
||||
<Trans>Manage</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TournamentCard;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useRollupRange } from "@/features/telemetry/queries";
|
||||
import { Sparkline } from "@/features/telemetry/components/charts";
|
||||
import { fillDailySeries, lastNDays, listDays } from "@/features/telemetry/range";
|
||||
|
||||
export const UsageCard = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const range = useMemo(() => lastNDays(7), []);
|
||||
|
||||
const { data: rollups } = useRollupRange({
|
||||
metrics: ["dau", "page_view.count"],
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
const { dauSeries, pageViewSeries } = useMemo(() => {
|
||||
const days = listDays(range);
|
||||
return {
|
||||
dauSeries: fillDailySeries(rollups ?? [], "dau", "", days),
|
||||
pageViewSeries: fillDailySeries(rollups ?? [], "page_view.count", "", days),
|
||||
};
|
||||
}, [rollups, range]);
|
||||
|
||||
return (
|
||||
<UnstyledButton w="100%" onClick={() => navigate({ to: "/admin/telemetry/usage" })}>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
<Trans>Usage, 7 days</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Active players</Trans>
|
||||
</Text>
|
||||
<Sparkline data={dauSeries} height={36} showArea label={t`Daily active players`} />
|
||||
</Stack>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Page views</Trans>
|
||||
</Text>
|
||||
<Sparkline data={pageViewSeries} height={36} showArea label={t`Daily page views`} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageCard;
|
||||
Reference in New Issue
Block a user