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 ( { + 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.spec.tsx b/app/components/TimeSeriesChart.spec.tsx new file mode 100644 index 000000000..1a2aead55 --- /dev/null +++ b/app/components/TimeSeriesChart.spec.tsx @@ -0,0 +1,68 @@ +/* + * 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: [[10]], + timestamps: [0], + 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() + }) +}) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 5cf3dad15..00fbb044e 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,45 +7,19 @@ */ 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 { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import * as R from 'remeda' +import { match } from 'ts-pattern' +import uPlot from 'uplot' +import UplotReact from 'uplot-react' import type { ChartDatum } from '@oxide/api' import { Error12Icon } from '@oxide/design-system/icons/react' +import { useElementSize } from '~/hooks/use-element-size' +import { subscribeToTheme } from '~/stores/theme' import { 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,51 +32,122 @@ 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)' - -// TODO: figure out how to do this with TW classes instead. As far as I can tell -// ticks only take direct styling -const textMonoMd = { - fontSize: '0.6875rem', - 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 +} + +const AXIS_FONT_REM_XS = 0.6875 +const AXIS_TICK_LENGTH = 6 +const AXIS_TICK_GAP = 8 +// Left padding (px-5) is taken from the container and given to uPlot instead, so the plot sits +// flush left while x-tick labels can bleed into the gutter without clipping. +const CHART_LEFT_PAD = 20 +const TOOLTIP_GAP = 12 + +type ChartTheme = { + fontFamily: string + stroke: string + fill: string + hoverPoint: string + axisLine: string + axisText: string + lineColors: string[] +} + +// Append an alpha channel to a resolved color, e.g. `oklch(l c h)` -> `oklch(l c h / 0.6)`. Assumes +// our colors are set in oklch! +const withAlpha = (color: string, alpha: number) => color.replace(/\)\s*$/, ` / ${alpha})`) + +// uPlot draws to a canvas, so it can't consume CSS custom properties directly. We subscribe to the +// theme instead. +function getChartTheme(): ChartTheme { + const style = getComputedStyle(document.body) + const v = (name: string) => style.getPropertyValue(name) + return { + fontFamily: v('--font-mono'), + stroke: v('--stroke-accent-secondary'), + fill: withAlpha(v('--surface-accent-secondary'), 0.6), + hoverPoint: v('--content-accent'), + axisLine: v('--stroke-secondary'), + axisText: v('--content-quaternary'), + lineColors: [ + '--color-green-800', + '--color-blue-800', + '--color-purple-800', + '--color-yellow-800', + '--color-red-800', + ].map(v), + } +} + +const seriesColor = (i: number, theme: ChartTheme): string => + theme.lineColors[i] || + `oklch(0.77 0.175 ${((163.7 + (i - theme.lineColors.length) * 137.508) % 360).toFixed(1)})` + +function useChartTheme(): ChartTheme { + const [colors, setColors] = useState(getChartTheme) + useEffect(() => subscribeToTheme(() => setColors(getChartTheme())), []) + return colors +} + +/** Offset the box into the quadrant away from the point so it never overflows an edge */ +type LeftRight = 'left' | 'right' +type TopBottom = 'top' | 'bottom' +function tooltipTransform(leftRight: LeftRight, topBottom: TopBottom): string { + const tx = match(leftRight) + .with('left', () => `calc(-100% - ${TOOLTIP_GAP}px)`) + .with('right', () => `${TOOLTIP_GAP}px`) + .exhaustive() + const ty = match(topBottom) + .with('top', () => `calc(-100% - ${TOOLTIP_GAP}px)`) + .with('bottom', () => `${TOOLTIP_GAP}px`) + .exhaustive() + return `translate(${tx}, ${ty})` } -// 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 - -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 +function ChartTooltip({ + timestamp, + value, + seriesName, + unit, +}: { + timestamp: number + value: number + seriesName: string + unit?: string +}) { return ( -
+
{longDateTime(timestamp)}
-
{name}
+
{seriesName}
{value.toLocaleString()} {unit && {unit}}
- {/* TODO: unit on value if relevant */}
) @@ -110,7 +155,8 @@ function renderTooltip(props: TooltipProps, unit?: string) { type TimeSeriesChartProps = { className?: string - data: ChartDatum[] | undefined + timestamps: number[] | undefined + data: (number | null)[][] | undefined title: string interpolation?: 'linear' | 'stepAfter' startTime: Date @@ -119,15 +165,7 @@ type TimeSeriesChartProps = { yAxisTickFormatter?: (val: number) => string hasError?: boolean 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 + seriesLabels?: readonly string[] } // this top margin is also in the chart, probably want a way of unifying the sizing between the two @@ -165,48 +203,223 @@ 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', startTime, endTime, unit, - yAxisTickFormatter = (val) => val.toLocaleString(), + yAxisTickFormatter = defaultYAxisTickFormatter, hasError = false, loading, + seriesLabels, }: 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 theme = useChartTheme() + const fontPx = remToPx(AXIS_FONT_REM_XS) + const axisFont = `${fontPx}px ${theme.fontFamily}` + + const [size, sizeRef] = useElementSize() + + const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime + + const [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 + 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 + } + + // 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() + + // cursor picks the y position, data picks the x position + const left = self.valToPos(x, 'x') + + 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, + 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)) + }, + }, + }), + [] + ) + + const uRef = useRef(null) + const yAxisTickFormatterRef = useRef<(val: number) => string>(yAxisTickFormatter) + yAxisTickFormatterRef.current = yAxisTickFormatter + useEffect(() => { + uRef.current?.redraw( + // Setting the `rebuildPaths` argument to true causes uPlot to reapply the _current_ x bounds, + // which in the right conditions (e.g., initial render) can leave the chart blank. We only + // need the axes recalculated anyways! + // + // See https://github.com/leeoniya/uPlot/issues/1099 + false, // rebuildPaths + true // recalcAxes + ) + }, [yAxisTickFormatter]) + + // 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: [ + {}, + ...R.times(data.length, (i) => ({ + show: true, + 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: [ + { + stroke: theme.axisText, + font: axisFont, + space: (_u, _axisIdx, _min, _max, plotDim) => plotDim / 5, + values: (_u, times) => times.map((t) => formatTime(t * 1000)), + border: { show: true, stroke: theme.axisLine, width: 1 }, + gap: AXIS_TICK_GAP, + grid: { show: false }, + size: fontPx + AXIS_TICK_GAP + AXIS_TICK_LENGTH, + ticks: { + show: true, + stroke: theme.axisLine, + width: 1, + size: AXIS_TICK_LENGTH, + }, + }, + { + stroke: theme.axisText, + font: axisFont, + side: 1, + border: { show: true, stroke: theme.axisLine, width: 1 }, + gap: AXIS_TICK_GAP, + ticks: { + show: true, + stroke: theme.axisLine, + width: 1, + size: AXIS_TICK_LENGTH, + filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)), + }, + values: (_u, yValues) => + yValues.map((v) => (v === 0 ? '' : yAxisTickFormatterRef.current(v))), + grid: { show: true, stroke: theme.axisLine, width: 1 }, + size: (_self, values) => { + const axisBase = AXIS_TICK_LENGTH + AXIS_TICK_GAP + // given the monospace font, longest by char count is longest by rendered width + const longestVal = R.firstBy(values ?? [], (s) => -s.length) || '' + return axisBase + measureTextWidth(longestVal, axisFont) + }, + }, + ], + 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 + 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, + [data.length, formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] + ) + + // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets + // its own layer of memo + const options = useMemo( + () => + ({ + ...chartOptions, + width: size?.width ?? 0, + height: 300, + }) satisfies uPlot.Options, + [chartOptions, size?.width] + ) + if (hasError) { return ( @@ -222,7 +435,7 @@ export function TimeSeriesChart({ ) } - if (!data || data.length === 0) { + if (!data || data.length === 0 || !timestamps || timestamps.length === 0) { return ( @@ -230,62 +443,54 @@ export function TimeSeriesChart({ ) } - // ResponsiveContainer has default height and width of 100% - // https://recharts.org/en-US/api/ResponsiveContainer + const aligned: uPlot.AlignedData = [timestamps.map((t) => t / 1000), ...data] + + 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 ( -
- - - - - - {/* 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' }} - /> - - - -
+
+
+ (uRef.current = u)} /> + {tooltip && hovered && hovered.value !== null && ( +
+ +
+ )} +
+ {seriesLabels && ( + + )} +
) } @@ -367,3 +572,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..d663ece6a --- /dev/null +++ b/app/components/form/fields/OxqlField.tsx @@ -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 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 ( + + typeof value === 'string' && value.trim() ? undefined : 'Enter a query' + } + {...props} + /> + ) +} 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/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/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..c8b85c4b4 --- /dev/null +++ b/app/pages/system/OxqlPage.tsx @@ -0,0 +1,486 @@ +/* + * 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`, + unalignedTables: `{ + get hardware_component:temperature; + get hardware_component:sensor_error_count +} + | 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]} + /> + +
+
+ {Object.entries(queries).map(([key, text]) => ( + + ))} +
+
+ +
+ +
+ + {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/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. 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/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', 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..1be94c53a 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -34,13 +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 { + 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' @@ -570,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 @@ -581,10 +619,87 @@ 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% + +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 vibe = getVibe(query) + + if (vibe.moreTables.length > 0) return getMultipleTables(vibe) + const stateValue = getCpuStateFromQuery(query) - return getMockOxqlInstanceData(metricName, stateValue) + const instanceId = getInstanceIdFromQuery(query) + + 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 ff54ac353..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, - }, - metric_type: 'gauge', - }, - ], - }, - }, - ], - }, - ], - }) -} 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/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( 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/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..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 @@ -238,7 +239,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,11 +250,21 @@ 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')], 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) + }) + } }) 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/.