From 46257590160e28759b6e1d6cf223fdeecfc6e931 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 30 Jul 2026 10:24:47 -0700 Subject: [PATCH 1/6] Add heatmap support --- app/components/ChartTooltip.tsx | 66 +++++ app/components/FramedChart.tsx | 78 ++++++ app/components/Heatmap.tsx | 384 +++++++++++++++++++++++++++++ app/components/TimeSeriesChart.tsx | 310 +++++------------------ app/pages/system/OxqlPage.tsx | 214 +++++++++++----- app/util/charts.ts | 206 ++++++++++++++++ mock-api/msw/util.ts | 16 ++ mock-api/oxql-metrics.ts | 49 ++++ 8 files changed, 1013 insertions(+), 310 deletions(-) create mode 100644 app/components/ChartTooltip.tsx create mode 100644 app/components/FramedChart.tsx create mode 100644 app/components/Heatmap.tsx create mode 100644 app/util/charts.ts diff --git a/app/components/ChartTooltip.tsx b/app/components/ChartTooltip.tsx new file mode 100644 index 000000000..e11ac0d58 --- /dev/null +++ b/app/components/ChartTooltip.tsx @@ -0,0 +1,66 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { format } from 'date-fns' +import type { ReactNode } from 'react' +import { match } from 'ts-pattern' + +const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss zz') + +type ChartTooltipProps = { + timestamp: number + left: number + top: number + offset: [LeftRight, TopBottom] + children: ReactNode +} + +/** Offset the box into the quadrant away from the point so it never overflows an edge */ +export type LeftRight = 'left' | 'right' +export type TopBottom = 'top' | 'bottom' + +const TOOLTIP_GAP = 12 +function tooltipTransform(leftRight: LeftRight, topBottom: TopBottom): string { + const tx = match(leftRight) + .with('left', () => `calc(-100% - ${TOOLTIP_GAP}px)`) + .with('right', () => `${TOOLTIP_GAP}px`) + .exhaustive() + const ty = match(topBottom) + .with('top', () => `calc(-100% - ${TOOLTIP_GAP}px)`) + .with('bottom', () => `${TOOLTIP_GAP}px`) + .exhaustive() + return `translate(${tx}, ${ty})` +} + +export function ChartTooltip({ + timestamp, + left, + top, + offset, + children, +}: ChartTooltipProps) { + return ( +
+
+
+ {longDateTime(timestamp)} +
+
{children}
+
+
+ ) +} diff --git a/app/components/FramedChart.tsx b/app/components/FramedChart.tsx new file mode 100644 index 000000000..f62105488 --- /dev/null +++ b/app/components/FramedChart.tsx @@ -0,0 +1,78 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useMemo, type ReactNode, type RefObject } from 'react' +import type uPlot from 'uplot' +import UplotReact from 'uplot-react' + +import { useElementSize } from '~/hooks/use-element-size' + +// The intended left padding (px-5) is taken from the container and given to +// uPlot instead, so the plot sits flush left while x-tick labels can bleed into +// the gutter without clipping. +const CHART_LEFT_PAD = 20 + +export type UPlotOptions = Omit + +type Props = { + title: string + height: number + chartOptions: UPlotOptions + data: uPlot.AlignedData + uRef: RefObject + children?: ReactNode + legend?: ReactNode +} + +export function FramedChart({ + title, + height, + chartOptions, + data, + uRef, + children, + legend, +}: Props) { + const [size, sizeRef] = useElementSize() + + // Width/height changes cause a cheaper "update" path for uplot, instead of + // "create", so it gets its own layer of memoization + const options = useMemo( + () => + ({ + ...chartOptions, + padding: [null, null, null, CHART_LEFT_PAD], + width: size?.width ?? 0, + height, + }) satisfies uPlot.Options, + [chartOptions, size?.width, height] + ) + + return ( +
+ {/* The actual chart is absolutely positioned so its fixed pixel width + doesn't influence layout and block future resizing. That in turn makes + its container need an explicit height */} +
+ {size && ( + (uRef.current = u)} + /> + )} + {children} +
+ {legend} +
+ ) +} diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx new file mode 100644 index 000000000..d9d08ea26 --- /dev/null +++ b/app/components/Heatmap.tsx @@ -0,0 +1,384 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useMemo, useState } from 'react' +import * as R from 'remeda' +import type uPlot from 'uplot' + +import { ChartTooltip, type TopBottom, type LeftRight } from '~/components/ChartTooltip' +import { type UPlotOptions, FramedChart } from '~/components/FramedChart' +import { + timeFormatterForRange, + useChartTheme, + useLiveAxisFormatter, + xTimeAxis, + yValueAxis, +} from '~/util/charts' + +export type HeatmapDistribution = { bins: number[]; counts: number[] } + +const CHART_HEIGHT = 300 + +// TODO: If you don't like seams between adjacent cells, you can bleed over the +// edges a little to hide them. Too much and you can see cells not abide by +// their grid. I honestly like it at 0, but up to 0.5 can get a smoother look +// without screwing up the grid too much +const CELL_OVERFLOW = 0 + +type OklchColor = { l: number; c: number; h: number } + +const parseOklch = (s: string): OklchColor | null => { + const m = s.match(/oklch\(([^)]+)\)/) + if (!m) return null + const [l, c, h] = m[1].split(/[\s/]+/).map(Number) + return { l, c, h } +} + +const lerp = (a: number, b: number, t: number) => a + (b - a) * t + +type ColorRamp = { + stops: OklchColor[] +} + +// Interpolates the color from between the two closest stops, for 0 <= t <= 1. +const rampColor = ({ stops }: ColorRamp, t: number): string => { + const clamped = R.clamp(t, { min: 0, max: 1 }) + const lastIndex = stops.length - 1 + const progress = clamped * lastIndex + const start = R.clamp(Math.floor(progress), { max: lastIndex - 1 }) + const lerpT = progress - start + const from = stops[start] + const to = stops[start + 1] + + return `oklch(${lerp(from.l, to.l, lerpT)} ${lerp(from.c, to.c, lerpT)} ${lerp(from.h, to.h, lerpT)})` +} + +const getColorRamp = (): ColorRamp => { + const style = getComputedStyle(document.body) + const v = (name: string) => style.getPropertyValue(name) + const stops = [ + // TODO: looks nice in dark and decent in light. maybe we want a different + // palette but it's a fine start + parseOklch(v('--surface-raise')), + parseOklch(v('--surface-accent-secondary')), + parseOklch(v('--content-accent')), + ].filter((x) => x !== null) + return { + stops: + stops.length >= 2 + ? stops + : // for the unlikely case the theme colors don't parse into oklch + [ + { l: 0.195, c: 0.009, h: 260 }, + { l: 0.77, c: 0.1919, h: 163.7 }, + ], + } +} + +// count -> ramp position. Doing a square root or a log makes for some different +// smoothing. Nothing is truly intuitive, but linearly showing the fraction is +// most easily compared to the legend at the bottom +const rampT = (count: number, maxCount: number) => { + const fraction = count / maxCount + // TODO: pick! + return fraction + // return Math.log10(lerp(1, 10, fraction)) + // return Math.sqrt(fraction) +} + +type Hover = { + col: number + row: number + left: number + top: number + leftRight: LeftRight + topBottom: TopBottom + cell: { left: number; top: number; width: number; height: number } +} + +type HeatmapProps = { + title: string + timestamps: number[] + startTimes: number[] + distributions: (HeatmapDistribution | null)[] + yAxisTickFormatter?: (val: number) => string + unit?: string +} + +const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() + +export function Heatmap({ + title, + timestamps, + startTimes, + distributions, + yAxisTickFormatter = defaultYAxisTickFormatter, + unit, +}: HeatmapProps) { + const theme = useChartTheme() + const colorRamp = useMemo(getColorRamp, [theme]) + const [hover, setHover] = useState(null) + + // All distributions in a timeseries share the same bucket definition, so we + // take the bins from the first present sample as the y-axis. + const bins = useMemo( + () => distributions.find((d) => d !== null)?.bins ?? [], + [distributions] + ) + const binCount = bins.length + const colCount = distributions.length + + const maxSampleCount = useMemo( + () => distributions.reduce((max, d) => (d ? Math.max(max, ...d.counts) : max), 0), + [distributions] + ) + + // uPlot's time scale wants seconds; metrics timestamps are milliseconds + const rightEdges = useMemo(() => timestamps.map((t) => t / 1000), [timestamps]) + const leftEdges = useMemo(() => startTimes.map((t) => t / 1000), [startTimes]) + + // uPlot doesn't actually have heatmap support, but we can use its position + // functions to make this (somewhat) easy + const drawCells = useMemo( + () => (u: uPlot) => { + if (binCount === 0 || colCount === 0) return + const ctx = u.ctx + ctx.save() + ctx.beginPath() + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height) + ctx.clip() + distributions.forEach((distribution, colIndex) => { + if (!distribution) return + + const leftEdge = u.valToPos(leftEdges[colIndex], 'x', true) + const rightEdge = u.valToPos(rightEdges[colIndex], 'x', true) + distribution.counts.forEach((count, rowIndex) => { + // leave empty cells blank instead of the "bottom" color + if (count === 0) return + + const topEdge = u.valToPos(rowIndex + 1, 'y', true) + const bottomEdge = u.valToPos(rowIndex, 'y', true) + ctx.fillStyle = rampColor(colorRamp, rampT(count, maxSampleCount)) + ctx.fillRect( + leftEdge - CELL_OVERFLOW, + topEdge - CELL_OVERFLOW, + rightEdge - leftEdge + CELL_OVERFLOW, + bottomEdge - topEdge + CELL_OVERFLOW + ) + }) + }) + ctx.restore() + }, + [distributions, leftEdges, rightEdges, colCount, binCount, maxSampleCount, colorRamp] + ) + + const tooltipPlugin = useMemo( + () => ({ + hooks: { + setCursor: (u) => { + const { left, top } = u.cursor + if (left == null || top == null || left < 0 || top < 0) { + setHover(null) + return + } + + const xVal = u.posToVal(left, 'x') + const col = R.findIndex( + leftEdges, + (leftEdge, i) => xVal >= leftEdge && xVal <= rightEdges[i] + ) + if (col === -1) { + setHover(null) + return + } + + const row = Math.min(binCount - 1, Math.max(0, Math.floor(u.posToVal(top, 'y')))) + // cursor coords are relative to the plot area, so we add in the diff between the plot + // and the whole container + const plotRect = u.over.getBoundingClientRect() + const chartRect = u.root.getBoundingClientRect() + const containerLeft = plotRect.left - chartRect.left + const containerTop = plotRect.top - chartRect.top + + const clampWidth = R.clamp({ min: 0, max: plotRect.width }) + const clampHeight = R.clamp({ min: 0, max: plotRect.height }) + const cellLeft = clampWidth(u.valToPos(leftEdges[col], 'x')) + const cellRight = clampWidth(u.valToPos(rightEdges[col], 'x')) + const cellTop = clampHeight(u.valToPos(row + 1, 'y')) + const cellBottom = clampHeight(u.valToPos(row, 'y')) + + // anchor the tooltip at the timestamp (the cell's right edge / identity) + const x = u.valToPos(rightEdges[col], 'x') + setHover({ + col, + row, + left: containerLeft + x, + top: containerTop + top, + leftRight: x > plotRect.width / 2 ? 'left' : 'right', + topBottom: top > plotRect.height / 2 ? 'top' : 'bottom', + cell: { + left: containerLeft + cellLeft, + top: containerTop + cellTop, + width: cellRight - cellLeft, + height: cellBottom - cellTop, + }, + }) + }, + init: (u) => { + u.over.addEventListener('mouseleave', () => setHover(null)) + }, + }, + }), + [binCount, rightEdges, leftEdges] + ) + + const startTime = timestamps.length ? new Date(Math.min(...timestamps)) : new Date() + const endTime = timestamps.length ? new Date(Math.max(...timestamps)) : new Date() + const formatTime = timeFormatterForRange(startTime, endTime) + + const { uRef, formatterRef } = useLiveAxisFormatter(yAxisTickFormatter) + + const chartOptions = useMemo(() => { + const highestFilledBin = Math.max( + -1, + ...distributions + .filter((d) => d !== null) + .map(({ counts }) => R.findLastIndex(counts, (n) => n > 0)) + ) + + const xRange: uPlot.Range.MinMax = + colCount > 0 ? [leftEdges[0], rightEdges[colCount - 1]] : [0, 1] + + return { + scales: { + x: { range: xRange }, + y: { + range: [ + 0, + highestFilledBin === -1 ? binCount : highestFilledBin + 1, + ] as uPlot.Range.MinMax, + }, + }, + // include an invisible series just to get uPlot drawing + series: [{}, { show: false }], + axes: [ + xTimeAxis({ theme, formatTime }), + yValueAxis({ + theme, + grid: { show: false }, + values: (_u, splits) => + splits.map((v) => { + const i = R.clamp(Math.round(v), { min: 0, max: binCount - 1 }) + return formatterRef.current(bins[i]) + }), + }), + ], + cursor: { x: false, y: false, drag: { x: false } }, + legend: { show: false }, + plugins: [{ hooks: { draw: [drawCells] } }, tooltipPlugin], + } satisfies UPlotOptions + }, [ + distributions, + rightEdges, + leftEdges, + colCount, + theme, + formatTime, + bins, + binCount, + drawCells, + tooltipPlugin, + formatterRef, + ]) + + const data = useMemo( + () => [rightEdges, Array(colCount).fill(null)], + [rightEdges, colCount] + ) + + if (binCount === 0 || colCount === 0) { + return ( +
+ No distribution data for this time period. +
+ ) + } + + return ( + + } + > + {hover && ( + <> +
+ +
+ {formatterRef.current(bins[hover.row])} + {bins[hover.row + 1] === undefined + ? `+` + : `\u2013${formatterRef.current(bins[hover.row + 1])}`} + {unit && {unit}} +
+
+ {(distributions[hover.col]?.counts[hover.row] ?? 0).toLocaleString()} samples +
+
+ + )} + + ) +} + +function HeatmapLegend({ + ramp, + maxSampleCount, + axisText, +}: { + ramp: ColorRamp + maxSampleCount: number + axisText: string +}) { + const backgroundImage = `linear-gradient(to right, ${ramp.stops + .map((r) => `oklch(${r.l} ${r.c} ${r.h})`) + .join(', ')})` + return ( +
+ + 0 + +
+ + {maxSampleCount.toLocaleString()} + +
+ ) +} diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index d1da5ddcb..2fb2d671c 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -6,153 +6,28 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' -import { format } from 'date-fns' -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import * as R from 'remeda' import { match } from 'ts-pattern' import uPlot from 'uplot' -import UplotReact from 'uplot-react' import type { ChartDatum } from '@oxide/api' import { Error12Icon } from '@oxide/design-system/icons/react' -import { useElementSize } from '~/hooks/use-element-size' -import { subscribeToTheme } from '~/stores/theme' +import { ChartTooltip, type LeftRight, type TopBottom } from '~/components/ChartTooltip' +import { FramedChart, type UPlotOptions } from '~/components/FramedChart' +import { + type ChartTheme, + seriesColor, + timeFormatterForRange, + useChartTheme, + useLiveAxisFormatter, + xTimeAxis, + yValueAxis, +} from '~/util/charts' import { classed } from '~/util/classed' -/** - * Check if the start and end time are on the same day - * If they are we can omit the day/month in the date time format - */ -function isSameDay(d1: Date, d2: Date) { - return ( - d1.getFullYear() === d2.getFullYear() && - d1.getMonth() === d2.getMonth() && - d1.getDate() === d2.getDate() - ) -} - -const shortDateTime = (ts: number) => { - const date = new Date(ts) - return format( - date, - date.getHours() === 0 && date.getMinutes() === 0 ? 'M/d' : 'M/d HH:mm' - ) -} -const shortTime = (ts: number) => format(new Date(ts), 'HH:mm') -const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss zz') - -const remToPx = (rem: number) => - rem * parseFloat(getComputedStyle(document.documentElement).fontSize) -// We measure axis label widths on a detached canvas instead of uPlot's to avoid overwriting its -// own font setting. -const measureCtx = document.createElement('canvas').getContext('2d') -const measureTextWidth = (text: string, font: string) => { - // getContext('2d') is only null if '2d' is unsupported, which, hey, you're not getting a graph - if (!measureCtx) return 0 - measureCtx.font = font - return measureCtx.measureText(text).width -} - -const AXIS_FONT_REM_XS = 0.6875 -const AXIS_TICK_LENGTH = 6 -const AXIS_TICK_GAP = 8 -// Left padding (px-5) is taken from the container and given to uPlot instead, so the plot sits -// flush left while x-tick labels can bleed into the gutter without clipping. -const CHART_LEFT_PAD = 20 const CHART_HEIGHT = 300 -const TOOLTIP_GAP = 12 - -type ChartTheme = { - fontFamily: string - stroke: string - fill: string - hoverPoint: string - axisLine: string - axisText: string - lineColors: string[] -} - -// Append an alpha channel to a resolved color, e.g. `oklch(l c h)` -> `oklch(l c h / 0.6)`. Assumes -// our colors are set in oklch! -const withAlpha = (color: string, alpha: number) => color.replace(/\)\s*$/, ` / ${alpha})`) - -// uPlot draws to a canvas, so it can't consume CSS custom properties directly. We subscribe to the -// theme instead. -function getChartTheme(): ChartTheme { - const style = getComputedStyle(document.body) - const v = (name: string) => style.getPropertyValue(name) - return { - fontFamily: v('--font-mono'), - stroke: v('--stroke-accent-secondary'), - fill: withAlpha(v('--surface-accent-secondary'), 0.6), - hoverPoint: v('--content-accent'), - axisLine: v('--stroke-secondary'), - axisText: v('--content-quaternary'), - lineColors: [ - '--color-green-800', - '--color-blue-800', - '--color-purple-800', - '--color-yellow-800', - '--color-red-800', - ].map(v), - } -} - -const seriesColor = (i: number, theme: ChartTheme): string => - theme.lineColors[i] || - `oklch(0.77 0.175 ${((163.7 + (i - theme.lineColors.length) * 137.508) % 360).toFixed(1)})` - -function useChartTheme(): ChartTheme { - const [colors, setColors] = useState(getChartTheme) - useEffect(() => subscribeToTheme(() => setColors(getChartTheme())), []) - return colors -} - -/** Offset the box into the quadrant away from the point so it never overflows an edge */ -type LeftRight = 'left' | 'right' -type TopBottom = 'top' | 'bottom' -function tooltipTransform(leftRight: LeftRight, topBottom: TopBottom): string { - const tx = match(leftRight) - .with('left', () => `calc(-100% - ${TOOLTIP_GAP}px)`) - .with('right', () => `${TOOLTIP_GAP}px`) - .exhaustive() - const ty = match(topBottom) - .with('top', () => `calc(-100% - ${TOOLTIP_GAP}px)`) - .with('bottom', () => `${TOOLTIP_GAP}px`) - .exhaustive() - return `translate(${tx}, ${ty})` -} - -function ChartTooltip({ - timestamp, - value, - seriesName, - unit, -}: { - timestamp: number - value: number - seriesName: string - unit?: string -}) { - return ( -
-
- {longDateTime(timestamp)} -
-
-
{seriesName}
-
- {value.toLocaleString()} - {unit && {unit}} -
-
-
- ) -} type TimeSeriesChartProps = { timestamps: number[] | undefined @@ -234,12 +109,8 @@ export function TimeSeriesChart({ seriesLabels, }: TimeSeriesChartProps) { const theme = useChartTheme() - const fontPx = remToPx(AXIS_FONT_REM_XS) - const axisFont = `${fontPx}px ${theme.fontFamily}` - - const [size, sizeRef] = useElementSize() - const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime + const formatTime = timeFormatterForRange(startTime, endTime) const dataLength = data?.length ?? 0 @@ -304,20 +175,7 @@ export function TimeSeriesChart({ [] ) - const uRef = useRef(null) - const yAxisTickFormatterRef = useRef<(val: number) => string>(yAxisTickFormatter) - yAxisTickFormatterRef.current = yAxisTickFormatter - useEffect(() => { - uRef.current?.redraw( - // Setting the `rebuildPaths` argument to true causes uPlot to reapply the _current_ x bounds, - // which in the right conditions (e.g., initial render) can leave the chart blank. We only - // need the axes recalculated anyways! - // - // See https://github.com/leeoniya/uPlot/issues/1099 - false, // rebuildPaths - true // recalcAxes - ) - }, [yAxisTickFormatter]) + const { uRef, formatterRef } = useLiveAxisFormatter(yAxisTickFormatter) // uplot-react rebuilds the whole chart (they call this the "create" path) when any top-level // option (other than width or height) changes by reference. @@ -344,47 +202,14 @@ export function TimeSeriesChart({ })), ], axes: [ - { - stroke: theme.axisText, - font: axisFont, - space: (_u, _axisIdx, _min, _max, plotDim) => plotDim / 5, - values: (_u, times) => times.map((t) => formatTime(t * 1000)), - border: { show: true, stroke: theme.axisLine, width: 1 }, - gap: AXIS_TICK_GAP, - grid: { show: false }, - size: fontPx + AXIS_TICK_GAP + AXIS_TICK_LENGTH, - ticks: { - show: true, - stroke: theme.axisLine, - width: 1, - size: AXIS_TICK_LENGTH, - }, - }, - { - stroke: theme.axisText, - font: axisFont, - side: 1, - border: { show: true, stroke: theme.axisLine, width: 1 }, - gap: AXIS_TICK_GAP, - ticks: { - show: true, - stroke: theme.axisLine, - width: 1, - size: AXIS_TICK_LENGTH, - filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)), - }, - values: (_u, yValues) => - yValues.map((v) => (v === 0 ? '' : yAxisTickFormatterRef.current(v))), + xTimeAxis({ theme, formatTime }), + yValueAxis({ + theme, grid: { show: true, stroke: theme.axisLine, width: 1 }, - size: (_self, values) => { - const axisBase = AXIS_TICK_LENGTH + AXIS_TICK_GAP - // given the monospace font, longest by char count is longest by rendered width - const longestVal = R.firstBy(values ?? [], (s) => -s.length) || '' - return axisBase + measureTextWidth(longestVal, axisFont) - }, - }, + values: (_u, yValues) => + yValues.map((v) => (v === 0 ? '' : formatterRef.current(v))), + }), ], - padding: [null, null, null, CHART_LEFT_PAD], focus: { alpha: 0.5 }, cursor: { // setting this property causes non-focused series to dim on hover. @@ -402,20 +227,8 @@ export function TimeSeriesChart({ }, legend: { show: false }, plugins: [tooltipPlugin], - }) satisfies Omit, - [dataLength, formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] - ) - - // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets - // its own layer of memo - const options = useMemo( - () => - ({ - ...chartOptions, - width: size?.width ?? 0, - height: CHART_HEIGHT, - }) satisfies uPlot.Options, - [chartOptions, size?.width] + }) satisfies UPlotOptions, + [dataLength, formatTime, tooltipPlugin, interpolation, theme, formatterRef] ) const aligned = useMemo(() => { @@ -464,55 +277,42 @@ export function TimeSeriesChart({ : undefined return ( -
- {/* The chart is absolutely positioned so its fixed pixel width doesn't feed back into the - container's min-content width — otherwise the chart props the container open and it can - grow but never shrink. The wrapper needs an explicit height because the absolute child - contributes none, so it gets the same fixed height passed to uPlot. */} -
- {/* Wait for the container measurement rather than creating a zero-width chart and - immediately resizing it. The chart may appear a frame after the container, but the - zero-width version had the same gap: an invisible chart until the measurement - arrived through the same ResizeObserver → setState path. */} - {size && ( - (uRef.current = u)} + - )} - {tooltip && hovered && ( -
- + ) + } + > + {tooltip && hovered && ( + +
+ {seriesLabels + ? seriesLabel(title, tooltip.hoveredSeriesIndex, seriesLabels) + : title}
- )} -
- {seriesLabels && ( - +
+ {hovered.value.toLocaleString()} + {unit && {unit}} +
+ )} -
+ ) } diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 1d1f1426d..7944d539a 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -16,16 +16,19 @@ import { api, useApiMutation, camelToSnake, - type Timeseries, - type Points, + type Distributiondouble, + type MetricType, type OxqlTable, + type Points, + type Timeseries, type TimeseriesQuery, - type Values, + type ValueArray, } from '@oxide/api' import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { OxqlField } from '~/components/form/fields/OxqlField' +import { Heatmap } from '~/components/Heatmap' import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeSeriesChart' import { useElementSize } from '~/hooks/use-element-size' import { Button } from '~/ui/lib/Button' @@ -63,6 +66,12 @@ const exampleItems: { label: string; value: string }[] = [ | filter timestamp > @now() - 10m | join`, }, + { + label: 'Virtual disk write latencies', + value: `get virtual_disk:io_latency + | filter timestamp > @now() - 10m + | filter io_kind == 'write'`, + }, ] const defaultValues: TimeseriesQuery = { @@ -71,8 +80,8 @@ const defaultValues: TimeseriesQuery = { export const handle = { crumb: 'OxQL Explorer' } -const narrowToNumbers = (vs: Values): (number | null)[] => - match(vs.values) +const narrowToNumbers = (vs: ValueArray): (number | null)[] => + match(vs) .with({ type: 'integer' }, ({ values }) => values) .with({ type: 'double' }, ({ values }) => values) .with({ type: 'boolean' }, ({ values }) => @@ -85,10 +94,21 @@ const narrowToNumbers = (vs: Values): (number | null)[] => ) ) .with({ type: 'string' }, () => []) // these don't exist in practice - .with({ type: 'integer_distribution' }, () => []) // by only calling this on aligned/joined tables, we know this is unreachable + // by only calling this on non-distribution tables (distributions can't be + // aligned/joined), we know this is unreachable + .with({ type: 'integer_distribution' }, () => []) .with({ type: 'double_distribution' }, () => []) // these don't exist in practice, and are also unreachable per above .exhaustive() +const narrowToDistributions = (vs: ValueArray): (Distributiondouble | null)[] => + match(vs) + .with( + { type: 'integer_distribution' }, + { type: 'double_distribution' }, + ({ values }) => values + ) + .otherwise(() => []) + const leftPad = (items: T[], length: number): (T | null)[] => items.length >= length ? items : [...Array(length - items.length).fill(null), ...items] @@ -98,6 +118,15 @@ type OxqlTimestamp = Points['timestamps'][number] const parseTs = (ts: OxqlTimestamp): number => new Date(ts).getTime() const toPosix = (timestamps: OxqlTimestamp[]): number[] => timestamps.map(parseTs) +// Distributions always carry start_times in practice, but this isn't a +// guaranteed invariant. But start times are generally "the timestamp +// preceding the current one", so we can derive that ourselves. +const fakeStartTimes = (timestamps: number[]): number[] => { + if (timestamps.length === 0) return [] + const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : 0 + return [timestamps[0] - interval, ...timestamps.slice(0, -1)] +} + type TimeseriesKind = 'joined' | 'aligned' | 'unaligned' /** @@ -157,11 +186,17 @@ type Chart = { } type Multiline = Chart<{ label: string; values: (number | null)[] }[]> +type Line = Chart<(number | null)[]> & { metricType: MetricType } +type Heatmap = Chart<(Distributiondouble | null)[]> & { + metricType: MetricType + startTimes: number[] +} type ChartGroup = | 'empty-timeseries' | ({ startTime: Date; endTime: Date } & ( - | { kind: 'unaligned'; charts: Chart[] } + | { kind: 'unaligned'; charts: Line[] } + | { kind: 'distributions'; charts: Heatmap[] } | { kind: 'aligned'; charts: Multiline[] } | { kind: 'joined'; charts: Multiline[] } )) @@ -212,7 +247,7 @@ const tableToGroup = (table: OxqlTable): ChartGroup => { metricNames[i] || // should be unreachable `${getFormattedFields(series)} #${i + 1}`, - values: narrowToNumbers(v), + values: narrowToNumbers(v.values), })), })), } @@ -227,22 +262,52 @@ const tableToGroup = (table: OxqlTable): ChartGroup => { .filter((s) => s.points.values.length > 0) .map((series) => ({ label: getFormattedFields(series), - values: leftPad(narrowToNumbers(series.points.values[0]), timestamps.length), + values: leftPad( + narrowToNumbers(series.points.values[0].values), + timestamps.length + ), })), }, ], })) - .with('unaligned', (kind) => ({ - kind, - charts: timeseries - .filter((s) => s.points.values.length > 0) - .map((series) => ({ - name, - description: getFormattedFields(series), - timestamps: toPosix(series.points.timestamps), - data: series.points.values[0], - })), - })) + .with('unaligned', () => { + const seriesList = timeseries.filter((s) => s.points.values.length > 0) + // all schemas in a table are the same, so we can just check the first + // https://github.com/oxidecomputer/omicron/blob/3de7e909b196c07811025bbf41aaa8a35e6fa3cf/oximeter/oxql-types/src/table.rs#L280 + const valueType = seriesList[0]?.points.values[0]?.values.type + // if no series had any values, there's nothing to chart + if (valueType === undefined) return { kind: 'unaligned' as const, charts: [] } + + return match(valueType) + .with('integer_distribution', 'double_distribution', () => ({ + kind: 'distributions' as const, + charts: seriesList.map((series): Heatmap => { + const timestamps = toPosix(series.points.timestamps) + return { + name, + description: getFormattedFields(series), + timestamps, + metricType: series.points.values[0].metricType, + startTimes: + series.points.startTimes?.map(parseTs) ?? fakeStartTimes(timestamps), + data: narrowToDistributions(series.points.values[0].values), + } + }), + })) + .with('integer', 'double', 'boolean', 'string', () => ({ + kind: 'unaligned' as const, + charts: seriesList.map( + (series): Line => ({ + name, + description: getFormattedFields(series), + timestamps: toPosix(series.points.timestamps), + metricType: series.points.values[0].metricType, + data: narrowToNumbers(series.points.values[0].values), + }) + ), + })) + .exhaustive() + }) .exhaustive() const timestamps = chart.charts.flatMap(({ timestamps }) => timestamps) const min = R.firstBy(timestamps, (t) => t) @@ -270,16 +335,31 @@ const formatTick = (n: number): string => { return (n / divisor).toLocaleString() + suffix } -// Drops (or keeps, without copying) the first sample of a series. We trim timestamps and values at -// the same time to be confident they're in sync. -type TimeAndData = { timestamps: number[]; data: (number | null)[][] } -type Trim = (t: TimeAndData) => TimeAndData -const firstPointDropper = - (drop: boolean): Trim => - ({ timestamps, data }) => - drop - ? { timestamps: timestamps.slice(1), data: data.map((d) => d.slice(1)) } - : { timestamps, data } +// Drops (or keeps, without copying) the first sample of a series. +const dropFirst = + (drop: boolean) => + (xs: T[]): T[] => + drop ? xs.slice(1) : xs + +// We trim timestamps and data at the same time to be confident they're in sync. +const trimSeries = ( + trim: boolean, + { timestamps, data }: { timestamps: number[]; data: T[][] } +) => { + const d = dropFirst(trim) + return { timestamps: d(timestamps), data: data.map(d) } +} +const trimHeatmap = ( + trim: boolean, + { + timestamps, + startTimes, + data, + }: { timestamps: number[]; startTimes: number[]; data: T[] } +) => { + const d = dropFirst(trim) + return { timestamps: d(timestamps), startTimes: d(startTimes), data: d(data) } +} // The first aligned point of a cumulative counter is diffed against the counter's start_time, // collapsing all pre-window history into one giant bucket. It's not "erroneous" but it's usually @@ -290,16 +370,17 @@ const groupHasPointWorthDropping = (g: ChartGroup): boolean => // Aligned/joined tables may be derived from cumulatives, so we assume it's worth offering .with({ kind: 'joined' }, { kind: 'aligned' }, () => true) // Gauges are, by definition, not cumulative, so you'll never see a giant first point - .with({ kind: 'unaligned' }, ({ charts }) => - charts.some((c) => c.data.metricType !== 'gauge') + .with({ kind: 'unaligned' }, { kind: 'distributions' }, ({ charts }) => + charts.some((c) => c.metricType !== 'gauge') ) .exhaustive() -// A simplified representation of a single chart. +// A flattened representation of a single chart. type ChartDisplay = { key: string; showDivider: boolean } & ( | { kind: 'empty' } | { kind: 'multiline'; startTime: Date; endTime: Date; chart: Multiline } - | { kind: 'line'; startTime: Date; endTime: Date; chart: Chart } + | { kind: 'line'; startTime: Date; endTime: Date; chart: Line } + | { kind: 'heatmap'; chart: Heatmap } ) // Virtualization relies on a list of near-same-size items, so we flatten out all the groups @@ -309,6 +390,16 @@ const toDisplays = (groups: ChartGroup[]): ChartDisplay[] => return [{ kind: 'empty', key: `t${t}`, showDivider: true }] const { startTime, endTime } = g return match(g) + .with({ kind: 'distributions' }, ({ charts }) => + charts.map( + (chart, i): ChartDisplay => ({ + kind: 'heatmap', + key: `t${t}.${i}`, + showDivider: i === 0, + chart, + }) + ) + ) .with({ kind: 'unaligned' }, ({ charts }) => charts.map( (chart, i): ChartDisplay => ({ @@ -341,10 +432,10 @@ function MultilineChart({ trim, }: { display: Extract - trim: Trim + trim: boolean }) { const { chart, startTime, endTime } = display - const trimmed = trim({ + const trimmed = trimSeries(trim, { timestamps: chart.timestamps, data: chart.data.map((d) => d.values), }) @@ -373,25 +464,10 @@ function LineChart({ trim, }: { display: Extract - trim: Trim + trim: boolean }) { const { chart, startTime, endTime } = display - const data = match(chart.data.values) - .with({ type: 'integer' }, ({ values }) => values) - .with({ type: 'double' }, ({ values }) => values) - .with({ type: 'boolean' }, ({ values }) => - values.map((b) => - match(b) - .with(true, () => 1) - .with(false, () => 0) - .with(null, () => null) - .exhaustive() - ) - ) - .with({ type: 'string' }, () => []) // these don't exist in practice - .with({ type: 'integer_distribution' }, { type: 'double_distribution' }, () => []) // heatmaps! - .exhaustive() - const trimmed = trim({ data: [data], timestamps: chart.timestamps }) + const trimmed = trimSeries(trim, { data: [chart.data], timestamps: chart.timestamps }) return ( @@ -410,7 +486,34 @@ function LineChart({ ) } -function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { +function HeatmapChart({ + display, + trim, +}: { + display: Extract + trim: boolean +}) { + const { chart } = display + const trimmed = trimHeatmap(trim, { + timestamps: chart.timestamps, + startTimes: chart.startTimes, + data: chart.data, + }) + return ( + + + + + ) +} + +function ChartEntry({ display, trim }: { display: ChartDisplay; trim: boolean }) { return ( <> {display.showDivider ? ( @@ -425,6 +528,7 @@ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { .with({ kind: 'empty' }, () =>

No results

) .with({ kind: 'multiline' }, (r) => ) .with({ kind: 'line' }, (r) => ) + .with({ kind: 'heatmap' }, (r) => ) .exhaustive()} ) @@ -473,7 +577,7 @@ export default function OxqlPage() { ) const hasTrimmableCharts = chartGroups?.some(groupHasPointWorthDropping) ?? false - const trim = firstPointDropper(dropFirstPoint && hasTrimmableCharts) + const trim = dropFirstPoint && hasTrimmableCharts const charts = useMemo(() => (chartGroups ? toDisplays(chartGroups) : []), [chartGroups]) diff --git a/app/util/charts.ts b/app/util/charts.ts new file mode 100644 index 000000000..4db17fc85 --- /dev/null +++ b/app/util/charts.ts @@ -0,0 +1,206 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { format } from 'date-fns' +import { useEffect, useRef, useState } from 'react' +import * as R from 'remeda' +import type uPlot from 'uplot' + +import { subscribeToTheme } from '~/stores/theme' + +/** + * Shared plumbing for our uPlot-based charts (`TimeSeriesChart`, `Heatmap`). + */ + +/** + * Check if the start and end time are on the same day. If they are we can omit + * the day/month in the date time format. + */ +function isSameDay(d1: Date, d2: Date) { + return ( + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() + ) +} + +const shortDateTime = (ts: number) => { + const date = new Date(ts) + return format( + date, + date.getHours() === 0 && date.getMinutes() === 0 ? 'M/d' : 'M/d HH:mm' + ) +} +const shortTime = (ts: number) => format(new Date(ts), 'HH:mm') +/** Pick the x-axis time formatter based on whether the window spans a day. */ +export const timeFormatterForRange = (startTime: Date, endTime: Date) => + isSameDay(startTime, endTime) ? shortTime : shortDateTime + +export const remToPx = (rem: number) => + rem * parseFloat(getComputedStyle(document.documentElement).fontSize) + +// We measure axis label widths on a detached canvas instead of uPlot's to avoid +// overwriting its own font setting. +const measureCtx = document.createElement('canvas').getContext('2d') +export const measureTextWidth = (text: string, font: string) => { + // getContext('2d') is only null if '2d' is unsupported, which, hey, you're not getting a graph + if (!measureCtx) return 0 + measureCtx.font = font + return measureCtx.measureText(text).width +} + +export const AXIS_FONT_REM_XS = 0.6875 +export const AXIS_TICK_LENGTH = 6 +export const AXIS_TICK_GAP = 8 + +export type ChartTheme = { + fontFamily: string + stroke: string + hoverPoint: string + fill: string + axisLine: string + axisText: string + lineColors: string[] +} + +// Append an alpha channel to a resolved color, e.g. `oklch(l c h)` -> `oklch(l c h / 0.6)`. Assumes +// our colors are set in oklch! +export const withAlpha = (color: string, alpha: number) => + color.replace(/\)\s*$/, ` / ${alpha})`) + +// uPlot draws to a canvas, so it can't consume CSS custom properties directly. We subscribe to the +// theme instead. +export function getChartTheme(): ChartTheme { + const style = getComputedStyle(document.body) + const v = (name: string) => style.getPropertyValue(name) + return { + fontFamily: v('--font-mono'), + stroke: v('--stroke-accent-secondary'), + hoverPoint: v('--content-accent'), + fill: withAlpha(v('--surface-accent-secondary'), 0.6), + axisLine: v('--stroke-secondary'), + axisText: v('--content-quaternary'), + lineColors: [ + '--color-green-800', + '--color-blue-800', + '--color-purple-800', + '--color-yellow-800', + '--color-red-800', + ].map(v), + } +} + +export const seriesColor = (i: number, theme: ChartTheme): string => + theme.lineColors[i] || + `oklch(0.77 0.175 ${((163.7 + (i - theme.lineColors.length) * 137.508) % 360).toFixed(1)})` + +export function useChartTheme(): ChartTheme { + const [colors, setColors] = useState(getChartTheme) + useEffect(() => subscribeToTheme(() => setColors(getChartTheme())), []) + return colors +} + +/** The monospace axis font at our standard axis size, plus its pixel size. */ +export function chartAxisFont(theme: ChartTheme): { fontPx: number; axisFont: string } { + const fontPx = remToPx(AXIS_FONT_REM_XS) + return { fontPx, axisFont: `${fontPx}px ${theme.fontFamily}` } +} + +/** + * Keeps a chart's y-axis labels in sync with a `yAxisTickFormatter` whose + * identity may change across renders. Returns two refs: + * + * - `formatterRef`: `.current` points to the yAxisTickFormatter so you can + * reference it without it being a memo dependency. + * - `uRef`: assign in ``. Needed to call for axis + * recalculation. + */ +export function useLiveAxisFormatter(yAxisTickFormatter: (val: number) => string) { + const uRef = useRef(null) + const formatterRef = useRef(yAxisTickFormatter) + formatterRef.current = yAxisTickFormatter + useEffect(() => { + // Setting the `rebuildPaths` argument to true causes uPlot to reapply the + // _current_ x bounds, which in the right conditions (e.g., initial render) + // can leave the chart blank. We only need the axes recalculated anyways! + // + // See https://github.com/leeoniya/uPlot/issues/1099 + uRef.current?.redraw( + false, // rebuildPaths + true // recalcAxes + ) + }, [yAxisTickFormatter]) + return { uRef, formatterRef } +} + +/** + * The bottom time axis, shared so every chart gets the same uPlot tick + * calculation, formatting, and styling. + */ +export function xTimeAxis({ + theme, + formatTime, +}: { + theme: ChartTheme + formatTime: (ts: number) => string +}): uPlot.Axis { + const { fontPx, axisFont } = chartAxisFont(theme) + return { + stroke: theme.axisText, + font: axisFont, + space: (_u, _axisIdx, _min, _max, plotDim) => plotDim / 5, + values: (_u, times) => times.map((t) => formatTime(t * 1000)), + border: { show: true, stroke: theme.axisLine, width: 1 }, + gap: AXIS_TICK_GAP, + grid: { show: false }, + size: fontPx + AXIS_TICK_GAP + AXIS_TICK_LENGTH, + ticks: { + show: true, + stroke: theme.axisLine, + width: 1, + size: AXIS_TICK_LENGTH, + }, + } +} + +/** + * The right value axis, shared so every chart gets the same uPlot tick + * calculation, formatting, and styling. + */ +export function yValueAxis({ + theme, + grid, + values, +}: { + theme: ChartTheme + grid: uPlot.Axis.Grid + values: uPlot.Axis.Values +}): uPlot.Axis { + const { axisFont } = chartAxisFont(theme) + return { + stroke: theme.axisText, + font: axisFont, + side: 1, + border: { show: true, stroke: theme.axisLine, width: 1 }, + gap: AXIS_TICK_GAP, + grid, + size: (_self, values) => { + const axisBase = AXIS_TICK_LENGTH + AXIS_TICK_GAP + // given the monospace font, longest by char count is longest by rendered width + const longestVal = R.firstBy(values ?? [], (s) => -s.length) || '' + return axisBase + measureTextWidth(longestVal, axisFont) + }, + ticks: { + show: true, + stroke: theme.axisLine, + width: 1, + size: AXIS_TICK_LENGTH, + filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)), + }, + values, + } +} diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 1be94c53a..de0698ef4 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -52,6 +52,7 @@ import { timeseriesFrom, resultFrom, getMockValues, + getHistoPoints, } from '../oxql-metrics' import { db, lookupById } from './db' import { Rando } from './rando' @@ -675,9 +676,24 @@ function getMultipleTables(vibe: OxqlVibe) { .exhaustive() } +const histogramTables = new Set([ + 'virtual_disk:io_latency', + 'virtual_disk:io_size', + 'http_service:request_latency_histogram', + 'oximeter_collector:database_queue_depth', +]) + export function handleOxqlMetrics({ query }: TimeseriesQuery): Json { const vibe = getVibe(query) + if (histogramTables.has(vibe.firstTable)) + return resultFrom([ + { + name: vibe.firstTable, + timeseries: [timeseriesFrom(instances[0].id, getHistoPoints())], + }, + ]) + if (vibe.moreTables.length > 0) return getMultipleTables(vibe) const stateValue = getCpuStateFromQuery(query) diff --git a/mock-api/oxql-metrics.ts b/mock-api/oxql-metrics.ts index 84649a09d..02d2745f7 100644 --- a/mock-api/oxql-metrics.ts +++ b/mock-api/oxql-metrics.ts @@ -5,6 +5,8 @@ * * Copyright Oxide Computer Company */ +import * as R from 'remeda' + import type { Timeseries, Points, OxqlQueryResult } from '~/api' import type { OxqlMetricName, OxqlVcpuState } from '~/components/oxql-metrics/util' @@ -336,3 +338,50 @@ const mockOxqlVcpuStateValues: Record = { 5131885.651897, 5188225.092888, 4388460.254213, 4075678.463765, 3943427.938256, ], } + +const histogramBins = Array.from({ length: 30 }, (_, i) => i * 250_000) + +export const getHistoPoints = (): Json => { + const rando = new Rando(0) + const binCount = histogramBins.length + + const timeline = getJitteredTimestamps(0) + const startTimes = timeline.slice(0, -1) + const timestamps = timeline.slice(1) + + const distributions = timestamps.map(() => { + const center = (binCount / 4) * 1.5 + const spread = 2.5 + const counts = histogramBins.map((_, binIndex) => { + const MAX_VALUE = 400 + const count = MAX_VALUE * Math.exp(-((binIndex - center) ** 2) / (2 * spread ** 2)) + const randomSubtraction = rando.next() * 30 + const randomDivision = 1 + rando.next() * 0.1 + const nearFinal = Math.round(Math.max(0, count / randomDivision - randomSubtraction)) + return nearFinal < MAX_VALUE / 10 && rando.next() > 0.75 ? 0 : nearFinal + }) + + return { + bins: histogramBins, + counts, + min: 0, + max: histogramBins[binCount - 1], + sum_of_samples: R.sum(counts), + squared_mean: 0, + p50: histogramBins[Math.round(center)] ?? 0, + p90: histogramBins[Math.min(binCount - 1, Math.round(center + spread))] ?? 0, + p99: histogramBins[Math.min(binCount - 1, Math.round(center + 2 * spread))] ?? 0, + } + }) + + return { + start_times: startTimes, + timestamps, + values: [ + { + values: { type: 'integer_distribution', values: distributions }, + metric_type: 'delta', + }, + ], + } +} From 89447644886f042292a86ea1d12c1914b8d56681 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 24 Aug 2026 11:43:24 -0700 Subject: [PATCH 2/6] Export skeletons from TimeSeriesChart We're in an odd state now; non-oxql pages pass their loading/error/empty responsibilities down to the chart, but when there's an unknown number of charts to render, we need to handle that responsibility up in the caller. --- app/components/TimeSeriesChart.tsx | 4 ++-- app/pages/system/OxqlPage.tsx | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 2fb2d671c..b97ecbdd6 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -44,7 +44,7 @@ type TimeSeriesChartProps = { } // this top margin is also in the chart, probably want a way of unifying the sizing between the two -const SkeletonMetric = ({ +export const SkeletonMetric = ({ children, shimmer = false, className, @@ -364,7 +364,7 @@ const MetricsError = () => ( /> ) -const MetricsEmpty = () => ( +export const MetricsEmpty = () => ( No data
} diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 7944d539a..f82a68de5 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -29,7 +29,13 @@ import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/r import { DocsPopover } from '~/components/DocsPopover' import { OxqlField } from '~/components/form/fields/OxqlField' import { Heatmap } from '~/components/Heatmap' -import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeSeriesChart' +import { + ChartContainer, + ChartHeader, + SkeletonMetric, + MetricsEmpty, + TimeSeriesChart, +} from '~/components/TimeSeriesChart' import { useElementSize } from '~/hooks/use-element-size' import { Button } from '~/ui/lib/Button' import { Divider } from '~/ui/lib/Divider' @@ -525,7 +531,11 @@ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: boolean })
)} {match(display) - .with({ kind: 'empty' }, () =>

No results

) + .with({ kind: 'empty' }, () => ( + + + + )) .with({ kind: 'multiline' }, (r) => ) .with({ kind: 'line' }, (r) => ) .with({ kind: 'heatmap' }, (r) => ) From 634dab703250306f4c957fb266d1979716ed63ac Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 24 Aug 2026 15:24:21 -0700 Subject: [PATCH 3/6] bug: quit parsing oklch I was doing it wrong and also I don't have to. --- app/components/Heatmap.tsx | 39 +++++++++++--------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index d9d08ea26..0a8cec393 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -29,53 +29,38 @@ const CHART_HEIGHT = 300 // without screwing up the grid too much const CELL_OVERFLOW = 0 -type OklchColor = { l: number; c: number; h: number } - -const parseOklch = (s: string): OklchColor | null => { - const m = s.match(/oklch\(([^)]+)\)/) - if (!m) return null - const [l, c, h] = m[1].split(/[\s/]+/).map(Number) - return { l, c, h } -} - -const lerp = (a: number, b: number, t: number) => a + (b - a) * t - type ColorRamp = { - stops: OklchColor[] + stops: string[] } -// Interpolates the color from between the two closest stops, for 0 <= t <= 1. +// Interpolates the color between the two closest stops, for 0 <= t <= 1. const rampColor = ({ stops }: ColorRamp, t: number): string => { const clamped = R.clamp(t, { min: 0, max: 1 }) const lastIndex = stops.length - 1 const progress = clamped * lastIndex const start = R.clamp(Math.floor(progress), { max: lastIndex - 1 }) - const lerpT = progress - start + const lerp = progress - start const from = stops[start] const to = stops[start + 1] - - return `oklch(${lerp(from.l, to.l, lerpT)} ${lerp(from.c, to.c, lerpT)} ${lerp(from.h, to.h, lerpT)})` + return `color-mix(in oklch, ${from} ${(1 - lerp) * 100}%, ${to})` } const getColorRamp = (): ColorRamp => { const style = getComputedStyle(document.body) - const v = (name: string) => style.getPropertyValue(name) + const v = (name: string) => style.getPropertyValue(name).trim() const stops = [ // TODO: looks nice in dark and decent in light. maybe we want a different // palette but it's a fine start - parseOklch(v('--surface-raise')), - parseOklch(v('--surface-accent-secondary')), - parseOklch(v('--content-accent')), - ].filter((x) => x !== null) + v('--surface-raise'), + v('--surface-accent-secondary'), + v('--content-accent'), + ].filter((x) => x !== '') return { stops: stops.length >= 2 ? stops : // for the unlikely case the theme colors don't parse into oklch - [ - { l: 0.195, c: 0.009, h: 260 }, - { l: 0.77, c: 0.1919, h: 163.7 }, - ], + ['oklch(0.195, 0.009, 260)', 'oklch(0.77, 0.1919, 163.7)'], } } @@ -367,9 +352,7 @@ function HeatmapLegend({ maxSampleCount: number axisText: string }) { - const backgroundImage = `linear-gradient(to right, ${ramp.stops - .map((r) => `oklch(${r.l} ${r.c} ${r.h})`) - .join(', ')})` + const backgroundImage = `linear-gradient(in oklch to right, ${ramp.stops.join(', ')})` return (
From 245f3173ec16b3c0b667fde4aeac1b1584ed032f Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 24 Aug 2026 15:32:12 -0700 Subject: [PATCH 4/6] drop the middle color from the heatmap gradient --- app/components/Heatmap.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index 0a8cec393..18a09fec5 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -52,7 +52,6 @@ const getColorRamp = (): ColorRamp => { // TODO: looks nice in dark and decent in light. maybe we want a different // palette but it's a fine start v('--surface-raise'), - v('--surface-accent-secondary'), v('--content-accent'), ].filter((x) => x !== '') return { From 48e459aae277cdde788881102e8ec7d64ab068f9 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Tue, 25 Aug 2026 10:55:17 +0100 Subject: [PATCH 5/6] Tweak css var resolution for more theme support --- app/components/Heatmap.tsx | 52 ++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index 18a09fec5..cf84ac92d 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -33,6 +33,13 @@ type ColorRamp = { stops: string[] } +// [CSS variable, fallback] pairs. Fallbacks cover the unlikely case the theme +// variables are missing. +const RAMP_STOPS = [ + ['--theme-neutral-300', 'oklch(0.195 0.009 260)'], + ['--theme-accent-800', 'oklch(0.77 0.1919 163.7)'], +] as const + // Interpolates the color between the two closest stops, for 0 <= t <= 1. const rampColor = ({ stops }: ColorRamp, t: number): string => { const clamped = R.clamp(t, { min: 0, max: 1 }) @@ -45,21 +52,14 @@ const rampColor = ({ stops }: ColorRamp, t: number): string => { return `color-mix(in oklch, ${from} ${(1 - lerp) * 100}%, ${to})` } -const getColorRamp = (): ColorRamp => { - const style = getComputedStyle(document.body) - const v = (name: string) => style.getPropertyValue(name).trim() - const stops = [ - // TODO: looks nice in dark and decent in light. maybe we want a different - // palette but it's a fine start - v('--surface-raise'), - v('--content-accent'), - ].filter((x) => x !== '') +// grabbing style from the chart root instead of document body +// that way it can be themed e.g. `yellow-theme` +const getColorRamp = (el: Element): ColorRamp => { + const style = getComputedStyle(el) return { - stops: - stops.length >= 2 - ? stops - : // for the unlikely case the theme colors don't parse into oklch - ['oklch(0.195, 0.009, 260)', 'oklch(0.77, 0.1919, 163.7)'], + stops: RAMP_STOPS.map( + ([name, fallback]) => style.getPropertyValue(name).trim() || fallback + ), } } @@ -104,7 +104,6 @@ export function Heatmap({ unit, }: HeatmapProps) { const theme = useChartTheme() - const colorRamp = useMemo(getColorRamp, [theme]) const [hover, setHover] = useState(null) // All distributions in a timeseries share the same bucket definition, so we @@ -130,6 +129,7 @@ export function Heatmap({ const drawCells = useMemo( () => (u: uPlot) => { if (binCount === 0 || colCount === 0) return + const colorRamp = getColorRamp(u.root) const ctx = u.ctx ctx.save() ctx.beginPath() @@ -157,7 +157,7 @@ export function Heatmap({ }) ctx.restore() }, - [distributions, leftEdges, rightEdges, colCount, binCount, maxSampleCount, colorRamp] + [distributions, leftEdges, rightEdges, colCount, binCount, maxSampleCount] ) const tooltipPlugin = useMemo( @@ -302,13 +302,7 @@ export function Heatmap({ chartOptions={chartOptions} data={data} uRef={uRef} - legend={ - - } + legend={} > {hover && ( <> @@ -343,21 +337,25 @@ export function Heatmap({ } function HeatmapLegend({ - ramp, maxSampleCount, axisText, }: { - ramp: ColorRamp maxSampleCount: number axisText: string }) { - const backgroundImage = `linear-gradient(in oklch to right, ${ramp.stops.join(', ')})` return (
0 -
+
`var(${name}, ${fallback})` + ).join(', ')})`, + }} + >
{maxSampleCount.toLocaleString()} From 4190bf53835246a43056999a4a2890fd9b2e16af Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Tue, 25 Aug 2026 15:24:10 +0100 Subject: [PATCH 6/6] Single colour gradient (make semantic) --- app/components/Heatmap.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index cf84ac92d..6156200e2 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -36,8 +36,8 @@ type ColorRamp = { // [CSS variable, fallback] pairs. Fallbacks cover the unlikely case the theme // variables are missing. const RAMP_STOPS = [ - ['--theme-neutral-300', 'oklch(0.195 0.009 260)'], - ['--theme-accent-800', 'oklch(0.77 0.1919 163.7)'], + ['--surface-accent', 'oklch(0.24 0.0722 183.7)'], + ['--content-accent', 'oklch(0.77 0.1919 163.7)'], ] as const // Interpolates the color between the two closest stops, for 0 <= t <= 1.