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,281 @@
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CardSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
||||
import { useRollupRange } from "../queries";
|
||||
import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range";
|
||||
import { BarList, Sparkline, StatTile } from "./charts";
|
||||
import { DetailSheet } from "./shared";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
|
||||
export const USAGE_METRICS = [
|
||||
"dau",
|
||||
"wau",
|
||||
"mau",
|
||||
"page_view.count",
|
||||
"page_view.sessions",
|
||||
"server_fn.count",
|
||||
"client_error.count",
|
||||
"vital.p75",
|
||||
];
|
||||
|
||||
const TOP_LIST_SIZE = 8;
|
||||
|
||||
type VitalName = "LCP" | "INP" | "CLS";
|
||||
|
||||
const VITAL_THRESHOLDS: Record<VitalName, { good: number; poor: number }> = {
|
||||
LCP: { good: 2500, poor: 4000 },
|
||||
INP: { good: 200, poor: 500 },
|
||||
CLS: { good: 0.1, poor: 0.25 },
|
||||
};
|
||||
|
||||
const vitalRating = (name: VitalName, value: number): "good" | "ok" | "poor" => {
|
||||
const { good, poor } = VITAL_THRESHOLDS[name];
|
||||
if (value <= good) return "good";
|
||||
if (value > poor) return "poor";
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const VitalBadge = ({ name, value }: { name: VitalName; value: number }) => {
|
||||
const rating = vitalRating(name, value);
|
||||
const color = rating === "good" ? "green" : rating === "ok" ? "yellow" : "red";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm">
|
||||
{rating === "good" ? (
|
||||
<Trans>Good</Trans>
|
||||
) : rating === "ok" ? (
|
||||
<Trans>Needs improvement</Trans>
|
||||
) : (
|
||||
<Trans>Poor</Trans>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatVital = (name: VitalName, value: number) =>
|
||||
name === "CLS" ? value.toFixed(2) : `${Math.round(value)}ms`;
|
||||
|
||||
const topItems = (totals: Map<string, number>, onClick?: (dim: string) => void) =>
|
||||
[...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_LIST_SIZE)
|
||||
.map(([dim, value]) => ({
|
||||
label: dim,
|
||||
value,
|
||||
onClick: onClick ? () => onClick(dim) : undefined,
|
||||
}));
|
||||
|
||||
const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: string }) => {
|
||||
const { data: rows } = useRollupRange({
|
||||
metrics: ["server_fn.duration.p50", "server_fn.duration.p95"],
|
||||
from,
|
||||
to,
|
||||
dim: fn,
|
||||
});
|
||||
const days = listDays({ from, to });
|
||||
const p50 = fillDailySeries(rows, "server_fn.duration.p50", fn, days);
|
||||
const p95 = fillDailySeries(rows, "server_fn.duration.p95", fn, days);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Sparkline data={p50} series2={p95} height={64} showArea />
|
||||
<Group gap="md">
|
||||
<Group gap={6}>
|
||||
<svg width={18} height={8} aria-hidden>
|
||||
<line x1={0} y1={4} x2={18} y2={4} stroke="var(--mantine-color-anchor)" strokeWidth={2} />
|
||||
</svg>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>p50</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<svg width={18} height={8} aria-hidden>
|
||||
<line
|
||||
x1={0}
|
||||
y1={4}
|
||||
x2={18}
|
||||
y2={4}
|
||||
stroke="var(--mantine-color-anchor)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
</svg>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>p95</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||
const { t } = useLingui();
|
||||
const [selectedFn, setSelectedFn] = useState<string | null>(null);
|
||||
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
|
||||
|
||||
const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to });
|
||||
|
||||
const days = useMemo(() => listDays({ from, to }), [from, to]);
|
||||
|
||||
const dauSeries = fillDailySeries(rows, "dau", "", days);
|
||||
const errorSeries = fillDailySeries(rows, "client_error.count", "", days);
|
||||
const sessions = fillDailySeries(rows, "page_view.sessions", "", days).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0
|
||||
);
|
||||
const pageViewItems = topItems(sumByDim(rows, "page_view.count"));
|
||||
const serverFnItems = topItems(sumByDim(rows, "server_fn.count"), (dim) => {
|
||||
setSelectedFn(dim);
|
||||
openFnDetail();
|
||||
});
|
||||
|
||||
const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({
|
||||
name,
|
||||
value: latestValue(rows, "vital.p75", name),
|
||||
}));
|
||||
|
||||
const handleCloseFnDetail = () => {
|
||||
setSelectedFn(null);
|
||||
closeFnDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Engagement</Trans>
|
||||
</Title>
|
||||
<StatTile
|
||||
label={<Trans>Daily active players</Trans>}
|
||||
value={(dauSeries[dauSeries.length - 1] ?? 0).toLocaleString()}
|
||||
spark={dauSeries}
|
||||
/>
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
<StatTile
|
||||
label={<Trans>WAU</Trans>}
|
||||
value={(latestValue(rows, "wau", "") ?? 0).toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={<Trans>MAU</Trans>}
|
||||
value={(latestValue(rows, "mau", "") ?? 0).toLocaleString()}
|
||||
/>
|
||||
<StatTile label={<Trans>Sessions</Trans>} value={sessions.toLocaleString()} />
|
||||
</SimpleGrid>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Page views by route</Trans>
|
||||
</Text>
|
||||
<BarList items={pageViewItems} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Server functions</Trans>
|
||||
</Title>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Top functions by call count</Trans>
|
||||
</Text>
|
||||
<BarList items={serverFnItems} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Errors</Trans>
|
||||
</Title>
|
||||
<StatTile
|
||||
label={<Trans>Client errors per day</Trans>}
|
||||
value={errorSeries.reduce((sum, value) => sum + value, 0).toLocaleString()}
|
||||
spark={errorSeries}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>
|
||||
<Trans>Vitals</Trans>
|
||||
</Title>
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
{vitals.map(({ name, value }) => (
|
||||
<StatTile
|
||||
key={name}
|
||||
label={name}
|
||||
value={value !== undefined ? formatVital(name, value) : "—"}
|
||||
caption={value !== undefined ? <VitalBadge name={name} value={value} /> : undefined}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<DetailSheet
|
||||
title={selectedFn ?? t`Function`}
|
||||
opened={fnDetailOpened}
|
||||
onClose={handleCloseFnDetail}
|
||||
fields={
|
||||
selectedFn
|
||||
? [
|
||||
{
|
||||
label: <Trans>Duration p50 / p95</Trans>,
|
||||
value: <FnDurationDetail fn={selectedFn} from={from} to={to} />,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsageScreen = () => {
|
||||
const { t } = useLingui();
|
||||
const [rangeDays, setRangeDays] = useState<"7" | "30">("7");
|
||||
const range = useMemo(() => lastNDays(Number(rangeDays)), [rangeDays]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
value={rangeDays}
|
||||
onChange={(value) => setRangeDays(value as "7" | "30")}
|
||||
data={[
|
||||
{ value: "7", label: t`7d` },
|
||||
{ value: "30", label: t`30d` },
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<Stack gap="lg">
|
||||
<StatTileSkeleton withCard align="row" />
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
<StatTileSkeleton withCard />
|
||||
<StatTileSkeleton withCard />
|
||||
<StatTileSkeleton withCard />
|
||||
</SimpleGrid>
|
||||
<CardSkeleton withAvatar={false} withBadge={false} />
|
||||
<CardSkeleton withAvatar={false} withBadge={false} />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<UsageSections from={range.from} to={range.to} />
|
||||
</Suspense>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageScreen;
|
||||
Reference in New Issue
Block a user