Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
16 changes: 13 additions & 3 deletions app/components/SystemMetric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { useMemo, useRef } from 'react'

import { api, q, synthesizeData, type ChartDatum, type SystemMetricName } from '@oxide/api'

import { ChartContainer, ChartHeader, TimeSeriesChart } from './TimeSeriesChart'
import {
ChartContainer,
ChartHeader,
TimeSeriesChart,
toChartSeries,
} from './TimeSeriesChart'

// The difference between system metric and silo metric is
// 1. different endpoints
Expand Down Expand Up @@ -84,11 +89,14 @@ export function SiloMetric({
// TODO: indicate time zone somewhere. doesn't have to be in the detail view
// in the tooltip. could be just once on the end of the x-axis like GCP

const { values, timestamps } = toChartSeries(data)

return (
<ChartContainer>
<ChartHeader title={title} label={`(${unit})`} />
<TimeSeriesChart
data={data}
timestamps={timestamps}
data={values}
title={title}
interpolation="stepAfter"
startTime={startTime}
Expand Down Expand Up @@ -153,11 +161,13 @@ export function SystemMetric({
// TODO: indicate time zone somewhere. doesn't have to be in the detail view
// in the tooltip. could be just once on the end of the x-axis like GCP

const { values, timestamps } = toChartSeries(data)
return (
<ChartContainer>
<ChartHeader title={title} label={`(${unit})`} />
<TimeSeriesChart
data={data}
data={values}
timestamps={timestamps}
title={title}
interpolation="stepAfter"
startTime={startTime}
Expand Down
3 changes: 2 additions & 1 deletion app/components/TimeSeriesChart.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ describe('safe redrawing', () => {
* "wrong" calls to redraw.
*/
const props = (formatter: (v: number) => string) => ({
data: [{ timestamp: 0, value: 10 }],
data: [[10]],
timestamps: [0],
title: 'CPU',
startTime: new Date(0),
endTime: new Date(3_600_000),
Expand Down
134 changes: 116 additions & 18 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type ChartTheme = {
hoverPoint: string
axisLine: string
axisText: string
lineColors: string[]
}

// Append an alpha channel to a resolved color, e.g. `oklch(l c h)` -> `oklch(l c h / 0.6)`. Assumes
Expand All @@ -87,9 +88,20 @@ function getChartTheme(): ChartTheme {
hoverPoint: v('--content-accent'),
axisLine: v('--stroke-secondary'),
axisText: v('--content-quaternary'),
lineColors: [
'--color-green-800',
'--color-blue-800',
'--color-purple-800',
'--color-yellow-800',
'--color-red-800',
].map(v),
}
}

const seriesColor = (i: number, theme: ChartTheme): string =>
theme.lineColors[i] ||
`oklch(0.77 0.175 ${((163.7 + (i - theme.lineColors.length) * 137.508) % 360).toFixed(1)})`

function useChartTheme(): ChartTheme {
const [colors, setColors] = useState(getChartTheme)
useEffect(() => subscribeToTheme(() => setColors(getChartTheme())), [])
Expand Down Expand Up @@ -143,7 +155,8 @@ function ChartTooltip({

type TimeSeriesChartProps = {
className?: string
data: ChartDatum[] | undefined
timestamps: number[] | undefined
data: (number | null)[][] | undefined
title: string
interpolation?: 'linear' | 'stepAfter'
startTime: Date
Expand All @@ -152,6 +165,7 @@ type TimeSeriesChartProps = {
yAxisTickFormatter?: (val: number) => string
hasError?: boolean
loading: boolean
seriesLabels?: readonly string[]
}

// this top margin is also in the chart, probably want a way of unifying the sizing between the two
Expand Down Expand Up @@ -191,7 +205,23 @@ const SkeletonMetric = ({

const defaultYAxisTickFormatter = (val: number) => val.toLocaleString()

/**
* Split a single `ChartDatum[]` into the parallel `timestamps`/`data` arrays the chart consumes.
* Returns `undefined` props when there's no data so the chart goes into the loading/empty state.
*/
export function toChartSeries(data: ChartDatum[] | undefined): {
timestamps: number[] | undefined
values: (number | null)[][] | undefined
} {
if (!data) return { timestamps: undefined, values: undefined }
return {
timestamps: data.map((d) => d.timestamp),
values: [data.map((d) => d.value)],
}
}

export function TimeSeriesChart({
timestamps,
data: rawData,
title,
interpolation = 'linear',
Expand All @@ -201,6 +231,7 @@ export function TimeSeriesChart({
yAxisTickFormatter = defaultYAxisTickFormatter,
hasError = false,
loading,
seriesLabels,
}: TimeSeriesChartProps) {
// falling back here instead of in the parent lets us avoid causing a
// re-render on every render of the parent when the data is undefined
Expand All @@ -215,7 +246,10 @@ export function TimeSeriesChart({
const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime

const [tooltip, setTooltip] = useState<{
// the x position
hoveredDataIndex: number
// which series is hovered
hoveredSeriesIndex: number
left: number
top: number
// which side of the point the box sits on
Expand All @@ -233,13 +267,20 @@ export function TimeSeriesChart({
return
}

const x = self.data[0][idx]
const y = self.data[1][idx]
if (y == null) {
// We hunt down the series whose Y is closest to the cursor position at the given X index.
// Reminder that the first series is the X values, so we start at series index 1 here.
const nearestSeriesIndex = R.firstBy(
R.range(1, self.series.length).filter((s) => self.data[s][idx] != null),
// non-null: the filter above dropped series that are null at this idx
(s) => Math.abs(self.valToPos(self.data[s][idx]!, 'y') - top)
)
if (nearestSeriesIndex === undefined) {
setTooltip(null)
return
}

const x = self.data[0][idx]

const plotRect = self.over.getBoundingClientRect()
const chartRect = self.root.getBoundingClientRect()

Expand All @@ -248,6 +289,7 @@ export function TimeSeriesChart({

setTooltip({
hoveredDataIndex: idx,
hoveredSeriesIndex: nearestSeriesIndex - 1,
// cursor coords are relative to the plot area, so we add in the diff between the plot
// and the whole container
left: plotRect.left - chartRect.left + left,
Expand Down Expand Up @@ -292,16 +334,16 @@ export function TimeSeriesChart({
},
series: [
{},
{
...R.times(data.length, (i) => ({
show: true,
stroke: theme.stroke,
fill: theme.fill,
stroke: seriesColor(i, theme),
fill: data.length === 1 ? theme.fill : undefined,
points: { show: false },
paths: match(interpolation)
.with('linear', () => uPlot.paths.linear?.())
.with('stepAfter', () => uPlot.paths.stepped?.({ align: 1 }))
.exhaustive(),
},
})),
],
axes: [
{
Expand Down Expand Up @@ -345,20 +387,25 @@ export function TimeSeriesChart({
},
],
padding: [null, null, null, CHART_LEFT_PAD],
focus: { alpha: 0.5 },
cursor: {
// setting this property causes non-focused series to dim on hover.
// 1e9 just means "any proximity will do"
focus: { prox: 1e9 },
x: false,
y: false,
// TODO: i like the drag and we should put it back in
drag: { x: false },
points: {
size: 6,
// TODO: with multiline, pinning the focused point color doesn't make much sense anymore
fill: theme.hoverPoint,
},
},
legend: { show: false },
plugins: [tooltipPlugin],
}) satisfies Omit<uPlot.Options, 'width' | 'height'>,
[formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx]
[data.length, formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx]
)

// Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets
Expand Down Expand Up @@ -388,23 +435,30 @@ export function TimeSeriesChart({
</SkeletonMetric>
)
}
if (!data || data.length === 0) {
if (!data || data.length === 0 || !timestamps || timestamps.length === 0) {
return (
<SkeletonMetric>
<MetricsEmpty />
</SkeletonMetric>
)
}

const aligned: uPlot.AlignedData = [
data.map(({ timestamp }) => timestamp / 1000),
data.map(({ value }) => value),
]

const hovered = tooltip ? data[tooltip.hoveredDataIndex] : undefined
const aligned: uPlot.AlignedData = [timestamps.map((t) => t / 1000), ...data]

const hovered: ChartDatum | undefined =
tooltip &&
// in case the data changed out from under us, let's at least check that we can find something
// to render
tooltip.hoveredSeriesIndex < data.length &&
tooltip.hoveredDataIndex < timestamps.length
? {
timestamp: timestamps[tooltip.hoveredDataIndex],
value: data[tooltip.hoveredSeriesIndex][tooltip.hoveredDataIndex],
}
: undefined
return (
<figure aria-label={title} className="m-0 pt-8 pr-5 pb-5 pl-0">
<div ref={sizeRef} className="relative">
<div ref={sizeRef} className="relative h-[300px]">
<UplotReact options={options} data={aligned} onCreate={(u) => (uRef.current = u)} />
{tooltip && hovered && hovered.value !== null && (
<div
Expand All @@ -418,12 +472,24 @@ export function TimeSeriesChart({
<ChartTooltip
timestamp={hovered.timestamp}
value={hovered.value}
seriesName={title}
seriesName={
seriesLabels
? seriesLabel(title, tooltip.hoveredSeriesIndex, seriesLabels)
: title
}
unit={unit}
/>
</div>
)}
</div>
{seriesLabels && (
<ChartLegend
title={title}
count={data.length}
seriesLabels={seriesLabels}
theme={theme}
/>
)}
</figure>
)
}
Expand Down Expand Up @@ -506,3 +572,35 @@ export function ChartHeader({ title, label, description, children }: ChartHeader
</div>
)
}

// 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 (
<div className="mt-2 flex max-h-24 flex-wrap gap-x-4 gap-y-1.5 overflow-y-auto pl-5">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="text-mono-xs text-secondary flex items-center gap-2">
<span
className="h-0.5 w-3 shrink-0 rounded-full"
style={{ backgroundColor: seriesColor(i, theme) }}
/>
{seriesLabel(title, i, seriesLabels)}
</div>
))}
</div>
)
}
21 changes: 21 additions & 0 deletions app/components/form/fields/OxqlField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import type { FieldPath, FieldValues } from 'react-hook-form'

import type { TextAreaProps } from '~/ui/lib/TextInput'

import { TextField, type TextFieldProps } from './TextField'

export function OxqlField<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
>(
props: Omit<TextFieldProps<TFieldValues, TName>, 'validate'> & Omit<TextAreaProps, 'as'>
) {
return <TextField as="textarea" fieldClassName="font-mono!" {...props} />
}
12 changes: 10 additions & 2 deletions app/components/oxql-metrics/OxqlMetric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import * as Dropdown from '~/ui/lib/DropdownMenu'
import { classed } from '~/util/classed'
import { docLinks, links } from '~/util/links'

import { ChartContainer, ChartHeader, TimeSeriesChart } from '../TimeSeriesChart'
import {
ChartContainer,
ChartHeader,
TimeSeriesChart,
toChartSeries,
} from '../TimeSeriesChart'
import { HighlightedOxqlQuery, toOxqlStr } from './HighlightedOxqlQuery'
import {
composeOxqlData,
Expand Down Expand Up @@ -86,6 +91,8 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric

const [modalOpen, setModalOpen] = useState(false)

const { values, timestamps } = toChartSeries(data)

return (
<ChartContainer>
<ChartHeader title={title} label={label} description={description}>
Expand All @@ -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
Expand Down
Loading
Loading