From eef56410bc609cafbc1c0e2dbb65bf584982fadb Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 9 Jul 2026 09:46:22 -0700 Subject: [PATCH 01/12] 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 | 51 +- app/components/TimeSeriesChart.spec.tsx | 25 +- app/components/TimeSeriesChart.tsx | 104 +++- app/components/form/fields/OxqlField.tsx | 21 + app/components/oxql-metrics/OxqlMetric.tsx | 22 +- 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, 663 insertions(+), 48 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 222bd778f1..7cf02330e6 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 421db4dc3e..4c00c78486 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -8,9 +8,14 @@ import { useQuery } from '@tanstack/react-query' import { useMemo, useRef } from 'react' -import { api, q, synthesizeData, type ChartDatum, type SystemMetricName } from '@oxide/api' +import { api, q, synthesizeData, 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 @@ -66,17 +71,19 @@ export function SiloMetric({ ) ) - const ref = useRef(undefined) + const ref = useRef(toChartSeries(undefined)) const isFetching = inRange.isFetching || beforeStart.isFetching - const data = useMemo(() => { + const { values, timestamps } = useMemo(() => { // big old hack to avoid the graph flashing with weird data while either query is loading if (isFetching) return ref.current - ref.current = synthesizeData( - inRange.data?.items, - beforeStart.data?.items, - startTime, - endTime, - valueTransform + ref.current = toChartSeries( + synthesizeData( + inRange.data?.items, + beforeStart.data?.items, + startTime, + endTime, + valueTransform + ) ) return ref.current }, [inRange.data, beforeStart.data, startTime, endTime, valueTransform, isFetching]) @@ -88,7 +95,8 @@ export function SiloMetric({ (undefined) + const ref = useRef(toChartSeries(undefined)) const isFetching = inRange.isFetching || beforeStart.isFetching - const data = useMemo(() => { + const { values, timestamps } = useMemo(() => { // big old hack to avoid the graph flashing with weird data while either query is loading if (isFetching) return ref.current - ref.current = synthesizeData( - inRange.data?.items, - beforeStart.data?.items, - startTime, - endTime, - valueTransform + ref.current = toChartSeries( + synthesizeData( + inRange.data?.items, + beforeStart.data?.items, + startTime, + endTime, + valueTransform + ) ) return ref.current }, [inRange.data, beforeStart.data, startTime, endTime, valueTransform, isFetching]) @@ -157,7 +167,8 @@ export function SystemMetric({ { * "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), @@ -77,19 +78,27 @@ describe('safe redrawing', () => { // uplot-react will do a deep comparison if the data reference changes to avoid rebuilding the // chart, but it would be even better to skip that comparison by maintaining a reference test('an unchanged data prop sends a stable reference down to uplot-react', () => { - const data = [ - { timestamp: 0, value: 10 }, - { timestamp: 1000, value: 20 }, - ] + const data = [[10, 20]] + const timestamps = [0, 1000] dataPropsPassed.length = 0 - const { rerender } = render( `${v}%`)} data={data} />) - rerender( `${v} pct`)} data={data} />) + const { rerender } = render( + `${v}%`)} timestamps={timestamps} data={data} /> + ) + rerender( + `${v} pct`)} timestamps={timestamps} data={data} /> + ) expect(dataPropsPassed.length).toBeGreaterThan(1) // it re-rendered expect(new Set(dataPropsPassed).size).toBe(1) // but every render passed the identical reference - rerender( `${v}%`)} data={[...data]} />) + rerender( + `${v}%`)} + timestamps={timestamps} + data={[...data]} + /> + ) expect(new Set(dataPropsPassed).size).toBe(2) // unless the reference changes }) }) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 75beb17f17..21d3c31f84 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -70,6 +70,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 @@ -88,9 +89,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 = { - 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, title, interpolation = 'linear', @@ -201,6 +231,7 @@ export function TimeSeriesChart({ yAxisTickFormatter = defaultYAxisTickFormatter, hasError = false, loading, + seriesLabels, }: TimeSeriesChartProps) { const theme = useChartTheme() const fontPx = remToPx(AXIS_FONT_REM_XS) @@ -210,6 +241,8 @@ export function TimeSeriesChart({ const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime + const dataLength = data?.length ?? 0 + const [tooltip, setTooltip] = useState<{ hoveredDataIndex: number left: number @@ -288,16 +321,16 @@ export function TimeSeriesChart({ }, series: [ {}, - { + ...R.times(dataLength, (i) => ({ show: true, - stroke: theme.stroke, - fill: theme.fill, + stroke: seriesColor(i, theme), + fill: dataLength === 1 ? theme.fill : undefined, points: { show: false }, paths: match(interpolation) .with('linear', () => uPlot.paths.linear?.()) .with('stepAfter', () => uPlot.paths.stepped?.({ align: 1 })) .exhaustive(), - }, + })), ], axes: [ { @@ -348,13 +381,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] + [dataLength, formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] ) // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets @@ -371,11 +405,10 @@ export function TimeSeriesChart({ const aligned = useMemo(() => { const points = data ?? [] - return [ - points.map(({ timestamp }) => timestamp / 1000), - points.map(({ value }) => value), - ] - }, [data]) + const times = timestamps ?? [] + + return [times.map((t) => t / 1000), ...points] + }, [data, timestamps]) if (hasError) { return ( @@ -393,7 +426,7 @@ export function TimeSeriesChart({ ) } - if (!data || data.length === 0) { + if (!data || data.length === 0 || !timestamps || timestamps.length === 0) { return ( @@ -401,7 +434,12 @@ export function TimeSeriesChart({ ) } - 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 (
{/* The chart is absolutely positioned so its fixed pixel width doesn't feed back into the @@ -439,6 +477,14 @@ export function TimeSeriesChart({ )} + {seriesLabels && ( + + )}
) } @@ -521,3 +567,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 0000000000..044e570c07 --- /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 7a28b68ae4..584c365481 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, @@ -78,10 +83,14 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric [metrics, errorMeansEmpty] ) - const { data, label, unitForSet, yAxisTickFormatter } = useMemo(() => { - if (unit === 'Bytes') return getBytesChartProps(chartData) - if (unit === 'Count') return getCountChartProps(chartData) - return getUtilizationChartProps(chartData, timeseriesCount) + const { values, timestamps, label, unitForSet, yAxisTickFormatter } = useMemo(() => { + const { data, ...props } = + unit === 'Bytes' + ? getBytesChartProps(chartData) + : unit === 'Count' + ? getCountChartProps(chartData) + : getUtilizationChartProps(chartData, timeseriesCount) + return { ...props, ...toChartSeries(data) } }, [unit, chartData, timeseriesCount]) const [modalOpen, setModalOpen] = useState(false) @@ -111,7 +120,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 fca0d33b88..fe4b050f2c 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 0000000000..65c1de29ac --- /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 2fdaadc22f..02b6e0c56b 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 82f02279d9..196d0ece04 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -465,6 +465,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 0179c31a46..78e021f7c9 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -91,9 +91,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 9fc90181e0..e12e99965c 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 e09ad45aa7..9e2b7185b3 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 67d165a37da94f89c6bc290f874f798a5fbeff50 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Sun, 26 Jul 2026 21:00:04 -0700 Subject: [PATCH 02/12] 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 | 49 +++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 21d3c31f84..38d8e39bd5 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -244,7 +244,10 @@ export function TimeSeriesChart({ const dataLength = data?.length ?? 0 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 @@ -262,13 +265,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() @@ -277,6 +287,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, @@ -374,7 +385,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 @@ -434,12 +449,20 @@ export function TimeSeriesChart({ ) } - const hovered: ChartDatum | undefined = tooltip - ? { - timestamp: timestamps[tooltip.hoveredDataIndex], - value: data[0][tooltip.hoveredDataIndex], // TODO(joe): no no no. - } - : undefined + // in case the data changed out from under us, let's at least check that we can find something + // to render + const hoveredValue = + tooltip && + tooltip.hoveredSeriesIndex < data.length && + tooltip.hoveredDataIndex < timestamps.length + ? data[tooltip.hoveredSeriesIndex][tooltip.hoveredDataIndex] + : null + + const hovered = + tooltip && hoveredValue != null + ? { timestamp: timestamps[tooltip.hoveredDataIndex], value: hoveredValue } + : undefined + return (
{/* The chart is absolutely positioned so its fixed pixel width doesn't feed back into the @@ -459,7 +482,7 @@ export function TimeSeriesChart({ onCreate={(u) => (uRef.current = u)} /> )} - {tooltip && hovered && hovered.value !== null && ( + {tooltip && hovered && (
From 523276bec2e89c9516778230c229aad00c2e4670 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 30 Jul 2026 22:08:28 -0700 Subject: [PATCH 03/12] 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 65c1de29ac..dafd9a6447 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 18a80405778e2bdd4b271f4368942dcc198b27c6 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Wed, 5 Aug 2026 16:46:46 -0700 Subject: [PATCH 04/12] 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._ --- 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 dafd9a6447..358bf05c61 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 b213c7dc8e..1be94c53a1 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 ff54ac353f..84649a09d5 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, - }, - metric_type: 'gauge', - }, - ], - }, - }, - ], - }, - ], - }) -} From 84b4b7a8c6ee3bcd2499334fc7471f91ad9682fc Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 10 Aug 2026 18:55:17 -0700 Subject: [PATCH 05/12] 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 38d8e39bd5..d1da5ddcb9 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -613,16 +613,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 044e570c07..d663ece6ab 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 0000000000..ab5cd4e275 --- /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 0000000000..eb10a500cf --- /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 d944edd6c6..7b0ed7c8d3 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) + }) + } }) From b72f900b599d6e5a33500007fcf29c308a1b942d Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 17 Aug 2026 19:31:43 -0700 Subject: [PATCH 06/12] Replace query params on successful queries Writing the query to the url is ugly, BUT it lets people bookmark! This change also adds functionality for disabling the loading bar, because by default it would begin _after_ successful queries (since I'm electing to only write queries that succeeded to the URL), which just looked weird. --- app/layouts/RootLayout.tsx | 3 ++- app/pages/system/OxqlPage.tsx | 22 ++++++++++++++++++++-- test/e2e/oxql.e2e.ts | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/layouts/RootLayout.tsx b/app/layouts/RootLayout.tsx index 1ccd051987..9c2f68b175 100644 --- a/app/layouts/RootLayout.tsx +++ b/app/layouts/RootLayout.tsx @@ -76,7 +76,8 @@ function LoadingBar() { // only used for checking the loading state from inside the timeout callback const loadingRef = useRef(false) - loadingRef.current = navigation.state === 'loading' + loadingRef.current = + navigation.state === 'loading' && navigation.location.state?.skipLoadingBar !== true useEffect(() => { const loading = navigation.state === 'loading' diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 358bf05c61..49a6f0d657 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -7,6 +7,7 @@ */ import { useMemo, useState } from 'react' import { useForm } from 'react-hook-form' +import { useSearchParams } from 'react-router' import * as R from 'remeda' import { match } from 'ts-pattern' @@ -299,13 +300,30 @@ const groupHasPointWorthDropping = (g: ChartGroups | 'empty-timeseries'): boolea export default function OxqlPage() { const query = useApiMutation(api.systemTimeseriesQuery) - const form = useForm({ defaultValues }) + const [searchParams, setSearchParams] = useSearchParams() + + const form = useForm({ + defaultValues: { query: searchParams.get('query') ?? defaultValues.query }, + }) const control = form.control const [dropFirstPoint, setDropFirstPoint] = useState(true) const onSubmit = (body: TimeseriesQuery) => { - query.mutate({ body }) + query.mutate( + { body }, + { + onSuccess: () => { + setSearchParams( + (params) => { + params.set('query', body.query) + return params + }, + { replace: true, preventScrollReset: true, state: { skipLoadingBar: true } } + ) + }, + } + ) } const chartGroups: (ChartGroups | 'empty-timeseries')[] | null = useMemo( diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index eb10a500cf..d04801ee23 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -121,3 +121,17 @@ test('a query the backend rejects surfaces an error instead of a chart', async ( await expect(page.getByText('Query failed')).toBeVisible() await expect(page.getByRole('figure')).toHaveCount(0) }) + +test('pages reads the initial query from the URL', async ({ page }) => { + await page.goto(`/system/oxql?query=${encodeURIComponent(oxqlQueries.basicTctl)}`) + await expect(page.getByRole('textbox')).toHaveValue(oxqlQueries.basicTctl) +}) + +test('pages writes the query to the URL after a successful run', async ({ page }) => { + await page.goto('/system/oxql') + await runQuery(page, oxqlQueries.basicTctl) + + await expect + .poll(() => new URL(page.url()).searchParams.get('query')) + .toBe(oxqlQueries.basicTctl) +}) From 16b0b5a20111201fe668af3d5fe2e3ef33cc9f6b Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Tue, 18 Aug 2026 18:36:08 -0700 Subject: [PATCH 07/12] Virtualize charts uPlot is fast, but it's not "render 1500 charts at once" fast. --- app/pages/system/OxqlPage.tsx | 393 +++++++++++++++++++++------------- package-lock.json | 1 + package.json | 1 + test/e2e/oxql.e2e.ts | 19 ++ 4 files changed, 264 insertions(+), 150 deletions(-) diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 49a6f0d657..2cb7ba5fea 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -5,7 +5,8 @@ * * Copyright Oxide Computer Company */ -import { useMemo, useState } from 'react' +import { useWindowVirtualizer } from '@tanstack/react-virtual' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { useSearchParams } from 'react-router' import * as R from 'remeda' @@ -26,6 +27,7 @@ import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/r import { DocsPopover } from '~/components/DocsPopover' import { OxqlField } from '~/components/form/fields/OxqlField' import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeSeriesChart' +import { useElementSize } from '~/hooks/use-element-size' import { Button } from '~/ui/lib/Button' import { Divider } from '~/ui/lib/Divider' import { Message } from '~/ui/lib/Message' @@ -160,13 +162,15 @@ type Chart = { data: Data } -type LabeledNumberLine = Chart<{ label: string; values: (number | null)[] }[]> +type Multiline = Chart<{ label: string; values: (number | null)[] }[]> -type ChartGroups = { startTime: Date; endTime: Date } & ( - | { kind: 'unaligned'; charts: Chart[] } - | { kind: 'aligned'; charts: LabeledNumberLine[] } - | { kind: 'joined'; charts: LabeledNumberLine[] } -) +type ChartGroup = + | 'empty-timeseries' + | ({ startTime: Date; endTime: Date } & ( + | { kind: 'unaligned'; charts: Chart[] } + | { kind: 'aligned'; charts: Multiline[] } + | { kind: 'joined'; charts: Multiline[] } + )) const getFormattedFields = (t: Timeseries): string => Object.entries(t.fields) @@ -174,7 +178,7 @@ const getFormattedFields = (t: Timeseries): string => .map(([fieldName, x]) => `${camelToSnake(fieldName)}: ${x.value}`) .join(' \u2022 ') -const tableToGroups = (table: OxqlTable): ChartGroups | 'empty-timeseries' => { +const tableToGroup = (table: OxqlTable): ChartGroup => { const { name, timeseries } = table if (timeseries.length === 0) return 'empty-timeseries' const kind: @@ -253,8 +257,7 @@ const tableToGroups = (table: OxqlTable): ChartGroups | 'empty-timeseries' => { 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 + // rendered in sequence startTime: new Date(min ?? 0), endTime: new Date(max ?? 0), } @@ -276,9 +279,10 @@ const formatTick = (n: number): string => { // Drops (or keeps, without copying) the first sample of a series. We trim timestamps and values at // the same time to be confident they're in sync. type TimeAndData = { timestamps: number[]; data: (number | null)[][] } +type Trim = (t: TimeAndData) => TimeAndData const firstPointDropper = - (drop: boolean) => - ({ timestamps, data }: TimeAndData): TimeAndData => + (drop: boolean): Trim => + ({ timestamps, data }) => drop ? { timestamps: timestamps.slice(1), data: data.map((d) => d.slice(1)) } : { timestamps, data } @@ -286,7 +290,7 @@ const firstPointDropper = // 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 => +const groupHasPointWorthDropping = (g: ChartGroup): boolean => match(g) .with('empty-timeseries', () => false) // Aligned/joined tables may be derived from cumulatives, so we assume it's worth offering @@ -297,6 +301,139 @@ const groupHasPointWorthDropping = (g: ChartGroups | 'empty-timeseries'): boolea ) .exhaustive() +// A simplified representation of a single chart. +type ChartDisplay = { key: string; showDivider: boolean } & ( + | { kind: 'empty' } + | { kind: 'multiline'; startTime: Date; endTime: Date; chart: Multiline } + | { kind: 'line'; startTime: Date; endTime: Date; chart: Chart } +) + +// Virtualization relies on a list of near-same-size items, so we flatten out all the groups +const toDisplays = (groups: ChartGroup[]): ChartDisplay[] => + groups.flatMap((g, t): ChartDisplay[] => { + if (g === 'empty-timeseries') + return [{ kind: 'empty', key: `t${t}`, showDivider: true }] + const { startTime, endTime } = g + return match(g) + .with({ kind: 'unaligned' }, ({ charts }) => + charts.map( + (chart, i): ChartDisplay => ({ + kind: 'line', + key: `t${t}.${i}`, + showDivider: i === 0, + startTime, + endTime, + chart, + }) + ) + ) + .with({ kind: 'joined' }, { kind: 'aligned' }, ({ charts }) => + charts.map( + (chart, i): ChartDisplay => ({ + kind: 'multiline', + key: `t${t}.${i}`, + showDivider: i === 0, + startTime, + endTime, + chart, + }) + ) + ) + .exhaustive() + }) + +function MultilineChart({ + display, + trim, +}: { + display: Extract + trim: Trim +}) { + const { chart, startTime, endTime } = display + const trimmed = trim({ + timestamps: chart.timestamps, + data: chart.data.map((d) => d.values), + }) + const seriesLabels = chart.data.map((l) => l.label) + return ( + + + + + ) +} + +function LineChart({ + display, + trim, +}: { + display: Extract + trim: Trim +}) { + const { chart, startTime, endTime } = display + const data = match(chart.data.values) + .with({ type: 'integer' }, ({ values }) => values) + .with({ type: 'double' }, ({ values }) => values) + .with({ type: 'boolean' }, ({ values }) => + values.map((b) => + match(b) + .with(true, () => 1) + .with(false, () => 0) + .with(null, () => null) + .exhaustive() + ) + ) + .with({ type: 'string' }, () => []) // these don't exist in practice + .with({ type: 'integer_distribution' }, { type: 'double_distribution' }, () => []) // heatmaps! + .exhaustive() + const trimmed = trim({ data: [data], timestamps: chart.timestamps }) + return ( + + + + + ) +} + +function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { + return ( + <> + {display.showDivider && ( + // Use padding for spacing so the virtualizer can measure the bounding box properly +
+ +
+ )} + {match(display) + .with({ kind: 'empty' }, () =>

No results

) + .with({ kind: 'multiline' }, (r) => ) + .with({ kind: 'line' }, (r) => ) + .exhaustive()} + + ) +} + export default function OxqlPage() { const query = useApiMutation(api.systemTimeseriesQuery) @@ -326,50 +463,92 @@ export default function OxqlPage() { ) } - const chartGroups: (ChartGroups | 'empty-timeseries')[] | null = useMemo( - () => (query.data ? query.data.tables.map(tableToGroups) : null), + const chartGroups: ChartGroup[] | null = useMemo( + () => (query.data ? query.data.tables.map(tableToGroup) : null), [query.data] ) const hasTrimmableCharts = chartGroups?.some(groupHasPointWorthDropping) ?? false const trim = firstPointDropper(dropFirstPoint && hasTrimmableCharts) + const charts = useMemo(() => (chartGroups ? toDisplays(chartGroups) : []), [chartGroups]) + + // Since the whole window is the scroll container, the virtualizer needs to + // know the offset from the top. By reacting to height changes in everything + // prior to the virtualized area, we can keep the list's offset height in sync. + const [preChartsSize, preChartsRef] = useElementSize() + const chartsRef = useRef(null) + const [scrollMargin, setScrollMargin] = useState(0) + useLayoutEffect(() => { + if (chartsRef.current) { + setScrollMargin(chartsRef.current.getBoundingClientRect().top + window.scrollY) + } + }, [preChartsSize?.height, charts.length]) + + const virtualizer = useWindowVirtualizer({ + count: charts.length, + estimateSize: () => 500, + overscan: 4, + scrollMargin, + getItemKey: (i) => charts[i].key, + }) + 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]} - /> - - -
- {Object.entries(queries).map(([key, text]) => ( - - ))} -
-
- + + }>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]} /> -
- - + +
+
+ {Object.entries(queries).map(([key, text]) => ( + + ))} +
+
+ +
+ + + {match(query) + .with( + { status: 'success' }, + () => + hasTrimmableCharts && ( +
+ +
+ ) + ) + .otherwise(() => '')} + {match(query) .with({ status: 'idle' }, () => null) @@ -394,109 +573,23 @@ export default function OxqlPage() { /> )) .with({ status: 'success' }, () => ( - <> - {hasTrimmableCharts && ( -
- +
+ {virtualizer.getVirtualItems().map((item) => ( +
+
- )} - {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/package-lock.json b/package-lock.json index 850f8d1131..e434894147 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@tanstack/react-query": "^5.90.7", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "^8.20.5", + "@tanstack/react-virtual": "^3.13.12", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "classnames": "^2.5.1", diff --git a/package.json b/package.json index 76681eeb36..a1476ac827 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@tanstack/react-query": "^5.90.7", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "^8.20.5", + "@tanstack/react-virtual": "^3.13.12", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "classnames": "^2.5.1", diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index d04801ee23..6092b3b815 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -102,6 +102,25 @@ test('"Drop first point" appears only for cumulative-derived charts', async ({ p await expect(page.getByRole('figure')).toHaveCount(3) }) +test('results list is virtualized', async ({ page }) => { + const getFirstRenderedIndex = () => + page + .locator('[data-index]') + .first() + .evaluate((el) => Number(el.getAttribute('data-index'))) + + await runQuery(page, `{${Array(100).fill('get sled_data_link:bytes_sent').join(';')}}`) + + const figures = page.getByRole('figure') + await expect(figures.first()).toBeVisible() + expect(await getFirstRenderedIndex()).toBe(0) + await expect.poll(() => figures.count()).toBeLessThan(20) // arbitrary, "not everything" + + // double check we're actually virtualizing! + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await expect.poll(getFirstRenderedIndex).not.toBe(0) +}) + test('empty query is blocked by client-side validation', async ({ page }) => { const textbox = page.getByRole('textbox') await textbox.fill('') From c31e880019dd60ff9602770105761d6a1a6f0be9 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Tue, 18 Aug 2026 20:56:21 -0700 Subject: [PATCH 08/12] Put some real example queries in there, tweak layout Still contrived, trying to show the different varieties of chart for the sake of review. But at least these are somewhat reasonable examples. --- app/pages/system/OxqlPage.tsx | 107 +++++++++++++++++----------------- test/e2e/oxql.e2e.ts | 13 +++++ 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 2cb7ba5fea..c6986420a6 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -7,7 +7,7 @@ */ import { useWindowVirtualizer } from '@tanstack/react-virtual' import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { useSearchParams } from 'react-router' import * as R from 'remeda' import { match } from 'ts-pattern' @@ -30,49 +30,43 @@ import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeS import { useElementSize } from '~/hooks/use-element-size' import { Button } from '~/ui/lib/Button' import { Divider } from '~/ui/lib/Divider' +import { Listbox, type ListboxItem } from '~/ui/lib/Listbox' 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`, - unalignedTables: `{ - get hardware_component:temperature; - get hardware_component:sensor_error_count -} - | filter timestamp > @now() - 1m`, - multiJoinedTable: `{ +const exampleItems: ListboxItem[] = [ { - get sled_data_link:bytes_sent; - get sled_data_link:errors_sent - } - | align mean_within(20s) - | join; + label: 'Power shelf fan speeds', + value: `get hardware_component:fan_speed + | filter chassis_kind == 'power' + | filter timestamp > @now() - 1m`, + }, { - get sled_data_link:bytes_received; - get sled_data_link:errors_received - } - | align mean_within(20s) - | join -} - | filter kind == 'vnic' + label: 'AMD CPU TCTL measurements per slot', + value: `get hardware_component:amd_cpu_tctl + | align mean_within(20s) + | group_by [slot] | filter timestamp > @now() - 10m`, - bytesSentAndReceived: `{ + }, + { + label: 'Bytes sent & received per sled', + value: `{ get sled_data_link:bytes_sent - | align mean_within(5s) - | group_by [sled_serial, link_name, kind]; + | align mean_within(30s) + | group_by [kind, sled_id]; get sled_data_link:bytes_received - | align mean_within(5s) - | group_by [sled_serial, link_name, kind] + | align mean_within(30s) + | group_by [kind, sled_id] } + | filter kind == 'physical' | filter timestamp > @now() - 10m - | filter kind == 'vnic' - | filter link_name == 'oxControlService20'`, -} + | join`, + }, +] const defaultValues: TimeseriesQuery = { - query: queries.bytesSentAndReceived, + query: '', } export const handle = { crumb: 'OxQL Explorer' } @@ -419,11 +413,13 @@ function LineChart({ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { return ( <> - {display.showDivider && ( + {display.showDivider ? ( // Use padding for spacing so the virtualizer can measure the bounding box properly
+ ) : ( +
)} {match(display) .with({ kind: 'empty' }, () =>

No results

) @@ -434,16 +430,26 @@ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { ) } +const getTextareaHeightForQuery = (q: string): number => Math.max(q.split('\n').length, 4) + export default function OxqlPage() { const query = useApiMutation(api.systemTimeseriesQuery) const [searchParams, setSearchParams] = useSearchParams() + const defaultQuery = searchParams.get('query') ?? defaultValues.query + + const [textareaRowCount, setTextareaRowCount] = useState( + getTextareaHeightForQuery(defaultQuery) + ) + const form = useForm({ - defaultValues: { query: searchParams.get('query') ?? defaultValues.query }, + defaultValues: { query: defaultQuery }, }) const control = form.control + const currentQuery = useWatch({ control, name: 'query' }) + const [dropFirstPoint, setDropFirstPoint] = useState(true) const onSubmit = (body: TimeseriesQuery) => { @@ -505,28 +511,19 @@ export default function OxqlPage() { links={[docLinks.oxql, docLinks.oxqlSchemas]} /> -
-
- {Object.entries(queries).map(([key, text]) => ( - - ))} -
-
- -
- diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index 6092b3b815..ce2e224bcb 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -9,6 +9,7 @@ import { expect, test, type Page, type Locator } from '@playwright/test' import { oxqlQueries } from './oxql-queries' +import { selectOption } from './utils' const runQuery = async (page: Page, query?: string) => { if (query !== undefined) await page.getByRole('textbox').fill(query) @@ -121,6 +122,18 @@ test('results list is virtualized', async ({ page }) => { await expect.poll(getFirstRenderedIndex).not.toBe(0) }) +test('picking an example populates the query and renders a chart', async ({ page }) => { + await selectOption( + page, + page.getByRole('button', { name: 'Load an example' }), + 'Power shelf fan speeds' + ) + await expect(page.getByRole('textbox')).toHaveValue(/get hardware_component:fan_speed/) + + await runQuery(page) + await expect(page.getByRole('figure').first()).toBeVisible() +}) + test('empty query is blocked by client-side validation', async ({ page }) => { const textbox = page.getByRole('textbox') await textbox.fill('') From a1658cc1bcbd4a354a1bf6fb48a9dc1de1cc5de7 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Wed, 19 Aug 2026 12:33:42 -0700 Subject: [PATCH 09/12] Update link names from oxql -> systemOxql --- app/layouts/SystemLayout.tsx | 4 ++-- app/util/path-builder.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index fe4b050f2c..647b7a8201 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -58,7 +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() }, + { value: 'OxQL Explorer', path: pb.systemOxql() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -109,7 +109,7 @@ export default function SystemLayout() { Fleet Access - + OxQL Explorer diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 9e2b7185b3..5de0438b40 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -115,7 +115,7 @@ export const pb = { siloImage: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}`, fleetAccess: () => '/system/access', - oxql: () => '/system/oxql', + systemOxql: () => '/system/oxql', systemUtilization: () => '/system/utilization', ipPools: () => '/system/networking/ip-pools', From a40ae0088c78e5040e1178b75140505acce38f6e Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Wed, 19 Aug 2026 12:44:04 -0700 Subject: [PATCH 10/12] snapshots --- app/util/__snapshots__/path-builder.spec.ts.snap | 12 ++++++------ app/util/path-builder.spec.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 196d0ece04..a05832ff56 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -465,12 +465,6 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], - "oxql (/system/oxql)": [ - { - "label": "OxQL Explorer", - "path": "/system/oxql", - }, - ], "profile (/settings/profile)": [ { "label": "Settings", @@ -895,6 +889,12 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], + "systemOxql (/system/oxql)": [ + { + "label": "OxQL Explorer", + "path": "/system/oxql", + }, + ], "systemUpdate (/system/update)": [ { "label": "System Update", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index e12e99965c..11ec3323f8 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -76,7 +76,6 @@ 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", @@ -115,6 +114,7 @@ test('path builder', () => { "subnetPoolMemberAdd": "/system/networking/subnet-pools/sp/members-add", "subnetPools": "/system/networking/subnet-pools", "subnetPoolsNew": "/system/networking/subnet-pools-new", + "systemOxql": "/system/oxql", "systemUpdate": "/system/update", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v/firewall-rules", From 1db723ca8e70b5d0d8279aaf3bfe3f37b6af563d Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Wed, 19 Aug 2026 12:15:45 -0700 Subject: [PATCH 11/12] Use a dropdown instead of a listbox --- app/pages/system/OxqlPage.tsx | 41 ++++++++++++++++++++++------------- test/e2e/oxql.e2e.ts | 8 ++----- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index c6986420a6..1d1f1426d0 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -7,7 +7,7 @@ */ import { useWindowVirtualizer } from '@tanstack/react-virtual' import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useForm, useWatch } from 'react-hook-form' +import { useForm } from 'react-hook-form' import { useSearchParams } from 'react-router' import * as R from 'remeda' import { match } from 'ts-pattern' @@ -30,12 +30,12 @@ import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeS import { useElementSize } from '~/hooks/use-element-size' import { Button } from '~/ui/lib/Button' import { Divider } from '~/ui/lib/Divider' -import { Listbox, type ListboxItem } from '~/ui/lib/Listbox' +import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { Message } from '~/ui/lib/Message' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { docLinks } from '~/util/links' -const exampleItems: ListboxItem[] = [ +const exampleItems: { label: string; value: string }[] = [ { label: 'Power shelf fan speeds', value: `get hardware_component:fan_speed @@ -448,8 +448,6 @@ export default function OxqlPage() { }) const control = form.control - const currentQuery = useWatch({ control, name: 'query' }) - const [dropFirstPoint, setDropFirstPoint] = useState(true) const onSubmit = (body: TimeseriesQuery) => { @@ -512,16 +510,29 @@ export default function OxqlPage() { />
- { - setTextareaRowCount(getTextareaHeightForQuery(text)) - form.setValue('query', text) - }} - /> +
+ + + Try an example + + } + /> + + {exampleItems.map(({ label, value }) => ( + { + setTextareaRowCount(getTextareaHeightForQuery(value)) + form.setValue('query', value) + }} + /> + ))} + + +