-
Notifications
You must be signed in to change notification settings - Fork 15
Display a metric #1095
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Display a metric #1095
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 103064c
1000 data points instead of 6
david-crespo 96bc7c9
correct metrics list req body, delete unused metrics page
david-crespo 124ca60
helper to extract value from datum
david-crespo 48ab308
display all 6 disk metrics. whatever
david-crespo d3ffe4c
upgrade recharts, make mock server behave itself
david-crespo 846501e
improve x-axis
david-crespo 1eeaacc
Merge main into babbys-first-metric
david-crespo 47769d6
shitty tooltip, data should be integers
david-crespo 70e3542
tooltip still bad, make data look cumulative instead of not
david-crespo c810af8
bunch of todo comments
david-crespo f7a13dc
use css vars for colors, mono font on ticks, other tweaks
david-crespo a87cb8f
delete helper I'm not using
david-crespo 08a93cb
basic date pickers
david-crespo fa72ead
hook up date range picker
david-crespo ed39e80
TODO comments
david-crespo 31109e4
very basic functioning date range presets
david-crespo ad29510
make cursor dotted, active dot smaller
david-crespo 1e28255
improve date picker, set record for number of TODOs per line of code
david-crespo e5d7a6f
Merge main into babbys-first-metric
david-crespo 90f52f8
clean up comments a little
david-crespo fc15339
do occams razor to the divs
david-crespo 699caa3
don't show am/pm and 24 hour time at the same time
david-crespo 98ac2b0
add todo
david-crespo b6d41bf
extract date range picker into hook. this was revealed to me in a dream
david-crespo a1b3fba
extract TimeSeriesChart
david-crespo c8217be
keepPreviousData to avoid blank flash while loading
david-crespo 205ac29
Apply color css var suggestions
david-crespo 6fb633b
Merge branch 'main' into babbys-first-metric
david-crespo f718c4e
turn babel plugin back on
david-crespo 23f9c73
don't show activations
david-crespo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)' | ||
| 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> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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-inactivewhich maps tobase-black-600. Not sure if that's right or not though.