diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx new file mode 100644 index 0000000000..af65785819 --- /dev/null +++ b/app/components/TimeSeriesChart.tsx @@ -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) { + 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 ( +
+
{longDateTime(timestamp)}
+
+
{name}
+
{value}
+ {/* TODO: unit on value if relevant */} +
+
+ ) +} + +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 ( + + + + + + {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} + + + ) +} diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx index c2651c4fe4..15ce7c1339 100644 --- a/app/components/form/fields/ListboxField.tsx +++ b/app/components/form/fields/ListboxField.tsx @@ -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 export function ListboxField({ id, @@ -23,14 +23,15 @@ export function ListboxField({ required, description, helpText, + onChange, + className, }: ListboxFieldProps) { - type ItemValue = typeof items[number]['value'] | undefined - const [, { value }, { setValue }] = useField({ + const [, { value }, { setValue }] = useField({ name, validate: (v) => (required && !v ? `${name} is required` : undefined), }) return ( -
+
{label} @@ -40,7 +41,10 @@ export function ListboxField({ setValue(i?.value)} + onChange={(i) => { + setValue(i?.value) + onChange?.(i) + }} disabled={disabled} aria-labelledby={cn(`${id}-label`, { [`${id}-help-text`]: !!description, diff --git a/app/components/form/fields/index.ts b/app/components/form/fields/index.ts index 4f9f87ef5b..bad242422b 100644 --- a/app/components/form/fields/index.ts +++ b/app/components/form/fields/index.ts @@ -8,3 +8,4 @@ export * from './NetworkInterfaceField' export * from './RadioField' export * from './TagsField' export * from './TextField' +export * from './useDateTimeRangePicker' diff --git a/app/components/form/fields/useDateTimeRangePicker.tsx b/app/components/form/fields/useDateTimeRangePicker.tsx new file mode 100644 index 0000000000..3dc895831d --- /dev/null +++ b/app/components/form/fields/useDateTimeRangePicker.tsx @@ -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 + +// Record ensures we have an entry for every preset +const computeStart: Record 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 = ( + { + 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 ( +
+ { + 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 */} + + + {/* 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 && ( + + )} + {enableInputs && ( + + )} + + ) + }} +
+ ) + + return { startTime, endTime, dateTimeRangePicker } +} diff --git a/app/pages/project/index.tsx b/app/pages/project/index.tsx index bd1b19075f..c194741b6e 100644 --- a/app/pages/project/index.tsx +++ b/app/pages/project/index.tsx @@ -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' diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 3aab88fd0f..56c0d3e105 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,46 +1,103 @@ -import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts' - -const data = [ - { name: '9:50', amt: 200, limit: 600 }, - { name: '10:00', amt: 300, limit: 600 }, - { name: '10:10', amt: 500, limit: 600 }, - { name: '10:20', amt: 450, limit: 600 }, - { name: '10:30', amt: 650, limit: 800 }, - { name: '10:40', amt: 550, limit: 800 }, - { name: '10:50', amt: 600, limit: 800 }, -] +import type { Cumulativeint64, DiskMetricName } from '@oxide/api' +import { useApiQuery } from '@oxide/api' +import { Spinner } from '@oxide/ui' + +import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart' +import { useDateTimeRangePicker } from 'app/components/form' +import { useRequiredParams } from 'app/hooks' + +type DiskMetricParams = { + title: string + startTime: Date + endTime: Date + metricName: DiskMetricName + diskParams: { orgName: string; projectName: string; diskName: string } + // TODO: specify bytes or count +} + +function DiskMetric({ + title, + startTime, + endTime, + metricName, + diskParams, +}: DiskMetricParams) { + // 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( + 'diskMetricsList', + { + ...diskParams, + metricName, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + limit: 1000, + }, + // avoid graphs flashing blank while loading when you change the time + { keepPreviousData: true } + ) + + const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ + timestamp: new Date(timestamp).getTime(), + // all of these metrics are cumulative ints + value: (datum.datum as Cumulativeint64).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 ( +
+

+ {title} {isLoading && } +

+ +
+ ) +} export function MetricsTab() { + const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') + const { orgName, projectName } = instanceParams + + const { data: disks } = useApiQuery('instanceDiskList', instanceParams) + const diskName = disks?.items[0].name + + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay') + + if (!diskName) return loading // TODO: loading state + + const diskParams = { orgName, projectName, diskName } + const commonProps = { startTime, endTime, diskParams } + return ( <> -
⚠️ Hard-coded fake data️ ⚠️
- - {/* TODO: pull these colors from TW config */} - - - - - - +

+ {/* TODO: need a nicer way of saying what the boot disk is */} + Boot disk ( {diskName} ) +

+ + {dateTimeRangePicker} + + {/* TODO: separate "Reads" from "(count)" so we can + a) style them differently in the title, and + b) show "Reads" but not "(count)" in the Tooltip? + */} +
+ {/* see the following link for the source of truth on what these mean + https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} + + + + + +
) } diff --git a/app/pages/project/metrics/MetricsPage.tsx b/app/pages/project/metrics/MetricsPage.tsx deleted file mode 100644 index e6388c0324..0000000000 --- a/app/pages/project/metrics/MetricsPage.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const MetricsPage = () => { - return
Not implemented
-} diff --git a/app/pages/project/metrics/index.ts b/app/pages/project/metrics/index.ts deleted file mode 100644 index 0e3f8ddb58..0000000000 --- a/app/pages/project/metrics/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './MetricsPage' diff --git a/libs/api-mocks/metrics.ts b/libs/api-mocks/metrics.ts new file mode 100644 index 0000000000..cbe56cc988 --- /dev/null +++ b/libs/api-mocks/metrics.ts @@ -0,0 +1,24 @@ +import { addSeconds, differenceInSeconds } from 'date-fns' + +import type { Measurement } from '@oxide/api' + +import type { Json } from './json-type' + +/** evenly distribute the `values` across the time interval */ +export const genCumulativeI64Data = ( + values: number[], + startTime: Date, + endTime: Date +): Json => { + const intervalSeconds = differenceInSeconds(endTime, startTime) / values.length + return values.map((value, i) => ({ + datum: { + datum: { + value, + start_time: startTime.toISOString(), + }, + type: 'cumulative_i64', + }, + timestamp: addSeconds(startTime, i * intervalSeconds).toISOString(), + })) +} diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 8c975ccd89..ff7dfa2ff0 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -27,6 +27,7 @@ export type VpcParams = Merge export type InstanceParams = Merge export type NetworkInterfaceParams = Merge export type DiskParams = Merge +export type DiskMetricParams = Merge export type VpcSubnetParams = Merge export type VpcRouterParams = Merge export type SshKeyParams = { sshKeyName: string } diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 1af5012618..c9305b8f94 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1,13 +1,16 @@ +import { subHours } from 'date-fns' import { compose, context, rest } from 'msw' import type { ApiTypes as Api } from '@oxide/api' import { pick, sortBy } from '@oxide/util' import type { Json } from '../json-type' +import { genCumulativeI64Data } from '../metrics' import { serial } from '../serial' import { sessionMe } from '../session' import { defaultSilo } from '../silo' import type { + DiskMetricParams, DiskParams, GlobalImageParams, IdParams, @@ -35,7 +38,7 @@ import { lookupVpcRouter, lookupVpcSubnet, } from './db' -import { json, paginated } from './util' +import { getDateParam, json, paginated } from './util' // Note the *JSON types. Those represent actual API request and response bodies, // the snake-cased objects coming straight from the API before the generated @@ -669,6 +672,42 @@ export const handlers = [ } ), + /** + * Approach to faking: always return 1000 data points spread evenly between start + * and end. + */ + rest.get | GetErr>( + '/organizations/:orgName/projects/:projectName/disks/:diskName/metrics/:metricName', + (req, res) => { + const [, err] = lookupDisk(req.params) + if (err) return res(err) + + const queryStartTime = getDateParam(req.url.searchParams, 'start_time') + const queryEndTime = getDateParam(req.url.searchParams, 'end_time') + + // if no start time or end time, give the last 24 hours. in this case the + // API will give all data available for the metric (paginated of course), + // so essentially we're pretending the last 24 hours just happens to be + // all the data. if we have an end time but no start time, same deal, pretend + // 24 hours before the given end time is where it starts + const now = new Date() + const endTime = queryEndTime || now + const startTime = queryStartTime || subHours(endTime, 24) + + if (endTime <= startTime) return res(json({ items: [] })) + + return res( + json({ + items: genCumulativeI64Data( + new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * 3000)), + startTime, + endTime + ), + }) + ) + } + ), + rest.get | GetErr>( '/organizations/:orgName/projects/:projectName/images', (req, res) => { diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index fddbfb01a1..712e8c6f26 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -1,3 +1,4 @@ +import { isValid, parseISO } from 'date-fns' import type { ResponseTransformer } from 'msw' import { compose, context } from 'msw' @@ -60,3 +61,13 @@ export const clone = (obj: T): T => typeof structuredClone !== 'undefined' ? structuredClone(obj) : JSON.parse(JSON.stringify(obj)) + +export function getDateParam(params: URLSearchParams, key: string): Date | null { + const value = params.get(key) + if (!value) return null + + const date = parseISO(value) + if (!isValid(date)) return null + + return date +} diff --git a/package.json b/package.json index 4efb1b66e5..91280d82c7 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "react-is": "^17.0.2", "react-popper": "^2.2.5", "react-router-dom": "^6.4.0-pre.13", - "recharts": "^2.1.6", + "recharts": "^2.1.12", "tiny-invariant": "^1.2.0", "ts-dedent": "^2.2.0", "tslib": "^2.4.0", diff --git a/yarn.lock b/yarn.lock index 3fc10af63f..c0fa8e8e31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3988,42 +3988,6 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== -"@types/d3-color@^2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-2.0.3.tgz#8bc4589073c80e33d126345542f588056511fe82" - integrity sha512-+0EtEjBfKEDtH9Rk3u3kLOUXM5F+iZK+WvASPb0MhIZl8J8NUvGeZRwKCXl+P3HkYx5TdU4YtcibpqHkSR9n7w== - -"@types/d3-interpolate@^2.0.0": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-2.0.2.tgz#78eddf7278b19e48e8652603045528d46897aba0" - integrity sha512-lElyqlUfIPyWG/cD475vl6msPL4aMU7eJvx1//Q177L8mdXoVPFl1djIESF2FKnc0NyaHvQlJpWwKJYwAhUoCw== - dependencies: - "@types/d3-color" "^2" - -"@types/d3-path@^2": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-2.0.1.tgz#ca03dfa8b94d8add97ad0cd97e96e2006b4763cb" - integrity sha512-6K8LaFlztlhZO7mwsZg7ClRsdLg3FJRzIIi6SZXDWmmSJc2x8dd2VkESbLXdk3p8cuvz71f36S0y8Zv2AxqvQw== - -"@types/d3-scale@^3.0.0": - version "3.3.2" - resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-3.3.2.tgz#18c94e90f4f1c6b1ee14a70f14bfca2bd1c61d06" - integrity sha512-gGqr7x1ost9px3FvIfUMi5XA/F/yAf4UkUDtdQhpH92XCT0Oa7zkkRzY61gPVJq+DxpHn/btouw5ohWkbBsCzQ== - dependencies: - "@types/d3-time" "^2" - -"@types/d3-shape@^2.0.0": - version "2.1.3" - resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-2.1.3.tgz#35d397b9e687abaa0de82343b250b9897b8cacf3" - integrity sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ== - dependencies: - "@types/d3-path" "^2" - -"@types/d3-time@^2": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-2.1.1.tgz#743fdc821c81f86537cbfece07093ac39b4bc342" - integrity sha512-9MVYlmIgmRR31C5b4FVSWtuMmBHh2mOWQYfl7XAYOa8dsnb7iEmUmRSWSFgXFtkjxO65d7hTUHQC+RhR/9IWFg== - "@types/debug@^4.1.7": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -4320,11 +4284,6 @@ "@types/scheduler" "*" csstype "^3.0.2" -"@types/resize-observer-browser@^0.1.6": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.6.tgz#d8e6c2f830e2650dc06fe74464472ff64b54a302" - integrity sha512-61IfTac0s9jvNtBCpyo86QeaN8qqpMGHdK0uGKCCIy2dt5/Yk84VduHIdWAcmkC5QvdkPL0p5eWYgUZtHKKUVg== - "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -6975,66 +6934,66 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -d3-array@2, d3-array@^2.3.0: - version "2.12.1" - resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" - integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ== +"d3-array@2 - 3", "d3-array@2.10.0 - 3": + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" + integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== dependencies: - internmap "^1.0.0" + internmap "1 - 2" -"d3-color@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" - integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== -"d3-format@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" - integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== +"d3-format@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" + integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== -"d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" - integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ== +"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== dependencies: - d3-color "1 - 2" + d3-color "1 - 3" -"d3-path@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8" - integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA== +"d3-path@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e" + integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w== -d3-scale@^3.0.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-3.3.0.tgz#28c600b29f47e5b9cd2df9749c206727966203f3" - integrity sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ== +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== dependencies: - d3-array "^2.3.0" - d3-format "1 - 2" - d3-interpolate "1.2.0 - 2" - d3-time "^2.1.1" - d3-time-format "2 - 3" + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" -d3-shape@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-2.1.0.tgz#3b6a82ccafbc45de55b57fcf956c584ded3b666f" - integrity sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA== +d3-shape@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556" + integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ== dependencies: - d3-path "1 - 2" + d3-path "1 - 3" -"d3-time-format@2 - 3": - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6" - integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag== +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== dependencies: - d3-time "1 - 2" + d3-time "1 - 3" -"d3-time@1 - 2", d3-time@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682" - integrity sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ== +"d3-time@1 - 3", "d3-time@2.1.1 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975" + integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ== dependencies: - d3-array "2" + d3-array "2 - 3" damerau-levenshtein@^1.0.7: version "1.0.8" @@ -9600,10 +9559,10 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" -internmap@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" - integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== interpret@^1.2.0: version "1.4.0" @@ -10700,11 +10659,6 @@ lodash.merge@^4.6.2: resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash.throttle@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" - integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= - lodash.uniq@4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz" @@ -12172,11 +12126,6 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= - picocolors@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz" @@ -12744,13 +12693,6 @@ quick-lru@^5.1.1: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== -raf@^3.4.0: - version "3.4.1" - resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" - integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== - dependencies: - performance-now "^2.1.0" - ramda@^0.21.0: version "0.21.0" resolved "https://registry.npmjs.org/ramda/-/ramda-0.21.0.tgz" @@ -12930,15 +12872,12 @@ react-remove-scroll@^2.4.3: use-callback-ref "^1.2.3" use-sidecar "^1.0.1" -react-resize-detector@^6.6.3: - version "6.7.6" - resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-6.7.6.tgz#4416994e5ead7eba76606e3a248a1dfca49b67a3" - integrity sha512-/6RZlul1yePSoYJxWxmmgjO320moeLC/khrwpEVIL+D2EjLKhqOwzFv+H8laMbImVj7Zu4FlMa0oA7au3/ChjQ== +react-resize-detector@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-7.1.2.tgz#8ef975dd8c3d56f9a5160ac382ef7136dcd2d86c" + integrity sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw== dependencies: - "@types/resize-observer-browser" "^0.1.6" - lodash.debounce "^4.0.8" - lodash.throttle "^4.1.1" - resize-observer-polyfill "^1.5.1" + lodash "^4.17.21" react-router-dom@^6.4.0-pre.13: version "6.4.0-pre.13" @@ -12954,13 +12893,12 @@ react-router@6.4.0-pre.13: dependencies: "@remix-run/router" "0.2.0-pre.8" -react-smooth@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-2.0.0.tgz#561647b33e498b2e25f449b3c6689b2e9111bf91" - integrity sha512-wK4dBBR6P21otowgMT9toZk+GngMplGS1O5gk+2WSiHEXIrQgDvhR5IIlT74Vtu//qpTcipkgo21dD7a7AUNxw== +react-smooth@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-2.0.1.tgz#74c7309916d6ccca182c4b30c8992f179e6c5a05" + integrity sha512-Own9TA0GPPf3as4vSwFhDouVfXP15ie/wIHklhyKBH5AN6NFtdk0UpHBnonV11BtqDkAWlt40MOUc+5srmW7NA== dependencies: fast-equals "^2.0.0" - raf "^3.4.0" react-transition-group "2.9.0" react-style-singleton@^2.1.0: @@ -13099,23 +13037,20 @@ recharts-scale@^0.4.4: dependencies: decimal.js-light "^2.4.1" -recharts@^2.1.6: - version "2.1.6" - resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.1.6.tgz#04b96233a30be27ae08a20795a980853397046ec" - integrity sha512-KnRNnCum1hL27DYhUfcdcKUEQkYnda6G+KDN4n/nCiTKp7UzJSgHfFHQvCkBujPi/U98dGd430DA2/8RJpkPlg== +recharts@^2.1.12: + version "2.1.13" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.1.13.tgz#61801acf3e13896b07dc6a8b38cbdd648480d0b7" + integrity sha512-9VWu2nzExmfiMFDHKqRFhYlJVmjzQGVKH5rBetXR4EuyEXuu3Y6cVxQuNEdusHhbm4SoPPrVDCwlBdREL3sQPA== dependencies: - "@types/d3-interpolate" "^2.0.0" - "@types/d3-scale" "^3.0.0" - "@types/d3-shape" "^2.0.0" classnames "^2.2.5" - d3-interpolate "^2.0.0" - d3-scale "^3.0.0" - d3-shape "^2.0.0" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" eventemitter3 "^4.0.1" lodash "^4.17.19" react-is "^16.10.2" - react-resize-detector "^6.6.3" - react-smooth "^2.0.0" + react-resize-detector "^7.1.2" + react-smooth "^2.0.1" recharts-scale "^0.4.4" reduce-css-calc "^2.1.8" @@ -13401,11 +13336,6 @@ requireindex@^1.1.0: resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef" integrity sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww== -resize-observer-polyfill@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" - integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== - resolve-dir@^1.0.0, resolve-dir@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz"