diff --git a/app/api/index.ts b/app/api/index.ts index 222bd778f..7cf02330e 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -14,6 +14,7 @@ export * from './client' export * from './roles' export * from './util' export * from './__generated__/Api' +export { camelToSnake } from './__generated__/util' // export * as ZVal from './__generated__/validate' export type { ApiTypes } diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index 421db4dc3..c133082da 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -10,7 +10,12 @@ import { useMemo, useRef } from 'react' import { api, q, synthesizeData, type ChartDatum, type SystemMetricName } from '@oxide/api' -import { ChartContainer, ChartHeader, TimeSeriesChart } from './TimeSeriesChart' +import { + ChartContainer, + ChartHeader, + TimeSeriesChart, + toChartSeries, +} from './TimeSeriesChart' // The difference between system metric and silo metric is // 1. different endpoints @@ -84,11 +89,14 @@ export function SiloMetric({ // TODO: indicate time zone somewhere. doesn't have to be in the detail view // in the tooltip. could be just once on the end of the x-axis like GCP + const { values, timestamps } = toChartSeries(data) + return ( { * "wrong" calls to redraw. */ const props = (formatter: (v: number) => string) => ({ - data: [{ timestamp: 0, value: 10 }], + data: [[10]], + timestamps: [0], title: 'CPU', startTime: new Date(0), endTime: new Date(3_600_000), diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index b1741a1b4..00fbb044e 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -69,6 +69,7 @@ type ChartTheme = { hoverPoint: string axisLine: string axisText: string + lineColors: string[] } // Append an alpha channel to a resolved color, e.g. `oklch(l c h)` -> `oklch(l c h / 0.6)`. Assumes @@ -87,9 +88,20 @@ function getChartTheme(): ChartTheme { hoverPoint: v('--content-accent'), axisLine: v('--stroke-secondary'), axisText: v('--content-quaternary'), + lineColors: [ + '--color-green-800', + '--color-blue-800', + '--color-purple-800', + '--color-yellow-800', + '--color-red-800', + ].map(v), } } +const seriesColor = (i: number, theme: ChartTheme): string => + theme.lineColors[i] || + `oklch(0.77 0.175 ${((163.7 + (i - theme.lineColors.length) * 137.508) % 360).toFixed(1)})` + function useChartTheme(): ChartTheme { const [colors, setColors] = useState(getChartTheme) useEffect(() => subscribeToTheme(() => setColors(getChartTheme())), []) @@ -143,7 +155,8 @@ function ChartTooltip({ type TimeSeriesChartProps = { className?: string - data: ChartDatum[] | undefined + timestamps: number[] | undefined + data: (number | null)[][] | undefined title: string interpolation?: 'linear' | 'stepAfter' startTime: Date @@ -152,6 +165,7 @@ type TimeSeriesChartProps = { yAxisTickFormatter?: (val: number) => string hasError?: boolean loading: boolean + seriesLabels?: readonly string[] } // this top margin is also in the chart, probably want a way of unifying the sizing between the two @@ -191,7 +205,23 @@ const SkeletonMetric = ({ const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() +/** + * Split a single `ChartDatum[]` into the parallel `timestamps`/`data` arrays the chart consumes. + * Returns `undefined` props when there's no data so the chart goes into the loading/empty state. + */ +export function toChartSeries(data: ChartDatum[] | undefined): { + timestamps: number[] | undefined + values: (number | null)[][] | undefined +} { + if (!data) return { timestamps: undefined, values: undefined } + return { + timestamps: data.map((d) => d.timestamp), + values: [data.map((d) => d.value)], + } +} + export function TimeSeriesChart({ + timestamps, data: rawData, title, interpolation = 'linear', @@ -201,6 +231,7 @@ export function TimeSeriesChart({ yAxisTickFormatter = defaultYAxisTickFormatter, hasError = false, loading, + seriesLabels, }: TimeSeriesChartProps) { // falling back here instead of in the parent lets us avoid causing a // re-render on every render of the parent when the data is undefined @@ -215,7 +246,10 @@ export function TimeSeriesChart({ const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime const [tooltip, setTooltip] = useState<{ + // the x position hoveredDataIndex: number + // which series is hovered + hoveredSeriesIndex: number left: number top: number // which side of the point the box sits on @@ -233,13 +267,20 @@ export function TimeSeriesChart({ return } - const x = self.data[0][idx] - const y = self.data[1][idx] - if (y == null) { + // We hunt down the series whose Y is closest to the cursor position at the given X index. + // Reminder that the first series is the X values, so we start at series index 1 here. + const nearestSeriesIndex = R.firstBy( + R.range(1, self.series.length).filter((s) => self.data[s][idx] != null), + // non-null: the filter above dropped series that are null at this idx + (s) => Math.abs(self.valToPos(self.data[s][idx]!, 'y') - top) + ) + if (nearestSeriesIndex === undefined) { setTooltip(null) return } + const x = self.data[0][idx] + const plotRect = self.over.getBoundingClientRect() const chartRect = self.root.getBoundingClientRect() @@ -248,6 +289,7 @@ export function TimeSeriesChart({ setTooltip({ hoveredDataIndex: idx, + hoveredSeriesIndex: nearestSeriesIndex - 1, // cursor coords are relative to the plot area, so we add in the diff between the plot // and the whole container left: plotRect.left - chartRect.left + left, @@ -292,16 +334,16 @@ export function TimeSeriesChart({ }, series: [ {}, - { + ...R.times(data.length, (i) => ({ show: true, - stroke: theme.stroke, - fill: theme.fill, + stroke: seriesColor(i, theme), + fill: data.length === 1 ? theme.fill : undefined, points: { show: false }, paths: match(interpolation) .with('linear', () => uPlot.paths.linear?.()) .with('stepAfter', () => uPlot.paths.stepped?.({ align: 1 })) .exhaustive(), - }, + })), ], axes: [ { @@ -345,20 +387,25 @@ 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 drag: { x: false }, points: { size: 6, + // TODO: with multiline, pinning the focused point color doesn't make much sense anymore fill: theme.hoverPoint, }, }, legend: { show: false }, plugins: [tooltipPlugin], }) satisfies Omit, - [formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] + [data.length, formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] ) // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets @@ -388,7 +435,7 @@ export function TimeSeriesChart({ ) } - if (!data || data.length === 0) { + if (!data || data.length === 0 || !timestamps || timestamps.length === 0) { return ( @@ -396,15 +443,22 @@ export function TimeSeriesChart({ ) } - const aligned: uPlot.AlignedData = [ - data.map(({ timestamp }) => timestamp / 1000), - data.map(({ value }) => value), - ] - - const hovered = tooltip ? data[tooltip.hoveredDataIndex] : undefined + 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 (
-
+
(uRef.current = u)} /> {tooltip && hovered && hovered.value !== null && (
)}
+ {seriesLabels && ( + + )}
) } @@ -506,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/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/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/mock-api/msw/util.ts b/mock-api/msw/util.ts index b213c7dc8..1be94c53a 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -34,14 +34,25 @@ import { } from '@oxide/api' import { json, type Json } from '~/api/__generated__/msw-handlers' -import type { OxqlNetworkMetricName, OxqlVcpuState } from '~/components/oxql-metrics/util' +import type { OxqlMetricName, OxqlVcpuState } from '~/components/oxql-metrics/util' import { parseIp } from '~/util/ip' import { GiB, TiB } from '~/util/units' import type { DbRoleAssignmentResourceType } from '..' -import { SENTINEL_FLAT_INSTANCE_ID, SENTINEL_SLOPE_INSTANCE_ID } from '../instance' +import { + instances, + SENTINEL_FLAT_INSTANCE_ID, + SENTINEL_SLOPE_INSTANCE_ID, +} from '../instance' import { genI64Data } from '../metrics' -import { getMockOxqlInstanceData } from '../oxql-metrics' +import { + pointsFrom, + fixedTimestamps, + getJitteredTimestamps, + timeseriesFrom, + resultFrom, + getMockValues, +} from '../oxql-metrics' import { db, lookupById } from './db' import { Rando } from './rando' @@ -571,8 +582,34 @@ export function updateDesc( } } -// The metric name is the second word in the query string -const getMetricNameFromQuery = (query: string) => query.split(' ')[1] +type Alignment = 'unaligned' | 'aligned' | 'joined' +type OxqlVibe = { + firstTable: OxqlMetricName + moreTables: OxqlMetricName[] + alignment: Alignment +} + +// This is a very approximate image of the incoming query. Just enough to +// determine whether the caller is looking for something more complex than a +// single table, but not actually matching the exact expected shape. +const getVibe = (query: string): OxqlVibe => { + const [firstTable, ...moreTables] = [...query.matchAll(/get ([a-z_]+:[a-z_]+)/g)].map( + (m) => m[1] as OxqlMetricName + ) + if (!firstTable) throw new Error(`no "get " found in query: ${query}`) + + const alignment = query.match(/\bjoin\b/) + ? 'joined' + : query.match(/\balign\b/) + ? 'aligned' + : 'unaligned' + + return { + firstTable, + moreTables, + alignment, + } +} // The state value is the string in quotes after 'state == ' in the query string // It might not be present in the string @@ -594,23 +631,75 @@ const invertUtilization = (percent: number): number => (percent * 5 * 1e9) / 100 const SENTINEL_CONSTANT_RAW_VALUE = invertUtilization(12345) // 12,345% const sentinelSlopeRawValue = (i: number) => invertUtilization((i + 1) * 1000) // (i + 1) * 1000% +const timestampsFor = (alignment: Alignment, seed: number): string[] => + match(alignment) + // Unaligned tables may _incidentally_ have aligned timestamps, but it's highly unlikely. + .with('unaligned', () => getJitteredTimestamps(seed)) + .with('aligned', 'joined', () => fixedTimestamps) + .exhaustive() + +function getMultipleTables(vibe: OxqlVibe) { + const tables = [vibe.firstTable, ...vibe.moreTables] + + return match(vibe.alignment) + .with('joined', () => + resultFrom([ + { + name: tables.join(','), + timeseries: R.times(3, (n) => + timeseriesFrom( + instances[n].id, + // joined tables have each metric's values "joined" into the values array + pointsFrom( + timestampsFor(vibe.alignment, n), + tables.map((t, index) => getMockValues(t, index + tables.length * n)) + ) + ) + ), + }, + ]) + ) + .with('aligned', 'unaligned', () => + resultFrom( + tables.map((name) => ({ + name, + timeseries: R.times(2, (n) => + timeseriesFrom( + instances[n].id, + pointsFrom(timestampsFor(vibe.alignment, n), [getMockValues(name, n)]) + ) + ), + })) + ) + ) + .exhaustive() +} + export function handleOxqlMetrics({ query }: TimeseriesQuery): Json { - const metricName = getMetricNameFromQuery(query) as OxqlNetworkMetricName - const stateValue = getCpuStateFromQuery(query) - const data = getMockOxqlInstanceData(metricName, stateValue) + const vibe = getVibe(query) - // Sentinel instances: replace the series with synthetic data — flat (constant) - // or a slope that increases with time — so tests can assert on plotted values. + if (vibe.moreTables.length > 0) return getMultipleTables(vibe) + + const stateValue = getCpuStateFromQuery(query) const instanceId = getInstanceIdFromQuery(query) - const points = data.tables[0].timeseries[0].points - const series = points.values[0].values.values - if (instanceId === SENTINEL_FLAT_INSTANCE_ID) { - points.values[0].values.values = series.map(() => SENTINEL_CONSTANT_RAW_VALUE) - } else if (instanceId === SENTINEL_SLOPE_INSTANCE_ID) { - points.values[0].values.values = series.map((_, i) => sentinelSlopeRawValue(i)) - } - return data + const timestamps = timestampsFor(vibe.alignment, 0) + + const values = match(instanceId) + .with(SENTINEL_FLAT_INSTANCE_ID, () => + timestamps.map(() => SENTINEL_CONSTANT_RAW_VALUE) + ) + .with(SENTINEL_SLOPE_INSTANCE_ID, () => + timestamps.map((_, i) => sentinelSlopeRawValue(i)) + ) + .otherwise(() => getMockValues(vibe.firstTable, 0, stateValue)) + + return resultFrom([ + { + name: vibe.firstTable, + timeseries: [timeseriesFrom(instances[0].id, pointsFrom(timestamps, [values]))], + }, + ]) } export function randomHex(length: number) { diff --git a/mock-api/oxql-metrics.ts b/mock-api/oxql-metrics.ts index 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/test/e2e/oxql-queries.ts b/test/e2e/oxql-queries.ts new file mode 100644 index 000000000..ab5cd4e27 --- /dev/null +++ b/test/e2e/oxql-queries.ts @@ -0,0 +1,44 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +export const oxqlQueries = { + basicTctl: `get hardware_component:amd_cpu_tctl + | filter timestamp > @now() - 1m`, + unalignedTables: `{ + get hardware_component:temperature; + get hardware_component:sensor_error_count +} + | filter timestamp > @now() - 1m`, + multiJoinedTables: `{ + { + get sled_data_link:bytes_sent; + get sled_data_link:errors_sent + } + | align mean_within(20s) + | join; + { + get sled_data_link:bytes_received; + get sled_data_link:errors_received + } + | align mean_within(20s) + | join +} + | filter kind == 'vnic' + | filter timestamp > @now() - 10m`, + bytesSentAndReceived: `{ + get sled_data_link:bytes_sent + | align mean_within(5s) + | group_by [sled_serial, link_name, kind]; + get sled_data_link:bytes_received + | align mean_within(5s) + | group_by [sled_serial, link_name, kind] +} + | filter timestamp > @now() - 10m + | filter kind == 'vnic' + | filter link_name == 'oxControlService20'`, +} diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts new file mode 100644 index 000000000..eb10a500c --- /dev/null +++ b/test/e2e/oxql.e2e.ts @@ -0,0 +1,123 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { expect, test, type Page, type Locator } from '@playwright/test' + +import { oxqlQueries } from './oxql-queries' + +const runQuery = async (page: Page, query?: string) => { + if (query !== undefined) await page.getByRole('textbox').fill(query) + await page.getByRole('button', { name: 'Run query' }).click() + + const loading = page.getByLabel('Chart loading') + await expect(loading).toBeVisible() + await expect(loading).toBeHidden() + await expect(page.getByText('Query failed')).toBeHidden() +} + +test.beforeEach(async ({ page }) => { + await page.goto('/system/oxql') + await expect(page.getByRole('heading', { name: 'OxQL Explorer' })).toBeVisible() +}) + +test('unaligned multi-table query renders a chart per series', async ({ page }) => { + await runQuery(page, oxqlQueries.unalignedTables) + + // Unaligned queries get you a chart for every series in the result, splitting + // up tables (since each list of values isn't aligned with the others!) + await expect(page.getByRole('figure')).toHaveCount(4) // product of table count and fields-per-table + await expect( + page.getByRole('figure', { name: 'hardware_component:temperature' }) + ).toHaveCount(2) + await expect( + page.getByRole('figure', { name: 'hardware_component:sensor_error_count' }) + ).toHaveCount(2) +}) + +const getLegendText = async (locator: Locator): Promise => + locator.getByRole('listitem').allTextContents() + +test('aligned multi-table query renders a chart per table', async ({ page }) => { + await runQuery(page, oxqlQueries.bytesSentAndReceived) + + const figures = page.getByRole('figure') + // Aligned tab + await expect(figures).toHaveCount(2) // number of tables in query + const first = figures.first() + + // On aligned queries, there's one chart per table queried, and one line (and + // legend item) per field combination. The legend item depends on mock data, + // so we just snapshot + const firstLegendText = await getLegendText(first) + expect(firstLegendText).toEqual([ + // depends on whatever mock data returns + 'instance_id: 935499b3-fd96-432a-9c21-83a3dc1eece4', + 'instance_id: b5946edc-5bed-4597-88ab-9a8beb9d32a4', + ]) + + const all = await figures.all() + for (let i = 1; i < all.length; i += 1) { + // Every chart should have the same sequence of fields, even if the actual + // combinations are dynamic + expect(await getLegendText(all[i])).toEqual(firstLegendText) + } +}) + +test('joined query renders a chart per instance with a legend line per metric', async ({ + page, +}) => { + await runQuery(page, oxqlQueries.multiJoinedTables) + + const figures = page.getByRole('figure') + // Joined queries are an inversion of aligned queries: they have one chart per + // _field combination,_ and one line/legend item per table in the join + await expect(figures).toHaveCount(3) // depends on mock data + const first = figures.first() + await expect(first.getByRole('listitem')).toHaveText([ + 'sled_data_link:bytes_sent', + 'sled_data_link:errors_sent', + 'sled_data_link:bytes_received', + 'sled_data_link:errors_received', + ]) +}) + +test('"Drop first point" appears only for cumulative-derived charts', async ({ page }) => { + const dropFirst = page.getByLabel('Drop first point') + + // a plain gauge is never cumulative, so there's no giant first point to drop + await runQuery(page, oxqlQueries.basicTctl) + await expect(dropFirst).toBeHidden() + + // joined/aligned tables may derive from cumulatives, so the option shows up + // TODO: if you know the schemas, you can check which tables are cumulative! + await runQuery(page, oxqlQueries.multiJoinedTables) + await expect(dropFirst).toBeChecked() + + await dropFirst.uncheck() + await expect(page.getByRole('figure')).toHaveCount(3) +}) + +test('empty query is blocked by client-side validation', async ({ page }) => { + const textbox = page.getByRole('textbox') + await textbox.fill('') + await page.getByRole('button', { name: 'Run query' }).click() + + await expect(textbox).toHaveAttribute('aria-invalid', 'true') + await expect(page.getByText('Enter a query').first()).toBeVisible() + await expect(page.getByRole('figure')).toHaveCount(0) +}) + +test('a query the backend rejects surfaces an error instead of a chart', async ({ + page, +}) => { + await page.getByRole('textbox').fill('junk junk junk!') + await page.getByRole('button', { name: 'Run query' }).click() + + await expect(page.getByText('Query failed')).toBeVisible() + await expect(page.getByRole('figure')).toHaveCount(0) +}) diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts index 9f139ebdf..7351cdbec 100644 --- a/test/visual/regression.e2e.ts +++ b/test/visual/regression.e2e.ts @@ -14,6 +14,7 @@ * CSS frameworks, or making broad styling changes. */ +import { oxqlQueries } from '../e2e/oxql-queries' import { expect, test } from '../e2e/utils' // set a fixed time to avoid diffs due to irrelevant time differences @@ -256,4 +257,14 @@ test.describe('Visual Regression', { tag: '@visual' }, () => { maskColor: '#0b0e14', }) }) + + for (const [name, query] of Object.entries(oxqlQueries)) { + test(`oxql ${name}`, async ({ page }) => { + await page.goto('/system/oxql', { waitUntil: 'networkidle' }) + await page.getByRole('textbox').fill(query) + await page.getByRole('button', { name: 'Run query' }).click() + await expect(page.locator('figure').first()).toBeVisible() + await expect(page).toHaveScreenshot(`oxql-${name}.png`, fullPage) + }) + } })