Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion OMICRON_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
74f3ca89af11b0ce6d9f9bd4b5bdcbeb04d1ba3e
d2c5d9de9a4b3a074ea3fc04b494706f708e90f7
77 changes: 77 additions & 0 deletions app/components/SystemMetric.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { ResourceName } from '@oxide/api'
import { useApiQuery } from '@oxide/api'
import { Spinner } from '@oxide/ui'

import { TimeSeriesAreaChart } from './TimeSeriesChart'

type SystemMetricProps = {
title: string
startTime: Date
endTime: Date
resourceName: ResourceName
/** Resource to filter data by. Can be fleet, silo, org, project. */
filterId: string
valueTransform?: (n: number) => number
}

export function SystemMetric({
title,
filterId,
startTime,
endTime,
resourceName,
valueTransform = (x) => x,
}: SystemMetricProps) {
// TODO: we're only pulling the first page. Should we bump the cap to 10k?
// Fetch multiple pages if 10k is not enough? That's a bit much.
const { data: metrics, isLoading } = useApiQuery(
'systemMetricsList',
{ id: filterId, resourceName, startTime, endTime },
{
// TODO: this is actually kind of useless unless the time interval slides forward as time passes
refetchInterval: 5000,
// avoid graphs flashing blank while loading when you change the time
keepPreviousData: true,
}
)

const data = (metrics?.items || []).map(({ datum, timestamp }) => ({
timestamp: timestamp.getTime(),
// all of these metrics are cumulative ints
value: valueTransform(datum.datum as number),
}))

// add fake points for the beginning and end of the time range (lol)
if (data.length > 0) {
const firstPoint = data[0]
const lastPoint = data[data.length - 1]

if (startTime.getTime() < firstPoint.timestamp) {
data.unshift({ timestamp: startTime.getTime(), value: firstPoint.value })
}

if (endTime.getTime() > lastPoint.timestamp) {
data.push({ timestamp: endTime.getTime(), value: lastPoint.value })
}
}

// 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

return (
<div>
<h2 className="flex items-center text-mono-md text-secondary">
{title} {isLoading && <Spinner className="ml-2" />}
</h2>
{/* TODO: this is supposed to be full width */}
<TimeSeriesAreaChart
className="mt-4"
data={data}
title={title}
width={480}
height={240}
interpolation="stepAfter"
/>
</div>
)
}
108 changes: 65 additions & 43 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { format } from 'date-fns'
import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts'
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { TooltipProps } from 'recharts/types/component/Tooltip'

// Recharts's built-in ticks behavior is useless and probably broken
Expand All @@ -24,7 +32,6 @@ const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss
const LIGHT_GRAY = 'var(--base-grey-600)'
const GRID_GRAY = 'var(--base-grey-1000)'
const GREEN = 'var(--chart-stroke-line)'
const DARK_GREEN = 'var(--chart-fill-item-quaternary)'

// TODO: figure out how to do this with TW classes instead. As far as I can tell
// ticks only take direct styling
Expand Down Expand Up @@ -67,52 +74,67 @@ type Props = {
title: string
width: number
height: number
interpolation?: 'linear' | 'stepAfter'
customXTicks?: boolean
}

// Limitations
// - Only one dataset — can't do overlapping area chart yet

export function TimeSeriesAreaChart({ className, data, title, width, height }: Props) {
export function TimeSeriesAreaChart({
className,
data,
title,
width,
height,
interpolation = 'linear',
customXTicks,
}: Props) {
return (
<ComposedChart
width={width}
height={height}
data={data}
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
className={className}
>
<CartesianGrid stroke={GRID_GRAY} vertical={false} />
<Area
dataKey="value"
name={title}
stroke={GREEN}
fillOpacity={1}
fill={DARK_GREEN}
isAnimationActive={false}
activeDot={{ fill: LIGHT_GRAY, r: 2, strokeWidth: 0 }}
/>
<XAxis
// TODO: show full given date range in the chart even if the data doesn't fill the range
domain={['auto', 'auto']}
dataKey="timestamp"
interval="preserveStart"
scale="time"
// TODO: use Date directly as x-axis values
type="number"
name="Time"
ticks={getTicks(data, 3)}
// TODO: decide timestamp format based on time range of chart
tickFormatter={shortDateTime}
tick={textMonoMd}
tickMargin={4}
/>
<YAxis orientation="right" tick={textMonoMd} tickSize={0} tickMargin={8} />
{/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */}
<Tooltip
isAnimationActive={false}
content={renderTooltip}
cursor={{ stroke: LIGHT_GRAY, strokeDasharray: '3,3' }}
/>
</ComposedChart>
<ResponsiveContainer width="100%" height={300}>
<AreaChart
width={width}
height={height}
data={data}
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
className={className}
>
<CartesianGrid stroke={GRID_GRAY} vertical={false} />
<Area
dataKey="value"
name={title}
type={interpolation}
stroke={GREEN}
strokeWidth={1}
// cheating to make this a line chart
fillOpacity={0}
isAnimationActive={false}
activeDot={{ fill: LIGHT_GRAY, r: 2, strokeWidth: 0 }}
/>
<XAxis
// TODO: show full given date range in the chart even if the data doesn't fill the range
domain={['auto', 'auto']}
dataKey="timestamp"
interval="preserveStart"
scale="time"
// TODO: use Date directly as x-axis values
type="number"
name="Time"
ticks={customXTicks ? getTicks(data, 5) : undefined}
// TODO: decide timestamp format based on time range of chart
tickFormatter={shortDateTime}
tick={textMonoMd}
tickMargin={4}
padding={{ right: 20 }}
/>
<YAxis orientation="right" tick={textMonoMd} tickSize={0} tickMargin={8} />
{/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */}
<Tooltip
isAnimationActive={false}
content={renderTooltip}
cursor={{ stroke: LIGHT_GRAY, strokeDasharray: '3,3' }}
/>
</AreaChart>
</ResponsiveContainer>
)
}
20 changes: 14 additions & 6 deletions app/components/form/fields/useDateTimeRangePicker.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ describe('useDateTimeRangePicker', () => {
['lastWeek', subDays(date, 7)],
['last30Days', subDays(date, 30)],
])('sets initial start and end', (preset, start) => {
const { result } = renderHook(() => useDateTimeRangePicker(preset as RangeKey))
const { result } = renderHook(() =>
useDateTimeRangePicker({ initialPreset: preset as RangeKey })
)
expect(result.current.startTime).toEqual(start)
expect(result.current.endTime).toEqual(date)
})
Expand All @@ -38,7 +40,7 @@ describe('useDateTimeRangePicker', () => {
['Last 30 days', subDays(date, 30)],
])('choosing a preset sets the times', async (option, start) => {
const { result, waitForNextUpdate } = renderHook(() =>
useDateTimeRangePicker('lastDay')
useDateTimeRangePicker({ initialPreset: 'lastDay' })
)
render(result.current.dateTimeRangePicker)

Expand All @@ -53,7 +55,9 @@ describe('useDateTimeRangePicker', () => {

describe('custom mode', () => {
it('enables datetime inputs', () => {
const { result } = renderHook(() => useDateTimeRangePicker('last3Hours'))
const { result } = renderHook(() =>
useDateTimeRangePicker({ initialPreset: 'last3Hours' })
)

render(result.current.dateTimeRangePicker)

Expand All @@ -69,7 +73,7 @@ describe('useDateTimeRangePicker', () => {

it('clicking load after changing date changes range', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useDateTimeRangePicker('last3Hours')
useDateTimeRangePicker({ initialPreset: 'last3Hours' })
)
expect(result.current.startTime).toEqual(subHours(date, 3))
expect(result.current.endTime).toEqual(date)
Expand Down Expand Up @@ -98,7 +102,9 @@ describe('useDateTimeRangePicker', () => {
})

it('clicking reset after changing inputs resets inputs', async () => {
const { result } = renderHook(() => useDateTimeRangePicker('last3Hours'))
const { result } = renderHook(() =>
useDateTimeRangePicker({ initialPreset: 'last3Hours' })
)

render(result.current.dateTimeRangePicker)
clickByRole('button', 'Choose a time range')
Expand All @@ -125,7 +131,9 @@ describe('useDateTimeRangePicker', () => {
})

it('shows error for invalid range', async () => {
const { result } = renderHook(() => useDateTimeRangePicker('last3Hours'))
const { result } = renderHook(() =>
useDateTimeRangePicker({ initialPreset: 'last3Hours' })
)

render(result.current.dateTimeRangePicker)
clickByRole('button', 'Choose a time range')
Expand Down
58 changes: 44 additions & 14 deletions app/components/form/fields/useDateTimeRangePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as Yup from 'yup'
import { format, subDays, subHours } from 'date-fns'
import { Form, Formik } from 'formik'
import { useMemo, useState } from 'react'
import { useMemo, useRef, useState } from 'react'

import { useInterval } from '@oxide/ui'
import { Button } from '@oxide/ui'

import { ListboxField } from './ListboxField'
Expand All @@ -20,7 +21,8 @@ const rangePresets = [
]

// custom doesn't have an associated range
export type RangeKey = Exclude<typeof rangePresets[number]['value'], 'custom'>
type RangeKeyAll = typeof rangePresets[number]['value']
export type RangeKey = Exclude<RangeKeyAll, 'custom'>

// Record ensures we have an entry for every preset
const computeStart: Record<RangeKey, (now: Date) => Date> = {
Expand All @@ -45,17 +47,40 @@ const dateRangeSchema = Yup.object({
// - no onChange, no way to control any inputs beyond initial preset
// - initial preset can't be "custom"

type Args = {
initialPreset: RangeKey
/**
* if set and range is a relative preset, update the range to have `endTime`
* of now every X ms
*/
slideInterval?: number
}

/**
* Exposes `startTime` and `endTime` plus the whole set of picker UI controls as
* a JSX element to render.
*/
export function useDateTimeRangePicker(initialPreset: RangeKey) {
export function useDateTimeRangePicker({ initialPreset, slideInterval }: Args) {
// default endTime is now, i.e., mount time
const now = useMemo(() => new Date(), [])

const [startTime, setStartTime] = useState(computeStart[initialPreset](now))
const [endTime, setEndTime] = useState(now)

// only exists to make current preset value available to window slider
const presetRef = useRef<RangeKeyAll>(initialPreset)

useInterval(
() => {
if (presetRef.current !== 'custom') {
const now = new Date()
setStartTime(computeStart[presetRef.current](now))
setEndTime(now)
}
},
slideInterval && presetRef.current !== 'custom' ? slideInterval : null
)

// We're using Formik to manage the state of the inputs, but this is not
// strictly necessary. It's convenient while we're using `TextField` with
// `type=datetime-local` because we get validationSchema and error display for
Expand All @@ -68,7 +93,7 @@ export function useDateTimeRangePicker(initialPreset: RangeKey) {
// values are strings, unfortunately
startTime: dateForInput(startTime),
endTime: dateForInput(endTime),
preset: 'lastDay', // satisfies RangeKey (TS 4.9),
preset: initialPreset as RangeKeyAll, // indicates preset can include 'custom'
}}
onSubmit={({ startTime, endTime }) => {
setStartTime(new Date(startTime))
Expand Down Expand Up @@ -101,16 +126,21 @@ export function useDateTimeRangePicker(initialPreset: RangeKey) {
// when we select a preset, set the input values to the range
// for that preset and submit the form to update the charts
onChange={(item) => {
if (item && item.value !== 'custom') {
const now = new Date()
const newStartTime = computeStart[item.value as RangeKey](now)
setRangeValues(newStartTime, now)
// goofy, but I like the idea of going through the submit
// pathway instead of duplicating the setStates
submitForm()
// TODO: if input is invalid while on custom, e.g.,
// because end is before start, changing to a preset does
// not clear the error. changing a second time does
if (item) {
// only done to make the value available to the range window slider interval
presetRef.current = item.value as RangeKeyAll

if (item.value !== 'custom') {
const now = new Date()
const newStartTime = computeStart[item.value as RangeKey](now)
setRangeValues(newStartTime, now)
// goofy, but I like the idea of going through the submit
// pathway instead of duplicating the setStates
submitForm()
// TODO: if input is invalid while on custom, e.g.,
// because end is before start, changing to a preset does
// not clear the error. changing a second time does
}
}
}}
required
Expand Down
5 changes: 4 additions & 1 deletion app/layouts/SiloLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Divider, Organization16Icon } from '@oxide/ui'
import { Divider, Organization16Icon, Snapshots16Icon } from '@oxide/ui'

import { DocsLinkItem, NavLinkItem, Sidebar } from 'app/components/Sidebar'
import { TopBar } from 'app/components/TopBar'
Expand All @@ -20,6 +20,9 @@ export default function SiloLayout() {
<NavLinkItem to={pb.orgs()}>
<Organization16Icon /> Organizations
</NavLinkItem>
<NavLinkItem to={pb.utilization()}>
<Snapshots16Icon /> Utilization
</NavLinkItem>
</Sidebar.Nav>
</Sidebar>
<ContentPane />
Expand Down
Loading