From f7284ebfa91b39f0be13dcf53c74985fb4ec213a Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:37:52 +0200 Subject: [PATCH] feat(dashboard): show per-region latency spread as range plots (#2917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tinybird): filter latency and timing phase endpoints by regions Add an optional regions parameter to the dns, grpc, http, icmp and tcp latency pipes and the http timing phase pipes, and to their client schemas. * feat(dashboard): show per-region latency spread as range plots Replace the regions table/chart tabs on the monitor overview with a single table that draws P50, P75–P95, P90 and P99 per region on a shared axis (new RangePlot primitive), with a legend and the degraded threshold. - Latency charts respect the selected regions. - One reset button in the header covers period, regions, percentile and interval. - Drop ChartLineRegion(s) and region-metrics.client. - Extract isKeyboardOwned into lib/keyboard for page shortcuts. * fix(dashboard): address review on the regions range plot - Regions table only lists the selected regions (paid plans; free plans ignore the filter server-side) and shows an error state when the query fails instead of a skeleton. - getRegionAxisMax averages the two middle P99s for an even count. - The plot is role=img so its aria-label is announced; the P90 tick sits above the overflow badge; the hover card is pointer-transparent. - Reset tooltip shows Ctrl off Apple platforms (shared useModifierKey). - Latency pipes use {% if regions %} like the rest, so an empty array doesn't fall back to the ams,fra default. * fix(dashboard): axis headroom for clipped P99s, keyboard tests - getRegionAxisMax adds 10% headroom when a P99 is clipped, so the overflow badge sits clear of the markers near the edge. - Extract isResetShortcut and getModifierKey into lib/keyboard and cover them and isKeyboardOwned with tests. - Drop leftover commented className in ButtonReset. * docs(dashboard): describe when a region axis outlier is drawn vs flagged An outlier P99 that lands under the rounded axis (headroom included) is drawn as a normal marker; the cell only flags p99 > max. Pin that case. --- .../monitors/[id]/overview/client.tsx | 178 ++++----- .../components/chart/chart-line-region.tsx | 93 ----- .../components/chart/chart-line-regions.tsx | 133 ------- .../src/components/content/composer.tsx | 19 +- .../src/components/content/range-plot.tsx | 291 ++++++++++++++ .../src/components/content/section.tsx | 2 +- .../controls-search/button-reset.tsx | 47 ++- .../controls-search/command-region.tsx | 6 +- .../controls-search/dropdown-interval.tsx | 56 +-- .../controls-search/dropdown-percentile.tsx | 61 ++- .../controls-search/dropdown-period.tsx | 68 ++-- .../response-logs/regions/columns.tsx | 376 ++++++++++++------ .../response-logs/regions/data-table.tsx | 80 ++++ .../data-table/table-cell-number.tsx | 33 +- .../components/popovers/popover-quantile.tsx | 2 +- .../popovers/popover-resolution.tsx | 2 +- .../dashboard/src/data/metrics.client.test.ts | 68 +++- apps/dashboard/src/data/metrics.client.ts | 109 +++-- .../src/data/region-metrics.client.ts | 27 -- apps/dashboard/src/data/region-metrics.ts | 47 +-- apps/dashboard/src/lib/keyboard.test.ts | 106 +++++ apps/dashboard/src/lib/keyboard.ts | 35 ++ ...endpoint__dns_metrics_latency_30d__v0.pipe | 1 + .../endpoint__dns_metrics_latency_7d__v0.pipe | 1 + ...endpoint__dns_metrics_latency_90d__v0.pipe | 1 + ...endpoint__grpc_metrics_latency_1d__v0.pipe | 1 + ...ndpoint__grpc_metrics_latency_30d__v0.pipe | 1 + ...endpoint__grpc_metrics_latency_7d__v0.pipe | 1 + ...ndpoint__grpc_metrics_latency_90d__v0.pipe | 1 + ...endpoint__http_metrics_latency_1d__v1.pipe | 1 + ...endpoint__http_metrics_latency_7d__v1.pipe | 1 + .../endpoint__http_timing_phases_14d__v1.pipe | 1 + .../endpoint__http_timing_phases_30d__v1.pipe | 1 + .../endpoint__http_timing_phases_90d__v1.pipe | 1 + ...endpoint__icmp_metrics_latency_1d__v0.pipe | 1 + ...ndpoint__icmp_metrics_latency_30d__v0.pipe | 1 + ...endpoint__icmp_metrics_latency_7d__v0.pipe | 1 + ...ndpoint__icmp_metrics_latency_90d__v0.pipe | 1 + .../endpoint__tcp_metrics_latency_1d__v1.pipe | 1 + ...endpoint__tcp_metrics_latency_30d__v1.pipe | 1 + .../endpoint__tcp_metrics_latency_7d__v1.pipe | 1 + ...endpoint__tcp_metrics_latency_90d__v1.pipe | 1 + packages/tinybird/src/client.ts | 14 + 43 files changed, 1169 insertions(+), 704 deletions(-) delete mode 100644 apps/dashboard/src/components/chart/chart-line-region.tsx delete mode 100644 apps/dashboard/src/components/chart/chart-line-regions.tsx create mode 100644 apps/dashboard/src/components/content/range-plot.tsx create mode 100644 apps/dashboard/src/components/data-table/response-logs/regions/data-table.tsx delete mode 100644 apps/dashboard/src/data/region-metrics.client.ts create mode 100644 apps/dashboard/src/lib/keyboard.test.ts create mode 100644 apps/dashboard/src/lib/keyboard.ts diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx index eaecd0af9..4fe52c33a 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx @@ -1,27 +1,26 @@ "use client"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@openstatus/ui/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { endOfDay } from "date-fns"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; -import React, { useEffect, useMemo } from "react"; +import React, { useEffect } from "react"; import { toast } from "sonner"; import { ChartAreaLatency } from "@/components/chart/chart-area-latency"; import { ChartAreaTimingPhases } from "@/components/chart/chart-area-timing-phases"; import { ChartBarUptime } from "@/components/chart/chart-bar-uptime"; -import { ChartLineRegions } from "@/components/chart/chart-line-regions"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; import { Section, SectionDescription, SectionGroup, SectionHeader, + SectionHeaderRow, SectionTitle, } from "@/components/content/section"; import { ButtonReset } from "@/components/controls-search/button-reset"; @@ -30,12 +29,13 @@ import { DropdownInterval } from "@/components/controls-search/dropdown-interval import { DropdownPercentile } from "@/components/controls-search/dropdown-percentile"; import { DropdownPeriod } from "@/components/controls-search/dropdown-period"; import { AuditLogsWrapper } from "@/components/data-table/audit-logs/wrapper"; -import { getColumns as getRegionColumns } from "@/components/data-table/response-logs/regions/columns"; +import { + RegionsDataTable, + RegionsLegend, +} from "@/components/data-table/response-logs/regions/data-table"; import { GlobalUptimeSection } from "@/components/metric/global-uptime/section"; import { PopoverQuantile } from "@/components/popovers/popover-quantile"; import { PopoverResolution } from "@/components/popovers/popover-resolution"; -import { DataTable } from "@/components/ui/data-table/data-table"; -import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; import { FREE_MAX_PERIOD, isPaidPeriod, @@ -98,54 +98,58 @@ export function Client() { enabled: !!monitor, } as const; - const { data: regionTimeline } = useQuery(regionTimelineQuery); + const { data: regionTimeline, isError: isRegionTimelineError } = + useQuery(regionTimelineQuery); + + // free plans ignore the region filter server-side, so they keep every row + const filtersRegions = + workspace?.plan !== "free" && !!selectedRegions?.length; const regionMetrics: RegionMetric[] = React.useMemo(() => { + const configured = [ + ...(monitor?.regions ?? []), + ...(monitor?.privateLocations?.map((location) => + location.id.toString(), + ) ?? []), + ]; return mapRegionMetrics( regionTimeline, - [ - ...(monitor?.regions ?? []), - ...(monitor?.privateLocations?.map((location) => - location.id.toString(), - ) ?? []), - ], - percentile, + filtersRegions + ? configured.filter((region) => selectedRegions?.includes(region)) + : configured, ); - }, [regionTimeline, monitor, percentile]); - - const regionColumns = useMemo( - () => getRegionColumns(monitor?.privateLocations ?? []), - [monitor?.privateLocations], - ); + }, [regionTimeline, monitor, filtersRegions, selectedRegions]); if (!monitor) return null; return (
- - {monitor.name} - - {monitor.jobType === "http" ? ( - - {monitor.url} - - ) : ( - monitor.url - )} - - -
+ + + {monitor.name} + + {monitor.jobType === "http" ? ( + + {monitor.url} + + ) : ( + monitor.url + )} + +
- including{" "} -
-
- -
+
+
+ including{" "} +
Latency - Response time across all the regions + Response time across the selected regions -
-
- The {" "} - quantile within a{" "} - {" "} - resolution -
-
- -
+
+ The quantile{" "} + within a {" "} + resolution
{monitor.jobType === "http" ? ( Regions - Every selected region's latency trend + Response time spread per selected region -
+
- The {" "} - quantile trend over the{" "} - -
-
- + Latency spread over the
+
- - - Table - Chart - - - ( - - )} - /> - - - region.region)} - privateLocations={monitor?.privateLocations ?? []} - data={regionMetrics.reduce( - (acc, region) => { - region.trend.forEach((t) => { - const existing = acc.find( - (d) => d.timestamp === t.timestamp, - ); - if (existing) { - existing[region.region] = t[region.region]; - } else { - acc.push({ - timestamp: t.timestamp, - [region.region]: t[region.region], - }); - } - }); - return acc; - }, - [] as { timestamp: number; [key: string]: number }[], - )} - /> - - + {isRegionTimelineError ? ( + + Couldn't load region metrics + + Try again in a moment or pick another period. + + + ) : ( + + )}
diff --git a/apps/dashboard/src/components/chart/chart-line-region.tsx b/apps/dashboard/src/components/chart/chart-line-region.tsx deleted file mode 100644 index f8ab945db..000000000 --- a/apps/dashboard/src/components/chart/chart-line-region.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@openstatus/ui/components/ui/chart"; -import { cn } from "@openstatus/ui/lib/utils"; -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; - -import { ChartTooltipNumber } from "./chart-tooltip-number"; - -const chartConfig = { - latency: { - label: "Latency", - color: "var(--success)", - }, -} satisfies ChartConfig; - -export type TrendPoint = { - timestamp: number; // unix millis - latency: number; // milliseconds -}; - -export function ChartLineRegion({ - className, - data, -}: { - className?: string; - data: TrendPoint[]; -}) { - const trendData = data ?? []; - - const chartData = trendData.map((d) => ({ - timestamp: new Date(d.timestamp).toLocaleString("default", { - hour: "numeric", - minute: "numeric", - day: "numeric", - month: "short", - }), - latency: d.latency, - })); - - return ( - - - - - ( - - )} - /> - } - /> - - `${value}ms`} - /> - - - ); -} diff --git a/apps/dashboard/src/components/chart/chart-line-regions.tsx b/apps/dashboard/src/components/chart/chart-line-regions.tsx deleted file mode 100644 index 40d97b184..000000000 --- a/apps/dashboard/src/components/chart/chart-line-regions.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import type { PrivateLocation } from "@openstatus/db/src/schema"; -import { monitorRegions } from "@openstatus/db/src/schema/constants"; -import { getRegionInfo } from "@openstatus/regions"; -import { - type ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from "@openstatus/ui/components/ui/chart"; -import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; - -import { getRegionColor } from "@/data/regions"; -import { cn } from "@/lib/utils"; - -import { ChartTooltipNumber } from "./chart-tooltip-number"; - -function getChartConfig(privateLocations?: PrivateLocation[]) { - return [ - ...monitorRegions, - ...(privateLocations?.map((location) => location.id.toString()) ?? []), - ].reduce((config, region) => { - const privateLocation = privateLocations?.find( - (location) => String(location.id) === String(region), - ); - const regionInfo = getRegionInfo(region, { - location: privateLocation?.name, - }); - const color = getRegionColor(region); - - if (regionInfo && color) { - config[region] = { - label: `${regionInfo.location} (${regionInfo.provider})`, - color, - }; - } - - return config; - }, {} as ChartConfig) satisfies ChartConfig; -} - -export type TrendPoint = { - timestamp: number; // unix millis - [key: string]: number; // milliseconds -}; - -export function ChartLineRegions({ - className, - data, - regions, - privateLocations, -}: { - className?: string; - data: TrendPoint[]; - regions: string[] | undefined; - privateLocations?: PrivateLocation[]; -}) { - const isMobile = useIsMobile(); - const trendData = data ?? []; - const chartConfig = getChartConfig(privateLocations); - const chartData = trendData.map((d) => ({ - ...d, - timestamp: new Date(d.timestamp).toLocaleString("default", { - hour: "numeric", - minute: "numeric", - day: "numeric", - month: "short", - }), - })); - - return ( - - - - - ( - - )} - /> - } - /> - {regions?.map((region) => { - return ( - - ); - })} - `${value}ms`} - /> - {regions && regions.length <= 6 && !isMobile ? ( - } - /> - ) : null} - - - ); -} diff --git a/apps/dashboard/src/components/content/composer.tsx b/apps/dashboard/src/components/content/composer.tsx index 0e99767fb..2be7684bc 100644 --- a/apps/dashboard/src/components/content/composer.tsx +++ b/apps/dashboard/src/components/content/composer.tsx @@ -33,8 +33,8 @@ import { } from "react"; import { ProcessMessage } from "@/components/content/process-message"; -import { useHydrated } from "@/hooks/use-hydrated"; import { useComposedRefs } from "@/lib/composition"; +import { isKeyboardOwned, useModifierKey } from "@/lib/keyboard"; type ComposerView = "write" | "preview"; @@ -113,14 +113,7 @@ function useFocusOnMount() { function isPreviewHotkey(e: KeyboardEvent) { if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return false; if (e.key.toLowerCase() !== "e" || e.isComposing || e.repeat) return false; - return !( - e.target instanceof HTMLElement && - (e.target.isContentEditable || - e.target.matches("input, textarea, select") || - e.target.closest( - '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="combobox"]', - )) - ); + return !isKeyboardOwned(e.target); } // With several composers on a page (incident notes and postmortem) the key @@ -128,14 +121,6 @@ function isPreviewHotkey(e: KeyboardEvent) { const composers: Array<() => void> = []; let activeComposer: (() => void) | null = null; -/** "⌘" on Apple platforms, "Ctrl" elsewhere; ⌘ until hydrated. */ -function useModifierKey() { - const hydrated = useHydrated(); - return hydrated && !/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) - ? "Ctrl" - : "⌘"; -} - function useComposer() { const ctx = useContext(ComposerContext); if (!ctx) diff --git a/apps/dashboard/src/components/content/range-plot.tsx b/apps/dashboard/src/components/content/range-plot.tsx new file mode 100644 index 000000000..c75e86e53 --- /dev/null +++ b/apps/dashboard/src/components/content/range-plot.tsx @@ -0,0 +1,291 @@ +import { cn } from "@openstatus/ui/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +/* + * Every part is positioned in CSS from `--max` (set once on `RangePlot`) and + * its own `--value`, so rows on the same `max` share one axis without context. + * Values past `max` clamp to the edge; flag them with `RangePlotOverflow`. + */ +const AT = "left-[calc(min(var(--value),var(--max))/var(--max)*100%)]"; + +function vars(values: Record) { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [`--${key}`, value]), + ) as React.CSSProperties; +} + +export function RangePlot({ + max, + className, + style, + ...props +}: React.ComponentProps<"div"> & { max: number }) { + return ( +
+ ); +} + +/** Vertical lines at 0, half and max; bleeds into the cell padding so stacked rows join. */ +export function RangePlotGrid({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+
+
+
+
+ ); +} + +/** Axis labels for a header cell: 0, half and max with the unit. */ +export function RangePlotAxis({ + max, + unit, + className, + ...props +}: React.ComponentProps<"div"> & { max: number; unit?: string }) { + return ( +
+ 0 + {max / 2} + + {max} + {unit} + +
+ ); +} + +/** Vertical line at a value (e.g. a degraded threshold); bleeds into the cell padding like the grid so rows join. */ +export function RangePlotThreshold({ + value, + className, + style, + ...props +}: React.ComponentProps<"div"> & { value: number }) { + return ( +
+ ); +} + +const rangeVariants = cva( + "absolute top-1/2 right-[calc(100%_-_min(var(--to),var(--max))/var(--max)*100%)] left-[calc(min(var(--from),var(--max))/var(--max)*100%)] -translate-y-1/2", + { + variants: { + variant: { + whisker: "bg-muted-foreground/50 h-px", + box: "border-muted-foreground/50 bg-muted h-2.5 rounded-xs border", + }, + }, + defaultVariants: { variant: "whisker" }, + }, +); + +export function RangePlotRange({ + from, + to, + variant, + className, + style, + ...props +}: React.ComponentProps<"div"> & + VariantProps & { from: number; to: number }) { + return ( +
+ ); +} + +const markerVariants = cva( + "bg-foreground absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full", + { + variants: { + variant: { + dot: "bg-muted-foreground size-2", + tick: "h-3 w-0.5", + end: "bg-muted-foreground h-2 w-px", + }, + }, + defaultVariants: { variant: "dot" }, + }, +); + +export function RangePlotMarker({ + value, + variant, + className, + style, + ...props +}: React.ComponentProps<"div"> & + VariantProps & { value: number }) { + return ( +
+ ); +} + +/** Pinned to the right edge when a value runs past `max`. */ +export function RangePlotOverflow({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export function RangePlotEmpty({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ); +} + +export function RangePlotLegend({ + className, + ...props +}: React.ComponentProps<"ul">) { + return ( +

    + ); +} + +const swatchVariants = cva("bg-foreground shrink-0 rounded-full", { + variants: { + variant: { + dot: "bg-muted-foreground size-2", + tick: "h-3 w-0.5", + end: "bg-muted-foreground h-2 w-px", + box: "border-muted-foreground/50 bg-muted h-2.5 w-3 rounded-xs border", + warning: "border-warning h-3 w-0 rounded-none border-l bg-transparent", + }, + }, + defaultVariants: { variant: "dot" }, +}); + +export function RangePlotLegendItem({ + variant, + className, + children, + ...props +}: React.ComponentProps<"li"> & VariantProps) { + return ( +
  • + + {children} +
  • + ); +} + +/** px offsets inside the 26px icon: whisker ends, box edges and middle. */ +const POSITIONS = { min: 2, start: 8, mid: 13, end: 18, max: 24 } as const; + +const positionDotVariants = cva( + "absolute top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full", + { + variants: { + variant: { + default: "bg-foreground", + warning: "bg-warning", + }, + }, + defaultVariants: { variant: "default" }, + }, +); + +/** A miniature of the plot (whisker + box) with one dot where a percentile sits. */ +export function RangePlotPositionIcon({ + at, + variant, + className, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { at: keyof typeof POSITIONS }) { + return ( + + + + + + ); +} diff --git a/apps/dashboard/src/components/content/section.tsx b/apps/dashboard/src/components/content/section.tsx index bbf12c6b0..3c3b068a5 100644 --- a/apps/dashboard/src/components/content/section.tsx +++ b/apps/dashboard/src/components/content/section.tsx @@ -18,7 +18,7 @@ export function SectionHeader({ ...props }: React.ComponentProps<"div">) { return ( -
    +
    {children}
    ); diff --git a/apps/dashboard/src/components/controls-search/button-reset.tsx b/apps/dashboard/src/components/controls-search/button-reset.tsx index 83ed8f38d..5b1bc48ca 100644 --- a/apps/dashboard/src/components/controls-search/button-reset.tsx +++ b/apps/dashboard/src/components/controls-search/button-reset.tsx @@ -2,19 +2,28 @@ import { Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; +import { Kbd, KbdGroup } from "@openstatus/ui/components/ui/kbd"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@openstatus/ui/components/ui/tooltip"; import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useEffectEvent } from "react"; + +import { isResetShortcut, useModifierKey } from "@/lib/keyboard"; export function ButtonReset({ only }: { only?: string[] }) { const searchParams = useSearchParams(); const router = useRouter(); + const modifier = useModifierKey(); // Determine if at least one parameter that should be reset is present (or any parameter if `only` is undefined) const hasParamsToReset = only ? only.some((key) => searchParams.has(key)) : !!searchParams.toString(); - if (!hasParamsToReset) return null; - const handleClick = () => { // Clone the current search params so we can mutate them const params = new URLSearchParams(searchParams.toString()); @@ -34,12 +43,38 @@ export function ButtonReset({ only }: { only?: string[] }) { } }; + const onShortcut = useEffectEvent(handleClick); + + useEffect(() => { + if (!hasParamsToReset) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (!isResetShortcut(e)) return; + e.preventDefault(); + onShortcut(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [hasParamsToReset]); + if (!hasParamsToReset) return null; return ( - + + + + + + + Reset filters + + {modifier} + Esc + + + + ); } diff --git a/apps/dashboard/src/components/controls-search/command-region.tsx b/apps/dashboard/src/components/controls-search/command-region.tsx index b2ff3ab99..62ff2aad0 100644 --- a/apps/dashboard/src/components/controls-search/command-region.tsx +++ b/apps/dashboard/src/components/controls-search/command-region.tsx @@ -61,7 +61,11 @@ export function CommandRegion({ return ( -