refactor(telemetry): chart scale labels, calmer vitals and error styling, usage-first tabs
- sparklines get start/end date and max-value footers - vitals rating shown by value color instead of badges - error rows use plain counts, subtle resolve button, terse empty state - telemetry tab order: usage, players, functions, events, errors; usage is default
This commit is contained in:
@@ -54,7 +54,7 @@ export const ToolsList = () => {
|
||||
<ListLink
|
||||
label={t`Telemetry`}
|
||||
Icon={PulseIcon}
|
||||
to="/admin/telemetry/functions"
|
||||
to="/admin/telemetry/usage"
|
||||
/>
|
||||
<ListLink
|
||||
label={t`Award Badges`}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { niceExtent, pointsToPath } from "./chart-path";
|
||||
|
||||
const VIEW_WIDTH = 240;
|
||||
const PAD = 3;
|
||||
|
||||
export interface SparklineScale {
|
||||
start?: string;
|
||||
end?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
series2?: number[];
|
||||
@@ -11,6 +17,7 @@ interface SparklineProps {
|
||||
showArea?: boolean;
|
||||
label?: string;
|
||||
color?: string;
|
||||
scale?: SparklineScale;
|
||||
}
|
||||
|
||||
export const Sparkline = ({
|
||||
@@ -20,6 +27,7 @@ export const Sparkline = ({
|
||||
showArea = false,
|
||||
label,
|
||||
color = "var(--mantine-color-anchor)",
|
||||
scale,
|
||||
}: SparklineProps) => {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@@ -38,7 +46,11 @@ export const Sparkline = ({
|
||||
? `${path} L${VIEW_WIDTH - PAD},${height - PAD} L${PAD},${height - PAD} Z`
|
||||
: null;
|
||||
|
||||
return (
|
||||
const allValues = series2?.length ? [...data, ...series2] : data;
|
||||
const max = Math.max(...allValues);
|
||||
const formatValue = scale?.formatValue ?? ((value: number) => value.toLocaleString());
|
||||
|
||||
const svg = (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
@@ -72,6 +84,25 @@ export const Sparkline = ({
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
if (!scale) return svg;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{svg}
|
||||
<Group justify="space-between" gap="xs" mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{scale.start}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
↑{formatValue(max)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{scale.end}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sparkline;
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import Sparkline from "./sparkline";
|
||||
import Sparkline, { SparklineScale } from "./sparkline";
|
||||
|
||||
interface StatTileProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
valueColor?: string;
|
||||
delta?: number;
|
||||
spark?: number[];
|
||||
sparkScale?: SparklineScale;
|
||||
caption?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const StatTile = ({ label, value, delta, spark, caption, onClick }: StatTileProps) => {
|
||||
export const StatTile = ({
|
||||
label,
|
||||
value,
|
||||
valueColor,
|
||||
delta,
|
||||
spark,
|
||||
sparkScale,
|
||||
caption,
|
||||
onClick,
|
||||
}: StatTileProps) => {
|
||||
const content = (
|
||||
<Stack gap={2}>
|
||||
<Group gap="xs" align="baseline" wrap="nowrap">
|
||||
<Text fz="xl" fw={700} lh={1.2}>
|
||||
<Text fz="xl" fw={700} lh={1.2} c={valueColor}>
|
||||
{value}
|
||||
</Text>
|
||||
{delta !== undefined && delta !== 0 && (
|
||||
@@ -29,7 +40,9 @@ export const StatTile = ({ label, value, delta, spark, caption, onClick }: StatT
|
||||
{label}
|
||||
</Text>
|
||||
{caption && <Box>{caption}</Box>}
|
||||
{spark && spark.length > 0 && <Sparkline data={spark} height={28} />}
|
||||
{spark && spark.length > 0 && (
|
||||
<Sparkline data={spark} height={28} scale={sparkScale} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { Container, Divider, Group, Stack, Switch } from "@mantine/core";
|
||||
import { ChecksIcon } from "@phosphor-icons/react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { Container, Divider, Group, Stack, Switch, Text } from "@mantine/core";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
import EmptyState from "@/components/empty-state";
|
||||
import { useClientErrorGroups } from "../../queries";
|
||||
import { lastNDays } from "../../range";
|
||||
import { ErrorGroupRow } from "./error-group-row";
|
||||
@@ -17,16 +15,13 @@ const ErrorGroupList = ({
|
||||
to: string;
|
||||
includeResolved: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const { data: groups } = useClientErrorGroups({ from, to, includeResolved });
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ChecksIcon size={32} />}
|
||||
title={t`No unresolved errors`}
|
||||
description={t`Nothing has broken in the last 30 days. Keep it up.`}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" px="md" py="xl" ta="center">
|
||||
<Trans>No unresolved errors</Trans>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
@@ -42,9 +41,14 @@ export const ErrorGroupRow = ({ group }: { group: ClientErrorGroup }) => {
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge color={group.resolved ? "gray" : "red"} variant="light" size="sm">
|
||||
{group.count.toLocaleString()}
|
||||
</Badge>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
c={group.resolved ? "dimmed" : "red"}
|
||||
style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{group.count.toLocaleString()}×
|
||||
</Text>
|
||||
<Text size="sm" fw={600} lineClamp={2} style={{ minWidth: 0 }}>
|
||||
{group.sample.message}
|
||||
</Text>
|
||||
@@ -85,7 +89,7 @@ export const ErrorGroupRow = ({ group }: { group: ClientErrorGroup }) => {
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
variant="subtle"
|
||||
color={group.resolved ? "yellow" : "green"}
|
||||
loading={resolveMutation.isPending}
|
||||
onClick={() =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
Badge,
|
||||
Divider,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
@@ -45,25 +44,18 @@ const vitalRating = (name: VitalName, value: number): "good" | "ok" | "poor" =>
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const VitalBadge = ({ name, value }: { name: VitalName; value: number }) => {
|
||||
const vitalColor = (name: VitalName, value: number): string | undefined => {
|
||||
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>
|
||||
);
|
||||
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])
|
||||
@@ -75,6 +67,7 @@ const topItems = (totals: Map<string, number>, onClick?: (dim: string) => void)
|
||||
}));
|
||||
|
||||
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,
|
||||
@@ -87,7 +80,17 @@ const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: stri
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Sparkline data={p50} series2={p95} height={64} showArea />
|
||||
<Sparkline
|
||||
data={p50}
|
||||
series2={p95}
|
||||
height={64}
|
||||
showArea
|
||||
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>
|
||||
@@ -119,7 +122,7 @@ const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: stri
|
||||
};
|
||||
|
||||
const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const [selectedFn, setSelectedFn] = useState<string | null>(null);
|
||||
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
|
||||
|
||||
@@ -159,6 +162,7 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||
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
|
||||
@@ -200,9 +204,10 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||
<Trans>Errors</Trans>
|
||||
</Title>
|
||||
<StatTile
|
||||
label={<Trans>Client errors per day</Trans>}
|
||||
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>
|
||||
|
||||
@@ -218,7 +223,7 @@ const UsageSections = ({ from, to }: { from: string; to: string }) => {
|
||||
key={name}
|
||||
label={name}
|
||||
value={value !== undefined ? formatVital(name, value) : "—"}
|
||||
caption={value !== undefined ? <VitalBadge name={name} value={value} /> : undefined}
|
||||
valueColor={value !== undefined ? vitalColor(name, value) : undefined}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
Reference in New Issue
Block a user