From dc7d2230818a1ee22f7d61be40264aebeaf4229c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 27 Jul 2022 14:43:44 -0500 Subject: [PATCH 01/28] we are literally displaying metrics right now --- .../instances/instance/tabs/MetricsTab.tsx | 84 ++++++++++--------- libs/api-mocks/metrics.ts | 18 ++++ libs/api-mocks/msw/db.ts | 1 + libs/api-mocks/msw/handlers.ts | 14 ++++ 4 files changed, 77 insertions(+), 40 deletions(-) create mode 100644 libs/api-mocks/metrics.ts diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 3aab88fd0f..3a48cec2d0 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,46 +1,50 @@ -import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts' +import { Area, CartesianGrid, ComposedChart, 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 { useApiQuery } from '@oxide/api' + +import { useRequiredParams } from 'app/hooks' 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 { data: metrics } = useApiQuery( + 'diskMetricsList', + { + orgName, + projectName, + diskName: diskName!, // force it because this only runs when diskName is there + metricName: 'read', + }, + { enabled: !!diskName } + ) + console.log(metrics) + + const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ + timestamp: new Date(timestamp).toLocaleString(), + value: datum.datum, + })) return ( - <> -
⚠️ Hard-coded fake data️ ⚠️
- - {/* TODO: pull these colors from TW config */} - - - - - - - + + {/* TODO: pull these colors from TW config */} + + + + + ) } diff --git a/libs/api-mocks/metrics.ts b/libs/api-mocks/metrics.ts new file mode 100644 index 0000000000..e9a4e49d2c --- /dev/null +++ b/libs/api-mocks/metrics.ts @@ -0,0 +1,18 @@ +import { addSeconds } from 'date-fns' + +import type { Measurement } from '@oxide/api' + +import type { Json } from './json-type' + +export const genI64Data = ( + values: number[], + start: Date, + intervalSeconds = 1 +): Json => + values.map((v, i) => ({ + datum: { + datum: v, + type: 'i64', + }, + timestamp: addSeconds(start, i * intervalSeconds).toISOString(), + })) diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 49786b8d5d..1ad0d07da0 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 9d97ad7438..5c5c022967 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -4,10 +4,12 @@ import type { ApiTypes as Api } from '@oxide/api' import { pick, sortBy } from '@oxide/util' import type { Json } from '../json-type' +import { genI64Data } from '../metrics' import { serial } from '../serial' import { sessionMe } from '../session' import { defaultSilo } from '../silo' import type { + DiskMetricParams, DiskParams, GlobalImageParams, IdParams, @@ -672,6 +674,18 @@ export const handlers = [ } ), + rest.get | GetErr>( + '/api/organizations/:orgName/projects/:projectName/disks/:diskName/metrics/:metricName', + (req, res) => { + const [, err] = lookupDisk(req.params) + if (err) return res(err) + + return res( + json({ items: genI64Data([5, 6, 6, 7, 7, 9, 12, 6, 8], new Date(2022, 3, 4)) }) + ) + } + ), + rest.get | GetErr>( '/api/organizations/:orgName/projects/:projectName/images', (req, res) => { From 103064c6f1294b5c964587ca94c4622bc0182f79 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 27 Jul 2022 15:24:27 -0500 Subject: [PATCH 02/28] 1000 data points instead of 6 --- libs/api-mocks/msw/handlers.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 5c5c022967..05614dbb97 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -681,7 +681,17 @@ export const handlers = [ if (err) return res(err) return res( - json({ items: genI64Data([5, 6, 6, 7, 7, 9, 12, 6, 8], new Date(2022, 3, 4)) }) + json({ + items: genI64Data( + new Array(1000) + .fill(0) + .map( + (x, i) => + Math.floor(i * (1000 - i) * (i % 100) + Math.random() * 10000000) / 1000 + ), + new Date(2022, 3, 4) + ), + }) ) } ), From 96bc7c96ce278e40b482710c36f1349d42e3522d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 18 Aug 2022 16:23:11 -0500 Subject: [PATCH 03/28] correct metrics list req body, delete unused metrics page --- app/pages/project/index.tsx | 1 - app/pages/project/instances/instance/tabs/MetricsTab.tsx | 3 +++ app/pages/project/metrics/MetricsPage.tsx | 3 --- app/pages/project/metrics/index.ts | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 app/pages/project/metrics/MetricsPage.tsx delete mode 100644 app/pages/project/metrics/index.ts 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 3a48cec2d0..48afa4c0fa 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,3 +1,4 @@ +import { subMinutes } from 'date-fns' import { Area, CartesianGrid, ComposedChart, XAxis, YAxis } from 'recharts' import { useApiQuery } from '@oxide/api' @@ -17,6 +18,8 @@ export function MetricsTab() { projectName, diskName: diskName!, // force it because this only runs when diskName is there metricName: 'read', + startTime: subMinutes(new Date(), 5).toISOString(), + endTime: new Date().toISOString(), }, { enabled: !!diskName } ) 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' From 124ca60538a664ac3ef53f935b6bdceca7def358 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 19 Aug 2022 12:56:12 -0500 Subject: [PATCH 04/28] helper to extract value from datum --- .../instances/instance/tabs/MetricsTab.tsx | 13 +++--- libs/api/util.ts | 42 ++++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 48afa4c0fa..a46db7dc76 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,7 +1,7 @@ -import { subMinutes } from 'date-fns' +import { addHours } from 'date-fns' import { Area, CartesianGrid, ComposedChart, XAxis, YAxis } from 'recharts' -import { useApiQuery } from '@oxide/api' +import { datumToValue, useApiQuery } from '@oxide/api' import { useRequiredParams } from 'app/hooks' @@ -11,6 +11,8 @@ export function MetricsTab() { const { data: disks } = useApiQuery('instanceDiskList', instanceParams) const diskName = disks?.items[0].name + const startTime = new Date(2022, 7, 18, 0) + const endTime = addHours(startTime, 24) const { data: metrics } = useApiQuery( 'diskMetricsList', { @@ -18,8 +20,9 @@ export function MetricsTab() { projectName, diskName: diskName!, // force it because this only runs when diskName is there metricName: 'read', - startTime: subMinutes(new Date(), 5).toISOString(), - endTime: new Date().toISOString(), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + limit: 1000, }, { enabled: !!diskName } ) @@ -27,7 +30,7 @@ export function MetricsTab() { const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ timestamp: new Date(timestamp).toLocaleString(), - value: datum.datum, + value: datumToValue(datum), })) return ( { .concat(`-${Math.random().toString(16).substring(2, 8)}`) ) } + +type DatumValue = D['type'] extends 'bool' + ? boolean + : D['type'] extends 'f64' | 'i64' + ? number + : D['type'] extends 'string' + ? string + : D['type'] extends 'bytes' + ? number[] + : D['type'] extends 'cumulative_f64' | 'cumulative_i64' + ? number + : D['type'] extends 'histogram_f64' + ? Bindouble[] + : D['type'] extends 'histogram_i64' + ? Binint64[] + : never + +export function datumToValue(datum: D): DatumValue { + switch (datum.type) { + case 'bool': + case 'bytes': + case 'f64': + case 'i64': + case 'string': + return datum.datum as DatumValue + case 'cumulative_f64': + case 'cumulative_i64': + return datum.datum.value as DatumValue + // this isn't really normal data is it + case 'histogram_f64': + case 'histogram_i64': + return datum.datum.bins as DatumValue + } +} From 48ab30878f0ea6c6dbc6def25c25f1dddd09d818 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 24 Aug 2022 15:57:19 -0500 Subject: [PATCH 05/28] display all 6 disk metrics. whatever --- .../instances/instance/tabs/MetricsTab.tsx | 137 ++++++++++++------ 1 file changed, 96 insertions(+), 41 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index a46db7dc76..79a7035676 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,56 +1,111 @@ -import { addHours } from 'date-fns' +import { useMemo, useState } from 'react' import { Area, CartesianGrid, ComposedChart, XAxis, YAxis } from 'recharts' -import { datumToValue, useApiQuery } from '@oxide/api' +import type { Cumulativeint64, DiskMetricName } from '@oxide/api' +import { useApiQuery } from '@oxide/api' 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 } = useApiQuery('diskMetricsList', { + ...diskParams, + metricName, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + limit: 1000, + }) + + // console.log(metrics) + + const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ + timestamp: new Date(timestamp).toLocaleString(), + // all of these metrics are cumulative ints + value: (datum.datum as Cumulativeint64).value, + })) + + // if (data.length > 0) { + // console.log('time range:', data[0].timestamp, data[data.length - 1].timestamp) + // } + + return ( +
+

{title}

+ + {/* TODO: pull these colors from TW config */} + + + + + +
+ ) +} + export function MetricsTab() { const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') const { orgName, projectName } = instanceParams + const { data: instance } = useApiQuery('instanceView', instanceParams) const { data: disks } = useApiQuery('instanceDiskList', instanceParams) const diskName = disks?.items[0].name - const startTime = new Date(2022, 7, 18, 0) - const endTime = addHours(startTime, 24) - const { data: metrics } = useApiQuery( - 'diskMetricsList', - { - orgName, - projectName, - diskName: diskName!, // force it because this only runs when diskName is there - metricName: 'read', - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - limit: 1000, - }, - { enabled: !!diskName } - ) - console.log(metrics) - const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ - timestamp: new Date(timestamp).toLocaleString(), - value: datumToValue(datum), - })) + const [startTime] = useState(instance?.timeCreated) + // TODO: add date picker + + // endTime is now, i.e., mount time + const endTime = useMemo(() => new Date(), []) + + if (!startTime || !diskName) return loading + + const commonProps = { + startTime, + endTime, + diskParams: { orgName, projectName, diskName }, + } + return ( - - {/* TODO: pull these colors from TW config */} - - - - - + <> +

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

+
+ + + + + + +
+ ) } From d3ffe4c26c0c0089bec61a5f5744880eaf2e95fe Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 24 Aug 2022 17:08:25 -0500 Subject: [PATCH 06/28] upgrade recharts, make mock server behave itself --- .../instances/instance/tabs/MetricsTab.tsx | 5 +- libs/api-mocks/metrics.ts | 11 +- libs/api-mocks/msw/handlers.ts | 4 +- package.json | 2 +- yarn.lock | 206 ++++++------------ 5 files changed, 81 insertions(+), 147 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 79a7035676..005782c415 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -32,14 +32,15 @@ function DiskMetric({ limit: 1000, }) - // console.log(metrics) - const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ timestamp: new Date(timestamp).toLocaleString(), // all of these metrics are cumulative ints value: (datum.datum as Cumulativeint64).value, })) + // console.log(metrics) + // console.log(data) + // if (data.length > 0) { // console.log('time range:', data[0].timestamp, data[data.length - 1].timestamp) // } diff --git a/libs/api-mocks/metrics.ts b/libs/api-mocks/metrics.ts index e9a4e49d2c..7a744eb79e 100644 --- a/libs/api-mocks/metrics.ts +++ b/libs/api-mocks/metrics.ts @@ -4,15 +4,18 @@ import type { Measurement } from '@oxide/api' import type { Json } from './json-type' -export const genI64Data = ( +export const genCumulativeI64Data = ( values: number[], start: Date, intervalSeconds = 1 ): Json => - values.map((v, i) => ({ + values.map((value, i) => ({ datum: { - datum: v, - type: 'i64', + datum: { + value, + start_time: start.toISOString(), + }, + type: 'cumulative_i64', }, timestamp: addSeconds(start, i * intervalSeconds).toISOString(), })) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 05614dbb97..200b1e5d8b 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -4,7 +4,7 @@ import type { ApiTypes as Api } from '@oxide/api' import { pick, sortBy } from '@oxide/util' import type { Json } from '../json-type' -import { genI64Data } from '../metrics' +import { genCumulativeI64Data } from '../metrics' import { serial } from '../serial' import { sessionMe } from '../session' import { defaultSilo } from '../silo' @@ -682,7 +682,7 @@ export const handlers = [ return res( json({ - items: genI64Data( + items: genCumulativeI64Data( new Array(1000) .fill(0) .map( diff --git a/package.json b/package.json index efc50d23dd..f30e7a0520 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 05fa9d02bc..c459a29878 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" @@ -6974,66 +6933,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" @@ -9599,10 +9558,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" @@ -10699,11 +10658,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" @@ -12171,11 +12125,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" @@ -12743,13 +12692,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" @@ -12929,15 +12871,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" @@ -12953,13 +12892,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: @@ -13098,23 +13036,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" @@ -13400,11 +13335,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" From 846501e81ab7b6da2366ffcc197ade9eee81c4b8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Aug 2022 10:24:22 -0500 Subject: [PATCH 07/28] improve x-axis --- .../project/instances/instance/tabs/MetricsTab.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 005782c415..b4a1119d80 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,3 +1,4 @@ +import { format } from 'date-fns' import { useMemo, useState } from 'react' import { Area, CartesianGrid, ComposedChart, XAxis, YAxis } from 'recharts' @@ -33,7 +34,7 @@ function DiskMetric({ }) const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ - timestamp: new Date(timestamp).toLocaleString(), + timestamp: new Date(timestamp), // all of these metrics are cumulative ints value: (datum.datum as Cumulativeint64).value, })) @@ -64,7 +65,13 @@ function DiskMetric({ fill="#112725" isAnimationActive={false} /> - + format(d, 'M/d HH:mm')} + />
From 47769d6d35a91804d040e2e3fe1242ae66f903d7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Aug 2022 17:59:37 -0500 Subject: [PATCH 08/28] shitty tooltip, data should be integers --- .../instances/instance/tabs/MetricsTab.tsx | 52 ++++++++++++++++--- libs/api-mocks/metrics.ts | 2 +- libs/api-mocks/msw/handlers.ts | 5 +- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index b4a1119d80..51c3b41d28 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,6 +1,7 @@ import { format } from 'date-fns' import { useMemo, useState } from 'react' -import { Area, CartesianGrid, ComposedChart, XAxis, YAxis } from 'recharts' +import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts' +import type { TooltipProps } from 'recharts/types/component/Tooltip' import type { Cumulativeint64, DiskMetricName } from '@oxide/api' import { useApiQuery } from '@oxide/api' @@ -16,6 +17,37 @@ type DiskMetricParams = { // TODO: specify bytes or count } +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') + const maxIdx = 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 H:mm aa') + +// TODO: pull from TW theme +const GRID_GRAY = '#1D2427' +const GREEN = '#2F8865' +const DARK_GREEN = '#112725' + +function renderTooltip({ payload }: TooltipProps) { + if (!payload || payload.length < 1) return null + // TODO: there has to be a better way to get these values + const { timestamp, value } = payload[0].payload + if (!timestamp || !value) return null + return ( +
+
{longDateTime(timestamp)}
+ {/* TODO: value needs a label (should be easy) */} +
{value}
+
+ ) +} + function DiskMetric({ title, startTime, @@ -34,7 +66,7 @@ function DiskMetric({ }) const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ - timestamp: new Date(timestamp), + timestamp: new Date(timestamp).getTime(), // all of these metrics are cumulative ints value: (datum.datum as Cumulativeint64).value, })) @@ -46,6 +78,8 @@ function DiskMetric({ // console.log('time range:', data[0].timestamp, data[data.length - 1].timestamp) // } + // console.log(getTicks(data)) + return (

{title}

@@ -56,23 +90,27 @@ function DiskMetric({ margin={{ top: 5, right: 20, bottom: 5, left: 0 }} className="mt-4" > - {/* TODO: pull these colors from TW config */} - + format(d, 'M/d HH:mm')} + tickFormatter={shortDateTime} /> +
) diff --git a/libs/api-mocks/metrics.ts b/libs/api-mocks/metrics.ts index 7a744eb79e..2c7d158220 100644 --- a/libs/api-mocks/metrics.ts +++ b/libs/api-mocks/metrics.ts @@ -7,7 +7,7 @@ import type { Json } from './json-type' export const genCumulativeI64Data = ( values: number[], start: Date, - intervalSeconds = 1 + intervalSeconds = 300 ): Json => values.map((value, i) => ({ datum: { diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 200b1e5d8b..d89a8ab087 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -685,9 +685,8 @@ export const handlers = [ items: genCumulativeI64Data( new Array(1000) .fill(0) - .map( - (x, i) => - Math.floor(i * (1000 - i) * (i % 100) + Math.random() * 10000000) / 1000 + .map((x, i) => + Math.floor((i * (1000 - i) * (i % 100) + Math.random() * 10000000) / 1000) ), new Date(2022, 3, 4) ), From 70e354264ab029c1a5409b9e5f7769c8fcc8f99e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Aug 2022 18:17:29 -0500 Subject: [PATCH 09/28] tooltip still bad, make data look cumulative instead of not --- .../instances/instance/tabs/MetricsTab.tsx | 17 +++++++++++++---- libs/api-mocks/msw/handlers.ts | 6 +----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 51c3b41d28..7150c72660 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -34,16 +34,24 @@ const GRID_GRAY = '#1D2427' const GREEN = '#2F8865' const DARK_GREEN = '#112725' -function renderTooltip({ payload }: TooltipProps) { +function renderTooltip(props: TooltipProps) { + const { payload } = props if (!payload || payload.length < 1) return null + console.log(props) // TODO: there has to be a better way to get these values - const { timestamp, value } = payload[0].payload + const { + name, + payload: { timestamp, value }, + } = payload[0] if (!timestamp || !value) return null return ( -
+
{longDateTime(timestamp)}
{/* TODO: value needs a label (should be easy) */} -
{value}
+
+ {name}: + {value} +
) } @@ -93,6 +101,7 @@ function DiskMetric({ - Math.floor((i * (1000 - i) * (i % 100) + Math.random() * 10000000) / 1000) - ), + new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * 3000)), new Date(2022, 3, 4) ), }) From c810af883692ab5256bb5f8de8076e2cedaddf20 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Aug 2022 18:35:40 -0500 Subject: [PATCH 10/28] bunch of todo comments --- .../instances/instance/tabs/MetricsTab.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 7150c72660..ec78c605ab 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -17,10 +17,16 @@ type DiskMetricParams = { // TODO: specify bytes or count } +// 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') - const maxIdx = data.length - 1 + // 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) @@ -37,7 +43,6 @@ const DARK_GREEN = '#112725' function renderTooltip(props: TooltipProps) { const { payload } = props if (!payload || payload.length < 1) return null - console.log(props) // TODO: there has to be a better way to get these values const { name, @@ -47,10 +52,10 @@ function renderTooltip(props: TooltipProps) { return (
{longDateTime(timestamp)}
- {/* TODO: value needs a label (should be easy) */}
{name}: {value} + {/* TODO: unit on value if relevant */}
) @@ -112,6 +117,8 @@ function DiskMetric({ 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)} @@ -119,6 +126,7 @@ function DiskMetric({ tickFormatter={shortDateTime} /> + {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */}
@@ -153,6 +161,10 @@ export function MetricsTab() { {/* TODO: need a nicer way of saying what the boot disk is */} Boot disk ( {diskName} ) + {/* TODO: separate "Activations" from "(count)" so we can + a) style them differently in the title, and + b) show "Activations" but not "(count)" in the Tooltip? + */}
From f7a13dcae750ebe736cab3644d954cee77025cd7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Aug 2022 21:49:39 -0500 Subject: [PATCH 11/28] use css vars for colors, mono font on ticks, other tweaks --- .../instances/instance/tabs/MetricsTab.tsx | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index ec78c605ab..604304521b 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -33,12 +33,20 @@ function getTicks(data: { timestamp: number }[], n: number): number[] { } const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm') -const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy H:mm aa') - -// TODO: pull from TW theme -const GRID_GRAY = '#1D2427' -const GREEN = '#2F8865' -const DARK_GREEN = '#112725' +const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy H:mm:ss aa') + +// TODO: change these to named colors so they work in light mode +const LIGHT_GRAY = 'var(--base-grey-600)' +const GRID_GRAY = 'var(--base-grey-1000)' +const GREEN = 'var(--base-green-600)' +const DARK_GREEN = 'var(--base-green-900)' + +// 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 @@ -50,8 +58,8 @@ function renderTooltip(props: TooltipProps) { } = payload[0] if (!timestamp || !value) return null return ( -
-
{longDateTime(timestamp)}
+
+
{longDateTime(timestamp)}
{name}: {value} @@ -84,18 +92,12 @@ function DiskMetric({ value: (datum.datum as Cumulativeint64).value, })) - // console.log(metrics) - // console.log(data) - - // if (data.length > 0) { - // console.log('time range:', data[0].timestamp, data[data.length - 1].timestamp) - // } - - // console.log(getTicks(data)) + // 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}

+

{title}

- + {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} - +
) @@ -166,12 +179,14 @@ export function MetricsTab() { b) show "Activations" 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 */} + + + + + +
) From a87cb8f84d821ffe7261f6441dd87b19245fe967 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Aug 2022 21:55:48 -0500 Subject: [PATCH 12/28] delete helper I'm not using --- libs/api/util.ts | 42 +----------------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/libs/api/util.ts b/libs/api/util.ts index 56b0570c02..6029dc0209 100644 --- a/libs/api/util.ts +++ b/libs/api/util.ts @@ -1,13 +1,7 @@ /// Helpers for working with API objects import { pick } from '@oxide/util' -import type { - Bindouble, - Binint64, - Datum, - VpcFirewallRule, - VpcFirewallRuleUpdate, -} from './__generated__/Api' +import type { VpcFirewallRule, VpcFirewallRuleUpdate } from './__generated__/Api' type PortRange = [number, number] @@ -63,37 +57,3 @@ export const genName = (...parts: [string, ...string[]]) => { .concat(`-${Math.random().toString(16).substring(2, 8)}`) ) } - -type DatumValue = D['type'] extends 'bool' - ? boolean - : D['type'] extends 'f64' | 'i64' - ? number - : D['type'] extends 'string' - ? string - : D['type'] extends 'bytes' - ? number[] - : D['type'] extends 'cumulative_f64' | 'cumulative_i64' - ? number - : D['type'] extends 'histogram_f64' - ? Bindouble[] - : D['type'] extends 'histogram_i64' - ? Binint64[] - : never - -export function datumToValue(datum: D): DatumValue { - switch (datum.type) { - case 'bool': - case 'bytes': - case 'f64': - case 'i64': - case 'string': - return datum.datum as DatumValue - case 'cumulative_f64': - case 'cumulative_i64': - return datum.datum.value as DatumValue - // this isn't really normal data is it - case 'histogram_f64': - case 'histogram_i64': - return datum.datum.bins as DatumValue - } -} From 08a93cb8f0820160eae724b2366c488dd8b63e65 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 26 Aug 2022 23:04:07 -0500 Subject: [PATCH 13/28] basic date pickers --- .../instances/instance/tabs/MetricsTab.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 604304521b..a7be7d820e 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,11 +1,14 @@ import { format } from 'date-fns' +import { Form, Formik } from 'formik' import { useMemo, useState } from 'react' import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts' import type { TooltipProps } from 'recharts/types/component/Tooltip' import type { Cumulativeint64, DiskMetricName } from '@oxide/api' import { useApiQuery } from '@oxide/api' +import { Button } from '@oxide/ui' +import { TextField } from 'app/components/form' import { useRequiredParams } from 'app/hooks' type DiskMetricParams = { @@ -34,6 +37,7 @@ function getTicks(data: { timestamp: number }[], n: number): number[] { const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm') const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy H:mm:ss aa') +const dateForInput = (d: Date) => format(d, "yyyy-MM-dd'T'HH:mm") // TODO: change these to named colors so they work in light mode const LIGHT_GRAY = 'var(--base-grey-600)' @@ -174,6 +178,21 @@ export function MetricsTab() { {/* TODO: need a nicer way of saying what the boot disk is */} Boot disk ( {diskName} ) + + console.log(values)} + > +
+ + + + +
+ {/* TODO: separate "Activations" from "(count)" so we can a) style them differently in the title, and b) show "Activations" but not "(count)" in the Tooltip? From fa72ead5decd8e4d9efc52d712d6ef89b0eadd73 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 29 Aug 2022 17:23:02 -0500 Subject: [PATCH 14/28] hook up date range picker --- .../instances/instance/tabs/MetricsTab.tsx | 46 ++++++++++++------- libs/api-mocks/metrics.ts | 17 ++++--- libs/api-mocks/msw/handlers.ts | 24 +++++++++- libs/api-mocks/msw/util.ts | 11 +++++ 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index a7be7d820e..62b17f8641 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,4 +1,5 @@ -import { format } from 'date-fns' +import * as Yup from 'yup' +import { format, subHours } from 'date-fns' import { Form, Formik } from 'formik' import { useMemo, useState } from 'react' import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts' @@ -62,11 +63,11 @@ function renderTooltip(props: TooltipProps) { } = payload[0] if (!timestamp || !value) return null return ( -
-
{longDateTime(timestamp)}
-
- {name}: - {value} +
+
{longDateTime(timestamp)}
+
+
{name}
+
{value}
{/* TODO: unit on value if relevant */}
@@ -150,21 +151,25 @@ function DiskMetric({ ) } +/** Validate that they're Dates and end is after start */ +const dateRangeSchema = Yup.object({ + startTime: Yup.date(), + endTime: Yup.date().min(Yup.ref('startTime'), 'End time must be later than start time'), +}) + export function MetricsTab() { const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') const { orgName, projectName } = instanceParams - const { data: instance } = useApiQuery('instanceView', instanceParams) const { data: disks } = useApiQuery('instanceDiskList', instanceParams) const diskName = disks?.items[0].name - const [startTime] = useState(instance?.timeCreated) - // TODO: add date picker - - // endTime is now, i.e., mount time - const endTime = useMemo(() => new Date(), []) + // default endTime is now, i.e., mount time + const now = useMemo(() => new Date(), []) + const [startTime, setStartTime] = useState(subHours(now, 24)) + const [endTime, setEndTime] = useState(now) - if (!startTime || !diskName) return loading + if (!diskName) return loading const commonProps = { startTime, @@ -181,15 +186,24 @@ export function MetricsTab() { console.log(values)} + onSubmit={({ startTime, endTime }) => { + setStartTime(new Date(startTime)) + setEndTime(new Date(endTime)) + }} + validationSchema={dateRangeSchema} > -
+ + {/* TODO: real React date picker lib instead of native for consistent styling across browsers */} - + {/* mt-6 is a hack to fake alignment with the inputs. this will change so it doesn't matter */} +
diff --git a/libs/api-mocks/metrics.ts b/libs/api-mocks/metrics.ts index 2c7d158220..cbe56cc988 100644 --- a/libs/api-mocks/metrics.ts +++ b/libs/api-mocks/metrics.ts @@ -1,21 +1,24 @@ -import { addSeconds } from 'date-fns' +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[], - start: Date, - intervalSeconds = 300 -): Json => - values.map((value, i) => ({ + startTime: Date, + endTime: Date +): Json => { + const intervalSeconds = differenceInSeconds(endTime, startTime) / values.length + return values.map((value, i) => ({ datum: { datum: { value, - start_time: start.toISOString(), + start_time: startTime.toISOString(), }, type: 'cumulative_i64', }, - timestamp: addSeconds(start, i * intervalSeconds).toISOString(), + timestamp: addSeconds(startTime, i * intervalSeconds).toISOString(), })) +} diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index b01de09637..e21e5d5da1 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1,3 +1,4 @@ +import { subHours } from 'date-fns' import { compose, context, rest } from 'msw' import type { ApiTypes as Api } from '@oxide/api' @@ -37,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 @@ -674,17 +675,36 @@ export const handlers = [ } ), + /** + * Approach to faking: always return 1000 data points spread evenly between start + * and end. + */ rest.get | GetErr>( '/api/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)), - new Date(2022, 3, 4) + startTime, + endTime ), }) ) diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index ddbf792612..1ebb9170fe 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' @@ -55,3 +56,13 @@ export const paginated = ( // testing pagination export const repeat = (obj: T, n: number): T[] => new Array(n).fill(0).map((_, i) => ({ ...obj, id: obj.id + i, name: obj.name + i })) + +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 +} From ed39e80bfe73b383a3b5962255293754571075a1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 29 Aug 2022 17:29:35 -0500 Subject: [PATCH 15/28] TODO comments --- app/pages/project/instances/instance/tabs/MetricsTab.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 62b17f8641..e17be645ed 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -166,10 +166,16 @@ export function MetricsTab() { // default endTime is now, i.e., mount time const now = useMemo(() => new Date(), []) + + // TODO: the whole point of formik is you don't have to sync it with state — + // the Formik form state *is* the state const [startTime, setStartTime] = useState(subHours(now, 24)) const [endTime, setEndTime] = useState(now) - if (!diskName) return loading + // TODO: add a dropdown with last hour, last 3 hours, etc. and a final option + // "Custom". Only on Custom are the date pickers shown. + + if (!diskName) return loading // TODO: loading state const commonProps = { startTime, From 31109e4e9f21588049ee120084f75841211fe598 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 30 Aug 2022 10:16:22 -0500 Subject: [PATCH 16/28] very basic functioning date range presets --- app/components/form/fields/ListboxField.tsx | 14 +-- .../instances/instance/tabs/MetricsTab.tsx | 90 ++++++++++++++++--- 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx index c2651c4fe4..69f7cea98a 100644 --- a/app/components/form/fields/ListboxField.tsx +++ b/app/components/form/fields/ListboxField.tsx @@ -1,18 +1,17 @@ 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 label: string - items: { value: string; label: string }[] - disabled?: boolean required?: boolean helpText?: string description?: string -} +} & Pick export function ListboxField({ id, @@ -23,9 +22,9 @@ export function ListboxField({ required, description, helpText, + onChange, }: 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), }) @@ -40,7 +39,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/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index e17be645ed..053e9024fb 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,5 +1,6 @@ import * as Yup from 'yup' -import { format, subHours } from 'date-fns' +import cn from 'classnames' +import { format, subDays, subHours } from 'date-fns' import { Form, Formik } from 'formik' import { useMemo, useState } from 'react' import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts' @@ -9,7 +10,7 @@ import type { Cumulativeint64, DiskMetricName } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Button } from '@oxide/ui' -import { TextField } from 'app/components/form' +import { ListboxField, TextField } from 'app/components/form' import { useRequiredParams } from 'app/hooks' type DiskMetricParams = { @@ -151,8 +152,29 @@ function DiskMetric({ ) } +const rangePresets = [ + { label: 'Last hour', value: 'lastHour' as const }, + { label: 'Last day', value: 'lastDay' as const }, + { label: 'Last week', value: 'lastWeek' 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), + lastDay: (now) => subDays(now, 1), + lastWeek: (now) => subDays(now, 7), +} + +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), + // TODO: only validate these when oneOf is custom? startTime: Yup.date(), endTime: Yup.date().min(Yup.ref('startTime'), 'End time must be later than start time'), }) @@ -167,9 +189,7 @@ export function MetricsTab() { // default endTime is now, i.e., mount time const now = useMemo(() => new Date(), []) - // TODO: the whole point of formik is you don't have to sync it with state — - // the Formik form state *is* the state - const [startTime, setStartTime] = useState(subHours(now, 24)) + const [startTime, setStartTime] = useState(subDays(now, 1)) const [endTime, setEndTime] = useState(now) // TODO: add a dropdown with last hour, last 3 hours, etc. and a final option @@ -195,6 +215,7 @@ export function MetricsTab() { // values are strings, unfortunately startTime: dateForInput(startTime), endTime: dateForInput(endTime), + preset: 'lastDay', // satisfies RangeKey (TS 4.9), }} onSubmit={({ startTime, endTime }) => { setStartTime(new Date(startTime)) @@ -202,15 +223,56 @@ export function MetricsTab() { }} validationSchema={dateRangeSchema} > -
- {/* TODO: real React date picker lib instead of native for consistent styling across browsers */} - - - {/* mt-6 is a hack to fake alignment with the inputs. this will change so it doesn't matter */} - - + {({ values, setFieldValue }) => ( +
+
+ {/* TODO: make this radio buttons instead of a listbox */} + { + // `item.value in computeStart` is overkill but whatever + if (item && item.value in computeStart && item.value !== 'custom') { + const now = new Date() + const startTime = computeStart[item.value as RangeKey](now) + setFieldValue('startTime', dateForInput(startTime)) + setFieldValue('endTime', dateForInput(now)) + // changing the listbox doesn't update the graphs, it only + // updates the start and end time fields. you still have to + // submit the form to update the graphs + } + }} + /> + +
+
+ {/* TODO: real React date picker lib instead of native for consistent styling across browsers */} + {/* TODO: for the presets, selecting a preset can automatically trigger a fetch and render. but + when you change dates there should probably be a submit step. that means we need to show whether + or not the current date range has been submitted and the graphs already reflect it */} + + + {/* mt-6 is a hack to fake alignment with the inputs. this will change so it doesn't matter */} +
+
+ )} {/* TODO: separate "Activations" from "(count)" so we can From ad29510ec7c965556184b788b6b8f3d4fba192b8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 30 Aug 2022 11:00:00 -0500 Subject: [PATCH 17/28] make cursor dotted, active dot smaller --- app/pages/project/instances/instance/tabs/MetricsTab.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 053e9024fb..a6a64bc310 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -119,11 +119,7 @@ function DiskMetric({ fillOpacity={1} fill={DARK_GREEN} isAnimationActive={false} - activeDot={{ - fill: LIGHT_GRAY, - r: 4, - strokeWidth: 0, - }} + activeDot={{ fill: LIGHT_GRAY, r: 2, strokeWidth: 0 }} />
From 1e28255ad35e69ae9a590aa691e95b404c782ad3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 30 Aug 2022 16:56:00 -0500 Subject: [PATCH 18/28] improve date picker, set record for number of TODOs per line of code --- .../instances/instance/tabs/MetricsTab.tsx | 130 +++++++++++------- 1 file changed, 79 insertions(+), 51 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index a6a64bc310..51c92e3903 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,5 +1,4 @@ import * as Yup from 'yup' -import cn from 'classnames' import { format, subDays, subHours } from 'date-fns' import { Form, Formik } from 'formik' import { useMemo, useState } from 'react' @@ -219,56 +218,85 @@ export function MetricsTab() { }} validationSchema={dateRangeSchema} > - {({ values, setFieldValue }) => ( -
-
- {/* TODO: make this radio buttons instead of a listbox */} - { - // `item.value in computeStart` is overkill but whatever - if (item && item.value in computeStart && item.value !== 'custom') { - const now = new Date() - const startTime = computeStart[item.value as RangeKey](now) - setFieldValue('startTime', dateForInput(startTime)) - setFieldValue('endTime', dateForInput(now)) - // changing the listbox doesn't update the graphs, it only - // updates the start and end time fields. you still have to - // submit the form to update the graphs - } - }} - /> - -
-
- {/* TODO: real React date picker lib instead of native for consistent styling across browsers */} - {/* TODO: for the presets, selecting a preset can automatically trigger a fetch and render. but - when you change dates there should probably be a submit step. that means we need to show whether - or not the current date range has been submitted and the graphs already reflect it */} - - - {/* mt-6 is a hack to fake alignment with the inputs. this will change so it doesn't matter */} -
-
- )} + {({ values, setFieldValue, submitForm }) => { + // only show submit button when the input fields have been changed from what is displayed + const customInputsDirty = + values.startTime !== dateForInput(startTime) || + values.endTime !== dateForInput(endTime) + const enableInputs = values.preset === 'custom' + + function setRangeValues(startTime: Date, endTime: Date) { + setFieldValue('startTime', dateForInput(startTime)) + setFieldValue('endTime', dateForInput(endTime)) + } + + return ( +
+
+ {/* TODO: make this radio buttons instead of a listbox */} + { + // `item.value in computeStart` is overkill but whatever + if (item && item.value in computeStart && 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 + } + }} + /> + +
+ {/* 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 && ( + + )} +
+
+
+ ) + }} {/* TODO: separate "Activations" from "(count)" so we can From 90f52f806b7410ec27bed681c2fd837e268d6925 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 30 Aug 2022 17:17:14 -0500 Subject: [PATCH 19/28] clean up comments a little --- .../instances/instance/tabs/MetricsTab.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 51c92e3903..5b76416b02 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -40,7 +40,7 @@ const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm') const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy H:mm:ss aa') const dateForInput = (d: Date) => format(d, "yyyy-MM-dd'T'HH:mm") -// TODO: change these to named colors so they work in light mode +// 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(--base-green-600)' @@ -149,9 +149,11 @@ function DiskMetric({ 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: 'Custom', value: 'custom' as const }, + { label: 'Last 30 days', value: 'last30Days' as const }, + { label: 'Custom...', value: 'custom' as const }, ] // custom doesn't have an associated range @@ -160,8 +162,10 @@ 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) @@ -169,7 +173,6 @@ 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), - // TODO: only validate these when oneOf is custom? startTime: Yup.date(), endTime: Yup.date().min(Yup.ref('startTime'), 'End time must be later than start time'), }) @@ -184,11 +187,14 @@ export function MetricsTab() { // default endTime is now, i.e., mount time const now = useMemo(() => new Date(), []) + // the range currently displayed in the charts. to update the charts, set these const [startTime, setStartTime] = useState(subDays(now, 1)) const [endTime, setEndTime] = useState(now) - // TODO: add a dropdown with last hour, last 3 hours, etc. and a final option - // "Custom". Only on Custom are the date pickers shown. + function updateCharts({ startTime, endTime }: { startTime: string; endTime: string }) { + setStartTime(new Date(startTime)) + setEndTime(new Date(endTime)) + } if (!diskName) return loading // TODO: loading state @@ -212,17 +218,16 @@ export function MetricsTab() { endTime: dateForInput(endTime), preset: 'lastDay', // satisfies RangeKey (TS 4.9), }} - onSubmit={({ startTime, endTime }) => { - setStartTime(new Date(startTime)) - setEndTime(new Date(endTime)) - }} + onSubmit={updateCharts} validationSchema={dateRangeSchema} > {({ values, setFieldValue, submitForm }) => { - // only show submit button when the input fields have been changed from what is displayed + // whether the time fields 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) { @@ -233,15 +238,15 @@ export function MetricsTab() { return (
- {/* TODO: make this radio buttons instead of a listbox */} { - // `item.value in computeStart` is overkill but whatever - if (item && item.value in computeStart && item.value !== 'custom') { + if (item && item.value !== 'custom') { const now = new Date() const newStartTime = computeStart[item.value as RangeKey](now) setRangeValues(newStartTime, now) @@ -253,6 +258,7 @@ export function MetricsTab() { // not clear the error. changing a second time does } }} + required />
@@ -263,9 +269,9 @@ export function MetricsTab() { id="startTime" type="datetime-local" label="Start time" - required disabled={!enableInputs} className="mr-4" + required /> Date: Tue, 30 Aug 2022 17:22:22 -0500 Subject: [PATCH 20/28] do occams razor to the divs --- app/components/form/fields/ListboxField.tsx | 4 +- .../instances/instance/tabs/MetricsTab.tsx | 119 +++++++++--------- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx index 69f7cea98a..15ce7c1339 100644 --- a/app/components/form/fields/ListboxField.tsx +++ b/app/components/form/fields/ListboxField.tsx @@ -7,6 +7,7 @@ import { FieldLabel, Listbox, TextInputHint } from '@oxide/ui' export type ListboxFieldProps = { name: string id: string + className?: string label: string required?: boolean helpText?: string @@ -23,13 +24,14 @@ export function ListboxField({ description, helpText, onChange, + className, }: ListboxFieldProps) { const [, { value }, { setValue }] = useField({ name, validate: (v) => (required && !v ? `${name} is required` : undefined), }) return ( -
+
{label} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 5b76416b02..9fd7ab6c77 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -236,70 +236,65 @@ export function MetricsTab() { } 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 - /> + + { + 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 + {/* 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 && ( - - )} -
-
+ + + {/* 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 && ( + + )} ) }} From 699caa32a5373e04625f3042bbf9deda30b79dcd Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 30 Aug 2022 17:26:09 -0500 Subject: [PATCH 21/28] don't show am/pm and 24 hour time at the same time --- app/pages/project/instances/instance/tabs/MetricsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 9fd7ab6c77..20acc1544f 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -37,7 +37,7 @@ function getTicks(data: { timestamp: number }[], n: number): number[] { } const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm') -const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy H:mm:ss aa') +const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss zz') const dateForInput = (d: Date) => format(d, "yyyy-MM-dd'T'HH:mm") // TODO: change these to theme colors so they work in light mode From 98ac2b0045d013ac355bda272f7e738d9e7b46d5 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 30 Aug 2022 17:29:08 -0500 Subject: [PATCH 22/28] add todo --- app/pages/project/instances/instance/tabs/MetricsTab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 20acc1544f..5b83ae42ff 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -121,6 +121,7 @@ function DiskMetric({ activeDot={{ fill: LIGHT_GRAY, r: 2, strokeWidth: 0 }} /> Date: Wed, 31 Aug 2022 00:48:21 -0500 Subject: [PATCH 23/28] extract date range picker into hook. this was revealed to me in a dream --- app/components/form/fields/index.ts | 1 + .../form/fields/useDateTimeRangePicker.tsx | 150 ++++++++++++++++++ .../instances/instance/tabs/MetricsTab.tsx | 147 +---------------- vite.config.ts | 13 +- 4 files changed, 164 insertions(+), 147 deletions(-) create mode 100644 app/components/form/fields/useDateTimeRangePicker.tsx 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..c4a4bf2a66 --- /dev/null +++ b/app/components/form/fields/useDateTimeRangePicker.tsx @@ -0,0 +1,150 @@ +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" + +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) + + 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/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 5b83ae42ff..f1db55f313 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,15 +1,11 @@ -import * as Yup from 'yup' -import { format, subDays, subHours } from 'date-fns' -import { Form, Formik } from 'formik' -import { useMemo, useState } from 'react' +import { format } from 'date-fns' import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts' import type { TooltipProps } from 'recharts/types/component/Tooltip' import type { Cumulativeint64, DiskMetricName } from '@oxide/api' import { useApiQuery } from '@oxide/api' -import { Button } from '@oxide/ui' -import { ListboxField, TextField } from 'app/components/form' +import { useDateTimeRangePicker } from 'app/components/form' import { useRequiredParams } from 'app/hooks' type DiskMetricParams = { @@ -38,7 +34,6 @@ function getTicks(data: { timestamp: number }[], n: number): number[] { 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') -const dateForInput = (d: Date) => format(d, "yyyy-MM-dd'T'HH:mm") // TODO: change these to theme colors so they work in light mode const LIGHT_GRAY = 'var(--base-grey-600)' @@ -148,36 +143,6 @@ function DiskMetric({ ) } -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'), -}) - export function MetricsTab() { const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') const { orgName, projectName } = instanceParams @@ -185,25 +150,12 @@ export function MetricsTab() { const { data: disks } = useApiQuery('instanceDiskList', instanceParams) const diskName = disks?.items[0].name - // default endTime is now, i.e., mount time - const now = useMemo(() => new Date(), []) - - // the range currently displayed in the charts. to update the charts, set these - const [startTime, setStartTime] = useState(subDays(now, 1)) - const [endTime, setEndTime] = useState(now) - - function updateCharts({ startTime, endTime }: { startTime: string; endTime: string }) { - setStartTime(new Date(startTime)) - setEndTime(new Date(endTime)) - } + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay') if (!diskName) return loading // TODO: loading state - const commonProps = { - startTime, - endTime, - diskParams: { orgName, projectName, diskName }, - } + const diskParams = { orgName, projectName, diskName } + const commonProps = { startTime, endTime, diskParams } return ( <> @@ -212,94 +164,7 @@ export function MetricsTab() { Boot disk ( {diskName} ) - - {({ values, setFieldValue, submitForm }) => { - // whether the time fields 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 && ( - - )} - - ) - }} -
+ {dateTimeRangePicker} {/* TODO: separate "Activations" from "(count)" so we can a) style them differently in the title, and diff --git a/vite.config.ts b/vite.config.ts index 23b945b794..c7ee9dea2b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,12 +29,13 @@ export default defineConfig(({ mode }) => ({ }, plugins: [ splitVendorChunkPlugin(), - react({ - babel: { - plugins: - mode === 'development' ? ['./libs/babel-transform-react-display-name'] : [], - }, - }), + react(), + // react({ + // babel: { + // plugins: + // mode === 'development' ? ['./libs/babel-transform-react-display-name'] : [], + // }, + // }), ], resolve: { // turn relative paths from tsconfig into absolute paths From a1b3fbac284d826c82b3564d88804dcd77c34e64 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 31 Aug 2022 01:04:58 -0500 Subject: [PATCH 24/28] extract TimeSeriesChart --- app/components/TimeSeriesChart.tsx | 117 ++++++++++++++++++ .../form/fields/useDateTimeRangePicker.tsx | 10 ++ .../instances/instance/tabs/MetricsTab.tsx | 101 +-------------- 3 files changed, 133 insertions(+), 95 deletions(-) create mode 100644 app/components/TimeSeriesChart.tsx diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx new file mode 100644 index 0000000000..f38cf2d4e1 --- /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(--base-green-600)' +const DARK_GREEN = 'var(--base-green-900)' + +// 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/useDateTimeRangePicker.tsx b/app/components/form/fields/useDateTimeRangePicker.tsx index c4a4bf2a66..3dc895831d 100644 --- a/app/components/form/fields/useDateTimeRangePicker.tsx +++ b/app/components/form/fields/useDateTimeRangePicker.tsx @@ -45,6 +45,10 @@ const dateRangeSchema = Yup.object({ // - 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(), []) @@ -52,6 +56,12 @@ export function useDateTimeRangePicker(initialPreset: RangeKey) { 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 = ( 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(--base-green-600)' -const DARK_GREEN = 'var(--base-green-900)' - -// 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 */} -
-
- ) -} - function DiskMetric({ title, startTime, @@ -98,47 +43,13 @@ function DiskMetric({ return (

{title}

- - - - - - {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} - - + />
) } From c8217be8abf1381aa16934e6ff02b910733a9ed7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 31 Aug 2022 10:25:47 -0500 Subject: [PATCH 25/28] keepPreviousData to avoid blank flash while loading --- .../instances/instance/tabs/MetricsTab.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 26323cec9c..5b8827e926 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,5 +1,6 @@ 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' @@ -23,13 +24,18 @@ function DiskMetric({ }: 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 } = useApiQuery('diskMetricsList', { - ...diskParams, - metricName, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - limit: 1000, - }) + 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(), @@ -42,7 +48,9 @@ function DiskMetric({ return (
-

{title}

+

+ {title} {isLoading && } +

Date: Wed, 31 Aug 2022 12:13:52 -0500 Subject: [PATCH 26/28] Apply color css var suggestions Co-authored-by: Justin Bennett --- app/components/TimeSeriesChart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f38cf2d4e1..af65785819 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -23,8 +23,8 @@ const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss // 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(--base-green-600)' -const DARK_GREEN = 'var(--base-green-900)' +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 From f718c4e2d6d52bd860204547b30283b9f728fcd9 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 31 Aug 2022 12:15:30 -0500 Subject: [PATCH 27/28] turn babel plugin back on --- vite.config.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index c7ee9dea2b..23b945b794 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,13 +29,12 @@ export default defineConfig(({ mode }) => ({ }, plugins: [ splitVendorChunkPlugin(), - react(), - // react({ - // babel: { - // plugins: - // mode === 'development' ? ['./libs/babel-transform-react-display-name'] : [], - // }, - // }), + react({ + babel: { + plugins: + mode === 'development' ? ['./libs/babel-transform-react-display-name'] : [], + }, + }), ], resolve: { // turn relative paths from tsconfig into absolute paths From 23f9c73bee8ffa8bd0d027e1da6e71a5aa1efcd4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 31 Aug 2022 12:17:23 -0500 Subject: [PATCH 28/28] don't show activations --- app/pages/project/instances/instance/tabs/MetricsTab.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 5b8827e926..56c0d3e105 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -85,14 +85,13 @@ export function MetricsTab() { {dateTimeRangePicker} - {/* TODO: separate "Activations" from "(count)" so we can + {/* TODO: separate "Reads" from "(count)" so we can a) style them differently in the title, and - b) show "Activations" but not "(count)" in the Tooltip? + 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 */} -