diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index f36fc4e00..9a287c1cd 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from 'react' import { DirectionDownIcon, DirectionUpIcon } from '@oxide/design-system/icons/react' +import { subscribeToTheme } from '~/stores/theme' import { classed } from '~/util/classed' import { AttachAddon } from './AttachAddon' @@ -110,16 +111,12 @@ export function Terminal({ ws }: TerminalProps) { // Update terminal colors when the theme changes. getComputedStyle in // getTheme() forces a synchronous style recalc, so the CSS custom // properties already reflect the new theme by the time we read them. - const observer = new MutationObserver(() => { + 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.tsx b/app/components/TimeSeriesChart.tsx index 5cf3dad15..c78523b65 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,110 @@ 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 +} + +// 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('--content-accent-tertiary'), + fill: withAlpha(v('--surface-accent-secondary'), 0.6), + hoverPoint: v('--content-accent'), + axisLine: v('--stroke-secondary'), + axisText: v('--content-quaternary'), + } +} + +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 */}
) @@ -121,15 +154,6 @@ type TimeSeriesChartProps = { 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 -} - // this top margin is also in the chart, probably want a way of unifying the sizing between the two const SkeletonMetric = ({ children, @@ -165,6 +189,8 @@ const SkeletonMetric = ({
) +const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() + export function TimeSeriesChart({ data: rawData, title, @@ -172,41 +198,173 @@ export function TimeSeriesChart({ startTime, endTime, unit, - yAxisTickFormatter = (val) => val.toLocaleString(), + yAxisTickFormatter = defaultYAxisTickFormatter, hasError = false, loading, }: 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<{ + hoveredDataIndex: 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 + } + + const x = self.data[0][idx] + const y = self.data[1][idx] + if (y == null) { + setTooltip(null) + return + } + + 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, + // 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() + }, [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: [ + {}, + { + show: true, + stroke: theme.stroke, + fill: theme.fill, + 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], + cursor: { + 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, + [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 ( @@ -230,62 +388,42 @@ export function TimeSeriesChart({ ) } - // ResponsiveContainer has default height and width of 100% - // https://recharts.org/en-US/api/ResponsiveContainer + const aligned: uPlot.AlignedData = [ + data.map(({ timestamp }) => timestamp / 1000), + data.map(({ value }) => value), + ] + + const hovered = tooltip ? data[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' }} +
+
+ {/* uPlot does not recover its x scale when initialized at zero width in production */} + {size && size.width > 0 && ( + (uRef.current = u)} /> - - - -
+ )} + {tooltip && hovered && hovered.value !== null && ( +
+ +
+ )} +
+ ) } 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/stores/theme.ts b/app/stores/theme.ts index dfd800f92..6d5eb627c 100644 --- a/app/stores/theme.ts +++ b/app/stores/theme.ts @@ -43,6 +43,20 @@ function getSystemIsLight() { return window.matchMedia('(prefers-color-scheme: light)').matches } +/** + * Run `cb` whenever the resolved theme (data-theme on ) changes. Use for + * canvas renderers that can't consume CSS custom properties directly. Returns + * an unsubscribe function. + */ +export function subscribeToTheme(cb: () => void) { + const observer = new MutationObserver(cb) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) + return () => observer.disconnect() +} + /** * Hook that applies the resolved theme to the document. Renders in RootLayout * so it runs on every page. diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index d65e951fb..61ee08385 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -29,6 +29,7 @@ @import '@oxide/design-system/styles/light.css'; @import '@oxide/design-system/styles/preflight.css' layer(base); @import 'simplebar-react/dist/simplebar.min.css' layer(components); +@import 'uplot/dist/uPlot.min.css' layer(components); @import '@oxide/design-system/styles/red.css'; @import '@oxide/design-system/styles/yellow.css'; diff --git a/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..4ce0bcd71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,13 +41,14 @@ "react-merge-refs": "^2.1.1", "react-router": "^8.0.0", "react-stately": "^3.32.2", - "recharts": "^2.15.1", "remeda": "^2.30.0", "semver": "^7.7.3", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", "tunnel-rat": "^0.1.2", + "uplot": "^1.6.32", + "uplot-react": "^1.2.4", "use-debounce": "^10.0.4", "uuid": "^14.0.0", "zod": "^4.0.17", @@ -1354,9 +1355,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1374,9 +1372,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1394,9 +1389,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1414,9 +1406,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1434,9 +1423,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1454,9 +1440,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1474,9 +1457,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1494,9 +1474,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1871,9 +1848,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1891,9 +1865,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1911,9 +1882,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1931,9 +1899,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1951,9 +1916,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1971,9 +1933,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1991,9 +1950,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2011,9 +1967,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4700,9 +4653,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4719,9 +4669,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4738,9 +4685,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4757,9 +4701,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4776,9 +4717,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4795,9 +4733,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5096,9 +5031,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5115,9 +5047,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5134,9 +5063,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5153,9 +5079,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5613,69 +5536,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", - "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -7068,129 +6928,9 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -7240,12 +6980,6 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7331,16 +7065,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7827,15 +7551,6 @@ "license": "MIT", "peer": true }, - "node_modules/fast-equals": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", - "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8482,6 +8197,18 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8528,15 +8255,6 @@ "license": "MIT", "peer": true }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/intl-messageformat": { "version": "10.7.17", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.17.tgz", @@ -8660,6 +8378,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8977,9 +8696,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9000,9 +8716,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9023,9 +8736,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9046,9 +8756,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9151,18 +8858,6 @@ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -9639,15 +9334,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -10420,23 +10106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -10705,21 +10374,6 @@ } } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-stately": { "version": "3.32.2", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.32.2.tgz", @@ -10754,66 +10408,6 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/recharts": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", - "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, - "node_modules/recharts/node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10917,9 +10511,9 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/retry": { @@ -11442,12 +11036,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11907,6 +11495,25 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/uplot-react": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/uplot-react/-/uplot-react-1.2.4.tgz", + "integrity": "sha512-mDe/mqD9KtXeHDR8llSJaUFpDcEJvYpHNS+cyUhJ2qvkbT9GPKod1BVXG+hNegRqYiV1ldsFBlI5+OKSi/yPNA==", + "license": "MIT", + "engines": { + "node": ">=8.10" + }, + "peerDependencies": { + "react": ">=16.8.6", + "uplot": "^1.6.32" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -12021,28 +11628,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/package.json b/package.json index 3bbdfb181..c8528f7df 100644 --- a/package.json +++ b/package.json @@ -65,13 +65,14 @@ "react-merge-refs": "^2.1.1", "react-router": "^8.0.0", "react-stately": "^3.32.2", - "recharts": "^2.15.1", "remeda": "^2.30.0", "semver": "^7.7.3", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", "tunnel-rat": "^0.1.2", + "uplot": "^1.6.32", + "uplot-react": "^1.2.4", "use-debounce": "^10.0.4", "uuid": "^14.0.0", "zod": "^4.0.17", diff --git a/test/e2e/combobox.e2e.ts b/test/e2e/combobox.e2e.ts index 45aa43b90..fe891fe7f 100644 --- a/test/e2e/combobox.e2e.ts +++ b/test/e2e/combobox.e2e.ts @@ -179,6 +179,8 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields', 'db2', 'db-stopped', 'db3', + 'sentinel-metrics-flat', + 'sentinel-metrics-slope', ]) await instanceInput.fill('d') diff --git a/test/e2e/instance-metrics.e2e.ts b/test/e2e/instance-metrics.e2e.ts index 071b2531b..4e8769a0d 100644 --- a/test/e2e/instance-metrics.e2e.ts +++ b/test/e2e/instance-metrics.e2e.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { OXQL_GROUP_BY_ERROR } from '~/api' @@ -40,6 +40,50 @@ test('Click through instance metrics', async ({ page }) => { await expect(page.getByText('Something went wrong')).toBeHidden() }) +async function readChartValueAt(page: Page, chart: Locator, fracX: number) { + const box = await chart.boundingBox() + if (!box) throw new Error('chart has no bounding box') + const x = box.x + box.width * fracX + await page.mouse.move(x, box.y) + await page.mouse.move(x, box.y + box.height * 0.25) + const tooltip = page.getByRole('tooltip') + await expect(tooltip).toBeVisible() + // within the tooltip, the value line is the only text that's just a number + unit + const value = tooltip.getByText(/^[\d,]+%$/) + return Number((await value.textContent())!.replace(/\D/g, '')) +} + +test('chart tooltip reads back the plotted value', async ({ page }) => { + // sentinel-metrics-flat returns a flat series (see handleOxqlMetrics), so a + // hover anywhere in the plot reads back the same value. + await page.goto('/projects/mock-project/instances/sentinel-metrics-flat/metrics/cpu') + + const heading = page.getByRole('heading', { name: 'CPU Utilization: Running' }) + await expect(heading).toBeVisible() + // wait for data so the chart, not the loading skeleton, is rendered + await expect(page.getByLabel('Chart loading')).toBeHidden() + + const chart = page.getByRole('figure', { name: 'CPU Utilization: Running' }) + expect(await readChartValueAt(page, chart, 0.5)).toBe(12345) +}) + +test('chart x-axis maps earlier times to the left', async ({ page }) => { + await page.goto('/projects/mock-project/instances/sentinel-metrics-slope/metrics/cpu') + + const heading = page.getByRole('heading', { name: 'CPU Utilization: Running' }) + await expect(heading).toBeVisible() + await expect(page.getByLabel('Chart loading')).toBeHidden() + + const chart = page.getByRole('figure', { name: 'CPU Utilization: Running' }) + const leftValue = await readChartValueAt(page, chart, 0.25) + const rightValue = await readChartValueAt(page, chart, 0.7) + + // sentinel-metrics-slope returns a series that increases with time (see + // handleOxqlMetrics), so a hover on the left of the plot reads a smaller value + // than one on the right. + expect(leftValue).toBeLessThan(rightValue) +}) + test('Date range picker: choosing a custom range', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1/metrics/cpu') await expect( diff --git a/test/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/.