Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dc7d223
we are literally displaying metrics right now
david-crespo Jul 27, 2022
103064c
1000 data points instead of 6
david-crespo Jul 27, 2022
96bc7c9
correct metrics list req body, delete unused metrics page
david-crespo Aug 18, 2022
124ca60
helper to extract value from datum
david-crespo Aug 19, 2022
48ab308
display all 6 disk metrics. whatever
david-crespo Aug 24, 2022
d3ffe4c
upgrade recharts, make mock server behave itself
david-crespo Aug 24, 2022
846501e
improve x-axis
david-crespo Aug 26, 2022
1eeaacc
Merge main into babbys-first-metric
david-crespo Aug 26, 2022
47769d6
shitty tooltip, data should be integers
david-crespo Aug 26, 2022
70e3542
tooltip still bad, make data look cumulative instead of not
david-crespo Aug 26, 2022
c810af8
bunch of todo comments
david-crespo Aug 26, 2022
f7a13dc
use css vars for colors, mono font on ticks, other tweaks
david-crespo Aug 27, 2022
a87cb8f
delete helper I'm not using
david-crespo Aug 27, 2022
08a93cb
basic date pickers
david-crespo Aug 27, 2022
fa72ead
hook up date range picker
david-crespo Aug 29, 2022
ed39e80
TODO comments
david-crespo Aug 29, 2022
31109e4
very basic functioning date range presets
david-crespo Aug 30, 2022
ad29510
make cursor dotted, active dot smaller
david-crespo Aug 30, 2022
1e28255
improve date picker, set record for number of TODOs per line of code
david-crespo Aug 30, 2022
e5d7a6f
Merge main into babbys-first-metric
david-crespo Aug 30, 2022
90f52f8
clean up comments a little
david-crespo Aug 30, 2022
fc15339
do occams razor to the divs
david-crespo Aug 30, 2022
699caa3
don't show am/pm and 24 hour time at the same time
david-crespo Aug 30, 2022
98ac2b0
add todo
david-crespo Aug 30, 2022
b6d41bf
extract date range picker into hook. this was revealed to me in a dream
david-crespo Aug 31, 2022
a1b3fba
extract TimeSeriesChart
david-crespo Aug 31, 2022
c8217be
keepPreviousData to avoid blank flash while loading
david-crespo Aug 31, 2022
205ac29
Apply color css var suggestions
david-crespo Aug 31, 2022
6fb633b
Merge branch 'main' into babbys-first-metric
david-crespo Aug 31, 2022
f718c4e
turn babel plugin back on
david-crespo Aug 31, 2022
23f9c73
don't show activations
david-crespo Aug 31, 2022
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
117 changes: 117 additions & 0 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { format } from 'date-fns'
import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts'
import type { TooltipProps } from 'recharts/types/component/Tooltip'

// 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.9) : data.length - 1
// if there are 4 ticks, their positions are 0/3, 1/3, 2/3, 3/3 (as fractions of maxIdx)
const idxs = new Array(n).fill(0).map((_, i) => Math.floor((maxIdx * i) / (n - 1)))
return idxs.map((i) => data[i].timestamp)
}

const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm')
const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss zz')

// TODO: change these to theme colors so they work in light mode
const LIGHT_GRAY = 'var(--base-grey-600)'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closest I can find that makes sense here is chart-fill-inactive which maps to base-black-600. Not sure if that's right or not though.

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
const textMonoMd = {
fontSize: '0.75rem',
fontFamily: '"GT America Mono", monospace',
}

function renderTooltip(props: TooltipProps<number, 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 || !value) return null
return (
<div className="bg-raise text-secondary text-sans-sm border border-secondary">
<div className="py-2 px-3 border-b border-secondary">{longDateTime(timestamp)}</div>
<div className="py-2 px-3">
<div className="text-default">{name}</div>
<div>{value}</div>
{/* TODO: unit on value if relevant */}
</div>
</div>
)
}

type Datum = {
timestamp: number
value: number
}

type Props = {
className?: string
data: Datum[]
title: string
width: number
height: number
}

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

export function TimeSeriesAreaChart({ className, data, title, width, height }: 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"
// we're doing the x axis as timestamp ms instead of Date primarily to make type=number work
// 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>
)
}
18 changes: 11 additions & 7 deletions app/components/form/fields/ListboxField.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import cn from 'classnames'
import { useField } from 'formik'

import type { ListboxProps } from '@oxide/ui'
import { FieldLabel, Listbox, TextInputHint } from '@oxide/ui'

export type ListboxFieldProps = {
name: string
id: string
className?: string
label: string
items: { value: string; label: string }[]
disabled?: boolean
required?: boolean
helpText?: string
description?: string
}
} & Pick<ListboxProps, 'disabled' | 'items' | 'onChange'>

export function ListboxField({
id,
Expand All @@ -23,14 +23,15 @@ export function ListboxField({
required,
description,
helpText,
onChange,
className,
}: ListboxFieldProps) {
type ItemValue = typeof items[number]['value'] | undefined
const [, { value }, { setValue }] = useField<ItemValue>({
const [, { value }, { setValue }] = useField<string | undefined>({
name,
validate: (v) => (required && !v ? `${name} is required` : undefined),
})
return (
<div className="max-w-lg">
<div className={cn('max-w-lg', className)}>
<div className="mb-2">
<FieldLabel id={`${id}-label`} tip={description} optional={!required}>
{label}
Expand All @@ -40,7 +41,10 @@ export function ListboxField({
<Listbox
defaultValue={value}
items={items}
onChange={(i) => setValue(i?.value)}
onChange={(i) => {
setValue(i?.value)
onChange?.(i)
}}
disabled={disabled}
aria-labelledby={cn(`${id}-label`, {
[`${id}-help-text`]: !!description,
Expand Down
1 change: 1 addition & 0 deletions app/components/form/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './NetworkInterfaceField'
export * from './RadioField'
export * from './TagsField'
export * from './TextField'
export * from './useDateTimeRangePicker'
160 changes: 160 additions & 0 deletions app/components/form/fields/useDateTimeRangePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import * as Yup from 'yup'
import { format, subDays, subHours } from 'date-fns'
import { Form, Formik } from 'formik'
import { useMemo, useState } from 'react'

import { Button } from '@oxide/ui'

import { ListboxField } from './ListboxField'
import { TextField } from './TextField'

const dateForInput = (d: Date) => format(d, "yyyy-MM-dd'T'HH:mm")

const rangePresets = [
{ label: 'Last hour', value: 'lastHour' as const },
{ label: 'Last 3 hours', value: 'last3Hours' as const },
{ label: 'Last day', value: 'lastDay' as const },
{ label: 'Last week', value: 'lastWeek' as const },
{ label: 'Last 30 days', value: 'last30Days' as const },
{ label: 'Custom...', value: 'custom' as const },
]

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

// Record ensures we have an entry for every preset
const computeStart: Record<RangeKey, (now: Date) => Date> = {
lastHour: (now) => subHours(now, 1),
last3Hours: (now) => subHours(now, 3),
lastDay: (now) => subDays(now, 1),
lastWeek: (now) => subDays(now, 7),
last30Days: (now) => subDays(now, 30),
}

const rangeKeys = rangePresets.map((item) => item.value)

/** Validate that they're Dates and end is after start */
const dateRangeSchema = Yup.object({
preset: Yup.string().oneOf(rangeKeys),
startTime: Yup.date(),
endTime: Yup.date().min(Yup.ref('startTime'), 'End time must be later than start time'),
})

// Limitations:
// - list of presets is hard-coded
// - no onChange, no way to control any inputs beyond initial preset
// - initial preset can't be "custom"

/**
* Exposes `startTime` and `endTime` plus the whole set of picker UI controls as
* a JSX element to render.
*/
export function useDateTimeRangePicker(initialPreset: RangeKey) {
// 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)

// 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
// free. Once we use a React date picker library, we can make the inputs
// controlled and manage everything through regular state. I think that will
// be a little cleaner.
const dateTimeRangePicker = (
<Formik
initialValues={{
// values are strings, unfortunately
startTime: dateForInput(startTime),
endTime: dateForInput(endTime),
preset: 'lastDay', // satisfies RangeKey (TS 4.9),
}}
onSubmit={({ startTime, endTime }) => {
setStartTime(new Date(startTime))
setEndTime(new Date(endTime))
}}
validationSchema={dateRangeSchema}
>
{({ values, setFieldValue, submitForm }) => {
// whether the time fields have been changed from what is displayed
const customInputsDirty =
values.startTime !== dateForInput(startTime) ||
values.endTime !== dateForInput(endTime)

// on presets, inputs visible (showing current range) but disabled
const enableInputs = values.preset === 'custom'

function setRangeValues(startTime: Date, endTime: Date) {
setFieldValue('startTime', dateForInput(startTime))
setFieldValue('endTime', dateForInput(endTime))
}

return (
<Form className="flex mt-8 mb-4 gap-4 h-24">
<ListboxField
className="mr-4" // in addition to gap-4
id="preset"
name="preset"
label="Choose a time range"
items={rangePresets}
// 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
}
}}
required
/>

{/* TODO: real React date picker lib instead of native for consistent styling across browsers */}
{/* TODO: the field labels look pretty stupid in this context, fix that. probably leave them
there for a11y purposes but hide them for sighted users */}
<TextField
id="startTime"
type="datetime-local"
label="Start time"
disabled={!enableInputs}
required
/>
<TextField
id="endTime"
type="datetime-local"
label="End time"
required
disabled={!enableInputs}
/>
{/* mt-6 is a hack to fake alignment with the inputs. this will change so it doesn't matter */}
{/* TODO: fix goofy ass button text. use icons? tooltips to explain? lord */}
{enableInputs && (
<Button
className="mt-6"
disabled={!customInputsDirty}
// reset inputs back to whatever they were
onClick={() => setRangeValues(startTime, endTime)}
>
Reset
</Button>
)}
{enableInputs && (
<Button className="mt-6" type="submit" disabled={!customInputsDirty}>
Load
</Button>
)}
</Form>
)
}}
</Formik>
)

return { startTime, endTime, dateTimeRangePicker }
}
1 change: 0 additions & 1 deletion app/pages/project/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@ export * from './access'
export * from './disks'
export * from './images'
export * from './instances'
export * from './metrics'
export * from './networking'
export * from './snapshots'
Loading