From f8b59a44029037b5ab43d38b835e3645f11f5c46 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 5 Oct 2022 16:59:19 -0500 Subject: [PATCH 01/23] basic c&u page --- app/pages/system/CapacityUtilizationPage.tsx | 27 ++++++++++++++++++++ app/routes.tsx | 7 ++++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 app/pages/system/CapacityUtilizationPage.tsx diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx new file mode 100644 index 0000000000..bacb94efff --- /dev/null +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -0,0 +1,27 @@ +import { useMemo } from 'react' + +import { apiQueryClient, useApiQuery } from '@oxide/api' +import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' + +CapacityUtilizationPage.loader = async () => { + await apiQueryClient.prefetchQuery('siloList', {}) +} + +export function CapacityUtilizationPage() { + const { data: silos } = useApiQuery('siloList', {}) + + const siloItems = useMemo( + () => silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || [], + [silos] + ) + + return ( + <> + + }>Capacity & Utilization + + + + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 1f4d6ba071..10767caad3 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -34,6 +34,7 @@ import { AppearancePage } from './pages/settings/AppearancePage' import { HotkeysPage } from './pages/settings/HotkeysPage' import { ProfilePage } from './pages/settings/ProfilePage' import { SSHKeysPage } from './pages/settings/SSHKeysPage' +import { CapacityUtilizationPage } from './pages/system/CapacityUtilizationPage' import SilosPage from './pages/system/SilosPage' import { pb } from './util/path-builder' @@ -81,7 +82,11 @@ export const routes = createRoutesFromElements( loader={SilosPage.loader} /> - + } + loader={CapacityUtilizationPage.loader} + /> From 22952ad3c25246b7313172e24d364dae36ea9b25 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 5 Oct 2022 22:01:16 -0500 Subject: [PATCH 02/23] tmp --- app/pages/system/CapacityUtilizationPage.tsx | 103 ++++++++++++++++++- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index bacb94efff..85f33a3b5b 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -1,15 +1,85 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' +import type { Cumulativeint64 } from '@oxide/api' import { apiQueryClient, useApiQuery } from '@oxide/api' -import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' +import { + Divider, + Listbox, + PageHeader, + PageTitle, + Snapshots24Icon, + Spinner, +} from '@oxide/ui' + +import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart' +import { useDateTimeRangePicker } from 'app/components/form' + +type DiskMetricParams = { + title: string + startTime: Date + endTime: Date + metricName: DiskMetricName + diskParams: { orgName: string; projectName: string; diskName: string } + // TODO: specify bytes or count +} + +function SystemMetric({ + title, + startTime, + endTime, + metricName, + diskParams, +}: DiskMetricParams) { + // TODO: we're only pulling the first page. Should we bump the cap to 10k? + // Fetch multiple pages if 10k is not enough? That's a bit much. + const { data: metrics, isLoading } = useApiQuery( + 'diskMetricsList', + { + ...diskParams, + metricName, + startTime, + endTime, + limit: 1000, + }, + // avoid graphs flashing blank while loading when you change the time + { keepPreviousData: true } + ) + + const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ + timestamp: timestamp.getTime(), + // all of these metrics are cumulative ints + value: (datum.datum as Cumulativeint64).value, + })) + + // TODO: indicate time zone somewhere. doesn't have to be in the detail view + // in the tooltip. could be just once on the end of the x-axis like GCP + + return ( +
+

+ {title} {isLoading && } +

+ +
+ ) +} CapacityUtilizationPage.loader = async () => { await apiQueryClient.prefetchQuery('siloList', {}) } export function CapacityUtilizationPage() { + const [siloId, setSiloId] = useState(null) const { data: silos } = useApiQuery('siloList', {}) + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay') + const siloItems = useMemo( () => silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || [], [silos] @@ -20,8 +90,33 @@ export function CapacityUtilizationPage() { }>Capacity & Utilization - - + +
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ { + if (item) { + setSiloId(item.value) + } + }} + /> + {/* TODO: need a button to clear the silo */} +
+ + {dateTimeRangePicker} +
+ ) } From 9d683c9bb948b0f87aefd05148044231fdcc428a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 16:05:37 -0500 Subject: [PATCH 03/23] gen API client --- OMICRON_VERSION | 2 +- libs/api/__generated__/Api.ts | 32 ++++++++++++++++++++++++++ libs/api/__generated__/OMICRON_VERSION | 2 +- libs/api/__generated__/validate.ts | 19 +++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 50f1812846..7f47e9433b 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -74f3ca89af11b0ce6d9f9bd4b5bdcbeb04d1ba3e +5c33dba478668f1091d0435b749f739d64a916ea diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index 1c9cc273cd..20ea896ad7 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -1716,6 +1716,14 @@ export type DiskMetricName = */ export type IdSortMode = 'id_ascending' +export type ResourceName = + | 'physical_disk_space_provisioned' + | 'physical_disk_space_capacity' + | 'cpus_provisioned' + | 'cpu_capacity' + | 'ram_provisioned' + | 'ram_capacity' + export interface DiskViewByIdParams { id: string } @@ -2348,6 +2356,15 @@ export interface IpPoolServiceRangeRemoveParams { rackId: string } +export interface SystemMetricsListParams { + resourceName: ResourceName + endTime?: Date + id?: string + limit?: number + pageToken?: string | null + startTime?: Date +} + export interface SystemPolicyViewParams {} export interface SystemPolicyUpdateParams {} @@ -2467,6 +2484,7 @@ export type ApiListMethods = Pick< | 'ipPoolList' | 'ipPoolRangeList' | 'ipPoolServiceRangeList' + | 'systemMetricsList' | 'sagaList' | 'siloList' | 'siloIdentityProviderList' @@ -4019,6 +4037,20 @@ export class Api extends HttpClient { ...params, }), + /** + * Access metrics data + */ + systemMetricsList: ( + { resourceName, ...query }: SystemMetricsListParams, + params: RequestParams = {} + ) => + this.request({ + path: `/system/metrics/${resourceName}`, + method: 'GET', + query, + ...params, + }), + /** * Fetch the top-level IAM policy */ diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 53375d654c..531e66437a 100644 --- a/libs/api/__generated__/OMICRON_VERSION +++ b/libs/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -74f3ca89af11b0ce6d9f9bd4b5bdcbeb04d1ba3e +5c33dba478668f1091d0435b749f739d64a916ea diff --git a/libs/api/__generated__/validate.ts b/libs/api/__generated__/validate.ts index a12231db65..6a449d6561 100644 --- a/libs/api/__generated__/validate.ts +++ b/libs/api/__generated__/validate.ts @@ -1439,6 +1439,15 @@ export const DiskMetricName = z.enum([ */ export const IdSortMode = z.enum(['id_ascending']) +export const ResourceName = z.enum([ + 'physical_disk_space_provisioned', + 'physical_disk_space_capacity', + 'cpus_provisioned', + 'cpu_capacity', + 'ram_provisioned', + 'ram_capacity', +]) + export const DiskViewByIdParams = z.object({ id: z.string().uuid(), }) @@ -2203,6 +2212,16 @@ export const IpPoolServiceRangeRemoveParams = z.object({ }) export type IpPoolServiceRangeRemoveParams = z.infer +export const SystemMetricsListParams = z.object({ + resourceName: ResourceName, + endTime: DateType.optional(), + id: z.string().uuid().optional(), + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + startTime: DateType.optional(), +}) +export type SystemMetricsListParams = z.infer + export const SystemPolicyViewParams = z.object({}) export type SystemPolicyViewParams = z.infer From cf441b6c7de7ca903457dfd9720ab36782db65f1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 16:28:45 -0500 Subject: [PATCH 04/23] mock system metrics endpoint, show disk and cpu graphs --- app/pages/system/CapacityUtilizationPage.tsx | 37 +++++++++---------- libs/api-mocks/msw/handlers.ts | 38 +++++++++++++------- libs/api-mocks/msw/util.ts | 20 +++++++++-- libs/api/index.ts | 1 + libs/api/path-params.ts | 1 + 5 files changed, 64 insertions(+), 33 deletions(-) diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 85f33a3b5b..2cbbc19f40 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' -import type { Cumulativeint64 } from '@oxide/api' +import type { Cumulativeint64, ResourceName } from '@oxide/api' import { apiQueryClient, useApiQuery } from '@oxide/api' import { Divider, @@ -18,29 +18,16 @@ type DiskMetricParams = { title: string startTime: Date endTime: Date - metricName: DiskMetricName - diskParams: { orgName: string; projectName: string; diskName: string } + resourceName: ResourceName // TODO: specify bytes or count } -function SystemMetric({ - title, - startTime, - endTime, - metricName, - diskParams, -}: DiskMetricParams) { +function SystemMetric({ title, startTime, endTime, resourceName }: DiskMetricParams) { // TODO: we're only pulling the first page. Should we bump the cap to 10k? // Fetch multiple pages if 10k is not enough? That's a bit much. const { data: metrics, isLoading } = useApiQuery( - 'diskMetricsList', - { - ...diskParams, - metricName, - startTime, - endTime, - limit: 1000, - }, + 'systemMetricsList', + { resourceName, startTime, endTime, limit: 1000 }, // avoid graphs flashing blank while loading when you change the time { keepPreviousData: true } ) @@ -75,6 +62,7 @@ CapacityUtilizationPage.loader = async () => { } export function CapacityUtilizationPage() { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ const [siloId, setSiloId] = useState(null) const { data: silos } = useApiQuery('siloList', {}) @@ -117,6 +105,19 @@ export function CapacityUtilizationPage() { {dateTimeRangePicker} + + + ) } diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index eae7f91cbc..0f70c89a6c 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1,4 +1,3 @@ -import { subHours } from 'date-fns' import { compose, context, rest } from 'msw' import type { ApiTypes as Api, PathParams as PP } from '@oxide/api' @@ -26,7 +25,7 @@ import { lookupVpcRouter, lookupVpcSubnet, } from './db' -import { getDateParam, json, paginated } from './util' +import { getStartAndEndTime, 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 @@ -684,17 +683,30 @@ export const handlers = [ 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) + const { startTime, endTime } = getStartAndEndTime(req.url.searchParams) + + if (endTime <= startTime) return res(json({ items: [] })) + + return res( + json({ + items: genCumulativeI64Data( + new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * 3000)), + startTime, + endTime + ), + }) + ) + } + ), + + rest.get | GetErr>( + '/system/metrics/:resourceName', + (req, res) => { + // const result = ZVal.ResourceName.safeParse(req.params.resourceName) + // if (!result.success) return res(notFoundErr) + // const resourceName = result.data + + const { startTime, endTime } = getStartAndEndTime(req.url.searchParams) if (endTime <= startTime) return res(json({ items: [] })) diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index 712e8c6f26..bf0595264c 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -1,4 +1,4 @@ -import { isValid, parseISO } from 'date-fns' +import { isValid, parseISO, subHours } from 'date-fns' import type { ResponseTransformer } from 'msw' import { compose, context } from 'msw' @@ -62,7 +62,7 @@ export const clone = (obj: T): T => ? structuredClone(obj) : JSON.parse(JSON.stringify(obj)) -export function getDateParam(params: URLSearchParams, key: string): Date | null { +function getDateParam(params: URLSearchParams, key: string): Date | null { const value = params.get(key) if (!value) return null @@ -71,3 +71,19 @@ export function getDateParam(params: URLSearchParams, key: string): Date | null return date } + +export function getStartAndEndTime(searchParams: URLSearchParams) { + const queryStartTime = getDateParam(searchParams, 'start_time') + const queryEndTime = getDateParam(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) + + return { startTime, endTime } +} diff --git a/libs/api/index.ts b/libs/api/index.ts index 2792febd30..87cde962b2 100644 --- a/libs/api/index.ts +++ b/libs/api/index.ts @@ -40,6 +40,7 @@ export const useApiQueryClient = getUseApiQueryClient(api.methods) export * from './roles' export * from './util' export * from './__generated__/Api' +export * as ZVal from './__generated__/validate' export type { ApiTypes } diff --git a/libs/api/path-params.ts b/libs/api/path-params.ts index db17186f1d..1c22a3f16c 100644 --- a/libs/api/path-params.ts +++ b/libs/api/path-params.ts @@ -14,3 +14,4 @@ export type SshKey = { sshKeyName: string } export type GlobalImage = { imageName: string } export type Silo = { siloName: string } export type Id = { id: string } +export type SystemMetric = { resourceName: string } From 3a74fa5296bef06923fa829e72120e7a1f57cfd3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 16:50:26 -0500 Subject: [PATCH 05/23] it works against real Nexus (at least, it doesn't error) --- app/pages/system/CapacityUtilizationPage.tsx | 35 +++++++++++++------- libs/api-mocks/msw/handlers.ts | 3 ++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 2cbbc19f40..2b364fe7a9 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -14,20 +14,29 @@ import { import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart' import { useDateTimeRangePicker } from 'app/components/form' +const FLEET_ID = '001de000-1334-4000-8000-000000000000' + type DiskMetricParams = { title: string startTime: Date endTime: Date resourceName: ResourceName + siloId: string // TODO: specify bytes or count } -function SystemMetric({ title, startTime, endTime, resourceName }: DiskMetricParams) { +function SystemMetric({ + title, + siloId, + startTime, + endTime, + resourceName, +}: DiskMetricParams) { // TODO: we're only pulling the first page. Should we bump the cap to 10k? // Fetch multiple pages if 10k is not enough? That's a bit much. const { data: metrics, isLoading } = useApiQuery( 'systemMetricsList', - { resourceName, startTime, endTime, limit: 1000 }, + { id: siloId, resourceName, startTime, endTime }, // avoid graphs flashing blank while loading when you change the time { keepPreviousData: true } ) @@ -46,6 +55,7 @@ function SystemMetric({ title, startTime, endTime, resourceName }: DiskMetricPar

{title} {isLoading && }

+ {/* TODO: this is supposed to be full width */} { export function CapacityUtilizationPage() { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - const [siloId, setSiloId] = useState(null) + const [siloId, setSiloId] = useState(FLEET_ID) const { data: silos } = useApiQuery('siloList', {}) const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay') - const siloItems = useMemo( - () => silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || [], - [silos] - ) + const siloItems = useMemo(() => { + const items = silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || [] + return [{ label: 'All silos', value: FLEET_ID }, ...items] + }, [silos]) + + const commonProps = { startTime, endTime, siloId } return ( <> @@ -88,7 +100,7 @@ export function CapacityUtilizationPage() { + {/* TODO: this divider is supposed to go all the way across */} ) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 0f70c89a6c..437cd2d3d2 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -706,6 +706,9 @@ export const handlers = [ // if (!result.success) return res(notFoundErr) // const resourceName = result.data + // note we're ignoring the required id query param. since the data is fake + // it wouldn't matter, though we should probably 400 if it's missing + const { startTime, endTime } = getStartAndEndTime(req.url.searchParams) if (endTime <= startTime) return res(json({ items: [] })) From 3bd13fe1a2f23818abeb00b13f683399f2be5f93 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 17:22:16 -0500 Subject: [PATCH 06/23] graphs go wide --- app/components/TimeSeriesChart.tsx | 93 +++++++++++-------- .../instances/instance/tabs/MetricsTab.tsx | 2 +- app/pages/system/CapacityUtilizationPage.tsx | 23 +++-- 3 files changed, 66 insertions(+), 52 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 8e3910e091..e476a45859 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -1,5 +1,13 @@ import { format } from 'date-fns' -import { Area, CartesianGrid, ComposedChart, Tooltip, XAxis, YAxis } from 'recharts' +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' import type { TooltipProps } from 'recharts/types/component/Tooltip' // Recharts's built-in ticks behavior is useless and probably broken @@ -74,45 +82,48 @@ type Props = { 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 */} - - + + + + + + + {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} + + + ) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 21a8258e18..c5f78ced88 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -112,7 +112,7 @@ function DiskMetrics({ disks }: { disks: Disk[] }) { a) style them differently in the title, and b) show "Reads" but not "(count)" in the Tooltip? */} -
+
{/* see the following link for the source of truth on what these mean https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 2b364fe7a9..50768638d6 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -118,17 +118,20 @@ export function CapacityUtilizationPage() {
{/* TODO: this divider is supposed to go all the way across */} - - +
+ + + +
) } From 7b31abeeb94ae86565dfa17c58ce5e26be8641d5 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 17:23:09 -0500 Subject: [PATCH 07/23] move disk picker left on instance metrics to match system metrics --- 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 c5f78ced88..80f6059e8a 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -82,7 +82,6 @@ function DiskMetrics({ disks }: { disks: Disk[] }) { return ( <>
- {dateTimeRangePicker} {/* TODO: using a Formik field here feels like overkill, but we've built ListboxField to require that, i.e., there's no way to get the nice worked-out layout from ListboxField without using Formik. Something to think about. */} @@ -106,6 +105,7 @@ function DiskMetrics({ disks }: { disks: Disk[] }) { defaultValue={diskName} />
+ {dateTimeRangePicker}
{/* TODO: separate "Reads" from "(count)" so we can From 60e371691616665197877fa7be185e0ceaf6e098 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 21:58:54 -0500 Subject: [PATCH 08/23] it actually works --- OMICRON_VERSION | 2 +- .../form/fields/useDateTimeRangePicker.tsx | 5 ++-- app/pages/system/CapacityUtilizationPage.tsx | 24 ++++++++++++++----- libs/api-mocks/metrics.ts | 15 ++++++++++++ libs/api-mocks/msw/handlers.ts | 4 ++-- libs/api/__generated__/OMICRON_VERSION | 2 +- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 7f47e9433b..7dafb8944b 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -5c33dba478668f1091d0435b749f739d64a916ea +356d4fc0d5b2ebf740ea271e6e936e7e0d8d045a diff --git a/app/components/form/fields/useDateTimeRangePicker.tsx b/app/components/form/fields/useDateTimeRangePicker.tsx index 200d43df8f..733d0e536c 100644 --- a/app/components/form/fields/useDateTimeRangePicker.tsx +++ b/app/components/form/fields/useDateTimeRangePicker.tsx @@ -20,7 +20,8 @@ const rangePresets = [ ] // custom doesn't have an associated range -export type RangeKey = Exclude +type RangeKeyAll = typeof rangePresets[number]['value'] +export type RangeKey = Exclude // Record ensures we have an entry for every preset const computeStart: Record Date> = { @@ -68,7 +69,7 @@ export function useDateTimeRangePicker(initialPreset: RangeKey) { // values are strings, unfortunately startTime: dateForInput(startTime), endTime: dateForInput(endTime), - preset: 'lastDay', // satisfies RangeKey (TS 4.9), + preset: initialPreset as RangeKeyAll, // indicates preset can include 'custom' }} onSubmit={({ startTime, endTime }) => { setStartTime(new Date(startTime)) diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 50768638d6..95adecf0d5 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' -import type { Cumulativeint64, ResourceName } from '@oxide/api' +import type { ResourceName } from '@oxide/api' import { apiQueryClient, useApiQuery } from '@oxide/api' import { Divider, @@ -10,11 +10,13 @@ import { Snapshots24Icon, Spinner, } from '@oxide/ui' +import { GiB } from '@oxide/util' import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart' import { useDateTimeRangePicker } from 'app/components/form' const FLEET_ID = '001de000-1334-4000-8000-000000000000' +const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000' type DiskMetricParams = { title: string @@ -22,7 +24,7 @@ type DiskMetricParams = { endTime: Date resourceName: ResourceName siloId: string - // TODO: specify bytes or count + valueTransform?: (n: number) => number } function SystemMetric({ @@ -31,6 +33,7 @@ function SystemMetric({ startTime, endTime, resourceName, + valueTransform = (x) => x, }: 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. @@ -44,9 +47,12 @@ function SystemMetric({ const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ timestamp: timestamp.getTime(), // all of these metrics are cumulative ints - value: (datum.datum as Cumulativeint64).value, + value: valueTransform(datum.datum as number), })) + // TODO: consider adding a fake data point for the end of the requested time range + // so it's filled out + // 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 @@ -76,11 +82,15 @@ export function CapacityUtilizationPage() { const [siloId, setSiloId] = useState(FLEET_ID) const { data: silos } = useApiQuery('siloList', {}) - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay') + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastHour') const siloItems = useMemo(() => { const items = silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || [] - return [{ label: 'All silos', value: FLEET_ID }, ...items] + return [ + { label: 'All silos', value: FLEET_ID }, + { label: '[default silo]', value: DEFAULT_SILO_ID }, + ...items, + ] }, [silos]) const commonProps = { startTime, endTime, siloId } @@ -120,10 +130,12 @@ export function CapacityUtilizationPage() {
+ {/* TODO: convert numbers to GiB PLEASE */} Math.floor(b / GiB)} /> => { + const intervalSeconds = differenceInSeconds(endTime, startTime) / values.length + return values.map((value, i) => ({ + datum: { + datum: value, + type: 'i64', + }, + timestamp: addSeconds(startTime, i * intervalSeconds).toISOString(), + })) +} diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 437cd2d3d2..be38861fc9 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -4,7 +4,7 @@ import type { ApiTypes as Api, PathParams as PP } from '@oxide/api' import { pick, sortBy } from '@oxide/util' import type { Json } from '../json-type' -import { genCumulativeI64Data } from '../metrics' +import { genCumulativeI64Data, genI64Data } from '../metrics' import { serial } from '../serial' import { sessionMe } from '../session' import { defaultSilo } from '../silo' @@ -715,7 +715,7 @@ export const handlers = [ return res( json({ - items: genCumulativeI64Data( + items: genI64Data( new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * 3000)), startTime, endTime diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 531e66437a..4876abf235 100644 --- a/libs/api/__generated__/OMICRON_VERSION +++ b/libs/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -5c33dba478668f1091d0435b749f739d64a916ea +356d4fc0d5b2ebf740ea271e6e936e7e0d8d045a From 7bbd86189b03c67ef8909300c478a4113938605e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 22:08:13 -0500 Subject: [PATCH 09/23] fuck it, refetch every 5 seconds --- app/pages/system/CapacityUtilizationPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 95adecf0d5..df75b4eec6 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -41,7 +41,7 @@ function SystemMetric({ 'systemMetricsList', { id: siloId, resourceName, startTime, endTime }, // avoid graphs flashing blank while loading when you change the time - { keepPreviousData: true } + { keepPreviousData: true, refetchInterval: 5000 } ) const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ From 6028c1c45eba31efa5c232df0fcf9dbfbbc108b3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 6 Oct 2022 22:28:18 -0500 Subject: [PATCH 10/23] TODO about sliding window --- app/pages/system/CapacityUtilizationPage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index df75b4eec6..8507f08c48 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -40,8 +40,12 @@ function SystemMetric({ const { data: metrics, isLoading } = useApiQuery( 'systemMetricsList', { id: siloId, resourceName, startTime, endTime }, - // avoid graphs flashing blank while loading when you change the time - { keepPreviousData: true, refetchInterval: 5000 } + { + // TODO: this is actually kind of useless unless the time interval slides forward as time passes + refetchInterval: 5000, + // avoid graphs flashing blank while loading when you change the time + keepPreviousData: true, + } ) const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ From 356741fbb95850763a566e7e5886166d8e1b3c4b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 09:21:06 -0500 Subject: [PATCH 11/23] bump api again --- OMICRON_VERSION | 2 +- libs/api/__generated__/Api.ts | 3 --- libs/api/__generated__/OMICRON_VERSION | 2 +- libs/api/__generated__/validate.ts | 3 --- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 7dafb8944b..d6c59b24fd 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -356d4fc0d5b2ebf740ea271e6e936e7e0d8d045a +d2c5d9de9a4b3a074ea3fc04b494706f708e90f7 diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index 20ea896ad7..c44ccb8f81 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -1718,11 +1718,8 @@ export type IdSortMode = 'id_ascending' export type ResourceName = | 'physical_disk_space_provisioned' - | 'physical_disk_space_capacity' | 'cpus_provisioned' - | 'cpu_capacity' | 'ram_provisioned' - | 'ram_capacity' export interface DiskViewByIdParams { id: string diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 4876abf235..458910c1fd 100644 --- a/libs/api/__generated__/OMICRON_VERSION +++ b/libs/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -356d4fc0d5b2ebf740ea271e6e936e7e0d8d045a +d2c5d9de9a4b3a074ea3fc04b494706f708e90f7 diff --git a/libs/api/__generated__/validate.ts b/libs/api/__generated__/validate.ts index 6a449d6561..9f6f9a10ea 100644 --- a/libs/api/__generated__/validate.ts +++ b/libs/api/__generated__/validate.ts @@ -1441,11 +1441,8 @@ export const IdSortMode = z.enum(['id_ascending']) export const ResourceName = z.enum([ 'physical_disk_space_provisioned', - 'physical_disk_space_capacity', 'cpus_provisioned', - 'cpu_capacity', 'ram_provisioned', - 'ram_capacity', ]) export const DiskViewByIdParams = z.object({ From 51d067d290c968c9b59c17a538ce677e24281db4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 09:28:46 -0500 Subject: [PATCH 12/23] add RAM chart --- app/components/TimeSeriesChart.tsx | 2 +- app/pages/system/CapacityUtilizationPage.tsx | 15 +++++++++++---- libs/api-mocks/msw/handlers.ts | 12 ++++++++---- libs/util/units.ts | 2 ++ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index e476a45859..707f4bf87b 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -90,6 +90,7 @@ export function TimeSeriesAreaChart({ className, data, title, width, height }: P margin={{ top: 5, right: 20, bottom: 5, left: 0 }} className={className} > + - Math.floor(b / GiB)} + title="Disk Space (GiB)" + valueTransform={bytesToGiB} /> + +
diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index be38861fc9..c603013bdf 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1,6 +1,7 @@ import { compose, context, rest } from 'msw' import type { ApiTypes as Api, PathParams as PP } from '@oxide/api' +import { ZVal } from '@oxide/api' import { pick, sortBy } from '@oxide/util' import type { Json } from '../json-type' @@ -9,6 +10,7 @@ import { serial } from '../serial' import { sessionMe } from '../session' import { defaultSilo } from '../silo' import type { NotFound } from './db' +import { notFoundErr } from './db' import { lookupSnapshot } from './db' import { lookupSilo } from './db' import { @@ -702,9 +704,11 @@ export const handlers = [ rest.get | GetErr>( '/system/metrics/:resourceName', (req, res) => { - // const result = ZVal.ResourceName.safeParse(req.params.resourceName) - // if (!result.success) return res(notFoundErr) - // const resourceName = result.data + const result = ZVal.ResourceName.safeParse(req.params.resourceName) + if (!result.success) return res(notFoundErr) + const resourceName = result.data + + const cap = resourceName === 'cpus_provisioned' ? 3000 : 4000000000000 // note we're ignoring the required id query param. since the data is fake // it wouldn't matter, though we should probably 400 if it's missing @@ -716,7 +720,7 @@ export const handlers = [ return res( json({ items: genI64Data( - new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * 3000)), + new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * cap)), startTime, endTime ), diff --git a/libs/util/units.ts b/libs/util/units.ts index 7627556647..ea49a5fec8 100644 --- a/libs/util/units.ts +++ b/libs/util/units.ts @@ -1,3 +1,5 @@ export const KiB = 1024 export const MiB = 1024 * KiB export const GiB = 1024 * MiB + +export const bytesToGiB = (b: number) => Math.floor(b / GiB) From 383794e5173299f2b0f4335c026b79d1fb6b09d5 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 09:36:37 -0500 Subject: [PATCH 13/23] it's a line!!!! --- app/components/TimeSeriesChart.tsx | 5 ++--- app/pages/system/CapacityUtilizationPage.tsx | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 707f4bf87b..a5c07535f7 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -32,7 +32,6 @@ const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss const LIGHT_GRAY = 'var(--base-grey-600)' const GRID_GRAY = 'var(--base-grey-1000)' const GREEN = 'var(--chart-stroke-line)' -const DARK_GREEN = 'var(--chart-fill-item-quaternary)' // TODO: figure out how to do this with TW classes instead. As far as I can tell // ticks only take direct styling @@ -96,8 +95,8 @@ export function TimeSeriesAreaChart({ className, data, title, width, height }: P name={title} stroke={GREEN} strokeWidth={1} - fillOpacity={1} - fill={DARK_GREEN} + // cheating to make this a line chart + fillOpacity={0} isAnimationActive={false} activeDot={{ fill: LIGHT_GRAY, r: 2, strokeWidth: 0 }} /> diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 466b6eac49..0407a06590 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -142,6 +142,7 @@ export function CapacityUtilizationPage() { valueTransform={bytesToGiB} /> + {/* TODO: figure out how to make this not show .5s in the y axis when the numbers are low */} Date: Fri, 7 Oct 2022 09:43:18 -0500 Subject: [PATCH 14/23] auto ticks, step interpolation --- app/components/TimeSeriesChart.tsx | 27 ++++++++------------ app/pages/system/CapacityUtilizationPage.tsx | 1 + 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index a5c07535f7..d074ee9822 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -10,21 +10,6 @@ import { } 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') @@ -74,12 +59,20 @@ type Props = { title: string width: number height: number + interpolation?: 'linear' | 'stepAfter' } // Limitations // - Only one dataset — can't do overlapping area chart yet -export function TimeSeriesAreaChart({ className, data, title, width, height }: Props) { +export function TimeSeriesAreaChart({ + className, + data, + title, + width, + height, + interpolation = 'linear', +}: Props) { return ( ) From c3a06a41bd3f8e93166977c49e296bbe251e48b1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 09:57:53 -0500 Subject: [PATCH 15/23] padding on the graph so we can see the last point --- app/components/TimeSeriesChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index d074ee9822..469f51e1f4 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -107,6 +107,7 @@ export function TimeSeriesAreaChart({ tickFormatter={shortDateTime} tick={textMonoMd} tickMargin={4} + padding={{ right: 20 }} /> {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} From ea3b61fe8235027982696d0fe6c6791f269dd893 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 09:58:57 -0500 Subject: [PATCH 16/23] be less cool so playwright will work --- libs/api-mocks/msw/handlers.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index c603013bdf..49e3928ce8 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1,7 +1,6 @@ import { compose, context, rest } from 'msw' import type { ApiTypes as Api, PathParams as PP } from '@oxide/api' -import { ZVal } from '@oxide/api' import { pick, sortBy } from '@oxide/util' import type { Json } from '../json-type' @@ -10,7 +9,6 @@ import { serial } from '../serial' import { sessionMe } from '../session' import { defaultSilo } from '../silo' import type { NotFound } from './db' -import { notFoundErr } from './db' import { lookupSnapshot } from './db' import { lookupSilo } from './db' import { @@ -704,11 +702,11 @@ export const handlers = [ rest.get | GetErr>( '/system/metrics/:resourceName', (req, res) => { - const result = ZVal.ResourceName.safeParse(req.params.resourceName) - if (!result.success) return res(notFoundErr) - const resourceName = result.data + // const result = ZVal.ResourceName.safeParse(req.params.resourceName) + // if (!result.success) return res(notFoundErr) + // const resourceName = result.data - const cap = resourceName === 'cpus_provisioned' ? 3000 : 4000000000000 + const cap = req.params.resourceName === 'cpus_provisioned' ? 3000 : 4000000000000 // note we're ignoring the required id query param. since the data is fake // it wouldn't matter, though we should probably 400 if it's missing From 42abd664ad005f43b839d147a5f3c58c58829198 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 10:08:47 -0500 Subject: [PATCH 17/23] fake data point at start and end --- .../fields/useDateTimeRangePicker.spec.tsx | 20 +++++++++++++------ .../form/fields/useDateTimeRangePicker.tsx | 6 +++++- .../instances/instance/tabs/MetricsTab.tsx | 4 +++- app/pages/system/CapacityUtilizationPage.tsx | 18 ++++++++++++++++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/app/components/form/fields/useDateTimeRangePicker.spec.tsx b/app/components/form/fields/useDateTimeRangePicker.spec.tsx index 81ffe8d758..91692468a8 100644 --- a/app/components/form/fields/useDateTimeRangePicker.spec.tsx +++ b/app/components/form/fields/useDateTimeRangePicker.spec.tsx @@ -25,7 +25,9 @@ describe('useDateTimeRangePicker', () => { ['lastWeek', subDays(date, 7)], ['last30Days', subDays(date, 30)], ])('sets initial start and end', (preset, start) => { - const { result } = renderHook(() => useDateTimeRangePicker(preset as RangeKey)) + const { result } = renderHook(() => + useDateTimeRangePicker({ initialPreset: preset as RangeKey }) + ) expect(result.current.startTime).toEqual(start) expect(result.current.endTime).toEqual(date) }) @@ -38,7 +40,7 @@ describe('useDateTimeRangePicker', () => { ['Last 30 days', subDays(date, 30)], ])('choosing a preset sets the times', async (option, start) => { const { result, waitForNextUpdate } = renderHook(() => - useDateTimeRangePicker('lastDay') + useDateTimeRangePicker({ initialPreset: 'lastDay' }) ) render(result.current.dateTimeRangePicker) @@ -53,7 +55,9 @@ describe('useDateTimeRangePicker', () => { describe('custom mode', () => { it('enables datetime inputs', () => { - const { result } = renderHook(() => useDateTimeRangePicker('last3Hours')) + const { result } = renderHook(() => + useDateTimeRangePicker({ initialPreset: 'last3Hours' }) + ) render(result.current.dateTimeRangePicker) @@ -69,7 +73,7 @@ describe('useDateTimeRangePicker', () => { it('clicking load after changing date changes range', async () => { const { result, waitForNextUpdate } = renderHook(() => - useDateTimeRangePicker('last3Hours') + useDateTimeRangePicker({ initialPreset: 'last3Hours' }) ) expect(result.current.startTime).toEqual(subHours(date, 3)) expect(result.current.endTime).toEqual(date) @@ -98,7 +102,9 @@ describe('useDateTimeRangePicker', () => { }) it('clicking reset after changing inputs resets inputs', async () => { - const { result } = renderHook(() => useDateTimeRangePicker('last3Hours')) + const { result } = renderHook(() => + useDateTimeRangePicker({ initialPreset: 'last3Hours' }) + ) render(result.current.dateTimeRangePicker) clickByRole('button', 'Choose a time range') @@ -125,7 +131,9 @@ describe('useDateTimeRangePicker', () => { }) it('shows error for invalid range', async () => { - const { result } = renderHook(() => useDateTimeRangePicker('last3Hours')) + const { result } = renderHook(() => + useDateTimeRangePicker({ initialPreset: 'last3Hours' }) + ) render(result.current.dateTimeRangePicker) clickByRole('button', 'Choose a time range') diff --git a/app/components/form/fields/useDateTimeRangePicker.tsx b/app/components/form/fields/useDateTimeRangePicker.tsx index 733d0e536c..fa17dbf1d5 100644 --- a/app/components/form/fields/useDateTimeRangePicker.tsx +++ b/app/components/form/fields/useDateTimeRangePicker.tsx @@ -46,11 +46,15 @@ const dateRangeSchema = Yup.object({ // - no onChange, no way to control any inputs beyond initial preset // - initial preset can't be "custom" +type Args = { + initialPreset: RangeKey +} + /** * Exposes `startTime` and `endTime` plus the whole set of picker UI controls as * a JSX element to render. */ -export function useDateTimeRangePicker(initialPreset: RangeKey) { +export function useDateTimeRangePicker({ initialPreset }: Args) { // default endTime is now, i.e., mount time const now = useMemo(() => new Date(), []) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 80f6059e8a..a0ff6bb766 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -70,7 +70,9 @@ function DiskMetric({ // which means we can easily set the default selected disk to the first one function DiskMetrics({ disks }: { disks: Disk[] }) { const { orgName, projectName } = useRequiredParams('orgName', 'projectName') - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay') + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastDay', + }) invariant(disks.length > 0, 'DiskMetrics should not be rendered with zero disks') const [diskName, setDiskName] = useState(disks[0].name) diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index d439af00a3..91b9e9ea6b 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -54,6 +54,20 @@ function SystemMetric({ value: valueTransform(datum.datum as number), })) + // add fake points for the beginning and end of the time range (lol) + if (data.length > 0) { + const firstPoint = data[0] + const lastPoint = data[data.length - 1] + + if (startTime.getTime() < firstPoint.timestamp) { + data.unshift({ timestamp: startTime.getTime(), value: firstPoint.value }) + } + + if (endTime.getTime() > lastPoint.timestamp) { + data.push({ timestamp: endTime.getTime(), value: lastPoint.value }) + } + } + // TODO: consider adding a fake data point for the end of the requested time range // so it's filled out @@ -87,7 +101,9 @@ export function CapacityUtilizationPage() { const [siloId, setSiloId] = useState(FLEET_ID) const { data: silos } = useApiQuery('siloList', {}) - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastHour') + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastHour', + }) const siloItems = useMemo(() => { const items = silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || [] From 5e42b6a55b5e4797a356668e03ab6ea29346a2b5 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 10:11:07 -0500 Subject: [PATCH 18/23] bring back the custom ticks on disk metrics --- app/components/TimeSeriesChart.tsx | 18 ++++++++++++++++++ .../instances/instance/tabs/MetricsTab.tsx | 1 + 2 files changed, 19 insertions(+) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 469f51e1f4..0c05f48a68 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -10,6 +10,21 @@ import { } 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') @@ -60,6 +75,7 @@ type Props = { width: number height: number interpolation?: 'linear' | 'stepAfter' + customXTicks?: boolean } // Limitations @@ -72,6 +88,7 @@ export function TimeSeriesAreaChart({ width, height, interpolation = 'linear', + customXTicks, }: Props) { return ( @@ -103,6 +120,7 @@ export function TimeSeriesAreaChart({ // TODO: use Date directly as x-axis values type="number" name="Time" + ticks={customXTicks ? getTicks(data, 5) : undefined} // TODO: decide timestamp format based on time range of chart tickFormatter={shortDateTime} tick={textMonoMd} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index a0ff6bb766..1f84138ae8 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -60,6 +60,7 @@ function DiskMetric({ title={title} width={480} height={240} + customXTicks /> ) From e82ccbcaadd30138c7d6ff137024efff0ffbdcd4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 10:47:40 -0500 Subject: [PATCH 19/23] sliding window --- .../form/fields/useDateTimeRangePicker.tsx | 47 ++++++++++++++----- app/pages/system/CapacityUtilizationPage.tsx | 1 + libs/ui/index.ts | 3 ++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/app/components/form/fields/useDateTimeRangePicker.tsx b/app/components/form/fields/useDateTimeRangePicker.tsx index fa17dbf1d5..52aaf6420b 100644 --- a/app/components/form/fields/useDateTimeRangePicker.tsx +++ b/app/components/form/fields/useDateTimeRangePicker.tsx @@ -1,8 +1,9 @@ import * as Yup from 'yup' import { format, subDays, subHours } from 'date-fns' import { Form, Formik } from 'formik' -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' +import { useInterval } from '@oxide/ui' import { Button } from '@oxide/ui' import { ListboxField } from './ListboxField' @@ -48,19 +49,36 @@ const dateRangeSchema = Yup.object({ type Args = { initialPreset: RangeKey + /** + * if set and range is a relative preset, update the range to have `endTime` + * of now every X ms + */ + slideInterval?: number } /** * Exposes `startTime` and `endTime` plus the whole set of picker UI controls as * a JSX element to render. */ -export function useDateTimeRangePicker({ initialPreset }: Args) { +export function useDateTimeRangePicker({ initialPreset, slideInterval }: Args) { // default endTime is now, i.e., mount time const now = useMemo(() => new Date(), []) const [startTime, setStartTime] = useState(computeStart[initialPreset](now)) const [endTime, setEndTime] = useState(now) + // only exists to make current preset value available to window slider + const presetRef = useRef(initialPreset) + + useInterval( + () => { + const now = new Date() + setStartTime(computeStart[initialPreset](now)) + setEndTime(now) + }, + slideInterval && presetRef.current !== 'custom' ? slideInterval : null + ) + // We're using Formik to manage the state of the inputs, but this is not // strictly necessary. It's convenient while we're using `TextField` with // `type=datetime-local` because we get validationSchema and error display for @@ -106,16 +124,21 @@ export function useDateTimeRangePicker({ initialPreset }: Args) { // when we select a preset, set the input values to the range // for that preset and submit the form to update the charts onChange={(item) => { - if (item && item.value !== 'custom') { - const now = new Date() - const newStartTime = computeStart[item.value as RangeKey](now) - setRangeValues(newStartTime, now) - // goofy, but I like the idea of going through the submit - // pathway instead of duplicating the setStates - submitForm() - // TODO: if input is invalid while on custom, e.g., - // because end is before start, changing to a preset does - // not clear the error. changing a second time does + if (item) { + // only done to make the value available to the range window slider interval + presetRef.current = item.value as RangeKeyAll + + if (item.value !== 'custom') { + const now = new Date() + const newStartTime = computeStart[item.value as RangeKey](now) + setRangeValues(newStartTime, now) + // goofy, but I like the idea of going through the submit + // pathway instead of duplicating the setStates + submitForm() + // TODO: if input is invalid while on custom, e.g., + // because end is before start, changing to a preset does + // not clear the error. changing a second time does + } } }} required diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 91b9e9ea6b..dfd0654d64 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -103,6 +103,7 @@ export function CapacityUtilizationPage() { const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ initialPreset: 'lastHour', + slideInterval: 5000, }) const siloItems = useMemo(() => { diff --git a/libs/ui/index.ts b/libs/ui/index.ts index 54d1e65dab..bd7d1a51a4 100644 --- a/libs/ui/index.ts +++ b/libs/ui/index.ts @@ -1,5 +1,8 @@ +import useInterval from './lib/hooks/use-interval' import './styles/index.css' +export { useInterval } + export * from './lib/action-menu/ActionMenu' export * from './lib/avatar/Avatar' export * from './lib/badge/Badge' From aede45af95111744422c066818da82d2482b4871 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 11:04:09 -0500 Subject: [PATCH 20/23] extract system metric --- app/components/SystemMetric.tsx | 77 +++++++++++++++++ app/pages/system/CapacityUtilizationPage.tsx | 88 +------------------- 2 files changed, 80 insertions(+), 85 deletions(-) create mode 100644 app/components/SystemMetric.tsx diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx new file mode 100644 index 0000000000..c25f934006 --- /dev/null +++ b/app/components/SystemMetric.tsx @@ -0,0 +1,77 @@ +import type { ResourceName } from '@oxide/api' +import { useApiQuery } from '@oxide/api' +import { Spinner } from '@oxide/ui' + +import { TimeSeriesAreaChart } from './TimeSeriesChart' + +type SystemMetricProps = { + title: string + startTime: Date + endTime: Date + resourceName: ResourceName + /** Resource to filter data by. Can be fleet, silo, org, project. */ + filterId: string + valueTransform?: (n: number) => number +} + +export function SystemMetric({ + title, + filterId, + startTime, + endTime, + resourceName, + valueTransform = (x) => x, +}: SystemMetricProps) { + // TODO: we're only pulling the first page. Should we bump the cap to 10k? + // Fetch multiple pages if 10k is not enough? That's a bit much. + const { data: metrics, isLoading } = useApiQuery( + 'systemMetricsList', + { id: filterId, resourceName, startTime, endTime }, + { + // TODO: this is actually kind of useless unless the time interval slides forward as time passes + refetchInterval: 5000, + // avoid graphs flashing blank while loading when you change the time + keepPreviousData: true, + } + ) + + const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ + timestamp: timestamp.getTime(), + // all of these metrics are cumulative ints + value: valueTransform(datum.datum as number), + })) + + // add fake points for the beginning and end of the time range (lol) + if (data.length > 0) { + const firstPoint = data[0] + const lastPoint = data[data.length - 1] + + if (startTime.getTime() < firstPoint.timestamp) { + data.unshift({ timestamp: startTime.getTime(), value: firstPoint.value }) + } + + if (endTime.getTime() > lastPoint.timestamp) { + data.push({ timestamp: endTime.getTime(), value: lastPoint.value }) + } + } + + // TODO: indicate time zone somewhere. doesn't have to be in the detail view + // in the tooltip. could be just once on the end of the x-axis like GCP + + return ( +
+

+ {title} {isLoading && } +

+ {/* TODO: this is supposed to be full width */} + +
+ ) +} diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index dfd0654d64..fdee8d6a9c 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -1,97 +1,15 @@ import { useMemo, useState } from 'react' -import type { ResourceName } from '@oxide/api' import { apiQueryClient, useApiQuery } from '@oxide/api' -import { - Divider, - Listbox, - PageHeader, - PageTitle, - Snapshots24Icon, - Spinner, -} from '@oxide/ui' +import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' import { bytesToGiB } from '@oxide/util' -import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart' +import { SystemMetric } from 'app/components/SystemMetric' import { useDateTimeRangePicker } from 'app/components/form' const FLEET_ID = '001de000-1334-4000-8000-000000000000' const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000' -type DiskMetricParams = { - title: string - startTime: Date - endTime: Date - resourceName: ResourceName - siloId: string - valueTransform?: (n: number) => number -} - -function SystemMetric({ - title, - siloId, - startTime, - endTime, - resourceName, - valueTransform = (x) => x, -}: DiskMetricParams) { - // TODO: we're only pulling the first page. Should we bump the cap to 10k? - // Fetch multiple pages if 10k is not enough? That's a bit much. - const { data: metrics, isLoading } = useApiQuery( - 'systemMetricsList', - { id: siloId, resourceName, startTime, endTime }, - { - // TODO: this is actually kind of useless unless the time interval slides forward as time passes - refetchInterval: 5000, - // avoid graphs flashing blank while loading when you change the time - keepPreviousData: true, - } - ) - - const data = (metrics?.items || []).map(({ datum, timestamp }) => ({ - timestamp: timestamp.getTime(), - // all of these metrics are cumulative ints - value: valueTransform(datum.datum as number), - })) - - // add fake points for the beginning and end of the time range (lol) - if (data.length > 0) { - const firstPoint = data[0] - const lastPoint = data[data.length - 1] - - if (startTime.getTime() < firstPoint.timestamp) { - data.unshift({ timestamp: startTime.getTime(), value: firstPoint.value }) - } - - if (endTime.getTime() > lastPoint.timestamp) { - data.push({ timestamp: endTime.getTime(), value: lastPoint.value }) - } - } - - // TODO: consider adding a fake data point for the end of the requested time range - // so it's filled out - - // TODO: indicate time zone somewhere. doesn't have to be in the detail view - // in the tooltip. could be just once on the end of the x-axis like GCP - - return ( -
-

- {title} {isLoading && } -

- {/* TODO: this is supposed to be full width */} - -
- ) -} - CapacityUtilizationPage.loader = async () => { await apiQueryClient.prefetchQuery('siloList', {}) } @@ -115,7 +33,7 @@ export function CapacityUtilizationPage() { ] }, [silos]) - const commonProps = { startTime, endTime, siloId } + const commonProps = { startTime, endTime, filterId: siloId } return ( <> From 842e93ec3d2c695fd27981232612620a1b4ac334 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 11:41:26 -0500 Subject: [PATCH 21/23] fix bug in refetch interval --- app/components/form/fields/useDateTimeRangePicker.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/components/form/fields/useDateTimeRangePicker.tsx b/app/components/form/fields/useDateTimeRangePicker.tsx index 52aaf6420b..aec2324c9c 100644 --- a/app/components/form/fields/useDateTimeRangePicker.tsx +++ b/app/components/form/fields/useDateTimeRangePicker.tsx @@ -72,9 +72,11 @@ export function useDateTimeRangePicker({ initialPreset, slideInterval }: Args) { useInterval( () => { - const now = new Date() - setStartTime(computeStart[initialPreset](now)) - setEndTime(now) + if (presetRef.current !== 'custom') { + const now = new Date() + setStartTime(computeStart[presetRef.current](now)) + setEndTime(now) + } }, slideInterval && presetRef.current !== 'custom' ? slideInterval : null ) From a092c0a365c8207c1751b4ab07bd55889b5ccce0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 11:42:45 -0500 Subject: [PATCH 22/23] silo utilization page with org and project picker --- app/layouts/SiloLayout.tsx | 5 +- app/pages/SiloUtilizationPage.tsx | 138 ++++++++++++++++++++++++++++++ app/routes.tsx | 6 ++ app/util/path-builder.ts | 2 + 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 app/pages/SiloUtilizationPage.tsx diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 7bddcaff7a..93ba513305 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -1,4 +1,4 @@ -import { Divider, Organization16Icon } from '@oxide/ui' +import { Divider, Organization16Icon, Snapshots16Icon } from '@oxide/ui' import { DocsLinkItem, NavLinkItem, Sidebar } from 'app/components/Sidebar' import { TopBar } from 'app/components/TopBar' @@ -20,6 +20,9 @@ export default function SiloLayout() { Organizations + + Utilization + diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx new file mode 100644 index 0000000000..10128dd9c5 --- /dev/null +++ b/app/pages/SiloUtilizationPage.tsx @@ -0,0 +1,138 @@ +import { useMemo, useState } from 'react' + +import { apiQueryClient, useApiQuery } from '@oxide/api' +import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' +import { bytesToGiB } from '@oxide/util' + +import { SystemMetric } from 'app/components/SystemMetric' +import { useDateTimeRangePicker } from 'app/components/form' + +const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000' +const ALL_PROJECTS = '|ALL_PROJECTS|' + +SiloUtilizationPage.loader = async () => { + await apiQueryClient.prefetchQuery('organizationList', {}) +} + +const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id }) + +export function SiloUtilizationPage() { + // this will come from /session/me + const siloId = DEFAULT_SILO_ID + + const [orgId, setOrgId] = useState(siloId) + const [projectId, setProjectId] = useState(null) + const { data: orgs } = useApiQuery('organizationList', {}) + + const orgName = orgs?.items.find((o) => orgId && o.id === orgId)?.name + + const { data: projects } = useApiQuery( + 'projectList', + { orgName: orgName! }, // only enabled if it's there + { enabled: !!orgName } + ) + + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastHour', + slideInterval: 5000, + }) + + const orgItems = useMemo(() => { + const items = orgs?.items.map(toListboxItem) || [] + return [{ label: 'All orgs', value: siloId }, ...items] + }, [orgs, siloId]) + + const projectItems = useMemo(() => { + const items = projects?.items.map(toListboxItem) || [] + return [{ label: 'All projects', value: ALL_PROJECTS }, ...items] + }, [projects]) + + const filterId = projectId || orgId + + const commonProps = { startTime, endTime, filterId } + + return ( + <> + + }>Capacity & Utilization + + +
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ { + if (item) { + setOrgId(item.value) + setProjectId(null) + } + }} + /> + {/* TODO: need a button to clear the silo */} +
+ + {orgId !== DEFAULT_SILO_ID && ( +
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ { + if (item) { + setProjectId(item.value === ALL_PROJECTS ? null : item.value) + } + }} + /> +
+ )} + + {dateTimeRangePicker} +
+ {/* TODO: this divider is supposed to go all the way across */} + + +
+ {/* TODO: convert numbers to GiB PLEASE */} + + + {/* TODO: figure out how to make this not show .5s in the y axis when the numbers are low */} + + + +
+ + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 10767caad3..97cba89986 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -18,6 +18,7 @@ import NotFound from './pages/NotFound' import { OrgAccessPage } from './pages/OrgAccessPage' import OrgsPage from './pages/OrgsPage' import ProjectsPage from './pages/ProjectsPage' +import { SiloUtilizationPage } from './pages/SiloUtilizationPage' import { DisksPage, ImagesPage, @@ -104,6 +105,11 @@ export const routes = createRoutesFromElements( /> }> + } + loader={SiloUtilizationPage.loader} + /> } diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 31ef35fbaa..a18f10aee7 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -32,6 +32,8 @@ export const pb = { vpc: (params: PP.Vpc) => `${pb.vpcs(params)}/${params.vpcName}`, vpcEdit: (params: PP.Vpc) => `${pb.vpc(params)}/edit`, + utilization: () => '/utilization', // silo metrics + system: () => '/sys', systemIssues: () => '/sys/issues', systemUtilization: () => '/sys/utilization', From 21e21aea4c7f5e69eba20ba93b3136bd355beb82 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 7 Oct 2022 11:54:20 -0500 Subject: [PATCH 23/23] update path builder snapshot --- app/util/path-builder.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index ca39d95f84..c32dd90977 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -49,6 +49,7 @@ test('path builder', () => { "systemSettings": "/sys/settings", "systemUpdate": "/sys/update", "systemUtilization": "/sys/utilization", + "utilization": "/utilization", "vpc": "/orgs/a/projects/b/vpcs/d", "vpcEdit": "/orgs/a/projects/b/vpcs/d/edit", "vpcNew": "/orgs/a/projects/b/vpcs-new",