From 33015823183bc194bc94e487b83c7b48810a641e Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 16 Jul 2026 12:25:40 -0700 Subject: [PATCH 01/17] Add a couple tooltip tests Getting these in puts us in a spot where we can pretty easily see the differences between uplot and recharts. --- app/components/TimeSeriesChart.tsx | 9 ++++-- flake.lock | 19 +++++++++++- flake.nix | 43 +++++++++++++++++++++------- mock-api/instance.ts | 28 ++++++++++++++++++ mock-api/msw/util.ts | 28 +++++++++++++++++- test/e2e/combobox.e2e.ts | 2 ++ test/e2e/instance-metrics.e2e.ts | 46 +++++++++++++++++++++++++++++- 7 files changed, 158 insertions(+), 17 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 5cf3dad15..f54bb3c00 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -92,7 +92,10 @@ function renderTooltip(props: TooltipProps, unit?: string) { } = payload[0] if (!timestamp || typeof value !== 'number') return null return ( -
+
{longDateTime(timestamp)}
@@ -233,7 +236,7 @@ export function TimeSeriesChart({ // ResponsiveContainer has default height and width of 100% // https://recharts.org/en-US/api/ResponsiveContainer return ( -
+
@@ -285,7 +288,7 @@ export function TimeSeriesChart({ /> -
+ ) } diff --git a/flake.lock b/flake.lock index 601bb5158..afa4c2b96 100644 --- a/flake.lock +++ b/flake.lock @@ -34,10 +34,27 @@ "type": "github" } }, + "nixpkgs-playwright": { + "locked": { + "lastModified": 1784120854, + "narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-playwright": "nixpkgs-playwright" } }, "systems": { diff --git a/flake.nix b/flake.nix index a39dd3a38..9aff164ed 100644 --- a/flake.nix +++ b/flake.nix @@ -1,25 +1,46 @@ { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + nixpkgs-playwright.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, nixpkgs-playwright, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let - pkgs = import nixpkgs { - inherit system; - }; + pkgs = nixpkgs.legacyPackages.${system}; + inherit (pkgs) lib; + + playwrightDriver = nixpkgs-playwright.legacyPackages.${system}.playwright-driver; + + # The @playwright/test dependency in package.json expects you to run `playwright install`, + # which installs binaries that won't run on nix. We install from playwright-driver instead; + # as long as the major.minor version matches, we'll have compatible browsers. + npmPlaywrightVersion = + (lib.importJSON ./package-lock.json).packages."node_modules/@playwright/test".version; in { - devShells.default = pkgs.mkShell { - nativeBuildInputs = with pkgs; [ - nodejs_22 - ]; - shellHook = '' - echo "Node $(node --version)" + devShells.default = + assert lib.assertMsg + (lib.versions.majorMinor npmPlaywrightVersion == lib.versions.majorMinor playwrightDriver.version) '' + Playwright version mismatch: package.json @playwright/test is ${npmPlaywrightVersion} + but the nixpkgs-playwright input's playwright-driver is ${playwrightDriver.version}. + Repin nixpkgs-playwright or upgrade (don't downgrade!) @playwright/test so they share a + major.minor for browser compatibility. ''; - }; + pkgs.mkShell { + packages = [ + pkgs.nodejs_22 + ]; + env = { + PLAYWRIGHT_BROWSERS_PATH = "${playwrightDriver.browsers}"; + # https://wiki.nixos.org/wiki/Playwright thinks you need this, but i haven't found it necessary + # PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; + }; + shellHook = '' + echo "Node $(node --version)" + ''; + }; } ); } diff --git a/mock-api/instance.ts b/mock-api/instance.ts index 60d6c3715..76f904a71 100644 --- a/mock-api/instance.ts +++ b/mock-api/instance.ts @@ -157,6 +157,32 @@ export const instanceDb3: Json = { run_state: 'running', } +// Flat, constant series. A tooltip hover reads back a known value regardless of +// cursor position. +export const SENTINEL_FLAT_INSTANCE_ID = 'f0968b0d-6f4a-49e8-8d96-a58dc2c93993' +export const sentinelFlatInstance: Json = { + ...base, + id: SENTINEL_FLAT_INSTANCE_ID, + name: 'sentinel-metrics-flat', + description: 'returns constant metric data for tooltip tests', + hostname: 'oxide.com', + project_id: project.id, + run_state: 'running', +} + +// Series that increases linearly with time. Lets you do slightly more thorough +// graph testing. +export const SENTINEL_SLOPE_INSTANCE_ID = 'c7d3b8a5-71f7-4588-bce8-38c9f1f85f2f' +export const sentinelSlopeInstance: Json = { + ...base, + id: SENTINEL_SLOPE_INSTANCE_ID, + name: 'sentinel-metrics-slope', + description: 'returns linearly increasing metric data for axis tests', + hostname: 'oxide.com', + project_id: project.id, + run_state: 'running', +} + export const instances: Json[] = [ instance, failedInstance, @@ -168,4 +194,6 @@ export const instances: Json[] = [ instanceDb2, stoppedInstance, instanceDb3, + sentinelFlatInstance, + sentinelSlopeInstance, ] diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index d51267a9b..b213c7dc8 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -39,6 +39,7 @@ import { parseIp } from '~/util/ip' import { GiB, TiB } from '~/util/units' import type { DbRoleAssignmentResourceType } from '..' +import { SENTINEL_FLAT_INSTANCE_ID, SENTINEL_SLOPE_INSTANCE_ID } from '../instance' import { genI64Data } from '../metrics' import { getMockOxqlInstanceData } from '../oxql-metrics' import { db, lookupById } from './db' @@ -581,10 +582,35 @@ const getCpuStateFromQuery = (query: string): OxqlVcpuState | undefined => { return match ? (match[1] as OxqlVcpuState) : undefined } +// Pull the instance UUID out of the `instance_id == "..."` filter (also matches +// the `attached_instance_id` used by disk metrics). +const getInstanceIdFromQuery = (query: string): string | undefined => + query.match(/(?:attached_)?instance_id\s*==\s*"([^"]+)"/)?.[1] + +// getUtilizationChartProps renders raw values on screen as value * 100 / (5s * +// 1e9 * 1 series); invertUtilization goes the other way — from a target percent +// to the raw value that produces it. +const invertUtilization = (percent: number): number => (percent * 5 * 1e9) / 100 +const SENTINEL_CONSTANT_RAW_VALUE = invertUtilization(12345) // 12,345% +const sentinelSlopeRawValue = (i: number) => invertUtilization((i + 1) * 1000) // (i + 1) * 1000% + export function handleOxqlMetrics({ query }: TimeseriesQuery): Json { const metricName = getMetricNameFromQuery(query) as OxqlNetworkMetricName const stateValue = getCpuStateFromQuery(query) - return getMockOxqlInstanceData(metricName, stateValue) + const data = getMockOxqlInstanceData(metricName, stateValue) + + // Sentinel instances: replace the series with synthetic data — flat (constant) + // or a slope that increases with time — so tests can assert on plotted values. + const instanceId = getInstanceIdFromQuery(query) + const points = data.tables[0].timeseries[0].points + const series = points.values[0].values.values + if (instanceId === SENTINEL_FLAT_INSTANCE_ID) { + points.values[0].values.values = series.map(() => SENTINEL_CONSTANT_RAW_VALUE) + } else if (instanceId === SENTINEL_SLOPE_INSTANCE_ID) { + points.values[0].values.values = series.map((_, i) => sentinelSlopeRawValue(i)) + } + + return data } export function randomHex(length: number) { diff --git a/test/e2e/combobox.e2e.ts b/test/e2e/combobox.e2e.ts index 45aa43b90..fe891fe7f 100644 --- a/test/e2e/combobox.e2e.ts +++ b/test/e2e/combobox.e2e.ts @@ -179,6 +179,8 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields', 'db2', 'db-stopped', 'db3', + 'sentinel-metrics-flat', + 'sentinel-metrics-slope', ]) await instanceInput.fill('d') diff --git a/test/e2e/instance-metrics.e2e.ts b/test/e2e/instance-metrics.e2e.ts index 071b2531b..4e8769a0d 100644 --- a/test/e2e/instance-metrics.e2e.ts +++ b/test/e2e/instance-metrics.e2e.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { OXQL_GROUP_BY_ERROR } from '~/api' @@ -40,6 +40,50 @@ test('Click through instance metrics', async ({ page }) => { await expect(page.getByText('Something went wrong')).toBeHidden() }) +async function readChartValueAt(page: Page, chart: Locator, fracX: number) { + const box = await chart.boundingBox() + if (!box) throw new Error('chart has no bounding box') + const x = box.x + box.width * fracX + await page.mouse.move(x, box.y) + await page.mouse.move(x, box.y + box.height * 0.25) + const tooltip = page.getByRole('tooltip') + await expect(tooltip).toBeVisible() + // within the tooltip, the value line is the only text that's just a number + unit + const value = tooltip.getByText(/^[\d,]+%$/) + return Number((await value.textContent())!.replace(/\D/g, '')) +} + +test('chart tooltip reads back the plotted value', async ({ page }) => { + // sentinel-metrics-flat returns a flat series (see handleOxqlMetrics), so a + // hover anywhere in the plot reads back the same value. + await page.goto('/projects/mock-project/instances/sentinel-metrics-flat/metrics/cpu') + + const heading = page.getByRole('heading', { name: 'CPU Utilization: Running' }) + await expect(heading).toBeVisible() + // wait for data so the chart, not the loading skeleton, is rendered + await expect(page.getByLabel('Chart loading')).toBeHidden() + + const chart = page.getByRole('figure', { name: 'CPU Utilization: Running' }) + expect(await readChartValueAt(page, chart, 0.5)).toBe(12345) +}) + +test('chart x-axis maps earlier times to the left', async ({ page }) => { + await page.goto('/projects/mock-project/instances/sentinel-metrics-slope/metrics/cpu') + + const heading = page.getByRole('heading', { name: 'CPU Utilization: Running' }) + await expect(heading).toBeVisible() + await expect(page.getByLabel('Chart loading')).toBeHidden() + + const chart = page.getByRole('figure', { name: 'CPU Utilization: Running' }) + const leftValue = await readChartValueAt(page, chart, 0.25) + const rightValue = await readChartValueAt(page, chart, 0.7) + + // sentinel-metrics-slope returns a series that increases with time (see + // handleOxqlMetrics), so a hover on the left of the plot reads a smaller value + // than one on the right. + expect(leftValue).toBeLessThan(rightValue) +}) + test('Date range picker: choosing a custom range', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1/metrics/cpu') await expect( From 3c57526d97e37295e7f17361f6072ef010acacce Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 16 Jul 2026 14:29:43 -0700 Subject: [PATCH 02/17] Get bash from env This is just a touch more portable. --- tools/generate-visual-baseline.sh | 2 +- tools/generate_api_client.sh | 2 +- tools/populate_omicron_data.sh | 2 +- tools/start_api.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/generate-visual-baseline.sh b/tools/generate-visual-baseline.sh index 002e76e2f..aa92310db 100755 --- a/tools/generate-visual-baseline.sh +++ b/tools/generate-visual-baseline.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. diff --git a/tools/generate_api_client.sh b/tools/generate_api_client.sh index b82610862..688d8597d 100755 --- a/tools/generate_api_client.sh +++ b/tools/generate_api_client.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. diff --git a/tools/populate_omicron_data.sh b/tools/populate_omicron_data.sh index 71abf47df..7abd27dab 100755 --- a/tools/populate_omicron_data.sh +++ b/tools/populate_omicron_data.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. diff --git a/tools/start_api.sh b/tools/start_api.sh index 5b5ff1bf3..06ef353f4 100755 --- a/tools/start_api.sh +++ b/tools/start_api.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. From 633e5e091f05f41b9f0dcaf59c199c7d8685bf27 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Fri, 17 Jul 2026 12:54:50 -0700 Subject: [PATCH 03/17] Migrate from recharts to uplot Verbose, but fairly straightforward. The only meaningful changes you should see are the lack of a top grid line (since uplot does tick calculation, this would often be quite tight with the line below) and uplot's tick selection logic replacing ours (which is for the greater good). --- app/components/TimeSeriesChart.tsx | 382 +++++++++++++--------- app/hooks/use-element-size.ts | 30 ++ app/ui/styles/index.css | 1 + package-lock.json | 491 +++-------------------------- package.json | 3 +- test/unit/setup.ts | 15 +- test/visual/regression.e2e.ts | 4 +- 7 files changed, 325 insertions(+), 601 deletions(-) create mode 100644 app/hooks/use-element-size.ts diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f54bb3c00..f317585d1 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,45 +7,18 @@ */ import cn from 'classnames' import { format } from 'date-fns' -import { useMemo, type ReactNode } from 'react' -import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts' -import type { TooltipProps } from 'recharts/types/component/Tooltip' +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 { classed } from '~/util/classed' -// Recharts's built-in ticks behavior is useless and probably broken -/** - * Split the data into n evenly spaced ticks, with one at the left end and one a - * little bit in from the right end, and the rest evenly spaced in between. - */ -function getTicks(data: { timestamp: number }[], n: number): number[] { - if (data.length === 0) return [] - if (n < 2) throw Error('n must be at least 2 because of the start and end ticks') - // bring the last tick in a bit from the end - const maxIdx = data.length > 10 ? Math.floor((data.length - 1) * 0.8) : data.length - 1 - const startOffset = Math.floor((data.length - maxIdx) * 0.6) - // if there are 4 ticks, their positions are 0/3, 1/3, 2/3, 3/3 (as fractions of maxIdx) - const idxs = Array.from({ length: n }).map((_, i) => - Math.floor((maxIdx * i) / (n - 1) + startOffset) - ) - return idxs.map((i) => data[i].timestamp) -} - -function getVerticalTicks(n: number, max: number): number[] { - return Array.from({ length: n }).map((_, i) => Math.floor(((i + 1) / n) * max)) -} - /** * 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 @@ -58,20 +31,28 @@ function isSameDay(d1: Date, d2: Date) { ) } -const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm') +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 GRID_GRAY = 'var(--stroke-secondary)' -const CURSOR = 'var(--chart-stroke-item)' -const GREEN_400 = 'var(--surface-accent-secondary)' -const GREEN_600 = 'var(--content-accent-tertiary)' -const GREEN_800 = 'var(--content-accent)' +// const GRID_GRAY = 'var(--stroke-secondary)' +// const CURSOR = 'var(--chart-stroke-item)' +// const GREEN_400 = 'var(--surface-accent-secondary)' +// const GREEN_600 = 'var(--content-accent-tertiary)' +// const GREEN_800 = 'var(--content-accent)' // TODO: figure out how to do this with TW classes instead. As far as I can tell // ticks only take direct styling +// TODO(joe) the above isn't going to change. but now it's even more fun, because i need to set the +// desired rem (0.6875) in px const textMonoMd = { - fontSize: '0.6875rem', + fontSize: '11px', fontFamily: '"GT America Mono", monospace', fill: 'var(--content-quaternary)', } @@ -80,17 +61,47 @@ const textMonoMd = { // Used for dynamically sizing the yAxis. If this were to fallback // the font would likely be thinner than the monospaced character // and therefore not overflow -const TEXT_CHAR_WIDTH = 6.82 - -function renderTooltip(props: TooltipProps, unit?: string) { - const { payload } = props - if (!payload || payload.length < 1) return null - // TODO: there has to be a better way to get these values - const { - name, - payload: { timestamp, value }, - } = payload[0] - if (!timestamp || typeof value !== 'number') return null +// const TEXT_CHAR_WIDTH = 6.82 + +// TODO(joe): swap for design-system CSS vars like Terminal.tsx does +const GREEN_STROKE = 'oklch(0.563 0.1714 170.4)' +const GREEN_FILL = 'oklch(0.379 0.1169 177 / 0.6)' +const AXIS_LINE = 'oklch(0.247 0.007 260)' +const AXIS_TEXT = 'oklch(0.483 0.0041 260)' +const AXIS_FONT = `${textMonoMd.fontSize} ${textMonoMd.fontFamily}` +const AXIS_TICK_LENGTH = 6 +const AXIS_TICK_GAP = 8 +// left padding (px-5) 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 TOOLTIP_GAP = 12 + +/** 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 (
, unit?: string) { {longDateTime(timestamp)}
-
{name}
+
{seriesName}
{value.toLocaleString()} {unit && {unit}}
- {/* TODO: unit on value if relevant */}
) @@ -124,15 +134,6 @@ type TimeSeriesChartProps = { loading: boolean } -const TICK_COUNT = 6 -const TICK_MARGIN = 8 -const TICK_SIZE = 6 - -/** Round `value` up to nearest number divisible by `divisor` */ -function roundUpToDivBy(value: number, divisor: number) { - return Math.ceil(value / divisor) * divisor -} - // this top margin is also in the chart, probably want a way of unifying the sizing between the two const SkeletonMetric = ({ children, @@ -168,6 +169,8 @@ const SkeletonMetric = ({
) +const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() + export function TimeSeriesChart({ data: rawData, title, @@ -175,41 +178,159 @@ export function TimeSeriesChart({ startTime, endTime, unit, - yAxisTickFormatter = (val) => val.toLocaleString(), + yAxisTickFormatter = defaultYAxisTickFormatter, hasError = false, loading, }: TimeSeriesChartProps) { - // We use the largest data point +20% for the graph scale. !rawData doesn't - // mean it's empty (it will never be empty because we fill in artificial 0s at - // beginning and end), it means the metrics requests haven't come back yet - const maxY = useMemo(() => { - if (!rawData) return null - const dataMax = Math.max( - ...rawData.map((datum) => datum.value).filter((x) => x !== null) - ) - return roundUpToDivBy(dataMax * 1.2, TICK_COUNT) // avoid uneven ticks - }, [rawData]) - - // If max value is set we normalize the graph so that - // is the maximum, we also use our own function as recharts - // doesn't fill the whole domain (just up to the data max) - const yTicks = maxY - ? { domain: [0, maxY], ticks: getVerticalTicks(TICK_COUNT, maxY) } - : undefined - - // We get the longest label length and multiply that with our `TICK_CHAR_WIDTH` - // and add the extra space for the tick stroke and spacing - // It's possible to get clever and calculate the width using the canvas or font metrics - // But our font is monospace so we can just use the length of the text * the baked width of the character - const maxLabelLength = yTicks - ? Math.max(...yTicks.ticks.map((tick) => yAxisTickFormatter(tick).length)) - : 0 - const maxLabelWidth = maxLabelLength * TEXT_CHAR_WIDTH + TICK_SIZE + TICK_MARGIN - // falling back here instead of in the parent lets us avoid causing a // re-render on every render of the parent when the data is undefined const data = useMemo(() => rawData || [], [rawData]) + const [size, sizeRef] = useElementSize() + + const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime + + const [tooltip, setTooltip] = useState<{ + hoveredDataIndex: number + left: number + top: number + // which side of the point the box sits on + leftRight: LeftRight + topBottom: TopBottom + } | null>(null) + + const tooltipPlugin = useMemo( + () => ({ + hooks: { + setCursor: (self) => { + const { idx, top } = self.cursor + if (idx == null || top == null) { + setTooltip(null) + return + } + + const x = self.data[0][idx] + const y = self.data[1][idx] + if (y == null) { + setTooltip(null) + return + } + + const plotRect = self.over.getBoundingClientRect() + const chartRect = self.root.getBoundingClientRect() + + // cursor picks the y position, data picks the x position + const left = self.valToPos(x, 'x') + + setTooltip({ + hoveredDataIndex: idx, + // cursor coords are relative to the plot area, so we add in the diff between the plot + // and the whole container + left: plotRect.left - chartRect.left + left, + top: plotRect.top - chartRect.top + top, + leftRight: left > plotRect.width / 2 ? 'left' : 'right', + topBottom: top > plotRect.height / 2 ? 'top' : 'bottom', + }) + }, + init: (self) => { + self.over.addEventListener('mouseleave', () => setTooltip(null)) + }, + }, + }), + [] + ) + + // 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. + const chartOptions = useMemo( + () => + ({ + scales: { + x: {}, + y: { + range: (_u, _min, max) => uPlot.rangeNum(0, max * 1.2, 0.1, true), + }, + }, + series: [ + {}, + { + show: true, + stroke: GREEN_STROKE, + fill: GREEN_FILL, + points: { show: false }, + paths: match(interpolation) + .with('linear', () => uPlot.paths.linear?.()) + .with('stepAfter', () => uPlot.paths.stepped?.({ align: 1 })) + .exhaustive(), + }, + ], + axes: [ + { + stroke: AXIS_TEXT, + font: AXIS_FONT, + space: (_u, _axisIdx, _min, _max, plotDim) => plotDim / 5, + values: (_u, times) => times.map((t) => formatTime(t * 1000)), + border: { show: true, stroke: AXIS_LINE, width: 1 }, + gap: AXIS_TICK_GAP, + // TODO(joe): evil! + size: parseInt(textMonoMd.fontSize, 10) + AXIS_TICK_GAP + AXIS_TICK_LENGTH, + ticks: { + show: true, + stroke: AXIS_LINE, + width: 1, + size: AXIS_TICK_LENGTH, + }, + }, + { + stroke: AXIS_TEXT, + font: AXIS_FONT, + side: 1, + border: { show: true, stroke: AXIS_LINE, width: 1 }, + gap: AXIS_TICK_GAP, + ticks: { + show: true, + stroke: AXIS_LINE, + width: 1, + size: AXIS_TICK_LENGTH, + filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)), + }, + values: (_u, yValues) => + yValues.map((v) => (v === 0 ? '' : yAxisTickFormatter(v))), + grid: { show: true, stroke: AXIS_LINE, 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) || '' + self.ctx.font = AXIS_FONT + return axisBase + self.ctx.measureText(longestVal).width + }, + }, + ], + padding: [null, null, null, CHART_LEFT_PAD], + cursor: { + x: false, + y: false, + // i like the drag and we should put it back in. but one thing at a time + drag: { x: false }, + }, + legend: { show: false }, + plugins: [tooltipPlugin], + }) satisfies Omit, + [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation] + ) + + // 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: 300, + }) satisfies uPlot.Options, + [chartOptions, size?.width] + ) + if (hasError) { return ( @@ -233,61 +354,34 @@ export function TimeSeriesChart({ ) } - // ResponsiveContainer has default height and width of 100% - // https://recharts.org/en-US/api/ResponsiveContainer + const aligned: uPlot.AlignedData = [ + data.map(({ timestamp }) => timestamp / 1000), + data.map(({ value }) => value), + ] + + const hovered = tooltip ? data[tooltip.hoveredDataIndex] : undefined return ( -
- - - - - - {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} - ) => renderTooltip(props, unit)} - cursor={{ stroke: CURSOR, strokeDasharray: '3,3' }} - wrapperStyle={{ outline: 'none' }} - /> - - - +
+
+ + {tooltip && hovered && hovered.value !== null && ( +
+ +
+ )} +
) } diff --git a/app/hooks/use-element-size.ts b/app/hooks/use-element-size.ts new file mode 100644 index 000000000..3bed99477 --- /dev/null +++ b/app/hooks/use-element-size.ts @@ -0,0 +1,30 @@ +/* + * 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 { useState, useRef, useCallback, type RefCallback } from 'react' + +type Size = { width: number; height: number } | null + +export function useElementSize(): [Size, RefCallback] { + const [size, setSize] = useState(null) + const observer = useRef(null) + + const ref = useCallback((element: HTMLElement | null) => { + observer.current?.disconnect() + if (!element) return + + observer.current = new ResizeObserver(([first]: ResizeObserverEntry[]) => { + setSize({ + width: first.contentBoxSize[0].inlineSize, + height: first.contentBoxSize[0].blockSize, + }) + }) + observer.current.observe(element) + }, []) + + return [size, ref] +} diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index d65e951fb..61ee08385 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -29,6 +29,7 @@ @import '@oxide/design-system/styles/light.css'; @import '@oxide/design-system/styles/preflight.css' layer(base); @import 'simplebar-react/dist/simplebar.min.css' layer(components); +@import 'uplot/dist/uPlot.min.css' layer(components); @import '@oxide/design-system/styles/red.css'; @import '@oxide/design-system/styles/yellow.css'; diff --git a/package-lock.json b/package-lock.json index 6931eb65a..4ce0bcd71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,13 +41,14 @@ "react-merge-refs": "^2.1.1", "react-router": "^8.0.0", "react-stately": "^3.32.2", - "recharts": "^2.15.1", "remeda": "^2.30.0", "semver": "^7.7.3", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", "tunnel-rat": "^0.1.2", + "uplot": "^1.6.32", + "uplot-react": "^1.2.4", "use-debounce": "^10.0.4", "uuid": "^14.0.0", "zod": "^4.0.17", @@ -1354,9 +1355,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1374,9 +1372,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1394,9 +1389,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1414,9 +1406,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1434,9 +1423,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1454,9 +1440,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1474,9 +1457,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1494,9 +1474,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1871,9 +1848,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1891,9 +1865,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1911,9 +1882,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1931,9 +1899,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1951,9 +1916,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1971,9 +1933,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1991,9 +1950,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2011,9 +1967,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4700,9 +4653,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4719,9 +4669,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4738,9 +4685,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4757,9 +4701,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4776,9 +4717,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4795,9 +4733,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5096,9 +5031,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5115,9 +5047,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5134,9 +5063,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5153,9 +5079,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5613,69 +5536,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", - "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -7068,129 +6928,9 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -7240,12 +6980,6 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7331,16 +7065,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7827,15 +7551,6 @@ "license": "MIT", "peer": true }, - "node_modules/fast-equals": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", - "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8482,6 +8197,18 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8528,15 +8255,6 @@ "license": "MIT", "peer": true }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/intl-messageformat": { "version": "10.7.17", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.17.tgz", @@ -8660,6 +8378,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8977,9 +8696,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9000,9 +8716,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9023,9 +8736,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9046,9 +8756,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9151,18 +8858,6 @@ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -9639,15 +9334,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -10420,23 +10106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -10705,21 +10374,6 @@ } } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-stately": { "version": "3.32.2", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.32.2.tgz", @@ -10754,66 +10408,6 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/recharts": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", - "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, - "node_modules/recharts/node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10917,9 +10511,9 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/retry": { @@ -11442,12 +11036,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11907,6 +11495,25 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/uplot-react": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/uplot-react/-/uplot-react-1.2.4.tgz", + "integrity": "sha512-mDe/mqD9KtXeHDR8llSJaUFpDcEJvYpHNS+cyUhJ2qvkbT9GPKod1BVXG+hNegRqYiV1ldsFBlI5+OKSi/yPNA==", + "license": "MIT", + "engines": { + "node": ">=8.10" + }, + "peerDependencies": { + "react": ">=16.8.6", + "uplot": "^1.6.32" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -12021,28 +11628,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/package.json b/package.json index 3bbdfb181..c8528f7df 100644 --- a/package.json +++ b/package.json @@ -65,13 +65,14 @@ "react-merge-refs": "^2.1.1", "react-router": "^8.0.0", "react-stately": "^3.32.2", - "recharts": "^2.15.1", "remeda": "^2.30.0", "semver": "^7.7.3", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", "tunnel-rat": "^0.1.2", + "uplot": "^1.6.32", + "uplot-react": "^1.2.4", "use-debounce": "^10.0.4", "uuid": "^14.0.0", "zod": "^4.0.17", diff --git a/test/unit/setup.ts b/test/unit/setup.ts index 48de20fe1..0d36aaceb 100644 --- a/test/unit/setup.ts +++ b/test/unit/setup.ts @@ -12,7 +12,7 @@ */ import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' -import { afterAll, afterEach, beforeAll } from 'vitest' +import { afterAll, afterEach, beforeAll, vi } from 'vitest' import { resetDb } from '../../mock-api/msw/db' import { server } from './server' @@ -21,6 +21,19 @@ import { server } from './server' // an error that the method is not implemented HTMLCanvasElement.prototype.getContext = () => null +// uPlot wants to matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + // jsdom has no ResizeObserver, but Headless UI (e.g. Listbox) constructs one for // popover positioning. A no-op stub is enough — there's no real layout to observe // in jsdom, so the callback never needs to fire. diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts index bd0b5c8e7..9f139ebdf 100644 --- a/test/visual/regression.e2e.ts +++ b/test/visual/regression.e2e.ts @@ -238,7 +238,7 @@ test.describe('Visual Regression', { tag: '@visual' }, () => { test('silo utilization', async ({ page }) => { await page.goto('/utilization', { waitUntil: 'networkidle' }) await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() - await expect(page.locator('.recharts-curve').first()).toBeVisible() + await expect(page.locator('figure').first()).toBeVisible() await expect(page).toHaveScreenshot('silo-utilization.png', { fullPage: true, mask: [page.getByTestId('refetch-interval-refresh')], @@ -249,7 +249,7 @@ test.describe('Visual Regression', { tag: '@visual' }, () => { test('system utilization metrics tab', async ({ page }) => { await page.goto('/system/utilization?tab=metrics', { waitUntil: 'networkidle' }) await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() - await expect(page.locator('.recharts-curve').first()).toBeVisible() + await expect(page.locator('figure').first()).toBeVisible() await expect(page).toHaveScreenshot('system-utilization-metrics-tab.png', { fullPage: true, mask: [page.getByTestId('refetch-interval-refresh')], From 27f4db9a10a6d2f202fb0de4d64cadc3528ebf70 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 20 Jul 2026 15:45:36 -0700 Subject: [PATCH 04/17] Use theme variables for colors and fonts Any canvas drawing element is going to need themes this way. I considered doing something more involved, like computing the style within the subscription, or actually making a the subscription an effect hook, but the thing is, you kind of want to only use getComputedStyle consciously. --- app/components/Terminal.tsx | 9 +-- app/components/TimeSeriesChart.tsx | 115 +++++++++++++++++------------ app/stores/theme.ts | 14 ++++ 3 files changed, 85 insertions(+), 53 deletions(-) diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index f36fc4e00..9a287c1cd 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from 'react' import { DirectionDownIcon, DirectionUpIcon } from '@oxide/design-system/icons/react' +import { subscribeToTheme } from '~/stores/theme' import { classed } from '~/util/classed' import { AttachAddon } from './AttachAddon' @@ -110,16 +111,12 @@ export function Terminal({ ws }: TerminalProps) { // Update terminal colors when the theme changes. getComputedStyle in // getTheme() forces a synchronous style recalc, so the CSS custom // properties already reflect the new theme by the time we read them. - const observer = new MutationObserver(() => { + const unsubscribe = subscribeToTheme(() => { newTerm.options.theme = getTheme() }) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-theme'], - }) return () => { - observer.disconnect() + unsubscribe() newTerm.dispose() window.removeEventListener('resize', resize) } diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f317585d1..e8d8954ed 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,7 +7,7 @@ */ import cn from 'classnames' import { format } from 'date-fns' -import { useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' import * as R from 'remeda' import { match } from 'ts-pattern' import uPlot from 'uplot' @@ -17,6 +17,7 @@ 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 { classed } from '~/util/classed' /** @@ -41,41 +42,58 @@ const shortDateTime = (ts: number) => { 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 GRID_GRAY = 'var(--stroke-secondary)' -// const CURSOR = 'var(--chart-stroke-item)' -// const GREEN_400 = 'var(--surface-accent-secondary)' -// const GREEN_600 = 'var(--content-accent-tertiary)' -// const GREEN_800 = 'var(--content-accent)' - -// TODO: figure out how to do this with TW classes instead. As far as I can tell -// ticks only take direct styling -// TODO(joe) the above isn't going to change. but now it's even more fun, because i need to set the -// desired rem (0.6875) in px -const textMonoMd = { - fontSize: '11px', - fontFamily: '"GT America Mono", monospace', - fill: 'var(--content-quaternary)', +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 } -// The length of a character in pixels at 11px with GT America Mono -// Used for dynamically sizing the yAxis. If this were to fallback -// the font would likely be thinner than the monospaced character -// and therefore not overflow -// const TEXT_CHAR_WIDTH = 6.82 - -// TODO(joe): swap for design-system CSS vars like Terminal.tsx does -const GREEN_STROKE = 'oklch(0.563 0.1714 170.4)' -const GREEN_FILL = 'oklch(0.379 0.1169 177 / 0.6)' -const AXIS_LINE = 'oklch(0.247 0.007 260)' -const AXIS_TEXT = 'oklch(0.483 0.0041 260)' -const AXIS_FONT = `${textMonoMd.fontSize} ${textMonoMd.fontFamily}` +const AXIS_FONT_REM_XS = 0.6875 const AXIS_TICK_LENGTH = 6 const AXIS_TICK_GAP = 8 -// left padding (px-5) 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 +// 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 TOOLTIP_GAP = 12 +type ChartTheme = { + fontFamily: string + stroke: string + fill: string + axisLine: string + axisText: 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('--content-accent-tertiary'), + fill: withAlpha(v('--surface-accent-secondary'), 0.6), + axisLine: v('--stroke-secondary'), + axisText: v('--content-quaternary'), + } +} + +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' @@ -186,6 +204,10 @@ export function TimeSeriesChart({ // re-render on every render of the parent when the data is undefined const data = useMemo(() => rawData || [], [rawData]) + 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 @@ -255,8 +277,8 @@ export function TimeSeriesChart({ {}, { show: true, - stroke: GREEN_STROKE, - fill: GREEN_FILL, + stroke: theme.stroke, + fill: theme.fill, points: { show: false }, paths: match(interpolation) .with('linear', () => uPlot.paths.linear?.()) @@ -266,43 +288,42 @@ export function TimeSeriesChart({ ], axes: [ { - stroke: AXIS_TEXT, - font: AXIS_FONT, + 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: AXIS_LINE, width: 1 }, + border: { show: true, stroke: theme.axisLine, width: 1 }, gap: AXIS_TICK_GAP, - // TODO(joe): evil! - size: parseInt(textMonoMd.fontSize, 10) + AXIS_TICK_GAP + AXIS_TICK_LENGTH, + grid: { show: false }, + size: fontPx + AXIS_TICK_GAP + AXIS_TICK_LENGTH, ticks: { show: true, - stroke: AXIS_LINE, + stroke: theme.axisLine, width: 1, size: AXIS_TICK_LENGTH, }, }, { - stroke: AXIS_TEXT, - font: AXIS_FONT, + stroke: theme.axisText, + font: axisFont, side: 1, - border: { show: true, stroke: AXIS_LINE, width: 1 }, + border: { show: true, stroke: theme.axisLine, width: 1 }, gap: AXIS_TICK_GAP, ticks: { show: true, - stroke: AXIS_LINE, + 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 ? '' : yAxisTickFormatter(v))), - grid: { show: true, stroke: AXIS_LINE, width: 1 }, - size: (self, values) => { + 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) || '' - self.ctx.font = AXIS_FONT - return axisBase + self.ctx.measureText(longestVal).width + return axisBase + measureTextWidth(longestVal, axisFont) }, }, ], @@ -310,13 +331,13 @@ export function TimeSeriesChart({ cursor: { x: false, y: false, - // i like the drag and we should put it back in. but one thing at a time + // TODO: i like the drag and we should put it back in drag: { x: false }, }, legend: { show: false }, plugins: [tooltipPlugin], }) satisfies Omit, - [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation] + [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation, theme, axisFont, fontPx] ) // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets diff --git a/app/stores/theme.ts b/app/stores/theme.ts index dfd800f92..6d5eb627c 100644 --- a/app/stores/theme.ts +++ b/app/stores/theme.ts @@ -43,6 +43,20 @@ function getSystemIsLight() { return window.matchMedia('(prefers-color-scheme: light)').matches } +/** + * Run `cb` whenever the resolved theme (data-theme on ) changes. Use for + * canvas renderers that can't consume CSS custom properties directly. Returns + * an unsubscribe function. + */ +export function subscribeToTheme(cb: () => void) { + const observer = new MutationObserver(cb) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) + return () => observer.disconnect() +} + /** * Hook that applies the resolved theme to the document. Renders in RootLayout * so it runs on every page. From a56a8d34b9ca51a69dc7874841f77598b2914d06 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 20 Jul 2026 14:06:30 -0700 Subject: [PATCH 05/17] Add guard against accidental recreations from formatter prop If you passed an anonymous function as the yAxisTickFormatter, that would cause rerender cycles that screwed with hovering, because the graph was being constantly re-created. You could say the caller is just responsible for not doing that, but I was feeling generous. --- app/components/TimeSeriesChart.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index e8d8954ed..a66297590 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,7 +7,7 @@ */ import cn from 'classnames' import { format } from 'date-fns' -import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import * as R from 'remeda' import { match } from 'ts-pattern' import uPlot from 'uplot' @@ -262,6 +262,13 @@ export function TimeSeriesChart({ [] ) + const uRef = useRef(null) + const yAxisTickFormatterRef = useRef<(val: number) => string>(yAxisTickFormatter) + yAxisTickFormatterRef.current = yAxisTickFormatter + useEffect(() => { + uRef.current?.redraw() + }, [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. const chartOptions = useMemo( @@ -317,7 +324,7 @@ export function TimeSeriesChart({ filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)), }, values: (_u, yValues) => - yValues.map((v) => (v === 0 ? '' : yAxisTickFormatter(v))), + yValues.map((v) => (v === 0 ? '' : yAxisTickFormatterRef.current(v))), grid: { show: true, stroke: theme.axisLine, width: 1 }, size: (_self, values) => { const axisBase = AXIS_TICK_LENGTH + AXIS_TICK_GAP @@ -337,7 +344,7 @@ export function TimeSeriesChart({ legend: { show: false }, plugins: [tooltipPlugin], }) satisfies Omit, - [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation, theme, axisFont, fontPx] + [formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] ) // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets @@ -384,7 +391,7 @@ export function TimeSeriesChart({ return (
- + (uRef.current = u)} /> {tooltip && hovered && hovered.value !== null && (
Date: Tue, 21 Jul 2026 15:32:01 -0500 Subject: [PATCH 06/17] Fix uPlot initialization in production builds --- app/components/TimeSeriesChart.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index a66297590..5aa78339f 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -391,7 +391,14 @@ export function TimeSeriesChart({ return (
- (uRef.current = u)} /> + {/* uPlot does not recover its x scale when initialized at zero width in production */} + {size && size.width > 0 && ( + (uRef.current = u)} + /> + )} {tooltip && hovered && hovered.value !== null && (
Date: Tue, 21 Jul 2026 10:56:48 +0100 Subject: [PATCH 07/17] Tweak hover point colour --- app/components/TimeSeriesChart.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 5aa78339f..c78523b65 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -66,6 +66,7 @@ type ChartTheme = { fontFamily: string stroke: string fill: string + hoverPoint: string axisLine: string axisText: string } @@ -83,6 +84,7 @@ function getChartTheme(): ChartTheme { fontFamily: v('--font-mono'), stroke: v('--content-accent-tertiary'), fill: withAlpha(v('--surface-accent-secondary'), 0.6), + hoverPoint: v('--content-accent'), axisLine: v('--stroke-secondary'), axisText: v('--content-quaternary'), } @@ -340,6 +342,10 @@ export function TimeSeriesChart({ y: false, // TODO: i like the drag and we should put it back in drag: { x: false }, + points: { + size: 6, + fill: theme.hoverPoint, + }, }, legend: { show: false }, plugins: [tooltipPlugin], From b54ad407b740da90673dfe629aa4829af25bf632 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Tue, 28 Jul 2026 10:30:11 -0700 Subject: [PATCH 08/17] Revert "Fix uPlot initialization in production builds" This reverts commit 9e153dbd66fd85c1cb0e0bac2db4f5659ea8711e. --- app/components/TimeSeriesChart.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index c78523b65..b8f27e7ce 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -397,14 +397,7 @@ export function TimeSeriesChart({ return (
- {/* uPlot does not recover its x scale when initialized at zero width in production */} - {size && size.width > 0 && ( - (uRef.current = u)} - /> - )} + (uRef.current = u)} /> {tooltip && hovered && hovered.value !== null && (
Date: Tue, 28 Jul 2026 13:59:59 -0700 Subject: [PATCH 09/17] Avoid recalculating paths --- app/components/TimeSeriesChart.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index b8f27e7ce..c65de1902 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -268,7 +268,15 @@ export function TimeSeriesChart({ const yAxisTickFormatterRef = useRef<(val: number) => string>(yAxisTickFormatter) yAxisTickFormatterRef.current = yAxisTickFormatter useEffect(() => { - uRef.current?.redraw() + 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]) // uplot-react rebuilds the whole chart (they call this the "create" path) when any top-level From 1c8a4bc06f581752553e81e448736daf71ba24f7 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 30 Jul 2026 17:24:04 -0700 Subject: [PATCH 10/17] Pick a better color name --- app/components/TimeSeriesChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index c65de1902..b1741a1b4 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -82,7 +82,7 @@ function getChartTheme(): ChartTheme { const v = (name: string) => style.getPropertyValue(name) return { fontFamily: v('--font-mono'), - stroke: v('--content-accent-tertiary'), + stroke: v('--stroke-accent-secondary'), fill: withAlpha(v('--surface-accent-secondary'), 0.6), hoverPoint: v('--content-accent'), axisLine: v('--stroke-secondary'), From b26d17d0e669cf6b59deeb5f21eb8de806aee8e8 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 30 Jul 2026 18:12:08 -0700 Subject: [PATCH 11/17] Add a test prohibiting "bad" redraw calls --- app/components/TimeSeriesChart.spec.tsx | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 app/components/TimeSeriesChart.spec.tsx diff --git a/app/components/TimeSeriesChart.spec.tsx b/app/components/TimeSeriesChart.spec.tsx new file mode 100644 index 000000000..6271ae63e --- /dev/null +++ b/app/components/TimeSeriesChart.spec.tsx @@ -0,0 +1,67 @@ +/* + * 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 { render } from '@testing-library/react' +import { useEffect, type ComponentProps } from 'react' +import type UplotReactComponent from 'uplot-react' +import { describe, expect, test, vi } from 'vitest' + +import { TimeSeriesChart } from './TimeSeriesChart' + +const redraw = vi.fn() + +vi.mock('uplot-react', () => { + const MeplotReactComponent = (props: ComponentProps) => { + useEffect(() => { + props.onCreate?.({ redraw } as never) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + return null + } + return { default: MeplotReactComponent } +}) + +describe('safe redrawing', () => { + /* + * TimeSeriesChart uses uPlot's `redraw` method to repaint when `yAxisTickFormatter` changes. This + * is perfectly fine as long as it's called "the right way". Calling redraw "the wrong way" can + * cause uPlot to get stuck with bad settings; in this case, that would be an x range of `null` to + * `null`. That leaves the series basically unplottable, and the visible effect is a blank chart. + * + * This is only visible in production builds because StrictMode incidentally forces a re-create + * AFTER the issue, hiding it, but these tests are fine either way, because they simply prohibit + * "wrong" calls to redraw. + */ + const props = (formatter: (v: number) => string) => ({ + data: [{ timestamp: 0, value: 10 }], + title: 'CPU', + startTime: new Date(0), + endTime: new Date(3_600_000), + yAxisTickFormatter: formatter, + loading: false, + }) + + const expectAllRedrawsSafe = () => { + for (const [rebuildPaths, recalcAxes] of redraw.mock.calls) { + expect(rebuildPaths).toBe(false) // the important part + expect(recalcAxes).toBe(true) + } + } + + test('mounting never triggers an unsafe redraw', () => { + render( `${v}%`)} />) + expectAllRedrawsSafe() + }) + + test('a new formatter triggers a safe redraw', () => { + const { rerender } = render( `${v}%`)} />) + redraw.mockClear() + rerender( `${v} pct`)} />) + expect(redraw).toHaveBeenCalled() + expectAllRedrawsSafe() + }) +}) From 0859cd2887b8acdf287e583643e036bd7ad8c624 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 9 Jul 2026 09:46:22 -0700 Subject: [PATCH 12/17] Add an OxQL explorer page, support multiline charts This also makes some design decisions unilaterally, such as a rotating color palette, and legends/what they look like. --- app/api/index.ts | 1 + app/components/SystemMetric.tsx | 16 +- app/components/TimeSeriesChart.spec.tsx | 3 +- app/components/TimeSeriesChart.tsx | 100 +++- app/components/form/fields/OxqlField.tsx | 21 + app/components/oxql-metrics/OxqlMetric.tsx | 12 +- app/layouts/SystemLayout.tsx | 5 + app/pages/system/OxqlPage.tsx | 467 ++++++++++++++++++ app/routes.tsx | 1 + .../__snapshots__/path-builder.spec.ts.snap | 6 + app/util/links.ts | 6 +- app/util/path-builder.spec.ts | 1 + app/util/path-builder.ts | 1 + 13 files changed, 620 insertions(+), 20 deletions(-) create mode 100644 app/components/form/fields/OxqlField.tsx create mode 100644 app/pages/system/OxqlPage.tsx diff --git a/app/api/index.ts b/app/api/index.ts index 222bd778f..7cf02330e 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -14,6 +14,7 @@ export * from './client' export * from './roles' export * from './util' export * from './__generated__/Api' +export { camelToSnake } from './__generated__/util' // export * as ZVal from './__generated__/validate' export type { ApiTypes } diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index 421db4dc3..c133082da 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -10,7 +10,12 @@ import { useMemo, useRef } from 'react' import { api, q, synthesizeData, type ChartDatum, type SystemMetricName } from '@oxide/api' -import { ChartContainer, ChartHeader, TimeSeriesChart } from './TimeSeriesChart' +import { + ChartContainer, + ChartHeader, + TimeSeriesChart, + toChartSeries, +} from './TimeSeriesChart' // The difference between system metric and silo metric is // 1. different endpoints @@ -84,11 +89,14 @@ export function SiloMetric({ // TODO: indicate time zone somewhere. doesn't have to be in the detail view // in the tooltip. could be just once on the end of the x-axis like GCP + const { values, timestamps } = toChartSeries(data) + return ( { * "wrong" calls to redraw. */ const props = (formatter: (v: number) => string) => ({ - data: [{ timestamp: 0, value: 10 }], + data: [[10]], + timestamps: [0], title: 'CPU', startTime: new Date(0), endTime: new Date(3_600_000), diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index b1741a1b4..b473cc8e3 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -69,6 +69,7 @@ type ChartTheme = { 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 @@ -87,9 +88,20 @@ function getChartTheme(): ChartTheme { 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())), []) @@ -143,7 +155,8 @@ function ChartTooltip({ type TimeSeriesChartProps = { className?: string - data: ChartDatum[] | undefined + timestamps: number[] | undefined + data: (number | null)[][] | undefined title: string interpolation?: 'linear' | 'stepAfter' startTime: Date @@ -152,6 +165,7 @@ type TimeSeriesChartProps = { yAxisTickFormatter?: (val: number) => string hasError?: boolean loading: boolean + seriesLabels?: readonly string[] } // this top margin is also in the chart, probably want a way of unifying the sizing between the two @@ -191,7 +205,23 @@ const SkeletonMetric = ({ const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() +/** + * Split a single `ChartDatum[]` into the parallel `timestamps`/`data` arrays the chart consumes. + * Returns `undefined` props when there's no data so the chart goes into the loading/empty state. + */ +export function toChartSeries(data: ChartDatum[] | undefined): { + timestamps: number[] | undefined + values: (number | null)[][] | undefined +} { + if (!data) return { timestamps: undefined, values: undefined } + return { + timestamps: data.map((d) => d.timestamp), + values: [data.map((d) => d.value)], + } +} + export function TimeSeriesChart({ + timestamps, data: rawData, title, interpolation = 'linear', @@ -201,6 +231,7 @@ export function TimeSeriesChart({ yAxisTickFormatter = defaultYAxisTickFormatter, hasError = false, loading, + seriesLabels, }: TimeSeriesChartProps) { // falling back here instead of in the parent lets us avoid causing a // re-render on every render of the parent when the data is undefined @@ -292,16 +323,16 @@ export function TimeSeriesChart({ }, series: [ {}, - { + ...R.times(data.length, (i) => ({ show: true, - stroke: theme.stroke, - fill: theme.fill, + stroke: seriesColor(i, theme), + fill: data.length === 1 ? theme.fill : undefined, points: { show: false }, paths: match(interpolation) .with('linear', () => uPlot.paths.linear?.()) .with('stepAfter', () => uPlot.paths.stepped?.({ align: 1 })) .exhaustive(), - }, + })), ], axes: [ { @@ -352,13 +383,14 @@ export function TimeSeriesChart({ drag: { x: false }, points: { size: 6, + // TODO: with multiline, pinning the focused point color doesn't make much sense anymore fill: theme.hoverPoint, }, }, legend: { show: false }, plugins: [tooltipPlugin], }) satisfies Omit, - [formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] + [data.length, formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] ) // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets @@ -388,7 +420,7 @@ export function TimeSeriesChart({ ) } - if (!data || data.length === 0) { + if (!data || data.length === 0 || !timestamps || timestamps.length === 0) { return ( @@ -396,15 +428,17 @@ export function TimeSeriesChart({ ) } - const aligned: uPlot.AlignedData = [ - data.map(({ timestamp }) => timestamp / 1000), - data.map(({ value }) => value), - ] + const aligned: uPlot.AlignedData = [timestamps.map((t) => t / 1000), ...data] - const hovered = tooltip ? data[tooltip.hoveredDataIndex] : undefined + const hovered: ChartDatum | undefined = tooltip + ? { + timestamp: timestamps[tooltip.hoveredDataIndex], + value: data[0][tooltip.hoveredDataIndex], // TODO(joe): no no no. + } + : undefined return (
-
+
(uRef.current = u)} /> {tooltip && hovered && hovered.value !== null && (
)}
+ {seriesLabels && ( + + )}
) } @@ -506,3 +548,35 @@ export function ChartHeader({ title, label, description, children }: ChartHeader
) } + +// We generally expect a list of labels to be the same length as the data list (or not provided), so +// the fallback here is just for bad behavior. +function seriesLabel(title: string, i: number, labels: readonly string[]): string { + return labels[i] ?? `${title} #${i + 1}` +} + +function ChartLegend({ + title, + count, + seriesLabels, + theme, +}: { + title: string + count: number + seriesLabels: readonly string[] + theme: ChartTheme +}) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+ + {seriesLabel(title, i, seriesLabels)} +
+ ))} +
+ ) +} diff --git a/app/components/form/fields/OxqlField.tsx b/app/components/form/fields/OxqlField.tsx new file mode 100644 index 000000000..044e570c0 --- /dev/null +++ b/app/components/form/fields/OxqlField.tsx @@ -0,0 +1,21 @@ +/* + * 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 type { FieldPath, FieldValues } from 'react-hook-form' + +import type { TextAreaProps } from '~/ui/lib/TextInput' + +import { TextField, type TextFieldProps } from './TextField' + +export function OxqlField< + TFieldValues extends FieldValues, + TName extends FieldPath, +>( + props: Omit, 'validate'> & Omit +) { + return +} diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 7a28b68ae..22035f42c 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -25,7 +25,12 @@ import * as Dropdown from '~/ui/lib/DropdownMenu' import { classed } from '~/util/classed' import { docLinks, links } from '~/util/links' -import { ChartContainer, ChartHeader, TimeSeriesChart } from '../TimeSeriesChart' +import { + ChartContainer, + ChartHeader, + TimeSeriesChart, + toChartSeries, +} from '../TimeSeriesChart' import { HighlightedOxqlQuery, toOxqlStr } from './HighlightedOxqlQuery' import { composeOxqlData, @@ -86,6 +91,8 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric const [modalOpen, setModalOpen] = useState(false) + const { values, timestamps } = toChartSeries(data) + return ( @@ -111,7 +118,8 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric startTime={startTime} endTime={endTime} unit={unitForSet} - data={data} + data={values} + timestamps={timestamps} yAxisTickFormatter={yAxisTickFormatter} hasError={hasError} // isLoading only covers first load --- future-proof against the reintroduction of interval refresh diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index fca0d33b8..fe4b050f2 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -11,6 +11,7 @@ import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Cloud16Icon, + Monitoring16Icon, IpGlobal16Icon, Metrics16Icon, Servers16Icon, @@ -57,6 +58,7 @@ export default function SystemLayout() { { value: 'Subnet Pools', path: pb.subnetPools() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, + { value: 'OxQL Explorer', path: pb.oxql() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -107,6 +109,9 @@ export default function SystemLayout() { Fleet Access + + OxQL Explorer + diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx new file mode 100644 index 000000000..d4463923e --- /dev/null +++ b/app/pages/system/OxqlPage.tsx @@ -0,0 +1,467 @@ +/* + * 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 { useForm } from 'react-hook-form' +import * as R from 'remeda' +import { match } from 'ts-pattern' + +import { + api, + useApiMutation, + camelToSnake, + type Timeseries, + type Points, + type OxqlTable, + type TimeseriesQuery, + type Values, +} 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 { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeSeriesChart' +import { Button } from '~/ui/lib/Button' +import { Divider } from '~/ui/lib/Divider' +import { Message } from '~/ui/lib/Message' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { docLinks } from '~/util/links' + +const queries = { + basicTctl: `get hardware_component:amd_cpu_tctl + | filter timestamp > @now() - 1m`, + multiJoinedTable: `{ + { + get sled_data_link:bytes_sent; + get sled_data_link:errors_sent + } + | align mean_within(20s) + | join; + { + get sled_data_link:bytes_received; + get sled_data_link:errors_received + } + | align mean_within(20s) + | join +} + | filter kind == 'vnic' + | filter timestamp > @now() - 10m`, + bytesSentAndReceived: `{ + get sled_data_link:bytes_sent + | align mean_within(5s) + | group_by [sled_serial, link_name, kind]; + get sled_data_link:bytes_received + | align mean_within(5s) + | group_by [sled_serial, link_name, kind] +} + | filter timestamp > @now() - 10m + | filter kind == 'vnic' + | filter link_name == 'oxControlService20'`, +} + +const defaultValues: TimeseriesQuery = { + query: queries.bytesSentAndReceived, +} + +export const handle = { crumb: 'OxQL Explorer' } + +const narrowToNumbers = (vs: Values): (number | null)[] => + match(vs.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' }, () => []) // by only calling this on aligned/joined tables, we know this is unreachable + .with({ type: 'double_distribution' }, () => []) // these don't exist in practice, and are also unreachable per above + .exhaustive() + +const leftPad = (items: T[], length: number): (T | null)[] => + items.length >= length ? items : [...Array(length - items.length).fill(null), ...items] + +// The generated client types timestamps as Date, but the wire format is actually ISO strings. Oops! +// `new Date` accepts both. +type OxqlTimestamp = Points['timestamps'][number] +const parseTs = (ts: OxqlTimestamp): number => new Date(ts).getTime() +const toPosix = (timestamps: OxqlTimestamp[]): number[] => timestamps.map(parseTs) + +type TimeseriesKind = 'joined' | 'aligned' | 'unaligned' + +/** + * When aligning a series, the timestamps are all on the same grid, but values at the beginning may + * be missing (e.g. [10,20,30] in one timestamp array, and [20,30] in another). As long as we can + * prove there's a regular grid all the way through, no big deal. + */ +const getAlignedTimestamps = ( + items: Timeseries[] +): { type: 'some'; timestamps: number[] } | { type: 'none' } => { + // aligned tables never have start times + if (!items[0] || items[0].points.startTimes) return { type: 'none' } + // similarly, aligned tables are always doubles (even if their inputs were integers!) + if (!items[0].points.values.every(({ values }) => values.type === 'double')) + return { type: 'none' } + + const longestSeries = R.firstBy(items, (i) => -i.points.timestamps.length) + if (!longestSeries || longestSeries.points.timestamps.length === 0) + return { type: 'none' } + + // converting to posix numbers knocks us down to millisecond precision, but uplot is going to + // plot by second anyways + const posixes = toPosix(longestSeries.points.timestamps) + + const end = R.last(posixes) + // aligned series may not share the same start time, but they will always have a common final + // timestamp + if ( + !items.every(({ points }) => { + const last = R.last(points.timestamps) + // no timestamps at all is fine; otherwise the final one must match the shared end + return last === undefined || parseTs(last) === end + }) + ) + return { type: 'none' } + + if (posixes.length === 1) return { type: 'some', timestamps: posixes } + + const [start, second] = posixes + + const step = second - start + // we'll assume all timestamp lists are aligned if every timestamp on our longest timestamp list + // is aligned, i.e. some `step` away from the first one we look at + if (!posixes.every((time) => (time - start) % step === 0)) return { type: 'none' } + + return { + type: 'some', + timestamps: posixes, + } +} + +type Chart = { + name: string + description?: string + timestamps: number[] + data: Data +} + +type LabeledNumberLine = Chart<{ label: string; values: (number | null)[] }[]> + +type ChartGroups = { startTime: Date; endTime: Date } & ( + | { kind: 'unaligned'; charts: Chart[] } + | { kind: 'aligned'; charts: LabeledNumberLine[] } + | { kind: 'joined'; charts: LabeledNumberLine[] } +) + +const getFormattedFields = (t: Timeseries): string => + Object.entries(t.fields) + // hello my evil friend. + .map(([fieldName, x]) => `${camelToSnake(fieldName)}: ${x.value}`) + .join(' \u2022 ') + +const tableToGroups = (table: OxqlTable): ChartGroups | 'empty-timeseries' => { + const { name, timeseries } = table + if (timeseries.length === 0) return 'empty-timeseries' + const kind: + | Exclude + | { kind: 'aligned'; timestamps: number[] } = + // we expect all values arrays to be the same length, so if the first isn't longer than 1, we + // expect singletons across the board + timeseries[0]?.points.values.length > 1 + ? ('joined' as const) + : match(getAlignedTimestamps(timeseries)) + .with({ type: 'none' }, () => 'unaligned' as const) + .with({ type: 'some' }, ({ timestamps }) => ({ + kind: 'aligned' as const, + timestamps, + })) + .exhaustive() + + const chart = match(kind) + .with('joined', (kind) => { + // In a joined table, each Values item is a distinct metric:target and the + // table name is those metric names comma-joined, index-aligned to the Values. + // So the line labels come from the table name, not the (identical-per-line) + // joined field. + const metricNames = name.split(',').map((s) => s.trim()) + + return { + kind, + // when joined, each timeseries is _also_ aligned, but we assume that users want to focus on + // cross-referencing between metrics, so we join the values within a given timeseries, going + // no further + charts: timeseries.map((series) => ({ + name, + description: getFormattedFields(series), + timestamps: toPosix(series.points.timestamps), + data: series.points.values.map((v, i) => ({ + label: + metricNames[i] || + // should be unreachable + `${getFormattedFields(series)} #${i + 1}`, + values: narrowToNumbers(v), + })), + })), + } + }) + .with({ kind: 'aligned' }, ({ kind, timestamps }) => ({ + kind, + charts: [ + { + name, + timestamps, + data: timeseries + .filter((s) => s.points.values.length > 0) + .map((series) => ({ + label: getFormattedFields(series), + values: leftPad(narrowToNumbers(series.points.values[0]), 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], + })), + })) + .exhaustive() + const timestamps = chart.charts.flatMap(({ timestamps }) => timestamps) + const min = R.firstBy(timestamps, (t) => t) + const max = R.firstBy(timestamps, (t) => -t) + + return { + ...chart, + // i figure any chart collection probably benefits from sharing their X-axis, even if they're + // rendered in sequence. when there's no data at all, min/max are undefined and the range is + // irrelevant (the charts render their empty state) — fall back to the epoch for valid Dates + startTime: new Date(min ?? 0), + endTime: new Date(max ?? 0), + } +} + +const TICK_UNITS = [ + // TODO: this doesn't quite match the suffixes in the oxql-metrics util, but i'm leaving it + // because i don't understand those + [1e12, 't'], + [1e9, 'b'], + [1e6, 'm'], + [1e3, 'k'], +] as const +const formatTick = (n: number): string => { + const [divisor, suffix] = TICK_UNITS.find(([min]) => Math.abs(n) >= min) ?? [1, ''] + 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)[][] } +const firstPointDropper = + (drop: boolean) => + ({ timestamps, data }: TimeAndData): TimeAndData => + drop + ? { timestamps: timestamps.slice(1), data: data.map((d) => d.slice(1)) } + : { timestamps, 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 +// not useful, and you'd want to hide it to get a more useful y-axis for the rest of your data. +const groupHasPointWorthDropping = (g: ChartGroups | 'empty-timeseries'): boolean => + match(g) + .with('empty-timeseries', () => false) + // 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') + ) + .exhaustive() + +export default function OxqlPage() { + const query = useApiMutation(api.systemTimeseriesQuery) + + const form = useForm({ defaultValues }) + const control = form.control + + const [dropFirstPoint, setDropFirstPoint] = useState(true) + + const onSubmit = (body: TimeseriesQuery) => { + query.mutate({ body }) + } + + const chartGroups: (ChartGroups | 'empty-timeseries')[] | null = useMemo( + () => (query.data ? query.data.tables.map(tableToGroups) : null), + [query.data] + ) + + const hasTrimmableCharts = chartGroups?.some(groupHasPointWorthDropping) ?? false + const trim = firstPointDropper(dropFirstPoint && hasTrimmableCharts) + + return ( + <> + + }>OxQL Explorer + } + summary="The Oximeter Query Language is a domain-specific language for interrogating telemetry data from software and hardware components across the rack." + links={[docLinks.oxql, docLinks.oxqlSchemas]} + /> + +
+ + + + + {match(query) + .with({ status: 'idle' }, () => null) + .with({ status: 'pending' }, () => ( + + + + )) + .with({ status: 'error' }, (q) => ( + {q.error.message}} + /> + )) + .with({ status: 'success' }, () => ( + <> + {hasTrimmableCharts && ( +
+ +
+ )} + {chartGroups && + chartGroups.map((s, tableNumber) => ( +
+ + {match(s) + .with('empty-timeseries', () => 'No results') + .with( + { kind: 'joined' }, + { kind: 'aligned' }, + ({ charts, startTime, endTime }) => ( +
+ {charts.map((chart, chartNumber) => { + const trimmed = trim({ + timestamps: chart.timestamps, + data: chart.data.map((d) => d.values), + }) + const seriesLabels = chart.data.map((l) => l.label) + return ( + + + + + ) + })} +
+ ) + ) + .with({ kind: 'unaligned' }, ({ charts, startTime, endTime }) => + charts.map((chart, chartNumber) => { + 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 }) + return ( + + + + + ) + }) + ) + .exhaustive()} +
+ ))} + + )) + .exhaustive()} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22..02b6e0c56 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -176,6 +176,7 @@ export const routes = createRoutesFromElements( path="utilization" lazy={() => import('./pages/system/UtilizationPage').then(convert)} /> + import('./pages/system/OxqlPage').then(convert)} /> import('./pages/system/inventory/InventoryPage.tsx').then(convert)} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 300fee583..35c4534a7 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -469,6 +469,12 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], + "oxql (/system/oxql)": [ + { + "label": "OxQL Explorer", + "path": "/system/oxql", + }, + ], "profile (/settings/profile)": [ { "label": "Settings", diff --git a/app/util/links.ts b/app/util/links.ts index 7c9fcfbf5..1e2f22df5 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -89,9 +89,13 @@ export const docLinks = { linkText: 'Instance Actions', }, oxql: { - href: 'https://docs.oxide.computer/guides/operator/system-metrics#_oxql_quickstart', + href: 'https://docs.oxide.computer/guides/metrics/oxql-tutorial#_oxql_quickstart', linkText: 'OxQL', }, + oxqlSchemas: { + href: 'https://docs.oxide.computer/guides/metrics/timeseries-schemas', + linkText: 'Timeseries schemas', + }, keyConceptsProjects: { href: 'https://docs.oxide.computer/guides/key-entities-and-concepts#_projects', linkText: 'Key Concepts', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 9fc90181e..e12e99965 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -76,6 +76,7 @@ test('path builder', () => { "ipPoolRangeAdd": "/system/networking/ip-pools/pl/ranges-add", "ipPools": "/system/networking/ip-pools", "ipPoolsNew": "/system/networking/ip-pools-new", + "oxql": "/system/oxql", "profile": "/settings/profile", "project": "/projects/p/instances", "projectAccess": "/projects/p/access", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index e09ad45aa..9e2b7185b 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -115,6 +115,7 @@ export const pb = { siloImage: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}`, fleetAccess: () => '/system/access', + oxql: () => '/system/oxql', systemUtilization: () => '/system/utilization', ipPools: () => '/system/networking/ip-pools', From c924dc59ffbffd9fc23d626c756fe020c0358c57 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Sun, 26 Jul 2026 21:00:04 -0700 Subject: [PATCH 13/17] Put in some sort of tooltip support for multi-line charts I'm not entirely sure this is what we're going to love. As you drag the mouse around, the alpha changes are quite noisy. I wonder if we can get by with just highlighting the active point (instead of _all_ the points on that X) and stick the color itself in the tooltip? The other thought I'm having here: in the legend, there's not much to do other than throw all the legend values in line like that (or come up with aliases, but then you need some sort of hover). Within a tooltip, though, this could be actually formatted! --- app/components/TimeSeriesChart.tsx | 44 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index b473cc8e3..e4565f5c0 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -246,7 +246,10 @@ export function TimeSeriesChart({ const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime const [tooltip, setTooltip] = useState<{ + // the x position hoveredDataIndex: number + // which series is hovered + hoveredSeriesIndex: number left: number top: number // which side of the point the box sits on @@ -264,13 +267,20 @@ export function TimeSeriesChart({ return } - const x = self.data[0][idx] - const y = self.data[1][idx] - if (y == null) { + // We hunt down the series whose Y is closest to the cursor position at the given X index. + // Reminder that the first series is the X values, so we start at series index 1 here. + const nearestSeriesIndex = R.firstBy( + R.range(1, self.series.length).filter((s) => self.data[s][idx] != null), + // non-null: the filter above dropped series that are null at this idx + (s) => Math.abs(self.valToPos(self.data[s][idx]!, 'y') - top) + ) + if (nearestSeriesIndex === undefined) { setTooltip(null) return } + const x = self.data[0][idx] + const plotRect = self.over.getBoundingClientRect() const chartRect = self.root.getBoundingClientRect() @@ -279,6 +289,7 @@ export function TimeSeriesChart({ setTooltip({ hoveredDataIndex: idx, + hoveredSeriesIndex: nearestSeriesIndex - 1, // cursor coords are relative to the plot area, so we add in the diff between the plot // and the whole container left: plotRect.left - chartRect.left + left, @@ -376,7 +387,11 @@ export function TimeSeriesChart({ }, ], padding: [null, null, null, CHART_LEFT_PAD], + focus: { alpha: 0.5 }, cursor: { + // setting this property causes non-focused series to dim on hover. + // 1e9 just means "any proximity will do" + focus: { prox: 1e9 }, x: false, y: false, // TODO: i like the drag and we should put it back in @@ -430,12 +445,17 @@ export function TimeSeriesChart({ const aligned: uPlot.AlignedData = [timestamps.map((t) => t / 1000), ...data] - const hovered: ChartDatum | undefined = tooltip - ? { - timestamp: timestamps[tooltip.hoveredDataIndex], - value: data[0][tooltip.hoveredDataIndex], // TODO(joe): no no no. - } - : undefined + const hovered: ChartDatum | undefined = + tooltip && + // in case the data changed out from under us, let's at least check that we can find something + // to render + tooltip.hoveredSeriesIndex < data.length && + tooltip.hoveredDataIndex < timestamps.length + ? { + timestamp: timestamps[tooltip.hoveredDataIndex], + value: data[tooltip.hoveredSeriesIndex][tooltip.hoveredDataIndex], + } + : undefined return (
@@ -452,7 +472,11 @@ export function TimeSeriesChart({
From f7824a521f59181a8f2fdde3f3c5583c307e58eb Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 30 Jul 2026 22:08:28 -0700 Subject: [PATCH 14/17] Add button to prefill some arbitrary queries Maybe we'll actually hang on to something like this in the long run, but for now it's just plain handy. --- app/pages/system/OxqlPage.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index d4463923e..0f4e40225 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -323,12 +323,26 @@ export default function OxqlPage() { />
- +
+ {Object.entries(queries).map(([key, text]) => ( + + ))} +
+
+ +
From 0169e776e144539706b4dc9db72579b416d7a405 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Wed, 5 Aug 2026 16:46:46 -0700 Subject: [PATCH 15/17] Make MSW always return some kind of data for OxQL metrics MSW already supports a few specific queries, and we could expand that support, but the challenge is less in adding more metrics/targets, and more in needing increasingly rich parsing of queries to determine what the query is actually asking for (multiple tables, alignments, joins, groupings). For now, I think our bases are covered by just guaranteeing it always returns _something._ --- mock-api/oxql-metrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock-api/oxql-metrics.ts b/mock-api/oxql-metrics.ts index ff54ac353..5138bab7f 100644 --- a/mock-api/oxql-metrics.ts +++ b/mock-api/oxql-metrics.ts @@ -302,7 +302,7 @@ export const getMockOxqlInstanceData = ( { values: { type: 'double', - values: values, + values: values || timestamps.map((_, i) => i * 1000), }, metric_type: 'gauge', }, From ff5349fabe0788276f98e75c0ea73f0f18b848d7 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 10 Aug 2026 18:13:58 -0700 Subject: [PATCH 16/17] More variety in oxql mock data Still not really respecting the actual details of the query, but this is enough to get some visual coverage. --- app/pages/system/OxqlPage.tsx | 5 ++ mock-api/msw/util.ts | 125 +++++++++++++++++++++++++++++----- mock-api/oxql-metrics.ts | 110 ++++++++++++++++++------------ 3 files changed, 178 insertions(+), 62 deletions(-) diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 0f4e40225..c8b85c4b4 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -33,6 +33,11 @@ import { docLinks } from '~/util/links' const queries = { basicTctl: `get hardware_component:amd_cpu_tctl + | filter timestamp > @now() - 1m`, + unalignedTables: `{ + get hardware_component:temperature; + get hardware_component:sensor_error_count +} | filter timestamp > @now() - 1m`, multiJoinedTable: `{ { diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index b213c7dc8..1be94c53a 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -34,14 +34,25 @@ import { } from '@oxide/api' import { json, type Json } from '~/api/__generated__/msw-handlers' -import type { OxqlNetworkMetricName, OxqlVcpuState } from '~/components/oxql-metrics/util' +import type { OxqlMetricName, OxqlVcpuState } from '~/components/oxql-metrics/util' import { parseIp } from '~/util/ip' import { GiB, TiB } from '~/util/units' import type { DbRoleAssignmentResourceType } from '..' -import { SENTINEL_FLAT_INSTANCE_ID, SENTINEL_SLOPE_INSTANCE_ID } from '../instance' +import { + instances, + SENTINEL_FLAT_INSTANCE_ID, + SENTINEL_SLOPE_INSTANCE_ID, +} from '../instance' import { genI64Data } from '../metrics' -import { getMockOxqlInstanceData } from '../oxql-metrics' +import { + pointsFrom, + fixedTimestamps, + getJitteredTimestamps, + timeseriesFrom, + resultFrom, + getMockValues, +} from '../oxql-metrics' import { db, lookupById } from './db' import { Rando } from './rando' @@ -571,8 +582,34 @@ export function updateDesc( } } -// The metric name is the second word in the query string -const getMetricNameFromQuery = (query: string) => query.split(' ')[1] +type Alignment = 'unaligned' | 'aligned' | 'joined' +type OxqlVibe = { + firstTable: OxqlMetricName + moreTables: OxqlMetricName[] + alignment: Alignment +} + +// This is a very approximate image of the incoming query. Just enough to +// determine whether the caller is looking for something more complex than a +// single table, but not actually matching the exact expected shape. +const getVibe = (query: string): OxqlVibe => { + const [firstTable, ...moreTables] = [...query.matchAll(/get ([a-z_]+:[a-z_]+)/g)].map( + (m) => m[1] as OxqlMetricName + ) + if (!firstTable) throw new Error(`no "get " found in query: ${query}`) + + const alignment = query.match(/\bjoin\b/) + ? 'joined' + : query.match(/\balign\b/) + ? 'aligned' + : 'unaligned' + + return { + firstTable, + moreTables, + alignment, + } +} // The state value is the string in quotes after 'state == ' in the query string // It might not be present in the string @@ -594,23 +631,75 @@ const invertUtilization = (percent: number): number => (percent * 5 * 1e9) / 100 const SENTINEL_CONSTANT_RAW_VALUE = invertUtilization(12345) // 12,345% const sentinelSlopeRawValue = (i: number) => invertUtilization((i + 1) * 1000) // (i + 1) * 1000% +const timestampsFor = (alignment: Alignment, seed: number): string[] => + match(alignment) + // Unaligned tables may _incidentally_ have aligned timestamps, but it's highly unlikely. + .with('unaligned', () => getJitteredTimestamps(seed)) + .with('aligned', 'joined', () => fixedTimestamps) + .exhaustive() + +function getMultipleTables(vibe: OxqlVibe) { + const tables = [vibe.firstTable, ...vibe.moreTables] + + return match(vibe.alignment) + .with('joined', () => + resultFrom([ + { + name: tables.join(','), + timeseries: R.times(3, (n) => + timeseriesFrom( + instances[n].id, + // joined tables have each metric's values "joined" into the values array + pointsFrom( + timestampsFor(vibe.alignment, n), + tables.map((t, index) => getMockValues(t, index + tables.length * n)) + ) + ) + ), + }, + ]) + ) + .with('aligned', 'unaligned', () => + resultFrom( + tables.map((name) => ({ + name, + timeseries: R.times(2, (n) => + timeseriesFrom( + instances[n].id, + pointsFrom(timestampsFor(vibe.alignment, n), [getMockValues(name, n)]) + ) + ), + })) + ) + ) + .exhaustive() +} + export function handleOxqlMetrics({ query }: TimeseriesQuery): Json { - const metricName = getMetricNameFromQuery(query) as OxqlNetworkMetricName - const stateValue = getCpuStateFromQuery(query) - const data = getMockOxqlInstanceData(metricName, stateValue) + const vibe = getVibe(query) - // Sentinel instances: replace the series with synthetic data — flat (constant) - // or a slope that increases with time — so tests can assert on plotted values. + if (vibe.moreTables.length > 0) return getMultipleTables(vibe) + + const stateValue = getCpuStateFromQuery(query) const instanceId = getInstanceIdFromQuery(query) - const points = data.tables[0].timeseries[0].points - const series = points.values[0].values.values - if (instanceId === SENTINEL_FLAT_INSTANCE_ID) { - points.values[0].values.values = series.map(() => SENTINEL_CONSTANT_RAW_VALUE) - } else if (instanceId === SENTINEL_SLOPE_INSTANCE_ID) { - points.values[0].values.values = series.map((_, i) => sentinelSlopeRawValue(i)) - } - return data + const timestamps = timestampsFor(vibe.alignment, 0) + + const values = match(instanceId) + .with(SENTINEL_FLAT_INSTANCE_ID, () => + timestamps.map(() => SENTINEL_CONSTANT_RAW_VALUE) + ) + .with(SENTINEL_SLOPE_INSTANCE_ID, () => + timestamps.map((_, i) => sentinelSlopeRawValue(i)) + ) + .otherwise(() => getMockValues(vibe.firstTable, 0, stateValue)) + + return resultFrom([ + { + name: vibe.firstTable, + timeseries: [timeseriesFrom(instances[0].id, pointsFrom(timestamps, [values]))], + }, + ]) } export function randomHex(length: number) { diff --git a/mock-api/oxql-metrics.ts b/mock-api/oxql-metrics.ts index 5138bab7f..84649a09d 100644 --- a/mock-api/oxql-metrics.ts +++ b/mock-api/oxql-metrics.ts @@ -5,23 +5,85 @@ * * Copyright Oxide Computer Company */ -import type { OxqlQueryResult } from '~/api' +import type { Timeseries, Points, OxqlQueryResult } from '~/api' import type { OxqlMetricName, OxqlVcpuState } from '~/components/oxql-metrics/util' -import { instances } from './instance' import type { Json } from './json-type' +import { Rando } from './msw/rando' const oneHourAgo = new Date() oneHourAgo.setHours(oneHourAgo.getHours() - 1) const now = new Date() -const timestamps: string[] = [] +export const fixedTimestamps: string[] = [] // Generate timestamps for the last hour for (let i = oneHourAgo.getTime(); i < now.getTime(); i += 60000) { - timestamps.push(new Date(i).toISOString()) + fixedTimestamps.push(new Date(i).toISOString()) } type ValueType = Record +export const getJitteredTimestamps = (seed: number): string[] => { + const rando = new Rando(seed) + if (fixedTimestamps.length < 2) + throw new Error("can't make jittered timestamps without at least two timestamps") + const basicInterval = Date.parse(fixedTimestamps[1]) - Date.parse(fixedTimestamps[0]) + if (Number.isNaN(basicInterval)) throw new Error("can't make a jittered timestamp array") + const jitterInterval = basicInterval / 10 + return fixedTimestamps.map((t) => + new Date(Date.parse(t) + Math.floor(jitterInterval * rando.next())).toISOString() + ) +} + +export const getMockValues = ( + name: OxqlMetricName, + offset: number, + state?: OxqlVcpuState +): number[] => { + const hardcoded = state ? mockOxqlVcpuStateValues[state] : mockOxqlValues[name] + if (hardcoded) return hardcoded + + // eslint-disable-next-line @typescript-eslint/no-misused-spread + const seed = [...name].reduce((sum, c) => sum + c.charCodeAt(0), 0) + const rando = new Rando(seed + offset) + return fixedTimestamps.map(() => 1000 + rando.next() * 500) +} + +export const pointsFrom = ( + timestamps: string[], + valueArrays: number[][] +): Json => ({ + timestamps: timestamps, + values: valueArrays.map((v) => ({ + values: { + type: 'double', + values: v, + }, + metric_type: 'gauge', + })), +}) + +export const timeseriesFrom = (id: string, points: Json): Json => ({ + fields: { + instanceId: { + type: 'uuid', + value: id, + }, + }, + points, +}) + +type TableArgs = { + name: string + timeseries: Json[] +} + +export const resultFrom = (tables: TableArgs[]): Json => + // structuredClone lets us mutate data in the calling code without messing up + // the source data + structuredClone({ + tables, + }) + const mockOxqlValues: ValueType = { 'instance_network_interface:bytes_received': [ 19589220.623748355, 24553203.242848497, 89094997.39982976, 88911367.62801822, @@ -274,43 +336,3 @@ const mockOxqlVcpuStateValues: Record = { 5131885.651897, 5188225.092888, 4388460.254213, 4075678.463765, 3943427.938256, ], } - -export const getMockOxqlInstanceData = ( - name: OxqlMetricName, - state?: OxqlVcpuState -): Json => { - const values = state ? mockOxqlVcpuStateValues[state] : mockOxqlValues[name] - // structuredClone lets us mutate data in the calling code without messing up - // the source data - return structuredClone({ - tables: [ - { - name: name, - timeseries: [ - // This is a fake metric ID - { - fields: { - instanceId: { - type: 'uuid', - value: instances[0].id, // project: mock-project; instance: db1 - }, - }, - points: { - start_times: [], - timestamps: timestamps, - values: [ - { - values: { - type: 'double', - values: values || timestamps.map((_, i) => i * 1000), - }, - metric_type: 'gauge', - }, - ], - }, - }, - ], - }, - ], - }) -} From 01d4d7ca95f552f0c834cc0c5d5c93892a6a07f0 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 10 Aug 2026 18:55:17 -0700 Subject: [PATCH 17/17] Add visual and e2e tests --- app/components/TimeSeriesChart.tsx | 8 +- app/components/form/fields/OxqlField.tsx | 11 +- test/e2e/oxql-queries.ts | 44 ++++++++ test/e2e/oxql.e2e.ts | 123 +++++++++++++++++++++++ test/visual/regression.e2e.ts | 11 ++ 5 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 test/e2e/oxql-queries.ts create mode 100644 test/e2e/oxql.e2e.ts diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index e4565f5c0..00fbb044e 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -591,16 +591,16 @@ function ChartLegend({ theme: ChartTheme }) { return ( -
+
    {Array.from({ length: count }, (_, i) => ( -
    +
  • {seriesLabel(title, i, seriesLabels)} -
  • + ))} -
+ ) } diff --git a/app/components/form/fields/OxqlField.tsx b/app/components/form/fields/OxqlField.tsx index 044e570c0..d663ece6a 100644 --- a/app/components/form/fields/OxqlField.tsx +++ b/app/components/form/fields/OxqlField.tsx @@ -17,5 +17,14 @@ export function OxqlField< >( props: Omit, 'validate'> & Omit ) { - return + return ( + + typeof value === 'string' && value.trim() ? undefined : 'Enter a query' + } + {...props} + /> + ) } diff --git a/test/e2e/oxql-queries.ts b/test/e2e/oxql-queries.ts new file mode 100644 index 000000000..ab5cd4e27 --- /dev/null +++ b/test/e2e/oxql-queries.ts @@ -0,0 +1,44 @@ +/* + * 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 + */ + +export const oxqlQueries = { + basicTctl: `get hardware_component:amd_cpu_tctl + | filter timestamp > @now() - 1m`, + unalignedTables: `{ + get hardware_component:temperature; + get hardware_component:sensor_error_count +} + | filter timestamp > @now() - 1m`, + multiJoinedTables: `{ + { + get sled_data_link:bytes_sent; + get sled_data_link:errors_sent + } + | align mean_within(20s) + | join; + { + get sled_data_link:bytes_received; + get sled_data_link:errors_received + } + | align mean_within(20s) + | join +} + | filter kind == 'vnic' + | filter timestamp > @now() - 10m`, + bytesSentAndReceived: `{ + get sled_data_link:bytes_sent + | align mean_within(5s) + | group_by [sled_serial, link_name, kind]; + get sled_data_link:bytes_received + | align mean_within(5s) + | group_by [sled_serial, link_name, kind] +} + | filter timestamp > @now() - 10m + | filter kind == 'vnic' + | filter link_name == 'oxControlService20'`, +} diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts new file mode 100644 index 000000000..eb10a500c --- /dev/null +++ b/test/e2e/oxql.e2e.ts @@ -0,0 +1,123 @@ +/* + * 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 { expect, test, type Page, type Locator } from '@playwright/test' + +import { oxqlQueries } from './oxql-queries' + +const runQuery = async (page: Page, query?: string) => { + if (query !== undefined) await page.getByRole('textbox').fill(query) + await page.getByRole('button', { name: 'Run query' }).click() + + const loading = page.getByLabel('Chart loading') + await expect(loading).toBeVisible() + await expect(loading).toBeHidden() + await expect(page.getByText('Query failed')).toBeHidden() +} + +test.beforeEach(async ({ page }) => { + await page.goto('/system/oxql') + await expect(page.getByRole('heading', { name: 'OxQL Explorer' })).toBeVisible() +}) + +test('unaligned multi-table query renders a chart per series', async ({ page }) => { + await runQuery(page, oxqlQueries.unalignedTables) + + // Unaligned queries get you a chart for every series in the result, splitting + // up tables (since each list of values isn't aligned with the others!) + await expect(page.getByRole('figure')).toHaveCount(4) // product of table count and fields-per-table + await expect( + page.getByRole('figure', { name: 'hardware_component:temperature' }) + ).toHaveCount(2) + await expect( + page.getByRole('figure', { name: 'hardware_component:sensor_error_count' }) + ).toHaveCount(2) +}) + +const getLegendText = async (locator: Locator): Promise => + locator.getByRole('listitem').allTextContents() + +test('aligned multi-table query renders a chart per table', async ({ page }) => { + await runQuery(page, oxqlQueries.bytesSentAndReceived) + + const figures = page.getByRole('figure') + // Aligned tab + await expect(figures).toHaveCount(2) // number of tables in query + const first = figures.first() + + // On aligned queries, there's one chart per table queried, and one line (and + // legend item) per field combination. The legend item depends on mock data, + // so we just snapshot + const firstLegendText = await getLegendText(first) + expect(firstLegendText).toEqual([ + // depends on whatever mock data returns + 'instance_id: 935499b3-fd96-432a-9c21-83a3dc1eece4', + 'instance_id: b5946edc-5bed-4597-88ab-9a8beb9d32a4', + ]) + + const all = await figures.all() + for (let i = 1; i < all.length; i += 1) { + // Every chart should have the same sequence of fields, even if the actual + // combinations are dynamic + expect(await getLegendText(all[i])).toEqual(firstLegendText) + } +}) + +test('joined query renders a chart per instance with a legend line per metric', async ({ + page, +}) => { + await runQuery(page, oxqlQueries.multiJoinedTables) + + const figures = page.getByRole('figure') + // Joined queries are an inversion of aligned queries: they have one chart per + // _field combination,_ and one line/legend item per table in the join + await expect(figures).toHaveCount(3) // depends on mock data + const first = figures.first() + await expect(first.getByRole('listitem')).toHaveText([ + 'sled_data_link:bytes_sent', + 'sled_data_link:errors_sent', + 'sled_data_link:bytes_received', + 'sled_data_link:errors_received', + ]) +}) + +test('"Drop first point" appears only for cumulative-derived charts', async ({ page }) => { + const dropFirst = page.getByLabel('Drop first point') + + // a plain gauge is never cumulative, so there's no giant first point to drop + await runQuery(page, oxqlQueries.basicTctl) + await expect(dropFirst).toBeHidden() + + // joined/aligned tables may derive from cumulatives, so the option shows up + // TODO: if you know the schemas, you can check which tables are cumulative! + await runQuery(page, oxqlQueries.multiJoinedTables) + await expect(dropFirst).toBeChecked() + + await dropFirst.uncheck() + await expect(page.getByRole('figure')).toHaveCount(3) +}) + +test('empty query is blocked by client-side validation', async ({ page }) => { + const textbox = page.getByRole('textbox') + await textbox.fill('') + await page.getByRole('button', { name: 'Run query' }).click() + + await expect(textbox).toHaveAttribute('aria-invalid', 'true') + await expect(page.getByText('Enter a query').first()).toBeVisible() + await expect(page.getByRole('figure')).toHaveCount(0) +}) + +test('a query the backend rejects surfaces an error instead of a chart', async ({ + page, +}) => { + await page.getByRole('textbox').fill('junk junk junk!') + await page.getByRole('button', { name: 'Run query' }).click() + + await expect(page.getByText('Query failed')).toBeVisible() + await expect(page.getByRole('figure')).toHaveCount(0) +}) diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts index 9f139ebdf..7351cdbec 100644 --- a/test/visual/regression.e2e.ts +++ b/test/visual/regression.e2e.ts @@ -14,6 +14,7 @@ * CSS frameworks, or making broad styling changes. */ +import { oxqlQueries } from '../e2e/oxql-queries' import { expect, test } from '../e2e/utils' // set a fixed time to avoid diffs due to irrelevant time differences @@ -256,4 +257,14 @@ test.describe('Visual Regression', { tag: '@visual' }, () => { maskColor: '#0b0e14', }) }) + + for (const [name, query] of Object.entries(oxqlQueries)) { + test(`oxql ${name}`, async ({ page }) => { + await page.goto('/system/oxql', { waitUntil: 'networkidle' }) + await page.getByRole('textbox').fill(query) + await page.getByRole('button', { name: 'Run query' }).click() + await expect(page.locator('figure').first()).toBeVisible() + await expect(page).toHaveScreenshot(`oxql-${name}.png`, fullPage) + }) + } })