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 ( -