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..e4565f5c0 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..044e570c0 --- /dev/null +++ b/app/components/form/fields/OxqlField.tsx @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { FieldPath, FieldValues } from 'react-hook-form' + +import type { TextAreaProps } from '~/ui/lib/TextInput' + +import { TextField, type TextFieldProps } from './TextField' + +export function OxqlField< + TFieldValues extends FieldValues, + TName extends FieldPath, +>( + props: Omit, 'validate'> & Omit +) { + return +} diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 7a28b68ae..22035f42c 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -25,7 +25,12 @@ import * as Dropdown from '~/ui/lib/DropdownMenu' import { classed } from '~/util/classed' import { docLinks, links } from '~/util/links' -import { ChartContainer, ChartHeader, TimeSeriesChart } from '../TimeSeriesChart' +import { + ChartContainer, + ChartHeader, + TimeSeriesChart, + toChartSeries, +} from '../TimeSeriesChart' import { HighlightedOxqlQuery, toOxqlStr } from './HighlightedOxqlQuery' import { composeOxqlData, @@ -86,6 +91,8 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric const [modalOpen, setModalOpen] = useState(false) + const { values, timestamps } = toChartSeries(data) + return ( @@ -111,7 +118,8 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric startTime={startTime} endTime={endTime} unit={unitForSet} - data={data} + data={values} + timestamps={timestamps} yAxisTickFormatter={yAxisTickFormatter} hasError={hasError} // isLoading only covers first load --- future-proof against the reintroduction of interval refresh diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index fca0d33b8..fe4b050f2 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -11,6 +11,7 @@ import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Cloud16Icon, + Monitoring16Icon, IpGlobal16Icon, Metrics16Icon, Servers16Icon, @@ -57,6 +58,7 @@ export default function SystemLayout() { { value: 'Subnet Pools', path: pb.subnetPools() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, + { value: 'OxQL Explorer', path: pb.oxql() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -107,6 +109,9 @@ export default function SystemLayout() { Fleet Access + + OxQL Explorer + diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx new file mode 100644 index 000000000..0f4e40225 --- /dev/null +++ b/app/pages/system/OxqlPage.tsx @@ -0,0 +1,481 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import * as R from 'remeda' +import { match } from 'ts-pattern' + +import { + api, + useApiMutation, + camelToSnake, + type Timeseries, + type Points, + type OxqlTable, + type TimeseriesQuery, + type Values, +} from '@oxide/api' +import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { OxqlField } from '~/components/form/fields/OxqlField' +import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeSeriesChart' +import { Button } from '~/ui/lib/Button' +import { Divider } from '~/ui/lib/Divider' +import { Message } from '~/ui/lib/Message' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { docLinks } from '~/util/links' + +const queries = { + basicTctl: `get hardware_component:amd_cpu_tctl + | filter timestamp > @now() - 1m`, + multiJoinedTable: `{ + { + get sled_data_link:bytes_sent; + get sled_data_link:errors_sent + } + | align mean_within(20s) + | join; + { + get sled_data_link:bytes_received; + get sled_data_link:errors_received + } + | align mean_within(20s) + | join +} + | filter kind == 'vnic' + | filter timestamp > @now() - 10m`, + bytesSentAndReceived: `{ + get sled_data_link:bytes_sent + | align mean_within(5s) + | group_by [sled_serial, link_name, kind]; + get sled_data_link:bytes_received + | align mean_within(5s) + | group_by [sled_serial, link_name, kind] +} + | filter timestamp > @now() - 10m + | filter kind == 'vnic' + | filter link_name == 'oxControlService20'`, +} + +const defaultValues: TimeseriesQuery = { + query: queries.bytesSentAndReceived, +} + +export const handle = { crumb: 'OxQL Explorer' } + +const narrowToNumbers = (vs: Values): (number | null)[] => + match(vs.values) + .with({ type: 'integer' }, ({ values }) => values) + .with({ type: 'double' }, ({ values }) => values) + .with({ type: 'boolean' }, ({ values }) => + values.map((b) => + match(b) + .with(true, () => 1) + .with(false, () => 0) + .with(null, () => null) + .exhaustive() + ) + ) + .with({ type: 'string' }, () => []) // these don't exist in practice + .with({ type: 'integer_distribution' }, () => []) // by only calling this on aligned/joined tables, we know this is unreachable + .with({ type: 'double_distribution' }, () => []) // these don't exist in practice, and are also unreachable per above + .exhaustive() + +const leftPad = (items: T[], length: number): (T | null)[] => + items.length >= length ? items : [...Array(length - items.length).fill(null), ...items] + +// The generated client types timestamps as Date, but the wire format is actually ISO strings. Oops! +// `new Date` accepts both. +type OxqlTimestamp = Points['timestamps'][number] +const parseTs = (ts: OxqlTimestamp): number => new Date(ts).getTime() +const toPosix = (timestamps: OxqlTimestamp[]): number[] => timestamps.map(parseTs) + +type TimeseriesKind = 'joined' | 'aligned' | 'unaligned' + +/** + * When aligning a series, the timestamps are all on the same grid, but values at the beginning may + * be missing (e.g. [10,20,30] in one timestamp array, and [20,30] in another). As long as we can + * prove there's a regular grid all the way through, no big deal. + */ +const getAlignedTimestamps = ( + items: Timeseries[] +): { type: 'some'; timestamps: number[] } | { type: 'none' } => { + // aligned tables never have start times + if (!items[0] || items[0].points.startTimes) return { type: 'none' } + // similarly, aligned tables are always doubles (even if their inputs were integers!) + if (!items[0].points.values.every(({ values }) => values.type === 'double')) + return { type: 'none' } + + const longestSeries = R.firstBy(items, (i) => -i.points.timestamps.length) + if (!longestSeries || longestSeries.points.timestamps.length === 0) + return { type: 'none' } + + // converting to posix numbers knocks us down to millisecond precision, but uplot is going to + // plot by second anyways + const posixes = toPosix(longestSeries.points.timestamps) + + const end = R.last(posixes) + // aligned series may not share the same start time, but they will always have a common final + // timestamp + if ( + !items.every(({ points }) => { + const last = R.last(points.timestamps) + // no timestamps at all is fine; otherwise the final one must match the shared end + return last === undefined || parseTs(last) === end + }) + ) + return { type: 'none' } + + if (posixes.length === 1) return { type: 'some', timestamps: posixes } + + const [start, second] = posixes + + const step = second - start + // we'll assume all timestamp lists are aligned if every timestamp on our longest timestamp list + // is aligned, i.e. some `step` away from the first one we look at + if (!posixes.every((time) => (time - start) % step === 0)) return { type: 'none' } + + return { + type: 'some', + timestamps: posixes, + } +} + +type Chart = { + name: string + description?: string + timestamps: number[] + data: Data +} + +type LabeledNumberLine = Chart<{ label: string; values: (number | null)[] }[]> + +type ChartGroups = { startTime: Date; endTime: Date } & ( + | { kind: 'unaligned'; charts: Chart[] } + | { kind: 'aligned'; charts: LabeledNumberLine[] } + | { kind: 'joined'; charts: LabeledNumberLine[] } +) + +const getFormattedFields = (t: Timeseries): string => + Object.entries(t.fields) + // hello my evil friend. + .map(([fieldName, x]) => `${camelToSnake(fieldName)}: ${x.value}`) + .join(' \u2022 ') + +const tableToGroups = (table: OxqlTable): ChartGroups | 'empty-timeseries' => { + const { name, timeseries } = table + if (timeseries.length === 0) return 'empty-timeseries' + const kind: + | Exclude + | { kind: 'aligned'; timestamps: number[] } = + // we expect all values arrays to be the same length, so if the first isn't longer than 1, we + // expect singletons across the board + timeseries[0]?.points.values.length > 1 + ? ('joined' as const) + : match(getAlignedTimestamps(timeseries)) + .with({ type: 'none' }, () => 'unaligned' as const) + .with({ type: 'some' }, ({ timestamps }) => ({ + kind: 'aligned' as const, + timestamps, + })) + .exhaustive() + + const chart = match(kind) + .with('joined', (kind) => { + // In a joined table, each Values item is a distinct metric:target and the + // table name is those metric names comma-joined, index-aligned to the Values. + // So the line labels come from the table name, not the (identical-per-line) + // joined field. + const metricNames = name.split(',').map((s) => s.trim()) + + return { + kind, + // when joined, each timeseries is _also_ aligned, but we assume that users want to focus on + // cross-referencing between metrics, so we join the values within a given timeseries, going + // no further + charts: timeseries.map((series) => ({ + name, + description: getFormattedFields(series), + timestamps: toPosix(series.points.timestamps), + data: series.points.values.map((v, i) => ({ + label: + metricNames[i] || + // should be unreachable + `${getFormattedFields(series)} #${i + 1}`, + values: narrowToNumbers(v), + })), + })), + } + }) + .with({ kind: 'aligned' }, ({ kind, timestamps }) => ({ + kind, + charts: [ + { + name, + timestamps, + data: timeseries + .filter((s) => s.points.values.length > 0) + .map((series) => ({ + label: getFormattedFields(series), + values: leftPad(narrowToNumbers(series.points.values[0]), timestamps.length), + })), + }, + ], + })) + .with('unaligned', (kind) => ({ + kind, + charts: timeseries + .filter((s) => s.points.values.length > 0) + .map((series) => ({ + name, + description: getFormattedFields(series), + timestamps: toPosix(series.points.timestamps), + data: series.points.values[0], + })), + })) + .exhaustive() + const timestamps = chart.charts.flatMap(({ timestamps }) => timestamps) + const min = R.firstBy(timestamps, (t) => t) + const max = R.firstBy(timestamps, (t) => -t) + + return { + ...chart, + // i figure any chart collection probably benefits from sharing their X-axis, even if they're + // rendered in sequence. when there's no data at all, min/max are undefined and the range is + // irrelevant (the charts render their empty state) — fall back to the epoch for valid Dates + startTime: new Date(min ?? 0), + endTime: new Date(max ?? 0), + } +} + +const TICK_UNITS = [ + // TODO: this doesn't quite match the suffixes in the oxql-metrics util, but i'm leaving it + // because i don't understand those + [1e12, 't'], + [1e9, 'b'], + [1e6, 'm'], + [1e3, 'k'], +] as const +const formatTick = (n: number): string => { + const [divisor, suffix] = TICK_UNITS.find(([min]) => Math.abs(n) >= min) ?? [1, ''] + return (n / divisor).toLocaleString() + suffix +} + +// Drops (or keeps, without copying) the first sample of a series. We trim timestamps and values at +// the same time to be confident they're in sync. +type TimeAndData = { timestamps: number[]; data: (number | null)[][] } +const firstPointDropper = + (drop: boolean) => + ({ timestamps, data }: TimeAndData): TimeAndData => + drop + ? { timestamps: timestamps.slice(1), data: data.map((d) => d.slice(1)) } + : { timestamps, data } + +// The first aligned point of a cumulative counter is diffed against the counter's start_time, +// collapsing all pre-window history into one giant bucket. It's not "erroneous" but it's usually +// not useful, and you'd want to hide it to get a more useful y-axis for the rest of your data. +const groupHasPointWorthDropping = (g: ChartGroups | 'empty-timeseries'): boolean => + match(g) + .with('empty-timeseries', () => false) + // Aligned/joined tables may be derived from cumulatives, so we assume it's worth offering + .with({ kind: 'joined' }, { kind: 'aligned' }, () => true) + // Gauges are, by definition, not cumulative, so you'll never see a giant first point + .with({ kind: 'unaligned' }, ({ charts }) => + charts.some((c) => c.data.metricType !== 'gauge') + ) + .exhaustive() + +export default function OxqlPage() { + const query = useApiMutation(api.systemTimeseriesQuery) + + const form = useForm({ defaultValues }) + const control = form.control + + const [dropFirstPoint, setDropFirstPoint] = useState(true) + + const onSubmit = (body: TimeseriesQuery) => { + query.mutate({ body }) + } + + const chartGroups: (ChartGroups | 'empty-timeseries')[] | null = useMemo( + () => (query.data ? query.data.tables.map(tableToGroups) : null), + [query.data] + ) + + const hasTrimmableCharts = chartGroups?.some(groupHasPointWorthDropping) ?? false + const trim = firstPointDropper(dropFirstPoint && hasTrimmableCharts) + + return ( + <> + + }>OxQL Explorer + } + summary="The Oximeter Query Language is a domain-specific language for interrogating telemetry data from software and hardware components across the rack." + links={[docLinks.oxql, docLinks.oxqlSchemas]} + /> + +
+
+ {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/oxql-metrics.ts b/mock-api/oxql-metrics.ts index ff54ac353..5138bab7f 100644 --- a/mock-api/oxql-metrics.ts +++ b/mock-api/oxql-metrics.ts @@ -302,7 +302,7 @@ export const getMockOxqlInstanceData = ( { values: { type: 'double', - values: values, + values: values || timestamps.map((_, i) => i * 1000), }, metric_type: 'gauge', },