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..145df4454 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -84,11 +84,19 @@ 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 } = data + ? { + timestamps: data.map(({ timestamp }) => timestamp), + values: [data.map(({ value }) => value)], + } + : {} + return ( timestamp), + values: [data.map(({ value }) => value)], + } + : {} 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..c5a414c26 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.7 0.17 ${((200 + (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,207 @@ const SkeletonMetric = ({
) +const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() + 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: [ + {}, + ...data.map((_, 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, + fill: theme.hoverPoint, + }, + }, + legend: { show: false }, + plugins: [tooltipPlugin], + }) satisfies Omit, + [data, 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 +419,7 @@ export function TimeSeriesChart({ ) } - if (!data || data.length === 0) { + if (!data || data.length === 0 || !timestamps || timestamps.length === 0) { return ( @@ -230,62 +427,49 @@ 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 + ? { + 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 +551,37 @@ 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 ( + // Cap the height so a chart with many series doesn't push everything down; + // overflow scrolls, like Grafana's legend. +
+ {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..356aa1525 --- /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 { TextField, type TextFieldProps } from './TextField' + +export function OxqlField< + TFieldValues extends FieldValues, + TName extends FieldPath, +>(props: Omit, 'validate'>) { + return +} + +export function validateDescription(_name: string) { + return true +} diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 7a28b68ae..e0944ef07 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -86,6 +86,13 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric const [modalOpen, setModalOpen] = useState(false) + const { values, timestamps } = data + ? { + timestamps: data.map(({ timestamp }) => timestamp), + values: [data.map(({ value }) => value)], + } + : {} + 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..5ffca8c66 --- /dev/null +++ b/app/pages/system/OxqlPage.tsx @@ -0,0 +1,909 @@ +/* + * 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 { useEffect, useMemo, useReducer, useState } from 'react' +import { useForm } from 'react-hook-form' +import * as R from 'remeda' +import { match } from 'ts-pattern' + +import { + api, + getListQFn, + queryClient, + useApiMutation, + usePrefetchedQuery, + camelToSnake, + type Timeseries, + type FieldSchema, + type FieldType, + type OxqlTable, + type TimeseriesQuery, + type TimeseriesSchema, + type TimeseriesSchemaResultsPage, + type Values, +} from '@oxide/api' +import { + Close16Icon, + Monitoring16Icon, + Monitoring24Icon, +} from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { oxqlTimestamp } from '~/components/oxql-metrics/util' +import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeSeriesChart' +import { Button } from '~/ui/lib/Button' +import { Checkbox } from '~/ui/lib/Checkbox' +import { Combobox } from '~/ui/lib/Combobox' +import { FieldLabel } from '~/ui/lib/FieldLabel' +import { Listbox } from '~/ui/lib/Listbox' +import { OxqlBlock } from '~/ui/lib/OxqlBlock' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { Tabs } from '~/ui/lib/Tabs' +import { TextInput } from '~/ui/lib/TextInput' +import { ALL_ISH } from '~/util/consts' +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, +} + +const schemaList = getListQFn(api.systemTimeseriesSchemaList, { + query: { limit: ALL_ISH }, +}) + +export async function clientLoader() { + // Not entirely sure this merits prefetching; might depend on whether we want to go builder-only, + // in which case there's not much to do without the schema + await queryClient.prefetchQuery(schemaList.optionsFn()) + return null +} + +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] + +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' } + + // generated client thinks these are dates but they're actually strings + const posixes = longestSeries.points.timestamps.map((ts) => + // converting to posix numbers knocks us down to millisecond precision, but uplot is going to + // plot by second anyways + Date.parse(ts as unknown as string) + ) + + 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 }) => + points.timestamps.length === 0 || // or no timestamp at all! + Date.parse(R.last(points.timestamps) as unknown as string) === 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 timeseriesDuckChecker = (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 Exclude) + : match(getAlignedTimestamps(timeseries)) + .with({ type: 'none' }, () => 'unaligned' as Exclude) + .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: series.points.timestamps.map((ts) => + Date.parse(ts as unknown as string) + ), // again, the types are lying to you. `ts` is an iso string, NOT a date!!!! + 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.map((series) => ({ + label: getFormattedFields(series), + values: leftPad(narrowToNumbers(series.points.values[0]), timestamps.length), + })), + }, + ], + })) + .with('unaligned', (kind) => ({ + kind, + charts: timeseries.map((series) => ({ + name, + description: getFormattedFields(series), + timestamps: series.points.timestamps.map((ts) => + Date.parse(ts as unknown as string) + ), // yes, the date lie + data: series.points.values[0], + })), + })) + .exhaustive() + const timestamps = chart.charts.flatMap(({ timestamps }) => timestamps) + const min = Math.min(...timestamps) + const max = Math.max(...timestamps) + + return { + ...chart, + // i figure any chart collection probably benefits from sharing their X-axis, even if they're + // rendered in sequence + startTime: new Date(min), + endTime: new Date(max), + } +} + +const TICK_UNITS = [ + [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 +} + +type Schema = Omit + +type Schemas = Record> +type ByConsoleSupport = { + supported: Schemas + unsupported: Schemas +} + +const arrangeSchemas = (data: TimeseriesSchemaResultsPage): ByConsoleSupport => + data.items.reduce( + (acc, { timeseriesName, ...schema }) => { + const [target, metric] = timeseriesName.split(':') + match(schema.datumType) + .with( + // we'll just treat it as a 1/0 + 'bool', + // actual numbers + 'i8', + 'u8', + 'i16', + 'u16', + 'i32', + 'u32', + 'i64', + 'u64', + 'f32', + 'f64', + // since we're encouraging alignment, cumulatives end up as gauges instead of deltas. nice + // and easy + 'cumulative_i64', + 'cumulative_u64', + 'cumulative_f32', + 'cumulative_f64', + () => { + acc.supported[target] = acc.supported[target] || {} + acc.supported[target][metric] = schema + } + ) + .with( + // no instances currently, but i think it'd have to be a table? + 'string', + // no instances either, this seems like a unit snuck in to the wrong club! + 'bytes', + // you can express these with a heatmap, but we have to let people skip alignment + 'histogram_i8', + 'histogram_u8', + 'histogram_i16', + 'histogram_u16', + 'histogram_i32', + 'histogram_u32', + 'histogram_i64', + 'histogram_u64', + 'histogram_f32', + 'histogram_f64', + () => { + acc.unsupported[target] = acc.unsupported[target] || {} + acc.unsupported[target][metric] = schema + } + ) + .exhaustive() + + return acc + }, + { supported: {}, unsupported: {} } + ) + +// Bucket sizes for `align mean_within(X)`. In practice, aligning seems much more likely to happen +// than not; it's required if you're grouping, and in our case, collecting two timeseries into the +// same chart is kind of wild without aligned times (if series A has a point every second with .00 +// hanging over, and series B has a point every second with .25 hanging over, you've either got to +// fill in a bunch of nulls (which may obscure a _meaningful_ null!), or interpolate between +// missing values, which is basically the same problem. +// +// For what it's worth, though, you can avoid that by detecting whether the resulting data is +// aligned (by checking the timestamps lists) and just not collating together timeseries that aren't +// aligned! +const BUCKET_SIZES = [ + { value: '5s', label: '5 seconds' }, + { value: '10s', label: '10 seconds' }, + { value: '30s', label: '30 seconds' }, + { value: '1m', label: '1 minute' }, + { value: '5m', label: '5 minutes' }, + { value: '10m', label: '10 minutes' }, + { value: '30m', label: '30 minutes' }, + { value: '1h', label: '1 hour' }, +] + +const GROUP_BY_OPS = [ + { value: 'mean', label: 'mean' }, + { value: 'sum', label: 'sum' }, +] + +type GroupBy = { cols: string[]; op: string } + +const buildQuery = ( + target: string, + metric: string, + startTime: Date, + endTime: Date, + bucket: string, + filterClauses: string[], + groupBy: GroupBy | null +) => + [ + `get ${target}:${metric}`, + ` | filter timestamp >= @${oxqlTimestamp(startTime)}`, + ` && timestamp < @${oxqlTimestamp(endTime)}`, + // each user filter is its own stage; OxQL ANDs successive filters together + ...filterClauses.map((clause) => ` | filter ${clause}`), + ` | align mean_within(${bucket})`, + // group_by must come after align, since it requires aligned input + ...(groupBy ? [` | group_by [${groupBy.cols.join(', ')}], ${groupBy.op}`] : []), + ].join('\n') + +type Filter = { + // just a monotonically increasing number for react keys + id: number + field: string + op: string + value: string +} + +// Comparison operators only make sense for numbers; everything else (strings, +// bools, UUIDs, IPs) gets equality only. +const NUMERIC_FIELD_TYPES = new Set([ + 'i8', + 'u8', + 'i16', + 'u16', + 'i32', + 'u32', + 'i64', + 'u64', +]) +const COMPARISON_OPS = [ + { value: '>', label: '>' }, + { value: '>=', label: '>=' }, + { value: '<', label: '<' }, + { value: '<=', label: '<=' }, +] +const EQUALITY_OPS = [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, +] + +const opsForType = (fieldType: FieldType | undefined) => + fieldType && NUMERIC_FIELD_TYPES.has(fieldType) + ? [...EQUALITY_OPS, ...COMPARISON_OPS] + : EQUALITY_OPS + +// Rather than making people know that uuids go in double quotes and strings go in single quotes, we +// can try to be nice and wrap quotes for them. Then the preview can be their education, instead of +// the error message. +const formatFilterValue = (fieldType: FieldType | undefined, value: string) => { + if (fieldType && (NUMERIC_FIELD_TYPES.has(fieldType) || fieldType === 'bool')) + return value + if (fieldType === 'uuid') return `"${value}"` + return `'${value}'` +} + +function FilterRow({ + fields, + filter, + onChange, + onRemove, +}: { + fields: FieldSchema[] + filter: Filter + onChange: (next: Filter) => void + onRemove: () => void +}) { + const fieldType = fields.find((f) => f.name === filter.field)?.fieldType + + return ( +
+ ({ value: f.name, label: f.name }))} + onChange={(field) => { + const ops = opsForType(fields.find((f) => f.name === field)?.fieldType) + const op = ops.some((o) => o.value === filter.op) ? filter.op : '==' + onChange({ ...filter, field, op }) + }} + /> + onChange({ ...filter, op })} + /> + onChange({ ...filter, value: e.target.value })} + /> + {/* Doesn't look how I'd like, but it _is_ an icon button */} + +
+ ) +} + +type BuilderState = { + target: string | null + metric: string | null + bucket: string + filters: Filter[] + groupCols: string[] + groupOp: string + nextFilterId: number +} + +const initialBuilderState: BuilderState = { + target: null, + metric: null, + bucket: '5s', + filters: [], + groupCols: [], + groupOp: 'mean', + nextFilterId: 0, +} + +type BuilderAction = + | { type: 'setTarget'; target: string } + | { type: 'setMetric'; metric: string } + | { type: 'setBucket'; bucket: string } + // field is resolved by the caller, which has the schema in scope + | { type: 'addFilter'; field: string } + | { type: 'updateFilter'; filter: Filter } + | { type: 'removeFilter'; id: number } + | { type: 'toggleGroupCol'; name: string } + | { type: 'setGroupOp'; op: string } + +function builderReducer(state: BuilderState, action: BuilderAction): BuilderState { + return { + ...state, + ...match(action) + .with({ type: 'setTarget' }, ({ target }) => ({ + target, + metric: null, + filters: [], + groupCols: [], + })) + .with({ type: 'setMetric' }, ({ metric }) => ({ + metric, + filters: [], + groupCols: [], + })) + .with({ type: 'setBucket' }, ({ bucket }) => ({ bucket })) + .with({ type: 'addFilter' }, ({ field }) => ({ + filters: [...state.filters, { id: state.nextFilterId, field, op: '==', value: '' }], + nextFilterId: state.nextFilterId + 1, + })) + .with({ type: 'updateFilter' }, ({ filter }) => ({ + filters: state.filters.map((f) => (f.id === filter.id ? filter : f)), + })) + .with({ type: 'removeFilter' }, ({ id }) => ({ + filters: state.filters.filter((f) => f.id !== id), + })) + .with({ type: 'toggleGroupCol' }, ({ name }) => { + const groupCols = state.groupCols.includes(name) + ? state.groupCols.filter((c) => c !== name) + : [...state.groupCols, name] + // try to keep filters downstream of the grouping toggles + const filters = groupCols.length + ? state.filters.filter((f) => !f.field || groupCols.includes(f.field)) + : state.filters + return { groupCols, filters } + }) + .with({ type: 'setGroupOp' }, ({ op }) => ({ groupOp: op })) + .exhaustive(), + } +} + +function QueryBuilder({ + schemas, + onRun, +}: { + schemas: Schemas + onRun: (query: string) => void +}) { + const [state, dispatch] = useReducer(builderReducer, initialBuilderState) + const { target, metric, bucket, filters, groupCols, groupOp } = state + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastHour', + }) + + const targetItems = Object.keys(schemas) + .sort() + .map((t) => ({ value: t, label: t, selectedLabel: t })) + const metricItems = (target ? Object.keys(schemas[target]) : []) + .sort() + .map((m) => ({ value: m, label: m, selectedLabel: m })) + + const fields = target && metric ? schemas[target][metric].fieldSchema : [] + const fieldTypes = new Map(fields.map((f) => [f.name, f.fieldType])) + + const isGrouping = groupCols.length > 0 + // You can only filter by fields that are grouped (unless you're not grouping at all) + const filterableFields = isGrouping + ? fields.filter((f) => groupCols.includes(f.name)) + : fields + + const filterClauses = filters + .filter((f) => f.field && f.value.trim() !== '') + .map((f) => `${f.field} ${f.op} ${formatFilterValue(fieldTypes.get(f.field), f.value)}`) + + const query = + target && metric + ? buildQuery( + target, + metric, + startTime, + endTime, + bucket, + filterClauses, + isGrouping ? { cols: groupCols, op: groupOp } : null + ) + : null + + return ( +
+ dispatch({ type: 'setTarget', target: value })} + required + /> + dispatch({ type: 'setMetric', metric: value })} + disabled={!target} + required + /> +
+ + Time range + + {dateTimeRangePicker} +
+ dispatch({ type: 'setBucket', bucket: value })} + required + /> +
+ + Group by + + {metric ? ( + <> +
+ {fields.map((f) => ( + dispatch({ type: 'toggleGroupCol', name: f.name })} + > + {f.name} + + ))} +
+ {isGrouping && ( + dispatch({ type: 'setGroupOp', op: value })} + /> + )} + + ) : ( + Select a metric first + )} +
+
+ + Filters + + {filters.map((filter) => ( + dispatch({ type: 'updateFilter', filter: next })} + onRemove={() => dispatch({ type: 'removeFilter', id: filter.id })} + /> + ))} +
+ +
+
+ {query && ( +
+
Query we'll send
+ {query} +
+ )} + +
+ ) +} + +export default function OxqlPage() { + const query = useApiMutation(api.systemTimeseriesQuery) + + const schemas = arrangeSchemas(usePrefetchedQuery(schemaList.optionsFn()).data) + useEffect(() => { + const unsupported = Object.entries(schemas.unsupported) + .flatMap(([target, metricSchema]) => + Object.entries(metricSchema).map( + ([metric, schema]) => + `\u2022 ${target}:${metric} has type \`${schema.datumType}\`` + ) + ) + .join('\n') + console.info(`The following metrics aren't supported in the console due to their type: + +${unsupported}`) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const form = useForm({ defaultValues }) + const control = form.control + + // 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 [dropFirstPoint, setDropFirstPoint] = useState(true) + + const onSubmit = (body: TimeseriesQuery) => { + query.mutate({ body }) + } + + const stuff: (ChartGroups | 'empty-timeseries')[] | null = useMemo( + () => (query.data ? query.data.tables.map(timeseriesDuckChecker) : null), + [query.data] + ) + + return ( + <> + + }>OxQL Explorer + } + summary="OxQL is so nice." + links={[docLinks.oxql]} + /> + + + + Raw query + Builder + + +
+ +
+ {Object.entries(queries).map(([key, text]) => ( + + ))} +
+ + +
+ + query.mutate({ body: { query: built } })} + /> + +
+ + {match(query) + .with({ status: 'pending' }, () => 'Loading...') + .with({ status: 'idle' }, () => '') + .with({ status: 'error' }, (q) => q.error.message) + .with({ status: 'success' }, () => ( + <> +
+ +
+ {stuff && + stuff.map((s) => + match(s) + .with('empty-timeseries', () => 'No results') + .with( + { kind: 'joined' }, + { kind: 'aligned' }, + ({ charts, startTime, endTime }) => + charts.map((chart, i) => { + const lineData = chart.data.map((l) => + dropFirstPoint ? l.values.slice(1) : l.values + ) + const seriesLabels = chart.data.map((l) => l.label) + return ( + + + + + ) + }) + ) + .with({ kind: 'unaligned' }, ({ charts, startTime, endTime }) => + charts.map((chart, i) => { + 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 lineData = dropFirstPoint ? data.slice(1) : data + console.info({ timestamps: chart.timestamps, lineData }) + 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/lib/OxqlBlock.tsx b/app/ui/lib/OxqlBlock.tsx new file mode 100644 index 000000000..f5d930f5f --- /dev/null +++ b/app/ui/lib/OxqlBlock.tsx @@ -0,0 +1,35 @@ +import { useState, useEffect } from 'react' +import { + createHighlighter, + type HighlighterGeneric, + type BundledLanguage, + type BundledTheme, +} from 'shiki' + +import theme from '../util/oxide-syntax.json' +import oxql from '../util/oxql.tmLanguage.json' + +export const OxqlBlock = ({ children }: { children: string }) => { + const [highlighter, setHighlighter] = useState | null>(null) + + useEffect(() => { + const go = async () => { + const highlighter = await createHighlighter({ themes: [theme], langs: [oxql] }) + setHighlighter(highlighter) + } + go() + }, []) + return ( + highlighter && ( +

+    )
+  )
+}
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/ui/util/oxide-syntax.json b/app/ui/util/oxide-syntax.json
new file mode 100644
index 000000000..575f5c86a
--- /dev/null
+++ b/app/ui/util/oxide-syntax.json
@@ -0,0 +1,1364 @@
+{
+  "name": "Oxide Dark",
+  "colors": {
+    "editor.background": "var(--syntax-bg)",
+    "editor.foreground": "var(--syntax-fg)"
+  },
+  "tokenColors": [
+    {
+      "scope": [
+        "text",
+        "source",
+        "variable.other.readwrite",
+        "punctuation.definition.variable"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "punctuation",
+      "settings": {
+        "foreground": "var(--syntax-comment)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["comment", "punctuation.definition.comment"],
+      "settings": {
+        "foreground": "var(--syntax-comment)"
+      }
+    },
+    {
+      "scope": ["string", "punctuation.definition.string"],
+      "settings": {
+        "foreground": "var(--syntax-string)"
+      }
+    },
+    {
+      "scope": "constant.character.escape",
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": [
+        "constant.numeric",
+        "variable.other.constant",
+        "entity.name.constant",
+        "constant.language.boolean",
+        "constant.language.false",
+        "constant.language.true",
+        "keyword.other.unit.user-defined",
+        "keyword.other.unit.suffix.floating-point"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "keyword",
+        "keyword.operator.word",
+        "keyword.operator.new",
+        "variable.language.super",
+        "support.type.primitive",
+        "storage.type",
+        "storage.modifier",
+        "punctuation.definition.keyword"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-keyword)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "entity.name.tag.documentation",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": [
+        "keyword.operator",
+        "punctuation.accessor",
+        "punctuation.definition.generic",
+        "meta.function.closure punctuation.section.parameters",
+        "punctuation.definition.tag",
+        "punctuation.separator.key-value"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": [
+        "entity.name.function",
+        "meta.function-call.method",
+        "support.function",
+        "support.function.misc",
+        "variable.function"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "entity.name.class",
+        "entity.other.inherited-class",
+        "support.class",
+        "meta.function-call.constructor",
+        "entity.name.struct"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "entity.name.enum",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": ["meta.enum variable.other.readwrite", "variable.other.enummember"],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "meta.property.object",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": ["meta.type", "meta.type-alias", "support.type", "entity.name.type"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "meta.annotation variable.function",
+        "meta.annotation variable.annotation.function",
+        "meta.annotation punctuation.definition.annotation",
+        "meta.decorator",
+        "punctuation.decorator"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": ["variable.parameter", "meta.function.parameters"],
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": ["constant.language", "support.function.builtin"],
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": "entity.other.attribute-name.documentation",
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": ["keyword.control.directive", "punctuation.definition.directive"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "punctuation.definition.typeparameters",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "entity.name.namespace",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "support.type.property-name.css",
+      "settings": {
+        "foreground": "var(--syntax-function)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": [
+        "variable.language.this",
+        "variable.language.this punctuation.definition.variable"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": "variable.object.property",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": ["string.template variable", "string variable"],
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "keyword.operator.new",
+      "settings": {
+        "fontStyle": "bold"
+      }
+    },
+    {
+      "scope": "storage.modifier.specifier.extern.cpp",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": [
+        "entity.name.scope-resolution.template.call.cpp",
+        "entity.name.scope-resolution.parameter.cpp",
+        "entity.name.scope-resolution.cpp",
+        "entity.name.scope-resolution.function.definition.cpp"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "storage.type.class.doxygen",
+      "settings": {
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["storage.modifier.reference.cpp"],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "meta.interpolation.cs",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "comment.block.documentation.cs",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": [
+        "source.css entity.other.attribute-name.class.css",
+        "entity.other.attribute-name.parent-selector.css punctuation.definition.entity.css"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "punctuation.separator.operator.css",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "source.css entity.other.attribute-name.pseudo-class",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "source.css constant.other.unicode-range",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "source.css variable.parameter.url",
+      "settings": {
+        "foreground": "var(--syntax-operator)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["support.type.vendored.property-name"],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "source.css meta.property-value variable",
+        "source.css meta.property-value variable.other.less",
+        "source.css meta.property-value variable.other.less punctuation.definition.variable.less",
+        "meta.definition.variable.scss"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": [
+        "source.css meta.property-list variable",
+        "meta.property-list variable.other.less",
+        "meta.property-list variable.other.less punctuation.definition.variable.less"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "keyword.other.unit.percentage.css",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "source.css meta.attribute-selector",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": [
+        "keyword.other.definition.ini",
+        "punctuation.support.type.property-name.json",
+        "support.type.property-name.json",
+        "punctuation.support.type.property-name.toml",
+        "support.type.property-name.toml",
+        "entity.name.tag.yaml",
+        "punctuation.support.type.property-name.yaml",
+        "support.type.property-name.yaml"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["constant.language.json", "constant.language.yaml"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": ["entity.name.type.anchor.yaml", "variable.other.alias.yaml"],
+      "settings": {
+        "foreground": "var(--syntax-number)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["support.type.property-name.table", "entity.name.section.group-title.ini"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "constant.other.time.datetime.offset.toml",
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": ["punctuation.definition.anchor.yaml", "punctuation.definition.alias.yaml"],
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": "entity.other.document.begin.yaml",
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": "markup.changed.diff",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "meta.diff.header.from-file",
+        "meta.diff.header.to-file",
+        "punctuation.definition.from-file.diff",
+        "punctuation.definition.to-file.diff"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "markup.inserted.diff",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "markup.deleted.diff",
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": ["variable.other.env"],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": ["string.quoted variable.other.env"],
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "support.function.builtin.gdscript",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "constant.language.gdscript",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "comment meta.annotation.go",
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": "comment meta.annotation.parameters.go",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "constant.language.go",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "variable.graphql",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "string.unquoted.alias.graphql",
+      "settings": {
+        "foreground": "var(--syntax-string-alias)"
+      }
+    },
+    {
+      "scope": "constant.character.enum.graphql",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "meta.objectvalues.graphql constant.object.key.graphql string.unquoted.graphql",
+      "settings": {
+        "foreground": "var(--syntax-string-alias)"
+      }
+    },
+    {
+      "scope": [
+        "keyword.other.doctype",
+        "meta.tag.sgml.doctype punctuation.definition.tag",
+        "meta.tag.metadata.doctype entity.name.tag",
+        "meta.tag.metadata.doctype punctuation.definition.tag"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": ["entity.name.tag"],
+      "settings": {
+        "foreground": "var(--syntax-function)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": [
+        "text.html constant.character.entity",
+        "text.html constant.character.entity punctuation",
+        "constant.character.entity.xml",
+        "constant.character.entity.xml punctuation",
+        "constant.character.entity.js.jsx",
+        "constant.charactger.entity.js.jsx punctuation",
+        "constant.character.entity.tsx",
+        "constant.character.entity.tsx punctuation"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": ["entity.other.attribute-name"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "support.class.component",
+        "support.class.component.jsx",
+        "support.class.component.tsx",
+        "support.class.component.vue"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-escape)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["punctuation.definition.annotation", "storage.type.annotation"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "constant.other.enum.java",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "storage.modifier.import.java",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "comment.block.javadoc.java keyword.other.documentation.javadoc.java",
+      "settings": {
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "meta.export variable.other.readwrite.js",
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": [
+        "variable.other.constant.js",
+        "variable.other.constant.ts",
+        "variable.other.property.js",
+        "variable.other.property.ts"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": ["variable.other.jsdoc", "comment.block.documentation variable.other"],
+      "settings": {
+        "foreground": "var(--syntax-parameter)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "storage.type.class.jsdoc",
+      "settings": {
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "support.type.object.console.js",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": ["support.constant.node", "support.type.object.module.js"],
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "storage.modifier.implements",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": [
+        "constant.language.null.js",
+        "constant.language.null.ts",
+        "constant.language.undefined.js",
+        "constant.language.undefined.ts",
+        "support.type.builtin.ts"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "variable.parameter.generic",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": ["keyword.declaration.function.arrow.js", "storage.type.function.arrow.ts"],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "punctuation.decorator.ts",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "keyword.operator.expression.in.js",
+        "keyword.operator.expression.in.ts",
+        "keyword.operator.expression.infer.ts",
+        "keyword.operator.expression.instanceof.js",
+        "keyword.operator.expression.instanceof.ts",
+        "keyword.operator.expression.is",
+        "keyword.operator.expression.keyof.ts",
+        "keyword.operator.expression.of.js",
+        "keyword.operator.expression.of.ts",
+        "keyword.operator.expression.typeof.ts"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "support.function.macro.julia",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "constant.language.julia",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "constant.other.symbol.julia",
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": "text.tex keyword.control.preamble",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "text.tex support.function.be",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "constant.other.general.math.tex",
+      "settings": {
+        "foreground": "var(--syntax-string-alias)"
+      }
+    },
+    {
+      "scope": "comment.line.double-dash.documentation.lua storage.type.annotation.lua",
+      "settings": {
+        "foreground": "var(--syntax-keyword)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": [
+        "comment.line.double-dash.documentation.lua entity.name.variable.lua",
+        "comment.line.double-dash.documentation.lua variable.lua"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": [
+        "heading.1.markdown punctuation.definition.heading.markdown",
+        "heading.1.markdown",
+        "heading.1.quarto punctuation.definition.heading.quarto",
+        "heading.1.quarto",
+        "markup.heading.atx.1.mdx",
+        "markup.heading.atx.1.mdx punctuation.definition.heading.mdx",
+        "markup.heading.setext.1.markdown",
+        "markup.heading.heading-0.asciidoc"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": [
+        "heading.2.markdown punctuation.definition.heading.markdown",
+        "heading.2.markdown",
+        "heading.2.quarto punctuation.definition.heading.quarto",
+        "heading.2.quarto",
+        "markup.heading.atx.2.mdx",
+        "markup.heading.atx.2.mdx punctuation.definition.heading.mdx",
+        "markup.heading.setext.2.markdown",
+        "markup.heading.heading-1.asciidoc"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "heading.3.markdown punctuation.definition.heading.markdown",
+        "heading.3.markdown",
+        "heading.3.quarto punctuation.definition.heading.quarto",
+        "heading.3.quarto",
+        "markup.heading.atx.3.mdx",
+        "markup.heading.atx.3.mdx punctuation.definition.heading.mdx",
+        "markup.heading.heading-2.asciidoc"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "heading.4.markdown punctuation.definition.heading.markdown",
+        "heading.4.markdown",
+        "heading.4.quarto punctuation.definition.heading.quarto",
+        "heading.4.quarto",
+        "markup.heading.atx.4.mdx",
+        "markup.heading.atx.4.mdx punctuation.definition.heading.mdx",
+        "markup.heading.heading-3.asciidoc"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": [
+        "heading.5.markdown punctuation.definition.heading.markdown",
+        "heading.5.markdown",
+        "heading.5.quarto punctuation.definition.heading.quarto",
+        "heading.5.quarto",
+        "markup.heading.atx.5.mdx",
+        "markup.heading.atx.5.mdx punctuation.definition.heading.mdx",
+        "markup.heading.heading-4.asciidoc"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "heading.6.markdown punctuation.definition.heading.markdown",
+        "heading.6.markdown",
+        "heading.6.quarto punctuation.definition.heading.quarto",
+        "heading.6.quarto",
+        "markup.heading.atx.6.mdx",
+        "markup.heading.atx.6.mdx punctuation.definition.heading.mdx",
+        "markup.heading.heading-5.asciidoc"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "markup.bold",
+      "settings": {
+        "foreground": "var(--syntax-builtin)",
+        "fontStyle": "bold"
+      }
+    },
+    {
+      "scope": "markup.italic",
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": "markup.strikethrough",
+      "settings": {
+        "foreground": "var(--syntax-comment)",
+        "fontStyle": "strikethrough"
+      }
+    },
+    {
+      "scope": ["punctuation.definition.link", "markup.underline.link"],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "text.html.markdown punctuation.definition.link.title",
+        "text.html.quarto punctuation.definition.link.title",
+        "string.other.link.title.markdown",
+        "string.other.link.title.quarto",
+        "markup.link",
+        "punctuation.definition.constant.markdown",
+        "punctuation.definition.constant.quarto",
+        "constant.other.reference.link.markdown",
+        "constant.other.reference.link.quarto",
+        "markup.substitution.attribute-reference"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-blue-pale)"
+      }
+    },
+    {
+      "scope": [
+        "punctuation.definition.raw.markdown",
+        "punctuation.definition.raw.quarto",
+        "markup.inline.raw.string.markdown",
+        "markup.inline.raw.string.quarto",
+        "markup.raw.block.markdown",
+        "markup.raw.block.quarto"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "fenced_code.block.language",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "markup.fenced_code.block punctuation.definition",
+        "markup.raw support.asciidoc"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-comment)"
+      }
+    },
+    {
+      "scope": ["markup.quote", "punctuation.definition.quote.begin"],
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": "meta.separator.markdown",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": [
+        "punctuation.definition.list.begin.markdown",
+        "punctuation.definition.list.begin.quarto",
+        "markup.list.bullet"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "markup.heading.quarto",
+      "settings": {
+        "fontStyle": "bold"
+      }
+    },
+    {
+      "scope": [
+        "entity.other.attribute-name.multipart.nix",
+        "entity.other.attribute-name.single.nix"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "variable.parameter.name.nix",
+      "settings": {
+        "foreground": "var(--syntax-fg)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "meta.embedded variable.parameter.name.nix",
+      "settings": {
+        "foreground": "var(--syntax-blue-pale)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "string.unquoted.path.nix",
+      "settings": {
+        "foreground": "var(--syntax-escape)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["support.attribute.builtin", "meta.attribute.php"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "meta.function.parameters.php punctuation.definition.variable.php",
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": "constant.language.php",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "text.html.php support.function",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "keyword.other.phpdoc.php",
+      "settings": {
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": ["support.variable.magic.python", "meta.function-call.arguments.python"],
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": ["support.function.magic.python"],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "variable.parameter.function.language.special.self.python",
+        "variable.language.special.self.python"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": ["keyword.control.flow.python", "keyword.operator.logical.python"],
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "storage.type.function.python",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": [
+        "support.token.decorator.python",
+        "meta.function.decorator.identifier.python"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": ["meta.function-call.python"],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "entity.name.function.decorator.python",
+        "punctuation.definition.decorator.python"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "constant.character.format.placeholder.other.python",
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": ["support.type.exception.python", "support.function.builtin.python"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": ["support.type.python"],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "constant.language.python",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": ["meta.indexed-name.python", "meta.item-access.python"],
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": "storage.type.string.python",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "meta.function.parameters.python",
+      "settings": {
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": [
+        "string.regexp punctuation.definition.string.begin",
+        "string.regexp punctuation.definition.string.end"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": "keyword.control.anchor.regexp",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "string.regexp.ts",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": [
+        "punctuation.definition.group.regexp",
+        "keyword.other.back-reference.regexp"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "punctuation.definition.character-class.regexp",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "constant.other.character-class.regexp",
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": "constant.other.character-class.range.regexp",
+      "settings": {
+        "foreground": "var(--syntax-rose)"
+      }
+    },
+    {
+      "scope": "keyword.operator.quantifier.regexp",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "constant.character.numeric.regexp",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "punctuation.definition.group.no-capture.regexp",
+        "meta.assertion.look-ahead.regexp",
+        "meta.assertion.negative-look-ahead.regexp"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": [
+        "meta.annotation.rust",
+        "meta.annotation.rust punctuation",
+        "meta.attribute.rust",
+        "punctuation.definition.attribute.rust"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "meta.attribute.rust string.quoted.double.rust",
+        "meta.attribute.rust string.quoted.single.char.rust"
+      ],
+      "settings": {
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": [
+        "entity.name.function.macro.rules.rust",
+        "storage.type.module.rust",
+        "storage.modifier.rust",
+        "storage.type.struct.rust",
+        "storage.type.enum.rust",
+        "storage.type.trait.rust",
+        "storage.type.union.rust",
+        "storage.type.impl.rust",
+        "storage.type.rust",
+        "storage.type.function.rust",
+        "storage.type.type.rust"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-keyword)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "entity.name.type.numeric.rust",
+      "settings": {
+        "foreground": "var(--syntax-keyword)",
+        "fontStyle": ""
+      }
+    },
+    {
+      "scope": "meta.generic.rust",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "entity.name.impl.rust",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "entity.name.module.rust",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "entity.name.trait.rust",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "storage.type.source.rust",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "entity.name.union.rust",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": "meta.enum.rust storage.type.source.rust",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": [
+        "support.macro.rust",
+        "meta.macro.rust support.function.rust",
+        "entity.name.function.macro.rust"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": ["storage.modifier.lifetime.rust", "entity.name.type.lifetime"],
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "string.quoted.double.rust constant.other.placeholder.rust",
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": "meta.function.return-type.rust meta.generic.rust storage.type.rust",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "meta.function.call.rust",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "punctuation.brackets.angle.rust",
+      "settings": {
+        "foreground": "var(--syntax-function)"
+      }
+    },
+    {
+      "scope": "constant.other.caps.rust",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": ["meta.function.definition.rust variable.other.rust"],
+      "settings": {
+        "foreground": "var(--syntax-parameter)"
+      }
+    },
+    {
+      "scope": "meta.function.call.rust variable.other.rust",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "variable.language.self.rust",
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": [
+        "variable.other.metavariable.name.rust",
+        "meta.macro.metavariable.rust keyword.operator.macro.dollar.rust"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": [
+        "comment.line.shebang",
+        "comment.line.shebang punctuation.definition.comment",
+        "comment.line.shebang",
+        "punctuation.definition.comment.shebang.shell",
+        "meta.shebang.shell"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-escape)"
+      }
+    },
+    {
+      "scope": "comment.line.shebang constant.language",
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": [
+        "meta.function-call.arguments.shell punctuation.definition.variable.shell",
+        "meta.function-call.arguments.shell punctuation.section.interpolation",
+        "meta.function-call.arguments.shell punctuation.definition.variable.shell",
+        "meta.function-call.arguments.shell punctuation.section.interpolation"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": "meta.string meta.interpolation.parameter.shell variable.other.readwrite",
+      "settings": {
+        "foreground": "var(--syntax-number)"
+      }
+    },
+    {
+      "scope": [
+        "source.shell punctuation.section.interpolation",
+        "punctuation.definition.evaluation.backticks.shell"
+      ],
+      "settings": {
+        "foreground": "var(--syntax-operator)"
+      }
+    },
+    {
+      "scope": "entity.name.tag.heredoc.shell",
+      "settings": {
+        "foreground": "var(--syntax-keyword)"
+      }
+    },
+    {
+      "scope": "string.quoted.double.shell variable.other.normal.shell",
+      "settings": {
+        "foreground": "var(--syntax-fg)"
+      }
+    },
+    {
+      "scope": "token.info-token",
+      "settings": {
+        "foreground": "var(--syntax-blue)"
+      }
+    },
+    {
+      "scope": "token.warn-token",
+      "settings": {
+        "foreground": "var(--syntax-amber)"
+      }
+    },
+    {
+      "scope": "token.error-token",
+      "settings": {
+        "foreground": "var(--syntax-builtin)"
+      }
+    },
+    {
+      "scope": "token.debug-token",
+      "settings": {
+        "foreground": "var(--syntax-purple)"
+      }
+    }
+  ]
+}
diff --git a/app/ui/util/oxql.tmLanguage.json b/app/ui/util/oxql.tmLanguage.json
new file mode 100644
index 000000000..22189eed6
--- /dev/null
+++ b/app/ui/util/oxql.tmLanguage.json
@@ -0,0 +1,72 @@
+{
+  "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
+  "name": "oxql",
+  "repository": {
+    "keywords": {
+      "patterns": [
+        {
+          "name": "keyword.control.oxql",
+          "match": "\\b(if|while|for|return)\\b"
+        }
+      ]
+    },
+    "strings": {
+      "name": "string.quoted.double.oxql",
+      "begin": "\"",
+      "end": "\"",
+      "patterns": [
+        {
+          "name": "constant.character.escape.oxql",
+          "match": "\\\\."
+        }
+      ]
+    }
+  },
+  "scopeName": "source.oxql",
+  "patterns": [
+    {
+      "name": "keyword.control.oxql",
+      "match": "\\b(get|join|align|filter|group_by)\\b"
+    },
+    {
+      "name": "string.quoted.double.oxql",
+      "begin": "\"",
+      "end": "\"",
+      "patterns": [
+        {
+          "name": "constant.character.escape.oxql",
+          "match": "\\\\."
+        }
+      ]
+    },
+    {
+      "name": "constant.numeric.oxql",
+      "match": "\\b\\d+[smhdw]\\b"
+    },
+    {
+      "name": "constant.numeric.datetime.oxql",
+      "match": "@\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"
+    },
+    {
+      "name": "constant.numeric.function.oxql",
+      "match": "@now\\(\\)"
+    },
+    {
+      "name": "constant.numeric.oxql",
+      "match": "\\b\\d+\\b"
+    },
+    {
+      "name": "comment.block.oxql",
+      "begin": "/\\*",
+      "end": "\\*/"
+    },
+    {
+      "name": "comment.line.double-slash.oxql",
+      "match": "//.*$"
+    },
+    {
+      "name": "keyword.operator.oxql",
+      "match": "\\|"
+    }
+  ]
+}
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/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..b213c7dc8 100644
--- a/mock-api/msw/util.ts
+++ b/mock-api/msw/util.ts
@@ -39,6 +39,7 @@ import { parseIp } from '~/util/ip'
 import { GiB, TiB } from '~/util/units'
 
 import type { DbRoleAssignmentResourceType } from '..'
+import { SENTINEL_FLAT_INSTANCE_ID, SENTINEL_SLOPE_INSTANCE_ID } from '../instance'
 import { genI64Data } from '../metrics'
 import { getMockOxqlInstanceData } from '../oxql-metrics'
 import { db, lookupById } from './db'
@@ -581,10 +582,35 @@ const getCpuStateFromQuery = (query: string): OxqlVcpuState | undefined => {
   return match ? (match[1] as OxqlVcpuState) : undefined
 }
 
+// Pull the instance UUID out of the `instance_id == "..."` filter (also matches
+// the `attached_instance_id` used by disk metrics).
+const getInstanceIdFromQuery = (query: string): string | undefined =>
+  query.match(/(?:attached_)?instance_id\s*==\s*"([^"]+)"/)?.[1]
+
+// getUtilizationChartProps renders raw values on screen as value * 100 / (5s *
+// 1e9 * 1 series); invertUtilization goes the other way — from a target percent
+// to the raw value that produces it.
+const invertUtilization = (percent: number): number => (percent * 5 * 1e9) / 100
+const SENTINEL_CONSTANT_RAW_VALUE = invertUtilization(12345) // 12,345%
+const sentinelSlopeRawValue = (i: number) => invertUtilization((i + 1) * 1000) // (i + 1) * 1000%
+
 export function handleOxqlMetrics({ query }: TimeseriesQuery): Json {
   const metricName = getMetricNameFromQuery(query) as OxqlNetworkMetricName
   const stateValue = getCpuStateFromQuery(query)
-  return getMockOxqlInstanceData(metricName, stateValue)
+  const data = getMockOxqlInstanceData(metricName, stateValue)
+
+  // Sentinel instances: replace the series with synthetic data — flat (constant)
+  // or a slope that increases with time — so tests can assert on plotted values.
+  const instanceId = getInstanceIdFromQuery(query)
+  const points = data.tables[0].timeseries[0].points
+  const series = points.values[0].values.values
+  if (instanceId === SENTINEL_FLAT_INSTANCE_ID) {
+    points.values[0].values.values = series.map(() => SENTINEL_CONSTANT_RAW_VALUE)
+  } else if (instanceId === SENTINEL_SLOPE_INSTANCE_ID) {
+    points.values[0].values.values = series.map((_, i) => sentinelSlopeRawValue(i))
+  }
+
+  return data
 }
 
 export function randomHex(length: number) {
diff --git a/package-lock.json b/package-lock.json
index 6931eb65a..2bde15658 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,13 +41,15 @@
         "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",
+        "shiki": "^4.3.1",
         "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 +1356,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1374,9 +1373,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1394,9 +1390,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1414,9 +1407,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1434,9 +1424,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1454,9 +1441,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1474,9 +1458,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1494,9 +1475,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1610,6 +1588,83 @@
         "react-dom": ">=17.0.0"
       }
     },
+    "node_modules/@oxide/design-system/node_modules/@shikijs/core": {
+      "version": "3.23.0",
+      "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz",
+      "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/types": "3.23.0",
+        "@shikijs/vscode-textmate": "^10.0.2",
+        "@types/hast": "^3.0.4",
+        "hast-util-to-html": "^9.0.5"
+      }
+    },
+    "node_modules/@oxide/design-system/node_modules/@shikijs/engine-javascript": {
+      "version": "3.23.0",
+      "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz",
+      "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/types": "3.23.0",
+        "@shikijs/vscode-textmate": "^10.0.2",
+        "oniguruma-to-es": "^4.3.4"
+      }
+    },
+    "node_modules/@oxide/design-system/node_modules/@shikijs/engine-oniguruma": {
+      "version": "3.23.0",
+      "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz",
+      "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/types": "3.23.0",
+        "@shikijs/vscode-textmate": "^10.0.2"
+      }
+    },
+    "node_modules/@oxide/design-system/node_modules/@shikijs/langs": {
+      "version": "3.23.0",
+      "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz",
+      "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/types": "3.23.0"
+      }
+    },
+    "node_modules/@oxide/design-system/node_modules/@shikijs/themes": {
+      "version": "3.23.0",
+      "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz",
+      "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/types": "3.23.0"
+      }
+    },
+    "node_modules/@oxide/design-system/node_modules/@shikijs/types": {
+      "version": "3.23.0",
+      "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
+      "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/vscode-textmate": "^10.0.2",
+        "@types/hast": "^3.0.4"
+      }
+    },
+    "node_modules/@oxide/design-system/node_modules/shiki": {
+      "version": "3.23.0",
+      "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz",
+      "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/core": "3.23.0",
+        "@shikijs/engine-javascript": "3.23.0",
+        "@shikijs/engine-oniguruma": "3.23.0",
+        "@shikijs/langs": "3.23.0",
+        "@shikijs/themes": "3.23.0",
+        "@shikijs/types": "3.23.0",
+        "@shikijs/vscode-textmate": "^10.0.2",
+        "@types/hast": "^3.0.4"
+      }
+    },
     "node_modules/@oxide/openapi-gen-ts": {
       "version": "0.14.0",
       "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.14.0.tgz",
@@ -1871,9 +1926,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1891,9 +1943,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1911,9 +1960,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1931,9 +1977,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1951,9 +1994,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1971,9 +2011,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1991,9 +2028,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -2011,9 +2045,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4700,9 +4731,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4719,9 +4747,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4738,9 +4763,6 @@
       "cpu": [
         "ppc64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4757,9 +4779,6 @@
       "cpu": [
         "s390x"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4776,9 +4795,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4795,9 +4811,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -4880,64 +4893,97 @@
       "license": "MIT"
     },
     "node_modules/@shikijs/core": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.13.0.tgz",
-      "integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz",
+      "integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==",
       "license": "MIT",
       "dependencies": {
-        "@shikijs/types": "3.13.0",
+        "@shikijs/primitive": "4.3.1",
+        "@shikijs/types": "4.3.1",
         "@shikijs/vscode-textmate": "^10.0.2",
         "@types/hast": "^3.0.4",
         "hast-util-to-html": "^9.0.5"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
     "node_modules/@shikijs/engine-javascript": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz",
-      "integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz",
+      "integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==",
       "license": "MIT",
       "dependencies": {
-        "@shikijs/types": "3.13.0",
+        "@shikijs/types": "4.3.1",
         "@shikijs/vscode-textmate": "^10.0.2",
-        "oniguruma-to-es": "^4.3.3"
+        "oniguruma-to-es": "^4.3.6"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
     "node_modules/@shikijs/engine-oniguruma": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.13.0.tgz",
-      "integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.1.tgz",
+      "integrity": "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==",
       "license": "MIT",
       "dependencies": {
-        "@shikijs/types": "3.13.0",
+        "@shikijs/types": "4.3.1",
         "@shikijs/vscode-textmate": "^10.0.2"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
     "node_modules/@shikijs/langs": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.13.0.tgz",
-      "integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.1.tgz",
+      "integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@shikijs/types": "4.3.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@shikijs/primitive": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.1.tgz",
+      "integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==",
       "license": "MIT",
       "dependencies": {
-        "@shikijs/types": "3.13.0"
+        "@shikijs/types": "4.3.1",
+        "@shikijs/vscode-textmate": "^10.0.2",
+        "@types/hast": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
     "node_modules/@shikijs/themes": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.13.0.tgz",
-      "integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.1.tgz",
+      "integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==",
       "license": "MIT",
       "dependencies": {
-        "@shikijs/types": "3.13.0"
+        "@shikijs/types": "4.3.1"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
     "node_modules/@shikijs/types": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz",
-      "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.1.tgz",
+      "integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==",
       "license": "MIT",
       "dependencies": {
         "@shikijs/vscode-textmate": "^10.0.2",
         "@types/hast": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
     "node_modules/@shikijs/vscode-textmate": {
@@ -5096,9 +5142,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -5115,9 +5158,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -5134,9 +5174,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -5153,9 +5190,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -5613,69 +5647,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",
@@ -5699,9 +5670,9 @@
       "license": "MIT"
     },
     "node_modules/@types/hast": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
-      "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+      "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
       "license": "MIT",
       "dependencies": {
         "@types/unist": "*"
@@ -6184,9 +6155,9 @@
       }
     },
     "node_modules/@ungap/structured-clone": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
-      "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
+      "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
       "license": "ISC"
     },
     "node_modules/@vitejs/plugin-basic-ssl": {
@@ -7068,129 +7039,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 +7091,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 +7176,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 +7662,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 +8308,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 +8366,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 +8489,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 +8807,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -9000,9 +8827,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -9023,9 +8847,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -9046,9 +8867,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -9151,18 +8969,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 +9445,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",
@@ -9706,19 +9503,19 @@
       }
     },
     "node_modules/oniguruma-parser": {
-      "version": "0.12.1",
-      "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
-      "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==",
+      "version": "0.12.2",
+      "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
+      "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
       "license": "MIT"
     },
     "node_modules/oniguruma-to-es": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz",
-      "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==",
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
+      "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
       "license": "MIT",
       "dependencies": {
-        "oniguruma-parser": "^0.12.1",
-        "regex": "^6.0.1",
+        "oniguruma-parser": "^0.12.2",
+        "regex": "^6.1.0",
         "regex-recursion": "^6.0.2"
       }
     },
@@ -10420,27 +10217,10 @@
       "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",
-      "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+      "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
       "license": "MIT",
       "funding": {
         "type": "github",
@@ -10705,21 +10485,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 +10519,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",
@@ -10829,9 +10534,9 @@
       }
     },
     "node_modules/regex": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz",
-      "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==",
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
+      "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
       "license": "MIT",
       "dependencies": {
         "regex-utilities": "^2.3.0"
@@ -10917,9 +10622,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": {
@@ -11140,19 +10845,22 @@
       }
     },
     "node_modules/shiki": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.13.0.tgz",
-      "integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.1.tgz",
+      "integrity": "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==",
       "license": "MIT",
       "dependencies": {
-        "@shikijs/core": "3.13.0",
-        "@shikijs/engine-javascript": "3.13.0",
-        "@shikijs/engine-oniguruma": "3.13.0",
-        "@shikijs/langs": "3.13.0",
-        "@shikijs/themes": "3.13.0",
-        "@shikijs/types": "3.13.0",
+        "@shikijs/core": "4.3.1",
+        "@shikijs/engine-javascript": "4.3.1",
+        "@shikijs/engine-oniguruma": "4.3.1",
+        "@shikijs/langs": "4.3.1",
+        "@shikijs/themes": "4.3.1",
+        "@shikijs/types": "4.3.1",
         "@shikijs/vscode-textmate": "^10.0.2",
         "@types/hast": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
     "node_modules/side-channel": {
@@ -11442,12 +11150,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",
@@ -11779,9 +11481,9 @@
       "peer": true
     },
     "node_modules/unist-util-is": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
-      "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+      "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
       "license": "MIT",
       "dependencies": {
         "@types/unist": "^3.0.0"
@@ -11818,9 +11520,9 @@
       }
     },
     "node_modules/unist-util-visit": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
-      "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+      "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
       "license": "MIT",
       "dependencies": {
         "@types/unist": "^3.0.0",
@@ -11833,9 +11535,9 @@
       }
     },
     "node_modules/unist-util-visit-parents": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz",
-      "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==",
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+      "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
       "license": "MIT",
       "dependencies": {
         "@types/unist": "^3.0.0",
@@ -11907,6 +11609,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 +11742,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..6197f93e3 100644
--- a/package.json
+++ b/package.json
@@ -65,13 +65,15 @@
     "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",
+    "shiki": "^4.3.1",
     "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/unit/setup.ts b/test/unit/setup.ts
index 48de20fe1..0d36aaceb 100644
--- a/test/unit/setup.ts
+++ b/test/unit/setup.ts
@@ -12,7 +12,7 @@
  */
 import '@testing-library/jest-dom/vitest'
 import { cleanup } from '@testing-library/react'
-import { afterAll, afterEach, beforeAll } from 'vitest'
+import { afterAll, afterEach, beforeAll, vi } from 'vitest'
 
 import { resetDb } from '../../mock-api/msw/db'
 import { server } from './server'
@@ -21,6 +21,19 @@ import { server } from './server'
 // an error that the method is not implemented
 HTMLCanvasElement.prototype.getContext = () => null
 
+// uPlot wants to matchMedia
+Object.defineProperty(window, 'matchMedia', {
+  writable: true,
+  value: vi.fn().mockImplementation((query: string) => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  })),
+})
+
 // jsdom has no ResizeObserver, but Headless UI (e.g. Listbox) constructs one for
 // popover positioning. A no-op stub is enough — there's no real layout to observe
 // in jsdom, so the callback never needs to fire.
diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts
index bd0b5c8e7..9f139ebdf 100644
--- a/test/visual/regression.e2e.ts
+++ b/test/visual/regression.e2e.ts
@@ -238,7 +238,7 @@ test.describe('Visual Regression', { tag: '@visual' }, () => {
   test('silo utilization', async ({ page }) => {
     await page.goto('/utilization', { waitUntil: 'networkidle' })
     await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible()
-    await expect(page.locator('.recharts-curve').first()).toBeVisible()
+    await expect(page.locator('figure').first()).toBeVisible()
     await expect(page).toHaveScreenshot('silo-utilization.png', {
       fullPage: true,
       mask: [page.getByTestId('refetch-interval-refresh')],
@@ -249,7 +249,7 @@ test.describe('Visual Regression', { tag: '@visual' }, () => {
   test('system utilization metrics tab', async ({ page }) => {
     await page.goto('/system/utilization?tab=metrics', { waitUntil: 'networkidle' })
     await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible()
-    await expect(page.locator('.recharts-curve').first()).toBeVisible()
+    await expect(page.locator('figure').first()).toBeVisible()
     await expect(page).toHaveScreenshot('system-utilization-metrics-tab.png', {
       fullPage: true,
       mask: [page.getByTestId('refetch-interval-refresh')],
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/.
diff --git a/vite.config.ts b/vite.config.ts
index 1c747b23e..3441f5f84 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -89,7 +89,7 @@ const cspNonce = randomBytes(8).toString('hex')
 const csp = headers['content-security-policy']
 const devHeaders = {
   ...headers,
-  'content-security-policy': `${csp}; script-src 'nonce-${cspNonce}' 'self'`,
+  'content-security-policy': `${csp}; script-src 'nonce-${cspNonce}' 'self' 'wasm-unsafe-eval'`,
 }
 
 // see https://vitejs.dev/config/