From ca979b22b94a6078ac88c8d8f436f2b58f4cf9fc Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 19 Jan 2023 10:06:58 -0600 Subject: [PATCH 1/7] kinda do something --- OMICRON_VERSION | 2 +- app/components/SystemMetric.tsx | 5 +- app/routes.tsx | 3 +- libs/api-mocks/msw/handlers.ts | 33 +++- libs/api/__generated__/Api.ts | 227 ++++++++++++++++++++++++- libs/api/__generated__/OMICRON_VERSION | 2 +- libs/api/__generated__/msw-handlers.ts | 78 +++++++++ libs/api/__generated__/validate.ts | 124 +++++++++++++- 8 files changed, 458 insertions(+), 16 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 65d586c405..067484141e 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -5438a551ec03db97f67da170882ba458ed708280 +4cf991a10b8919625f3358fa4e0eb978eeda8da9 diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index 44ea30396d..f24db19427 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -1,4 +1,4 @@ -// import type { SystemMetricName } from '@oxide/api' +import type { SystemMetricName } from '@oxide/api' // import { useApiQuery } from '@oxide/api' import type { MeasurementResultsPage } from '@oxide/api' import { Spinner } from '@oxide/ui' @@ -9,8 +9,7 @@ type SystemMetricProps = { title: string startTime: Date endTime: Date - metricName: string - // metricName: SystemMetricName + metricName: SystemMetricName /** Resource to filter data by. Can be fleet, silo, org, project. */ filterId: string valueTransform?: (n: number) => number diff --git a/app/routes.tsx b/app/routes.tsx index ae660d4dca..2fbbd67e3e 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -46,6 +46,7 @@ import { SerialConsoleTab } from './pages/project/instances/instance/tabs/Serial import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { ProfilePage } from './pages/settings/ProfilePage' import { SSHKeysPage } from './pages/settings/SSHKeysPage' +import { CapacityUtilizationPage } from './pages/system/CapacityUtilizationPage' import { SiloPage } from './pages/system/SiloPage' import SilosPage from './pages/system/SilosPage' import { pb } from './util/path-builder' @@ -96,7 +97,7 @@ export const routes = createRoutesFromElements( } /> - + } /> diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index a00c6946ad..b0a7ec8afc 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -5,7 +5,7 @@ import type { Json } from '@oxide/gen/msw-handlers' import { json, makeHandlers } from '@oxide/gen/msw-handlers' import { pick, sortBy } from '@oxide/util' -import { genCumulativeI64Data } from '../metrics' +import { genCumulativeI64Data, genI64Data } from '../metrics' import { FLEET_ID } from '../role-assignment' import { serial } from '../serial' import { defaultSilo, toIdp } from '../silo' @@ -910,6 +910,29 @@ export const handlers = makeHandlers({ return { role_assignments } }, + systemMetric: (params) => { + // const result = ZVal.ResourceName.safeParse(req.params.resourceName) + // if (!result.success) return res(notFoundErr) + // const resourceName = result.data + + const cap = params.path.metricName === '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 + + const { startTime, endTime } = getStartAndEndTime(params.query) + + if (endTime <= startTime) return { items: [] } + + return { + items: genI64Data( + new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * cap)), + startTime, + endTime + ), + } + }, + diskViewById: lookupById(db.disks), imageViewById: lookupById(db.images), instanceNetworkInterfaceViewById: lookupById(db.networkInterfaces), @@ -990,4 +1013,12 @@ export const handlers = makeHandlers({ projectPolicyViewV1: NotImplemented, projectUpdateV1: NotImplemented, projectViewV1: NotImplemented, + + diskListV1: NotImplemented, + diskCreateV1: NotImplemented, + diskViewV1: NotImplemented, + diskDeleteV1: NotImplemented, + instanceDiskListV1: NotImplemented, + instanceDiskAttachV1: NotImplemented, + instanceDiskDetachV1: NotImplemented, }) diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index a394bf30f0..10065094b7 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -251,10 +251,14 @@ export type DiskCreate = { } /** - * Parameters for the {@link Disk} to be attached or detached to an instance + * TODO-v1: Delete this Parameters for the {@link Disk} to be attached or detached to an instance */ export type DiskIdentifier = { name: Name } +export type NameOrId = string | Name + +export type DiskPath = { disk: NameOrId } + /** * A single page of results */ @@ -1767,7 +1771,10 @@ export type DiskMetricName = | 'write' | 'write_bytes' -export type NameOrId = string | Name +export type SystemMetricName = + | 'virtual_disk_space_provisioned' + | 'cpus_provisioned' + | 'ram_provisioned' export interface DiskViewByIdPathParams { id: string @@ -2431,6 +2438,18 @@ export interface IpPoolServiceRangeListQueryParams { pageToken?: string } +export interface SystemMetricPathParams { + metricName: SystemMetricName +} + +export interface SystemMetricQueryParams { + endTime?: Date + id?: string + limit?: number + pageToken?: string + startTime?: Date +} + export interface SagaListQueryParams { limit?: number pageToken?: string @@ -2532,12 +2551,43 @@ export interface UserListQueryParams { sortBy?: IdSortMode } +export interface DiskListV1QueryParams { + limit?: number + organization?: NameOrId + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode +} + +export interface DiskCreateV1QueryParams { + organization?: NameOrId + project?: NameOrId +} + +export interface DiskViewV1PathParams { + disk: NameOrId +} + +export interface DiskViewV1QueryParams { + organization?: NameOrId + project?: NameOrId +} + +export interface DiskDeleteV1PathParams { + disk: NameOrId +} + +export interface DiskDeleteV1QueryParams { + organization?: NameOrId + project?: NameOrId +} + export interface InstanceListV1QueryParams { limit?: number organization?: NameOrId pageToken?: string project?: NameOrId - sortBy?: NameSortMode + sortBy?: NameOrIdSortMode } export interface InstanceCreateV1QueryParams { @@ -2563,6 +2613,36 @@ export interface InstanceDeleteV1QueryParams { project?: NameOrId } +export interface InstanceDiskListV1PathParams { + instance: NameOrId +} + +export interface InstanceDiskListV1QueryParams { + limit?: number + organization?: NameOrId + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode +} + +export interface InstanceDiskAttachV1PathParams { + instance: NameOrId +} + +export interface InstanceDiskAttachV1QueryParams { + organization?: NameOrId + project?: NameOrId +} + +export interface InstanceDiskDetachV1PathParams { + instance: NameOrId +} + +export interface InstanceDiskDetachV1QueryParams { + organization?: NameOrId + project?: NameOrId +} + export interface InstanceMigrateV1PathParams { instance: NameOrId } @@ -3200,7 +3280,7 @@ export class Api extends HttpClient { }) }, /** - * Create a disk + * Use `POST /v1/disks` instead */ diskCreate: ( { path, body }: { path: DiskCreatePathParams; body: DiskCreate }, @@ -3226,7 +3306,7 @@ export class Api extends HttpClient { }) }, /** - * Delete a disk + * Use `DELETE /v1/disks/{disk}` instead */ diskDelete: ({ path }: { path: DiskDeletePathParams }, params: RequestParams = {}) => { const { diskName, orgName, projectName } = path @@ -4483,6 +4563,24 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Access metrics data + */ + systemMetric: ( + { + path, + query = {}, + }: { path: SystemMetricPathParams; query?: SystemMetricQueryParams }, + params: RequestParams = {} + ) => { + const { metricName } = path + return this.request({ + path: `/system/metrics/${metricName}`, + method: 'GET', + query, + ...params, + }) + }, /** * Fetch the top-level IAM policy */ @@ -4803,6 +4901,68 @@ export class Api extends HttpClient { ...params, }) }, + /** + * List disks + */ + diskListV1: ( + { query = {} }: { query?: DiskListV1QueryParams }, + params: RequestParams = {} + ) => { + return this.request({ + path: `/v1/disks`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create a disk + */ + diskCreateV1: ( + { query = {}, body }: { query?: DiskCreateV1QueryParams; body: DiskCreate }, + params: RequestParams = {} + ) => { + return this.request({ + path: `/v1/disks`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Fetch a disk + */ + diskViewV1: ( + { path, query = {} }: { path: DiskViewV1PathParams; query?: DiskViewV1QueryParams }, + params: RequestParams = {} + ) => { + const { disk } = path + return this.request({ + path: `/v1/disks/${disk}`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Delete a disk + */ + diskDeleteV1: ( + { + path, + query = {}, + }: { path: DiskDeleteV1PathParams; query?: DiskDeleteV1QueryParams }, + params: RequestParams = {} + ) => { + const { disk } = path + return this.request({ + path: `/v1/disks/${disk}`, + method: 'DELETE', + query, + ...params, + }) + }, /** * List instances */ @@ -4868,6 +5028,63 @@ export class Api extends HttpClient { ...params, }) }, + instanceDiskListV1: ( + { + path, + query = {}, + }: { path: InstanceDiskListV1PathParams; query?: InstanceDiskListV1QueryParams }, + params: RequestParams = {} + ) => { + const { instance } = path + return this.request({ + path: `/v1/instances/${instance}/disks`, + method: 'GET', + query, + ...params, + }) + }, + instanceDiskAttachV1: ( + { + path, + query = {}, + body, + }: { + path: InstanceDiskAttachV1PathParams + query?: InstanceDiskAttachV1QueryParams + body: DiskPath + }, + params: RequestParams = {} + ) => { + const { instance } = path + return this.request({ + path: `/v1/instances/${instance}/disks/attach`, + method: 'POST', + body, + query, + ...params, + }) + }, + instanceDiskDetachV1: ( + { + path, + query = {}, + body, + }: { + path: InstanceDiskDetachV1PathParams + query?: InstanceDiskDetachV1QueryParams + body: DiskPath + }, + params: RequestParams = {} + ) => { + const { instance } = path + return this.request({ + path: `/v1/instances/${instance}/disks/detach`, + method: 'POST', + body, + query, + ...params, + }) + }, /** * Migrate an instance */ diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 249c7f45fe..f85a91a0ec 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 -5438a551ec03db97f67da170882ba458ed708280 +4cf991a10b8919625f3358fa4e0eb978eeda8da9 diff --git a/libs/api/__generated__/msw-handlers.ts b/libs/api/__generated__/msw-handlers.ts index 3fff334fd0..f0fca781e3 100644 --- a/libs/api/__generated__/msw-handlers.ts +++ b/libs/api/__generated__/msw-handlers.ts @@ -507,6 +507,11 @@ export interface MSWHandlers { }) => HandlerResult /** `POST /system/ip-pools-service/ranges/remove` */ ipPoolServiceRangeRemove: (params: { body: Json }) => StatusCode + /** `GET /system/metrics/:metricName` */ + systemMetric: (params: { + path: Api.SystemMetricPathParams + query: Api.SystemMetricQueryParams + }) => HandlerResult /** `GET /system/policy` */ systemPolicyView: () => HandlerResult /** `PUT /system/policy` */ @@ -589,6 +594,25 @@ export interface MSWHandlers { userList: (params: { query: Api.UserListQueryParams }) => HandlerResult + /** `GET /v1/disks` */ + diskListV1: (params: { + query: Api.DiskListV1QueryParams + }) => HandlerResult + /** `POST /v1/disks` */ + diskCreateV1: (params: { + query: Api.DiskCreateV1QueryParams + body: Json + }) => HandlerResult + /** `GET /v1/disks/:disk` */ + diskViewV1: (params: { + path: Api.DiskViewV1PathParams + query: Api.DiskViewV1QueryParams + }) => HandlerResult + /** `DELETE /v1/disks/:disk` */ + diskDeleteV1: (params: { + path: Api.DiskDeleteV1PathParams + query: Api.DiskDeleteV1QueryParams + }) => StatusCode /** `GET /v1/instances` */ instanceListV1: (params: { query: Api.InstanceListV1QueryParams @@ -608,6 +632,23 @@ export interface MSWHandlers { path: Api.InstanceDeleteV1PathParams query: Api.InstanceDeleteV1QueryParams }) => StatusCode + /** `GET /v1/instances/:instance/disks` */ + instanceDiskListV1: (params: { + path: Api.InstanceDiskListV1PathParams + query: Api.InstanceDiskListV1QueryParams + }) => HandlerResult + /** `POST /v1/instances/:instance/disks/attach` */ + instanceDiskAttachV1: (params: { + path: Api.InstanceDiskAttachV1PathParams + query: Api.InstanceDiskAttachV1QueryParams + body: Json + }) => HandlerResult + /** `POST /v1/instances/:instance/disks/detach` */ + instanceDiskDetachV1: (params: { + path: Api.InstanceDiskDetachV1PathParams + query: Api.InstanceDiskDetachV1QueryParams + body: Json + }) => HandlerResult /** `POST /v1/instances/:instance/migrate` */ instanceMigrateV1: (params: { path: Api.InstanceMigrateV1PathParams @@ -1342,6 +1383,10 @@ export function makeHandlers(handlers: MSWHandlers): RestHandler[] { '/system/ip-pools-service/ranges/remove', handler(handlers['ipPoolServiceRangeRemove'], null, schema.IpRange) ), + rest.get( + '/system/metrics/:metricName', + handler(handlers['systemMetric'], schema.SystemMetricParams, null) + ), rest.get('/system/policy', handler(handlers['systemPolicyView'], null, null)), rest.put( '/system/policy', @@ -1440,6 +1485,19 @@ export function makeHandlers(handlers: MSWHandlers): RestHandler[] { handler(handlers['timeseriesSchemaGet'], schema.TimeseriesSchemaGetParams, null) ), rest.get('/users', handler(handlers['userList'], schema.UserListParams, null)), + rest.get('/v1/disks', handler(handlers['diskListV1'], schema.DiskListV1Params, null)), + rest.post( + '/v1/disks', + handler(handlers['diskCreateV1'], schema.DiskCreateV1Params, schema.DiskCreate) + ), + rest.get( + '/v1/disks/:disk', + handler(handlers['diskViewV1'], schema.DiskViewV1Params, null) + ), + rest.delete( + '/v1/disks/:disk', + handler(handlers['diskDeleteV1'], schema.DiskDeleteV1Params, null) + ), rest.get( '/v1/instances', handler(handlers['instanceListV1'], schema.InstanceListV1Params, null) @@ -1460,6 +1518,26 @@ export function makeHandlers(handlers: MSWHandlers): RestHandler[] { '/v1/instances/:instance', handler(handlers['instanceDeleteV1'], schema.InstanceDeleteV1Params, null) ), + rest.get( + '/v1/instances/:instance/disks', + handler(handlers['instanceDiskListV1'], schema.InstanceDiskListV1Params, null) + ), + rest.post( + '/v1/instances/:instance/disks/attach', + handler( + handlers['instanceDiskAttachV1'], + schema.InstanceDiskAttachV1Params, + schema.DiskPath + ) + ), + rest.post( + '/v1/instances/:instance/disks/detach', + handler( + handlers['instanceDiskDetachV1'], + schema.InstanceDiskDetachV1Params, + schema.DiskPath + ) + ), rest.post( '/v1/instances/:instance/migrate', handler( diff --git a/libs/api/__generated__/validate.ts b/libs/api/__generated__/validate.ts index 53440874d7..409c8e7f83 100644 --- a/libs/api/__generated__/validate.ts +++ b/libs/api/__generated__/validate.ts @@ -280,10 +280,17 @@ export const DiskCreate = z.preprocess( ) /** - * Parameters for the {@link Disk} to be attached or detached to an instance + * TODO-v1: Delete this Parameters for the {@link Disk} to be attached or detached to an instance */ export const DiskIdentifier = z.preprocess(processResponseBody, z.object({ name: Name })) +export const NameOrId = z.preprocess( + processResponseBody, + z.union([z.string().uuid(), Name]) +) + +export const DiskPath = z.preprocess(processResponseBody, z.object({ disk: NameOrId })) + /** * A single page of results */ @@ -1793,9 +1800,9 @@ export const DiskMetricName = z.preprocess( z.enum(['activated', 'flush', 'read', 'read_bytes', 'write', 'write_bytes']) ) -export const NameOrId = z.preprocess( +export const SystemMetricName = z.preprocess( processResponseBody, - z.union([z.string().uuid(), Name]) + z.enum(['virtual_disk_space_provisioned', 'cpus_provisioned', 'ram_provisioned']) ) export const DiskViewByIdParams = z.preprocess( @@ -3192,6 +3199,22 @@ export const IpPoolServiceRangeRemoveParams = z.preprocess( }) ) +export const SystemMetricParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + metricName: SystemMetricName, + }), + query: z.object({ + endTime: DateType.optional(), + id: z.string().uuid().optional(), + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + startTime: DateType.optional(), + }), + }) +) + export const SystemPolicyViewParams = z.preprocess( processResponseBody, z.object({ @@ -3435,6 +3458,57 @@ export const UserListParams = z.preprocess( }) ) +export const DiskListV1Params = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).optional(), + organization: NameOrId.optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const DiskCreateV1Params = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + organization: NameOrId.optional(), + project: NameOrId.optional(), + }), + }) +) + +export const DiskViewV1Params = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + disk: NameOrId, + }), + query: z.object({ + organization: NameOrId.optional(), + project: NameOrId.optional(), + }), + }) +) + +export const DiskDeleteV1Params = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + disk: NameOrId, + }), + query: z.object({ + organization: NameOrId.optional(), + project: NameOrId.optional(), + }), + }) +) + export const InstanceListV1Params = z.preprocess( processResponseBody, z.object({ @@ -3444,7 +3518,7 @@ export const InstanceListV1Params = z.preprocess( organization: NameOrId.optional(), pageToken: z.string().optional(), project: NameOrId.optional(), - sortBy: NameSortMode.optional(), + sortBy: NameOrIdSortMode.optional(), }), }) ) @@ -3486,6 +3560,48 @@ export const InstanceDeleteV1Params = z.preprocess( }) ) +export const InstanceDiskListV1Params = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + instance: NameOrId, + }), + query: z.object({ + limit: z.number().min(1).max(4294967295).optional(), + organization: NameOrId.optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const InstanceDiskAttachV1Params = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + instance: NameOrId, + }), + query: z.object({ + organization: NameOrId.optional(), + project: NameOrId.optional(), + }), + }) +) + +export const InstanceDiskDetachV1Params = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + instance: NameOrId, + }), + query: z.object({ + organization: NameOrId.optional(), + project: NameOrId.optional(), + }), + }) +) + export const InstanceMigrateV1Params = z.preprocess( processResponseBody, z.object({ From ee0ebfed3afd13b23fd8dcded1675b0fe5340805 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Fri, 20 Jan 2023 16:28:54 +0000 Subject: [PATCH 2/7] Metrics design touchup (#1331) * Metrics design improvements - Area chart -> Line chart - Shorter day format for same day metrics - Tweak colours - Tweak spacing - Wrapper with border - Separating/styling title and unit * Round the tooltip corners * Cleanup * Unify props for both charts * Add elevation to tooltip * Tweak chart padding and height --- app/components/SystemMetric.tsx | 2 + app/components/TimeSeriesChart.tsx | 133 ++++++++++++++---- .../instances/instance/tabs/MetricsTab.tsx | 51 ++++--- 3 files changed, 145 insertions(+), 41 deletions(-) diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index f24db19427..3b24d367e2 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -72,6 +72,8 @@ export function SystemMetric({ width={480} height={240} interpolation="stepAfter" + startTime={startTime} + endTime={endTime} /> ) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 76d57aa29d..a784829f39 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -1,8 +1,11 @@ +import cn from 'classnames' import { format } from 'date-fns' import { Area, AreaChart, CartesianGrid, + Line, + LineChart, ResponsiveContainer, Tooltip, XAxis, @@ -19,25 +22,42 @@ 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 + const maxIdx = data.length > 10 ? Math.floor((data.length - 1) * 0.8) : data.length - 1 + const startOffset = Math.floor((data.length - maxIdx) * 0.6) // 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))) + const idxs = new Array(n) + .fill(0) + .map((_, i) => Math.floor((maxIdx * i) / (n - 1) + startOffset)) return idxs.map((i) => data[i].timestamp) } +/** + * Check if the start and end time are on the same day + * If they are we can omit the day/month in the date time format + */ +function isSameDay(d1: Date, d2: Date) { + return ( + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() + ) +} + const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm') +const shortTime = (ts: number) => format(new Date(ts), '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(--stroke-default)' const GRID_GRAY = 'var(--stroke-secondary)' -const GREEN = 'var(--base-green-500)' +const GREEN_400 = 'var(--theme-accent-400)' +const GREEN_600 = 'var(--theme-accent-600)' +const GREEN_800 = 'var(--theme-accent-800)' // 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', + fontSize: '0.6875rem', fontFamily: '"GT America Mono", monospace', + fill: 'var(--content-quaternary)', } function renderTooltip(props: TooltipProps) { @@ -50,10 +70,10 @@ function renderTooltip(props: TooltipProps) { } = payload[0] if (!timestamp || !value) return null return ( -
+
{longDateTime(timestamp)}
-
{name}
+
{name}
{value}
{/* TODO: unit on value if relevant */}
@@ -76,6 +96,8 @@ type Props = { height: number interpolation?: 'linear' | 'stepAfter' customXTicks?: boolean + startTime: Date + endTime: Date } // Limitations @@ -89,31 +111,83 @@ export function TimeSeriesAreaChart({ height, interpolation = 'linear', customXTicks, + startTime, + endTime, }: Props) { return ( - + + + + {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} + + + + ) +} + +export function TimeSeriesLineChart({ + className, + data, + title, + width, + height, + interpolation = 'linear', + customXTicks, + startTime, + endTime, +}: Props) { + return ( + + + {/* 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 feb3753858..60a15d92c9 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -5,12 +5,13 @@ import type { Cumulativeint64, Disk, DiskMetricName } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Listbox, Spinner } from '@oxide/ui' -import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart' +import { TimeSeriesLineChart } from 'app/components/TimeSeriesChart' import { useDateTimeRangePicker } from 'app/components/form' import { useRequiredParams } from 'app/hooks' type DiskMetricParams = { title: string + unit?: string startTime: Date endTime: Date metricName: DiskMetricName @@ -20,6 +21,7 @@ type DiskMetricParams = { function DiskMetric({ title, + unit, startTime, endTime, metricName, @@ -47,17 +49,20 @@ function DiskMetric({ // in the tooltip. could be just once on the end of the x-axis like GCP return ( -
-

- {title} {isLoading && } +
+

+ {title} {unit &&
{unit}
} + {isLoading && }

-
) @@ -96,18 +101,32 @@ function DiskMetrics({ disks }: { disks: Disk[] }) { {dateTimeRangePicker}

- {/* TODO: separate "Reads" from "(count)" so we can - a) style them differently in the title, and - b) show "Reads" but not "(count)" in the Tooltip? - */} -
+
{/* see the following link for the source of truth on what these mean https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} - - - - - +
+ + +
+ +
+ + +
+ +
+ +
) From 4c28994a70e1c274655f1bcf76ef6cdab1c1f967 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 20 Jan 2023 10:52:29 -0600 Subject: [PATCH 3/7] turns out that in order to show the data, you have to fetch the data --- app/components/SystemMetric.tsx | 31 +++++++------- app/components/TimeSeriesChart.tsx | 66 ------------------------------ app/routes.tsx | 7 +++- 3 files changed, 21 insertions(+), 83 deletions(-) diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index 3b24d367e2..72849f9499 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -1,9 +1,8 @@ import type { SystemMetricName } from '@oxide/api' -// import { useApiQuery } from '@oxide/api' -import type { MeasurementResultsPage } from '@oxide/api' +import { useApiQuery } from '@oxide/api' import { Spinner } from '@oxide/ui' -import { TimeSeriesAreaChart } from './TimeSeriesChart' +import { TimeSeriesLineChart } from './TimeSeriesChart' type SystemMetricProps = { title: string @@ -19,22 +18,22 @@ export function SystemMetric({ title, startTime, endTime, + metricName, + filterId, 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( - // 'systemMetric', - // { id: filterId, metricName, 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 metrics: MeasurementResultsPage = { items: [] } - const isLoading = false + const { data: metrics, isLoading } = useApiQuery( + 'systemMetric', + { path: { metricName }, query: { id: filterId, 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(), @@ -65,7 +64,7 @@ export function SystemMetric({ {title} {isLoading && } {/* TODO: this is supposed to be full width */} - - - - - - {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} - - - - - ) -} - export function TimeSeriesLineChart({ className, data, diff --git a/app/routes.tsx b/app/routes.tsx index 2fbbd67e3e..74ae5c513d 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -31,6 +31,7 @@ import { OrgAccessPage } from './pages/OrgAccessPage' import OrgsPage from './pages/OrgsPage' import ProjectsPage from './pages/ProjectsPage' import { SiloAccessPage } from './pages/SiloAccessPage' +import { SiloUtilizationPage } from './pages/SiloUtilizationPage' import { DisksPage, ImagesPage, @@ -115,7 +116,11 @@ export const routes = createRoutesFromElements( /> }> - + } + loader={SiloUtilizationPage.loader} + /> } loader={OrgsPage.loader}> Date: Fri, 20 Jan 2023 10:56:09 -0600 Subject: [PATCH 4/7] get rid of customXTicks prop, make it always custom --- app/components/TimeSeriesChart.tsx | 4 +--- app/pages/project/instances/instance/tabs/MetricsTab.tsx | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 43065b3cb1..72876a4252 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -93,7 +93,6 @@ type Props = { width: number height: number interpolation?: 'linear' | 'stepAfter' - customXTicks?: boolean startTime: Date endTime: Date } @@ -105,7 +104,6 @@ export function TimeSeriesLineChart({ width, height, interpolation = 'linear', - customXTicks, startTime, endTime, }: Props) { @@ -130,7 +128,7 @@ export function TimeSeriesLineChart({ // TODO: use Date directly as x-axis values type="number" name="Time" - ticks={customXTicks ? getTicks(data, 5) : undefined} + ticks={getTicks(data, 5)} tickFormatter={isSameDay(startTime, endTime) ? shortTime : shortDateTime} tick={textMonoMd} tickMargin={8} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 60a15d92c9..f59dd888be 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -60,7 +60,6 @@ function DiskMetric({ title={title} width={480} height={240} - customXTicks startTime={startTime} endTime={endTime} /> From 0b6ee0e77025d78f95f9ac30ff9f991f0472fbf7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 20 Jan 2023 11:07:59 -0600 Subject: [PATCH 5/7] code split silo and system utilization pages --- app/pages/SiloUtilizationPage.tsx | 9 ++------- app/pages/silo-utilization-loader.ts | 7 +++++++ app/pages/system/CapacityUtilizationPage.tsx | 9 ++------- app/pages/system/capacity-utilization-loader.ts | 7 +++++++ app/routes.tsx | 17 +++++++++++++---- 5 files changed, 31 insertions(+), 18 deletions(-) create mode 100644 app/pages/silo-utilization-loader.ts create mode 100644 app/pages/system/capacity-utilization-loader.ts diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx index c7f71795a2..5939b5fc82 100644 --- a/app/pages/SiloUtilizationPage.tsx +++ b/app/pages/SiloUtilizationPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' -import { apiQueryClient, useApiQuery } from '@oxide/api' +import { useApiQuery } from '@oxide/api' import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' import { bytesToGiB } from '@oxide/util' @@ -10,14 +10,9 @@ 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', {}) - return null -} - const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id }) -export function SiloUtilizationPage() { +export default function SiloUtilizationPage() { // this will come from /session/me const siloId = DEFAULT_SILO_ID diff --git a/app/pages/silo-utilization-loader.ts b/app/pages/silo-utilization-loader.ts new file mode 100644 index 0000000000..7c334cc8f7 --- /dev/null +++ b/app/pages/silo-utilization-loader.ts @@ -0,0 +1,7 @@ +/** Separate file because the page component is code-split and loaded async */ +import { apiQueryClient } from '@oxide/api' + +export const siloUtilizationPageloader = async () => { + await apiQueryClient.prefetchQuery('organizationList', {}) + return null +} diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 3fad1a9361..8548201ecc 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' -import { apiQueryClient, useApiQuery } from '@oxide/api' +import { useApiQuery } from '@oxide/api' import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' import { bytesToGiB } from '@oxide/util' @@ -10,12 +10,7 @@ import { useDateTimeRangePicker } from 'app/components/form' const FLEET_ID = '001de000-1334-4000-8000-000000000000' const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000' -CapacityUtilizationPage.loader = async () => { - await apiQueryClient.prefetchQuery('siloList', {}) - return null -} - -export function CapacityUtilizationPage() { +export default function CapacityUtilizationPage() { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ const [siloId, setSiloId] = useState(FLEET_ID) const { data: silos } = useApiQuery('siloList', {}) diff --git a/app/pages/system/capacity-utilization-loader.ts b/app/pages/system/capacity-utilization-loader.ts new file mode 100644 index 0000000000..188caffcd3 --- /dev/null +++ b/app/pages/system/capacity-utilization-loader.ts @@ -0,0 +1,7 @@ +/** Separate file because the page component is code-split and loaded async */ +import { apiQueryClient } from '@oxide/api' + +export const capacityUtilizationPageLoader = async () => { + await apiQueryClient.prefetchQuery('siloList', {}) + return null +} diff --git a/app/routes.tsx b/app/routes.tsx index 74ae5c513d..59ee35d604 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -31,7 +31,6 @@ import { OrgAccessPage } from './pages/OrgAccessPage' import OrgsPage from './pages/OrgsPage' import ProjectsPage from './pages/ProjectsPage' import { SiloAccessPage } from './pages/SiloAccessPage' -import { SiloUtilizationPage } from './pages/SiloUtilizationPage' import { DisksPage, ImagesPage, @@ -47,7 +46,7 @@ import { SerialConsoleTab } from './pages/project/instances/instance/tabs/Serial import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { ProfilePage } from './pages/settings/ProfilePage' import { SSHKeysPage } from './pages/settings/SSHKeysPage' -import { CapacityUtilizationPage } from './pages/system/CapacityUtilizationPage' +import { siloUtilizationPageloader } from './pages/silo-utilization-loader' import { SiloPage } from './pages/system/SiloPage' import SilosPage from './pages/system/SilosPage' import { pb } from './util/path-builder' @@ -56,6 +55,12 @@ const MetricsTab = React.lazy( () => import('./pages/project/instances/instance/tabs/MetricsTab') ) +// loaders are imported separately the normal way +const SiloUtilizationPage = React.lazy(() => import('./pages/SiloUtilizationPage')) +const CapacityUtilizationPage = React.lazy( + () => import('./pages/system/CapacityUtilizationPage') +) + const orgCrumb: CrumbFunc = (m) => m.params.orgName! const projectCrumb: CrumbFunc = (m) => m.params.projectName! const instanceCrumb: CrumbFunc = (m) => m.params.instanceName! @@ -118,8 +123,12 @@ export const routes = createRoutesFromElements( }> } - loader={SiloUtilizationPage.loader} + element={ + + + + } + loader={siloUtilizationPageloader} /> } loader={OrgsPage.loader}> From 585d9d2f557d45c2742ff95af4db583fc75cff2e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 20 Jan 2023 11:28:14 -0600 Subject: [PATCH 6/7] make TimeSeriesChart the only dynamic import --- app/components/SystemMetric.tsx | 28 ++++++++------- app/components/TimeSeriesChart.tsx | 2 +- app/pages/SiloUtilizationPage.tsx | 9 +++-- .../instances/instance/tabs/MetricsTab.tsx | 29 ++++++++------- app/pages/silo-utilization-loader.ts | 7 ---- app/pages/system/CapacityUtilizationPage.tsx | 9 +++-- .../system/capacity-utilization-loader.ts | 7 ---- app/routes.tsx | 35 ++++++------------- 8 files changed, 58 insertions(+), 68 deletions(-) delete mode 100644 app/pages/silo-utilization-loader.ts delete mode 100644 app/pages/system/capacity-utilization-loader.ts diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index 72849f9499..e8bde6bc39 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -1,8 +1,10 @@ +import React, { Suspense } from 'react' + import type { SystemMetricName } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Spinner } from '@oxide/ui' -import { TimeSeriesLineChart } from './TimeSeriesChart' +const TimeSeriesChart = React.lazy(() => import('./TimeSeriesChart')) type SystemMetricProps = { title: string @@ -63,17 +65,19 @@ export function SystemMetric({

{title} {isLoading && }

- {/* TODO: this is supposed to be full width */} - + {/* TODO: proper skeleton for empty chart */} + }> + +
) } diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 72876a4252..6dee943b21 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -97,7 +97,7 @@ type Props = { endTime: Date } -export function TimeSeriesLineChart({ +export default function TimeSeriesChart({ className, data, title, diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx index 5939b5fc82..7363ef9661 100644 --- a/app/pages/SiloUtilizationPage.tsx +++ b/app/pages/SiloUtilizationPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' -import { useApiQuery } from '@oxide/api' +import { apiQueryClient, useApiQuery } from '@oxide/api' import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' import { bytesToGiB } from '@oxide/util' @@ -12,7 +12,12 @@ const ALL_PROJECTS = '|ALL_PROJECTS|' const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id }) -export default function SiloUtilizationPage() { +SiloUtilizationPage.loader = async () => { + await apiQueryClient.prefetchQuery('organizationList', {}) + return null +} + +export function SiloUtilizationPage() { // this will come from /session/me const siloId = DEFAULT_SILO_ID diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index f59dd888be..bdfeee76da 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,14 +1,16 @@ -import { useState } from 'react' +import { Suspense, useState } from 'react' +import React from 'react' import invariant from 'tiny-invariant' import type { Cumulativeint64, Disk, DiskMetricName } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Listbox, Spinner } from '@oxide/ui' -import { TimeSeriesLineChart } from 'app/components/TimeSeriesChart' import { useDateTimeRangePicker } from 'app/components/form' import { useRequiredParams } from 'app/hooks' +const TimeSeriesChart = React.lazy(() => import('app/components/TimeSeriesChart')) + type DiskMetricParams = { title: string unit?: string @@ -16,7 +18,6 @@ type DiskMetricParams = { endTime: Date metricName: DiskMetricName diskParams: { orgName: string; projectName: string; diskName: string } - // TODO: specify bytes or count } function DiskMetric({ @@ -54,15 +55,17 @@ function DiskMetric({ {title} {unit &&
{unit}
} {isLoading && } - + }> + +
) } @@ -138,7 +141,7 @@ const Loading = () => (
) -export default function MetricsTab() { +export function MetricsTab() { const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') const { data: disks } = useApiQuery('instanceDiskList', { path: instanceParams }) diff --git a/app/pages/silo-utilization-loader.ts b/app/pages/silo-utilization-loader.ts deleted file mode 100644 index 7c334cc8f7..0000000000 --- a/app/pages/silo-utilization-loader.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Separate file because the page component is code-split and loaded async */ -import { apiQueryClient } from '@oxide/api' - -export const siloUtilizationPageloader = async () => { - await apiQueryClient.prefetchQuery('organizationList', {}) - return null -} diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx index 8548201ecc..3fad1a9361 100644 --- a/app/pages/system/CapacityUtilizationPage.tsx +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' -import { useApiQuery } from '@oxide/api' +import { apiQueryClient, useApiQuery } from '@oxide/api' import { Divider, Listbox, PageHeader, PageTitle, Snapshots24Icon } from '@oxide/ui' import { bytesToGiB } from '@oxide/util' @@ -10,7 +10,12 @@ import { useDateTimeRangePicker } from 'app/components/form' const FLEET_ID = '001de000-1334-4000-8000-000000000000' const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000' -export default function CapacityUtilizationPage() { +CapacityUtilizationPage.loader = async () => { + await apiQueryClient.prefetchQuery('siloList', {}) + return null +} + +export function CapacityUtilizationPage() { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ const [siloId, setSiloId] = useState(FLEET_ID) const { data: silos } = useApiQuery('siloList', {}) diff --git a/app/pages/system/capacity-utilization-loader.ts b/app/pages/system/capacity-utilization-loader.ts deleted file mode 100644 index 188caffcd3..0000000000 --- a/app/pages/system/capacity-utilization-loader.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Separate file because the page component is code-split and loaded async */ -import { apiQueryClient } from '@oxide/api' - -export const capacityUtilizationPageLoader = async () => { - await apiQueryClient.prefetchQuery('siloList', {}) - return null -} diff --git a/app/routes.tsx b/app/routes.tsx index 59ee35d604..e5728ce727 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -1,4 +1,3 @@ -import React, { Suspense } from 'react' import { Navigate, Route, createRoutesFromElements } from 'react-router-dom' import { RouterDataErrorBoundary } from './components/ErrorBoundary' @@ -31,6 +30,7 @@ import { OrgAccessPage } from './pages/OrgAccessPage' import OrgsPage from './pages/OrgsPage' import ProjectsPage from './pages/ProjectsPage' import { SiloAccessPage } from './pages/SiloAccessPage' +import { SiloUtilizationPage } from './pages/SiloUtilizationPage' import { DisksPage, ImagesPage, @@ -41,26 +41,17 @@ import { VpcPage, VpcsPage, } from './pages/project' +import { MetricsTab } from './pages/project/instances/instance/tabs/MetricsTab' import { NetworkingTab } from './pages/project/instances/instance/tabs/NetworkingTab' import { SerialConsoleTab } from './pages/project/instances/instance/tabs/SerialConsoleTab' import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { ProfilePage } from './pages/settings/ProfilePage' import { SSHKeysPage } from './pages/settings/SSHKeysPage' -import { siloUtilizationPageloader } from './pages/silo-utilization-loader' +import { CapacityUtilizationPage } from './pages/system/CapacityUtilizationPage' import { SiloPage } from './pages/system/SiloPage' import SilosPage from './pages/system/SilosPage' import { pb } from './util/path-builder' -const MetricsTab = React.lazy( - () => import('./pages/project/instances/instance/tabs/MetricsTab') -) - -// loaders are imported separately the normal way -const SiloUtilizationPage = React.lazy(() => import('./pages/SiloUtilizationPage')) -const CapacityUtilizationPage = React.lazy( - () => import('./pages/system/CapacityUtilizationPage') -) - const orgCrumb: CrumbFunc = (m) => m.params.orgName! const projectCrumb: CrumbFunc = (m) => m.params.projectName! const instanceCrumb: CrumbFunc = (m) => m.params.instanceName! @@ -103,7 +94,11 @@ export const routes = createRoutesFromElements( } /> - } /> + } + loader={CapacityUtilizationPage.loader} + /> @@ -123,12 +118,8 @@ export const routes = createRoutesFromElements( }> - - - } - loader={siloUtilizationPageloader} + element={} + loader={SiloUtilizationPage.loader} /> } loader={OrgsPage.loader}> @@ -209,11 +200,7 @@ export const routes = createRoutesFromElements( /> - - - } + element={} handle={{ crumb: 'metrics' }} /> Date: Fri, 20 Jan 2023 11:58:02 -0600 Subject: [PATCH 7/7] prefetch disks list for instance metrics tab --- .../instances/instance/tabs/MetricsTab.tsx | 57 +++++++++---------- app/routes.tsx | 1 + 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index bdfeee76da..a7ff79ba8a 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -1,13 +1,15 @@ -import { Suspense, useState } from 'react' +import { Suspense, useMemo, useState } from 'react' import React from 'react' +import type { LoaderFunctionArgs } from 'react-router-dom' import invariant from 'tiny-invariant' -import type { Cumulativeint64, Disk, DiskMetricName } from '@oxide/api' +import type { Cumulativeint64, DiskMetricName } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Listbox, Spinner } from '@oxide/ui' import { useDateTimeRangePicker } from 'app/components/form' -import { useRequiredParams } from 'app/hooks' +import { requireInstanceParams, useRequiredParams } from 'app/hooks' const TimeSeriesChart = React.lazy(() => import('app/components/TimeSeriesChart')) @@ -46,9 +48,6 @@ function DiskMetric({ 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 (

@@ -70,15 +69,31 @@ function DiskMetric({ ) } -// The only reason this needs to be its own component instead of inlined into -// MetricsTab is so we can wait to render _after_ we have the disks response, -// which means we can easily set the default selected disk to the first one -function DiskMetrics({ disks }: { disks: Disk[] }) { +// We could figure out how to prefetch the metrics data, but it would be +// annoying because it relies on the default date range, plus there are 5 calls. +// Considering the data is going to be swapped out as soon as they change the +// date range, I'm inclined to punt. + +MetricsTab.loader = async ({ params }: LoaderFunctionArgs) => { + await apiQueryClient.prefetchQuery('instanceDiskList', { + path: requireInstanceParams(params), + }) + return null +} + +export function MetricsTab() { + const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') + const { data } = useApiQuery('instanceDiskList', { path: instanceParams }) + const disks = useMemo(() => data?.items || [], [data]) + + // because of prefetch in the loader and because an instance should always + // have a disk, we should never see an empty list here + invariant(disks.length > 0, 'Instance disks list should never be empty') + const { orgName, projectName } = useRequiredParams('orgName', 'projectName') const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay') - invariant(disks.length > 0, 'DiskMetrics should not be rendered with zero disks') const [diskName, setDiskName] = useState(disks[0].name) const diskItems = disks.map(({ name }) => ({ label: name, value: name })) @@ -87,6 +102,7 @@ function DiskMetrics({ disks }: { disks: Disk[] }) { return ( <> +

Disk metrics

) } - -// spinner should be temporary. wrapping div is to get left alignment -const Loading = () => ( -
- -
-) - -export function MetricsTab() { - const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') - const { data: disks } = useApiQuery('instanceDiskList', { path: instanceParams }) - - return ( - <> -

Disk metrics

- {disks && disks.items.length > 0 ? : } - - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index e5728ce727..6382d8b5ca 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -201,6 +201,7 @@ export const routes = createRoutesFromElements( } + loader={MetricsTab.loader} handle={{ crumb: 'metrics' }} />