diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 50f1812846..d6c59b24fd 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -74f3ca89af11b0ce6d9f9bd4b5bdcbeb04d1ba3e +d2c5d9de9a4b3a074ea3fc04b494706f708e90f7 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/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 8e3910e091..0c05f48a68 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 @@ -24,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 @@ -67,52 +74,67 @@ type Props = { title: string width: number height: number + interpolation?: 'linear' | 'stepAfter' + customXTicks?: boolean } // 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', + customXTicks, +}: 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/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 200d43df8f..aec2324c9c 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' @@ -20,7 +21,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> = { @@ -45,17 +47,40 @@ 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 + /** + * 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: RangeKey) { +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( + () => { + if (presetRef.current !== 'custom') { + const now = new Date() + setStartTime(computeStart[presetRef.current](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 @@ -68,7 +93,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)) @@ -101,16 +126,21 @@ export function useDateTimeRangePicker(initialPreset: RangeKey) { // 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/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/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 21a8258e18..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 /> ) @@ -70,7 +71,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) @@ -82,7 +85,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,13 +108,14 @@ function DiskMetrics({ disks }: { disks: Disk[] }) { defaultValue={diskName} />
+ {dateTimeRangePicker} {/* TODO: separate "Reads" from "(count)" so we can a) style them differently in the title, and b) show "Reads" but not "(count)" in the Tooltip? */} -
+
{/* see the following link for the source of truth on what these mean https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} diff --git a/app/pages/system/CapacityUtilizationPage.tsx b/app/pages/system/CapacityUtilizationPage.tsx new file mode 100644 index 0000000000..fdee8d6a9c --- /dev/null +++ b/app/pages/system/CapacityUtilizationPage.tsx @@ -0,0 +1,97 @@ +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 FLEET_ID = '001de000-1334-4000-8000-000000000000' +const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000' + +CapacityUtilizationPage.loader = async () => { + await apiQueryClient.prefetchQuery('siloList', {}) +} + +export function CapacityUtilizationPage() { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + const [siloId, setSiloId] = useState(FLEET_ID) + const { data: silos } = useApiQuery('siloList', {}) + + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastHour', + slideInterval: 5000, + }) + + const siloItems = useMemo(() => { + const items = silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || [] + return [ + { label: 'All silos', value: FLEET_ID }, + { label: '[default silo]', value: DEFAULT_SILO_ID }, + ...items, + ] + }, [silos]) + + const commonProps = { startTime, endTime, filterId: siloId } + + return ( + <> + + }>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} +
+ {/* 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 1f4d6ba071..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, @@ -34,6 +35,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 +83,11 @@ export const routes = createRoutesFromElements( loader={SilosPage.loader} /> - + } + loader={CapacityUtilizationPage.loader} + /> @@ -99,6 +105,11 @@ export const routes = createRoutesFromElements( /> }> + } + loader={SiloUtilizationPage.loader} + /> } 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", 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', diff --git a/libs/api-mocks/metrics.ts b/libs/api-mocks/metrics.ts index cbe56cc988..ea486042ed 100644 --- a/libs/api-mocks/metrics.ts +++ b/libs/api-mocks/metrics.ts @@ -22,3 +22,18 @@ export const genCumulativeI64Data = ( timestamp: addSeconds(startTime, i * intervalSeconds).toISOString(), })) } + +export const genI64Data = ( + values: number[], + startTime: Date, + endTime: Date +): Json => { + 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 eae7f91cbc..49e3928ce8 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1,11 +1,10 @@ -import { subHours } from 'date-fns' import { compose, context, rest } from 'msw' 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' @@ -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,7 @@ 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: [] })) @@ -710,6 +699,34 @@ 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 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 + + const { startTime, endTime } = getStartAndEndTime(req.url.searchParams) + + if (endTime <= startTime) return res(json({ items: [] })) + + return res( + json({ + items: genI64Data( + new Array(1000).fill(0).map((x, i) => Math.floor(Math.tanh(i / 500) * cap)), + startTime, + endTime + ), + }) + ) + } + ), + rest.get | GetErr>( '/organizations/:orgName/projects/:projectName/images', (req, res) => { diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index 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/__generated__/Api.ts b/libs/api/__generated__/Api.ts index 1c9cc273cd..c44ccb8f81 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -1716,6 +1716,11 @@ export type DiskMetricName = */ export type IdSortMode = 'id_ascending' +export type ResourceName = + | 'physical_disk_space_provisioned' + | 'cpus_provisioned' + | 'ram_provisioned' + export interface DiskViewByIdParams { id: string } @@ -2348,6 +2353,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 +2481,7 @@ export type ApiListMethods = Pick< | 'ipPoolList' | 'ipPoolRangeList' | 'ipPoolServiceRangeList' + | 'systemMetricsList' | 'sagaList' | 'siloList' | 'siloIdentityProviderList' @@ -4019,6 +4034,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..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 -74f3ca89af11b0ce6d9f9bd4b5bdcbeb04d1ba3e +d2c5d9de9a4b3a074ea3fc04b494706f708e90f7 diff --git a/libs/api/__generated__/validate.ts b/libs/api/__generated__/validate.ts index a12231db65..9f6f9a10ea 100644 --- a/libs/api/__generated__/validate.ts +++ b/libs/api/__generated__/validate.ts @@ -1439,6 +1439,12 @@ export const DiskMetricName = z.enum([ */ export const IdSortMode = z.enum(['id_ascending']) +export const ResourceName = z.enum([ + 'physical_disk_space_provisioned', + 'cpus_provisioned', + 'ram_provisioned', +]) + export const DiskViewByIdParams = z.object({ id: z.string().uuid(), }) @@ -2203,6 +2209,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 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 } 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' 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)