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
+
+
+
+ {/* 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?
*/}
-
+ {
+ 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)