- rollups emit event.count (dim name) and event.detail.count (dim name|detail from props.source/emoji/tournament) - usage screen: events section with tap-through breakdown; 7d/30d/180d/custom date-range presets
388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
import { Suspense, useMemo, useState } from "react";
|
|
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import {
|
|
Divider,
|
|
Group,
|
|
SegmentedControl,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
|
|
import { useRollupRange } from "../queries";
|
|
import type { RollupRow } from "../types";
|
|
import { DatePickerInput } from "@mantine/dates";
|
|
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",
|
|
"event.count",
|
|
"event.detail.count",
|
|
];
|
|
|
|
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 vitalColor = (name: VitalName, value: number): string | undefined => {
|
|
const rating = vitalRating(name, value);
|
|
if (rating === "good") return undefined;
|
|
return rating === "ok" ? "yellow.8" : "red";
|
|
};
|
|
|
|
const formatVital = (name: VitalName, value: number) =>
|
|
name === "CLS" ? value.toFixed(2) : `${Math.round(value)}ms`;
|
|
|
|
const dayLabel = (i18n: { date: (d: Date, opts?: Intl.DateTimeFormatOptions) => string }, day: string) =>
|
|
i18n.date(new Date(`${day}T12:00:00Z`), { month: "short", day: "numeric" });
|
|
|
|
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 { i18n } = useLingui();
|
|
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);
|
|
const lastNonZero = (series: number[]) =>
|
|
[...series].reverse().find((value) => value > 0) ?? 0;
|
|
|
|
return (
|
|
<Stack gap="xs">
|
|
<Sparkline
|
|
data={p50}
|
|
series2={p95}
|
|
height={64}
|
|
scale={{
|
|
start: dayLabel(i18n, from),
|
|
end: dayLabel(i18n, to),
|
|
formatValue: (value) => `${Math.round(value)}ms`,
|
|
}}
|
|
/>
|
|
<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> · {Math.round(lastNonZero(p50))}ms
|
|
</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-dimmed)"
|
|
strokeWidth={2}
|
|
strokeDasharray="4 3"
|
|
/>
|
|
</svg>
|
|
<Text size="xs" c="dimmed">
|
|
<Trans>p95</Trans> · {Math.round(lastNonZero(p95))}ms
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
const EventBreakdown = ({ name, rows }: { name: string; rows: RollupRow[] }) => {
|
|
const items = useMemo(() => {
|
|
const totals = new Map<string, number>();
|
|
const prefix = `${name}|`;
|
|
for (const row of rows) {
|
|
if (row.metric !== "event.detail.count" || !row.dim.startsWith(prefix)) continue;
|
|
const detail = row.dim.slice(prefix.length);
|
|
totals.set(detail, (totals.get(detail) ?? 0) + row.value);
|
|
}
|
|
return [...totals.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([label, value]) => ({ label, value }));
|
|
}, [name, rows]);
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<Text size="sm" c="dimmed">
|
|
<Trans>No breakdown recorded</Trans>
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return <BarList items={items} />;
|
|
};
|
|
|
|
const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
|
const { t, i18n } = useLingui();
|
|
const [selectedFn, setSelectedFn] = useState<string | null>(null);
|
|
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
|
|
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
|
|
const { isOpen: eventDetailOpened, open: openEventDetail, close: closeEventDetail } = 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 eventItems = topItems(sumByDim(rows, "event.count"), (dim) => {
|
|
setSelectedEvent(dim);
|
|
openEventDetail();
|
|
});
|
|
|
|
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}
|
|
sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }}
|
|
/>
|
|
<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>
|
|
<Stack gap="xs">
|
|
<Text size="xs" c="dimmed">
|
|
<Trans>Page views by route</Trans>
|
|
</Text>
|
|
<BarList items={pageViewItems} />
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<Divider />
|
|
|
|
<Stack gap="sm">
|
|
<Title order={4}>
|
|
<Trans>Events</Trans>
|
|
</Title>
|
|
{eventItems.length > 0 ? (
|
|
<Stack gap="xs">
|
|
<Text size="xs" c="dimmed">
|
|
<Trans>Top interactions, tap for breakdown</Trans>
|
|
</Text>
|
|
<BarList items={eventItems} />
|
|
</Stack>
|
|
) : (
|
|
<Text size="sm" c="dimmed">
|
|
<Trans>No events recorded in this range</Trans>
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
|
|
<Divider />
|
|
|
|
<Stack gap="sm">
|
|
<Title order={4}>
|
|
<Trans>Server functions</Trans>
|
|
</Title>
|
|
<Stack gap="xs">
|
|
<Text size="xs" c="dimmed">
|
|
<Trans>Top functions by call count</Trans>
|
|
</Text>
|
|
<BarList items={serverFnItems} />
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<Divider />
|
|
|
|
<Stack gap="sm">
|
|
<Title order={4}>
|
|
<Trans>Errors</Trans>
|
|
</Title>
|
|
<StatTile
|
|
label={<Trans>Client errors</Trans>}
|
|
value={errorSeries.reduce((sum, value) => sum + value, 0).toLocaleString()}
|
|
spark={errorSeries}
|
|
sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }}
|
|
/>
|
|
</Stack>
|
|
|
|
<Divider />
|
|
|
|
<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) : "—"}
|
|
valueColor={value !== undefined ? vitalColor(name, value) : undefined}
|
|
/>
|
|
))}
|
|
</SimpleGrid>
|
|
</Stack>
|
|
|
|
<DetailSheet
|
|
title={selectedEvent ?? t`Event`}
|
|
opened={eventDetailOpened}
|
|
onClose={() => {
|
|
setSelectedEvent(null);
|
|
closeEventDetail();
|
|
}}
|
|
fields={
|
|
selectedEvent
|
|
? [
|
|
{
|
|
label: <Trans>Breakdown</Trans>,
|
|
value: <EventBreakdown name={selectedEvent} rows={rows} />,
|
|
},
|
|
]
|
|
: []
|
|
}
|
|
/>
|
|
|
|
<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 [preset, setPreset] = useState<"7" | "30" | "180" | "custom">("7");
|
|
const [customRange, setCustomRange] = useState<[string | null, string | null]>([null, null]);
|
|
|
|
const range = useMemo(() => {
|
|
if (preset !== "custom") return lastNDays(Number(preset));
|
|
const [start, end] = customRange;
|
|
if (!start || !end) return null;
|
|
return { from: start, to: end };
|
|
}, [preset, customRange]);
|
|
|
|
return (
|
|
<Stack gap="md" px="md" pb="md">
|
|
<SegmentedControl
|
|
value={preset}
|
|
onChange={(value) => setPreset(value as typeof preset)}
|
|
data={[
|
|
{ value: "7", label: t`7d` },
|
|
{ value: "30", label: t`30d` },
|
|
{ value: "180", label: t`180d` },
|
|
{ value: "custom", label: t`Custom` },
|
|
]}
|
|
fullWidth
|
|
/>
|
|
|
|
{preset === "custom" && (
|
|
<DatePickerInput
|
|
type="range"
|
|
value={customRange}
|
|
onChange={setCustomRange}
|
|
maxDate={new Date()}
|
|
placeholder={t`Pick a date range`}
|
|
/>
|
|
)}
|
|
|
|
{range ? (
|
|
<Suspense
|
|
fallback={
|
|
<Stack gap="lg">
|
|
<StatTileSkeleton align="row" />
|
|
<SimpleGrid cols={3} spacing="sm">
|
|
<StatTileSkeleton />
|
|
<StatTileSkeleton />
|
|
<StatTileSkeleton />
|
|
</SimpleGrid>
|
|
<ListSkeleton rows={6} withTrailing="text" />
|
|
</Stack>
|
|
}
|
|
>
|
|
<UsageSections from={range.from} to={range.to} />
|
|
</Suspense>
|
|
) : (
|
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
|
<Trans>Pick a date range to see usage</Trans>
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default UsageScreen;
|