diff --git a/app/components/Heatmap.tsx b/app/components/Heatmap.tsx index 7ec19b28b..c90498316 100644 --- a/app/components/Heatmap.tsx +++ b/app/components/Heatmap.tsx @@ -7,10 +7,16 @@ */ import { useMemo, useState } from 'react' import * as R from 'remeda' +import { match } from 'ts-pattern' import type uPlot from 'uplot' import { ChartTooltip, type TopBottom, type LeftRight } from '~/components/ChartTooltip' import { type UPlotOptions, FramedChart } from '~/components/FramedChart' +import { + getSampleCountMetadata, + getScalingFunction, + type ColorScale, +} from '~/components/heatmap-color-scale' import { timeFormatterForRange, useChartTheme, @@ -40,38 +46,20 @@ const RAMP_STOPS = [ ['--content-accent', 'oklch(0.77 0.1919 163.7)'], ] as const +const CSS_VAR_RAMP: ColorRamp = { + stops: RAMP_STOPS.map(([name, fallback]) => `var(${name}, ${fallback})`), +} + // Interpolates the color between the two closest stops, for 0 <= t <= 1. const rampColor = ({ stops }: ColorRamp, t: number): string => { const clamped = R.clamp(t, { min: 0, max: 1 }) const lastIndex = stops.length - 1 const progress = clamped * lastIndex const start = R.clamp(Math.floor(progress), { max: lastIndex - 1 }) - const lerp = progress - start + const localT = progress - start const from = stops[start] const to = stops[start + 1] - return `color-mix(in oklch, ${from} ${(1 - lerp) * 100}%, ${to})` -} - -// grabbing style from the chart root instead of document body -// that way it can be themed e.g. `yellow-theme` -const getColorRamp = (el: Element): ColorRamp => { - const style = getComputedStyle(el) - return { - stops: RAMP_STOPS.map( - ([name, fallback]) => style.getPropertyValue(name).trim() || fallback - ), - } -} - -// count -> ramp position. Doing a square root or a log makes for some different -// smoothing. Nothing is truly intuitive, but linearly showing the fraction is -// most easily compared to the legend at the bottom -const rampT = (count: number, maxCount: number) => { - const fraction = count / maxCount - // TODO: pick! - return fraction - // return Math.log10(lerp(1, 10, fraction)) - // return Math.sqrt(fraction) + return `color-mix(in oklch, ${from}, ${to} ${localT * 100}%)` } type Hover = { @@ -93,6 +81,7 @@ type HeatmapProps = { unit?: string /** Drawn rotated alongside the y-axis tick labels, e.g. a unit */ yAxisLabel?: string + colorScale?: ColorScale } const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() @@ -105,6 +94,7 @@ export function Heatmap({ yAxisTickFormatter = defaultYAxisTickFormatter, unit, yAxisLabel, + colorScale = 'ranked', }: HeatmapProps) { const theme = useChartTheme() const [hover, setHover] = useState(null) @@ -118,8 +108,8 @@ export function Heatmap({ const binCount = bins.length const colCount = distributions.length - const maxSampleCount = useMemo( - () => distributions.reduce((max, d) => (d ? Math.max(max, ...d.counts) : max), 0), + const sampleCountMetadata = useMemo( + () => getSampleCountMetadata(distributions.flatMap((d) => d?.counts ?? [])), [distributions] ) @@ -129,10 +119,22 @@ export function Heatmap({ // uPlot doesn't actually have heatmap support, but we can use its position // functions to make this (somewhat) easy - const drawCells = useMemo( - () => (u: uPlot) => { + const drawCells = useMemo(() => { + const scaleCount = getScalingFunction(sampleCountMetadata, colorScale) + + return (u: uPlot) => { if (binCount === 0 || colCount === 0) return - const colorRamp = getColorRamp(u.root) + + // since a canvas can't resolve CSS variables, we simulate it by reading + // the ramp colors off the element furthest down the tree (the chart's + // root element) + const style = getComputedStyle(u.root) + const colorRamp = { + stops: RAMP_STOPS.map( + ([name, fallback]) => style.getPropertyValue(name).trim() || fallback + ), + } + const ctx = u.ctx ctx.save() ctx.beginPath() @@ -149,7 +151,7 @@ export function Heatmap({ const topEdge = u.valToPos(rowIndex + 1, 'y', true) const bottomEdge = u.valToPos(rowIndex, 'y', true) - ctx.fillStyle = rampColor(colorRamp, rampT(count, maxSampleCount)) + ctx.fillStyle = rampColor(colorRamp, scaleCount(count)) ctx.fillRect( leftEdge - CELL_OVERFLOW, topEdge - CELL_OVERFLOW, @@ -159,9 +161,16 @@ export function Heatmap({ }) }) ctx.restore() - }, - [distributions, leftEdges, rightEdges, colCount, binCount, maxSampleCount] - ) + } + }, [ + distributions, + leftEdges, + rightEdges, + colCount, + binCount, + colorScale, + sampleCountMetadata, + ]) const tooltipPlugin = useMemo( () => ({ @@ -307,7 +316,19 @@ export function Heatmap({ chartOptions={chartOptions} data={data} uRef={uRef} - legend={} + legend={ + sampleCountMetadata.ranks.size > 0 && ( + sampleCountMetadata.minSampleCount) + .with('linear', () => 0) + .with('logarithmic', () => 1) + .exhaustive()} + maxSampleCount={sampleCountMetadata.maxSampleCount} + axisText={theme.axisText} + /> + ) + } > {hover && ( <> @@ -342,28 +363,69 @@ export function Heatmap({ } function HeatmapLegend({ + minSampleCount, maxSampleCount, axisText, }: { + minSampleCount: number maxSampleCount: number axisText: string }) { return (
- - 0 - -
`var(${name}, ${fallback})` - ).join(', ')})`, - }} - >
+ {minSampleCount === maxSampleCount ? ( +
+ ) : ( + <> + + {minSampleCount.toLocaleString()} + +
+ + )} {maxSampleCount.toLocaleString()}
) } + +const SAMPLE_VALUES = [1, 5, 10, 50, 95, 100] +const SAMPLE_VALUES_METADATA = getSampleCountMetadata(SAMPLE_VALUES) +const LONGEST_SAMPLE_ITEM = SAMPLE_VALUES.map((n) => n.toLocaleString().length).reduce( + (acc, length) => Math.max(acc, length), + 0 +) + +export function SampleColorStrip({ colorScale }: { colorScale: ColorScale }) { + const scaleCount = getScalingFunction(SAMPLE_VALUES_METADATA, colorScale) + + return ( +
+ {SAMPLE_VALUES.map((n) => ( +
+
+ {n.toLocaleString()} +
+
+
+ ))} +
+ ) +} diff --git a/app/components/heatmap-color-scale.spec.ts b/app/components/heatmap-color-scale.spec.ts new file mode 100644 index 000000000..94b2a99b1 --- /dev/null +++ b/app/components/heatmap-color-scale.spec.ts @@ -0,0 +1,89 @@ +/* + * 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 { describe, expect, it } from 'vitest' + +import { + getSampleCountMetadata, + getScalingFunction, + type ColorScale, +} from './heatmap-color-scale' + +const scale = (counts: number[], colorScale: ColorScale) => + getScalingFunction(getSampleCountMetadata(counts), colorScale) + +describe('getSampleCountMetadata', () => { + it('ignores zeros and duplicates', () => { + const { minSampleCount, maxSampleCount, ranks } = getSampleCountMetadata([ + 50, 0, 5, 50, 0, 100, + ]) + expect(minSampleCount).toBe(5) + expect(maxSampleCount).toBe(100) + expect([...ranks]).toEqual([ + [5, 0], + [50, 1], + [100, 2], + ]) + }) + + it('handles lack of samples', () => { + const { minSampleCount, maxSampleCount, ranks } = getSampleCountMetadata([0, 0]) + expect(minSampleCount).toBe(0) + expect(maxSampleCount).toBe(0) + expect(ranks.size).toBe(0) + }) +}) + +describe('linear', () => { + it('is the fraction of the max', () => { + const f = scale([10, 50, 200], 'linear') + expect(f(10)).toBe(0.05) + expect(f(50)).toBe(0.25) + expect(f(200)).toBe(1) + }) +}) + +describe('logarithmic', () => { + it("has 1 as 0 and the collection's greatest value as 1", () => { + const f = scale([10, 200], 'logarithmic') + expect(f(1)).toBe(0) + expect(f(200)).toBe(1) + }) + + it('gives equal ratios equal steps', () => { + const expectEqualSpacing = (values: number[]) => { + const f = scale(values, 'logarithmic') + values.forEach((value, i) => expect(f(value)).toBeCloseTo(i / (values.length - 1))) + } + + expectEqualSpacing([1, 10, 100, 1000, 10000]) + expectEqualSpacing([1, 7, 49, 343]) + }) + + it("makes an exception when the collection's greatest value is 1", () => { + expect(scale([1, 0], 'logarithmic')(1)).toBe(1) + }) +}) + +describe('ranked', () => { + it('spaces distinct counts evenly', () => { + const f = scale([1, 2, 1000], 'ranked') + expect(f(1)).toBe(0) + expect(f(2)).toBe(0.5) + expect(f(1000)).toBe(1) + }) + + it('ignores duplicates', () => { + const f = scale([3, 3, 7, 9], 'ranked') + expect(f(3)).toBe(0) + expect(f(7)).toBe(0.5) + }) + + it('puts a single count at the top', () => { + expect(scale([4, 4, 0], 'ranked')(4)).toBe(1) + }) +}) diff --git a/app/components/heatmap-color-scale.ts b/app/components/heatmap-color-scale.ts new file mode 100644 index 000000000..ea26dc43e --- /dev/null +++ b/app/components/heatmap-color-scale.ts @@ -0,0 +1,61 @@ +/* + * 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 * as R from 'remeda' +import { match } from 'ts-pattern' + +export type ColorScale = 'linear' | 'logarithmic' | 'ranked' + +const scaleLinear = + (maxCount: number) => + (count: number): number => + count / maxCount +const scaleLog = (maxCount: number) => + maxCount <= 1 + ? // if the max is 1, a logarithmic scaling function is undefined. but + // since 1 is then the only valid value, in a charting context it makes + // sense to treat that as a scale of 100% + R.constant(1) + : (count: number): number => Math.log(count) / Math.log(maxCount) +const scaleRank = (ranks: Map) => + ranks.size <= 1 + ? R.constant(1) + : (count: number): number => (ranks.get(count) ?? 0) / (ranks.size - 1) + +export type SampleCountMetadata = { + minSampleCount: number + maxSampleCount: number + ranks: Map +} + +export const getSampleCountMetadata = (counts: number[]): SampleCountMetadata => { + const allCounts = R.sort( + R.unique(counts).filter((x) => x !== 0), + (a, b) => a - b + ) + + return { + minSampleCount: R.first(allCounts) ?? 0, + maxSampleCount: R.last(allCounts) ?? 0, + ranks: new Map(allCounts.map((n, i) => [n, i])), + } +} + +/** + * Maps a sample count to a position for a color ramp, from 0 to 1. + * Expects that the count given to the final scaling function is a nonzero + * natural number. + */ +export const getScalingFunction = ( + metadata: SampleCountMetadata, + colorScale: ColorScale +): ((n: number) => number) => + match(colorScale) + .with('linear', () => scaleLinear(metadata.maxSampleCount)) + .with('logarithmic', () => scaleLog(metadata.maxSampleCount)) + .with('ranked', () => scaleRank(metadata.ranks)) + .exhaustive() diff --git a/app/pages/system/MetricsExplorer.tsx b/app/pages/system/MetricsExplorer.tsx index 07a72bf01..9c938747a 100644 --- a/app/pages/system/MetricsExplorer.tsx +++ b/app/pages/system/MetricsExplorer.tsx @@ -5,8 +5,10 @@ * * Copyright Oxide Computer Company */ +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' import { useQuery } from '@tanstack/react-query' import { useWindowVirtualizer } from '@tanstack/react-virtual' +import cn from 'classnames' import { Fragment, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useController, useForm } from 'react-hook-form' import { useSearchParams } from 'react-router' @@ -34,11 +36,13 @@ import { Info12Icon, Monitoring16Icon, Monitoring24Icon, + Settings16Icon, } from '@oxide/design-system/icons/react' import { timeseries as timeseriesMetadata } from '~/api/__generated__/timeseries-metadata' import { DocsPopover } from '~/components/DocsPopover' -import { Heatmap } from '~/components/Heatmap' +import { Heatmap, SampleColorStrip } from '~/components/Heatmap' +import { type ColorScale } from '~/components/heatmap-color-scale' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { errorMessageToSegments, @@ -57,7 +61,7 @@ import { } from '~/components/TimeSeriesChart' import { useElementSize } from '~/hooks/use-element-size' import { addToast } from '~/stores/toast' -import { Button } from '~/ui/lib/Button' +import { Button, buttonStyle } from '~/ui/lib/Button' import { CardBlock } from '~/ui/lib/CardBlock' import { Checkbox } from '~/ui/lib/Checkbox' import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' @@ -67,6 +71,7 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { ErrorInlineCode } from '~/ui/lib/InlineCode' import { Message } from '~/ui/lib/Message' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { Radio } from '~/ui/lib/Radio' import { TextInputError } from '~/ui/lib/TextInput' import { TipIcon } from '~/ui/lib/TipIcon' import { Tooltip } from '~/ui/lib/Tooltip' @@ -699,7 +704,13 @@ function ChartCard({ display }: { display: Extract }) { +function HeatmapCard({ + display, + colorScale, +}: { + display: Extract + colorScale: ColorScale +}) { const units = tableUnitLabels(display.name) return ( @@ -709,6 +720,7 @@ function HeatmapCard({ display }: { display: Extract ( @@ -739,10 +757,98 @@ function ChartEntry({ display }: { display: ChartDisplay }) { )) .with({ kind: 'chart' }, (r) => ) - .with({ kind: 'heatmap' }, (r) => ) + .with({ kind: 'heatmap' }, (r) => ( + + )) .exhaustive() } +type Setting = { value: T; onChange: (value: T) => void } | undefined + +type ChartSettingsProps = { + dropFirstPoint: Setting + colorScale: Setting +} + +const colorScaleInfo = { + linear: { + label: 'Linear', + description: + 'Shades each cell by its count as a fraction of the highest measured count.', + }, + logarithmic: { + label: 'Logarithmic', + description: + "Shades each cell by its count's order of magnitude, so 1, 10, and 100 are evenly spaced.", + }, + ranked: { + label: 'Ranked', + description: + 'Shades a cell by where its count ranks among all measured counts. Not a direct representation of magnitude, but helps emphasize patterns and outliers.', + }, +} satisfies Record + +function ChartSettings({ dropFirstPoint, colorScale }: ChartSettingsProps) { + return ( + + +
+ +
+
+ +

Chart settings

+ +
+ {dropFirstPoint && ( +
+ dropFirstPoint.onChange(e.target.checked)} + > + Drop first data point + + + With deltas and cumulative counters, the initial point is a delta from the + earliest observed value. It's therefore typically much larger, and not worth + comparing to the rest of your data. + +
+ )} + {colorScale && ( +
+ + Heatmap color scale + +
+ {R.entries(colorScaleInfo).map(([value, { label, description }]) => ( + + colorScale.onChange(value)} + > + {label} + + +

{description}

+ +
+
+ ))} +
+
+ )} +
+
+
+ ) +} + const copyText = (text: string, toastMessage: string) => { window.navigator.clipboard.writeText(text).then(() => addToast(toastMessage)) } @@ -847,6 +953,7 @@ export default function MetricsExplorer() { }) const [dropFirstPoint, setDropFirstPoint] = useState(true) + const [colorScale, setColorScale] = useState('ranked') const onSubmit = (body: TimeseriesQuery) => { query.mutate( @@ -898,6 +1005,10 @@ export default function MetricsExplorer() { [chartGroups, trim] ) + // a query can return several tables, only some of which are distributions + const hasHeatmaps = charts.some((c) => c.kind === 'heatmap') + const hasSettings = hasTrimmableCharts || hasHeatmaps + // Since the whole window is the scroll container, the virtualizer needs to // know the offset from the top. By reacting to height changes in everything // prior to the virtualized area, we can keep the list's offset height in sync. @@ -908,7 +1019,7 @@ export default function MetricsExplorer() { if (chartsRef.current) { setScrollMargin(chartsRef.current.getBoundingClientRect().top + window.scrollY) } - }, [preChartsSize?.height, charts.length]) + }, [preChartsSize?.height, charts.length, hasSettings]) const virtualizer = useWindowVirtualizer({ count: charts.length, @@ -1009,19 +1120,18 @@ export default function MetricsExplorer() { )) .with({ status: 'success' }, () => ( - {hasTrimmableCharts && ( -
- setDropFirstPoint(e.target.checked)} - > - Drop first data point - - - With deltas and cumulative counters, the initial point is a delta from the - earliest observed value. It's therefore typically much larger, and not - worth comparing to the rest of your data. - + {hasSettings && ( +
+
)}
- +
))}
diff --git a/test/e2e/oxql-queries.ts b/test/e2e/oxql-queries.ts index ab5cd4e27..804a1282f 100644 --- a/test/e2e/oxql-queries.ts +++ b/test/e2e/oxql-queries.ts @@ -9,6 +9,8 @@ export const oxqlQueries = { basicTctl: `get hardware_component:amd_cpu_tctl | filter timestamp > @now() - 1m`, + diskLatency: `get virtual_disk:io_latency + | filter timestamp > @now() - 1m`, unalignedTables: `{ get hardware_component:temperature; get hardware_component:sensor_error_count diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index a81459dbc..c2a66e74b 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -97,20 +97,82 @@ test('joined query renders a chart per instance with a legend line per metric', ]) }) -test('"Drop first point" appears only for cumulative-derived charts', async ({ page }) => { +// Chart contents have no semantic representation, but we can compare canvas +// snapshots within a run. +const chartImage = (canvas: Locator) => + canvas.evaluate((el: HTMLCanvasElement) => el.toDataURL()) + +test('"Drop first point" appears for cumulative-derived charts and updates them', async ({ + page, +}) => { + const settings = page.getByRole('button', { name: 'Chart settings' }) const dropFirst = page.getByLabel('Drop first data point') - // a plain gauge is never cumulative, so there's no giant first point to drop + // a plain gauge is never cumulative, so there's no giant first point to drop. + // and since the only other option is about heatmaps, there should be no + // settings button await runQuery(page, oxqlQueries.basicTctl) - await expect(dropFirst).toBeHidden() + await expect(settings).toBeHidden() // joined/aligned tables may derive from cumulatives, so the option shows up // TODO: if you know the schemas, you can check which tables are cumulative! await runQuery(page, oxqlQueries.multiJoinedTables) + + const canvas = page + .getByRole('figure', { name: 'sled_data_link:bytes_sent,sled_data_link:errors_sent' }) + .first() + .locator('canvas') + await expect(canvas).toBeVisible() + const firstDroppedImage = await chartImage(canvas) + await settings.click() await expect(dropFirst).toBeChecked() await dropFirst.uncheck() - await expect(page.getByRole('figure')).toHaveCount(3) + expect(await chartImage(canvas)).not.toEqual(firstDroppedImage) + + await dropFirst.check() + expect(await chartImage(canvas)).toEqual(firstDroppedImage) +}) + +test('heatmap color scale appears for distributions and updates the chart', async ({ + page, +}) => { + const settings = page.getByRole('button', { name: 'Chart settings' }) + const colorScale = page.getByRole('group', { name: 'Heatmap color scale' }) + + await runQuery(page, oxqlQueries.multiJoinedTables) + await settings.click() + await expect(page.getByLabel('Drop first data point')).toBeVisible() + await expect(colorScale).toBeHidden() + await page.keyboard.press('Escape') + + await runQuery(page, oxqlQueries.diskLatency) + await settings.click() + const ranked = colorScale.getByRole('radio', { name: 'Ranked', exact: true }) + const linear = colorScale.getByRole('radio', { name: 'Linear', exact: true }) + const logarithmic = colorScale.getByRole('radio', { name: 'Logarithmic', exact: true }) + await expect(ranked).toBeChecked() + + const chart = page.getByRole('figure', { name: 'virtual_disk:io_latency' }).first() + const canvas = chart.locator('canvas') + await expect(canvas).toBeVisible() + + const rankedImage = await chartImage(canvas) + await expect(chart.getByText('1', { exact: true })).toBeVisible() + + await linear.check() + const linearImage = await chartImage(canvas) + expect(linearImage).not.toEqual(rankedImage) + await expect(chart.getByText('0', { exact: true })).toBeVisible() + + await logarithmic.check() + const logarithmicImage = await chartImage(canvas) + expect(logarithmicImage).not.toEqual(rankedImage) + expect(logarithmicImage).not.toEqual(linearImage) + await expect(chart.getByText('1', { exact: true })).toBeVisible() + + await ranked.check() + expect(await chartImage(canvas)).toEqual(rankedImage) }) test('results list is virtualized', async ({ page }) => {