Skip to content

Commit

Permalink
Silo utilization table (#1795)
Browse files Browse the repository at this point in the history
* CPU utilization should be whole numbers

* Forgive me for my Typescript sins

* Type fixes

* Even simpler

* as const avoids TS error while still checking the value

* move static object construction out of render cycle

* by jove, it works without any

* we don't need `as QueryKey` anymore, not sure why

* rework mergeSiloMetrics and write tests

* Fix double header styling issues

* Add missing brace

* Fix incorrect label

* Make tabs full-width

* Fixing e2e tests

* Unit styling

* The Gang Goes Overboard

* take Capacity out of system utilization page header

* epic test

* Usage -> Summary

---------

Co-authored-by: David Crespo <[email protected]>
  • Loading branch information
benjaminleonard and david-crespo authored Oct 23, 2023
1 parent 8978e07 commit a50777c
Show file tree
Hide file tree
Showing 15 changed files with 349 additions and 67 deletions.
2 changes: 0 additions & 2 deletions app/components/RefetchIntervalPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,6 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) {
<Listbox
selected={enabled ? intervalPreset : 'Off'}
className="w-24 [&>button]:!rounded-l-none"
aria-labelledby="silo-id-label"
name="silo-id"
items={intervalItems}
onChange={setIntervalPreset}
disabled={!enabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,24 @@ import { getLocalTimeZone, now } from '@internationalized/date'
import { useIsFetching } from '@tanstack/react-query'
import { useMemo, useState } from 'react'

import { FLEET_ID, apiQueryClient, totalCapacity, usePrefetchedApiQuery } from '@oxide/api'
import type { SiloResultsPage } from '@oxide/api'
import {
FLEET_ID,
apiQueryClient,
totalCapacity,
useApiQueries,
usePrefetchedApiQuery,
} from '@oxide/api'
import {
Cpu16Icon,
Divider,
Listbox,
Metrics24Icon,
PageHeader,
PageTitle,
Ram16Icon,
Ssd16Icon,
Table,
Tabs,
} from '@oxide/ui'
import { bytesToGiB, bytesToTiB } from '@oxide/util'

Expand All @@ -27,7 +35,9 @@ import { useIntervalPicker } from 'app/components/RefetchIntervalPicker'
import { SystemMetric } from 'app/components/SystemMetric'
import { useDateTimeRangePicker } from 'app/components/form'

CapacityUtilizationPage.loader = async () => {
import { tabularizeSiloMetrics } from './metrics-util'

SystemUtilizationPage.loader = async () => {
await Promise.all([
apiQueryClient.prefetchQuery('siloList', {}),
apiQueryClient.prefetchQuery('systemMetric', {
Expand All @@ -47,45 +57,16 @@ CapacityUtilizationPage.loader = async () => {
return null
}

export function CapacityUtilizationPage() {
const { data: silos } = usePrefetchedApiQuery('siloList', {})

const siloItems = useMemo(() => {
const items = silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || []
return [{ label: 'All silos', value: FLEET_ID }, ...items]
}, [silos])

export function SystemUtilizationPage() {
const { data: sleds } = usePrefetchedApiQuery('sledList', {})
const { data: silos } = usePrefetchedApiQuery('siloList', {})

const capacity = totalCapacity(sleds.items)

const [filterId, setFilterId] = useState<string>(FLEET_ID)

// pass refetch interval to this to keep the date up to date
const { preset, startTime, endTime, dateTimeRangePicker, onRangeChange } =
useDateTimeRangePicker({
initialPreset: 'lastHour',
maxValue: now(getLocalTimeZone()),
})

const { intervalPicker } = useIntervalPicker({
enabled: preset !== 'custom',
isLoading: useIsFetching({ queryKey: ['systemMetric'] }) > 0,
// sliding the range forward is sufficient to trigger a refetch
fn: () => onRangeChange(preset),
})

const commonProps = {
startTime,
endTime,
// the way we tell the API we want the fleet is by passing no filter
silo: filterId === FLEET_ID ? undefined : filterId,
}

return (
<>
<PageHeader>
<PageTitle icon={<Metrics24Icon />}>Capacity &amp; Utilization</PageTitle>
<PageTitle icon={<Metrics24Icon />}>Utilization</PageTitle>
</PageHeader>

<div className="mb-12 flex min-w-min flex-col gap-3 lg+:flex-row">
Expand All @@ -110,8 +91,60 @@ export function CapacityUtilizationPage() {
capacity={capacity.ram_gib}
/>
</div>
<Tabs.Root defaultValue="metrics" className="full-width">
<Tabs.List>
<Tabs.Trigger value="metrics">Metrics</Tabs.Trigger>
<Tabs.Trigger value="summary">Summary</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="metrics">
<MetricsTab capacity={capacity} silos={silos} />
</Tabs.Content>
<Tabs.Content value="summary">
<UsageTab silos={silos} />
</Tabs.Content>
</Tabs.Root>
</>
)
}

const MetricsTab = ({
capacity,
silos,
}: {
capacity: ReturnType<typeof totalCapacity>
silos: SiloResultsPage
}) => {
const siloItems = useMemo(() => {
const items = silos?.items.map((silo) => ({ label: silo.name, value: silo.id })) || []
return [{ label: 'All silos', value: FLEET_ID }, ...items]
}, [silos])

const [filterId, setFilterId] = useState<string>(FLEET_ID)

// pass refetch interval to this to keep the date up to date
const { preset, startTime, endTime, dateTimeRangePicker, onRangeChange } =
useDateTimeRangePicker({
initialPreset: 'lastHour',
maxValue: now(getLocalTimeZone()),
})

const { intervalPicker } = useIntervalPicker({
enabled: preset !== 'custom',
isLoading: useIsFetching({ queryKey: ['systemMetric'] }) > 0,
// sliding the range forward is sufficient to trigger a refetch
fn: () => onRangeChange(preset),
})

<div className="mt-8 flex justify-between gap-3">
const commonProps = {
startTime,
endTime,
// the way we tell the API we want the fleet is by passing no filter
silo: filterId === FLEET_ID ? undefined : filterId,
}

return (
<>
<div className="mb-3 mt-8 flex justify-between gap-3">
<Listbox
selected={filterId}
className="w-48"
Expand All @@ -124,8 +157,6 @@ export function CapacityUtilizationPage() {
<div className="flex items-center gap-2">{dateTimeRangePicker}</div>
</div>

<Divider className="my-6" />

{intervalPicker}

<div className="mb-12 space-y-12">
Expand Down Expand Up @@ -156,3 +187,58 @@ export function CapacityUtilizationPage() {
</>
)
}

function UsageTab({ silos }: { silos: SiloResultsPage }) {
const results = useApiQueries(
'systemMetric',
silos.items.flatMap((silo) => {
const query = { ...capacityQueryParams, silo: silo.name }
return [
{ path: { metricName: 'virtual_disk_space_provisioned' as const }, query },
{ path: { metricName: 'ram_provisioned' as const }, query },
{ path: { metricName: 'cpus_provisioned' as const }, query },
]
})
)

// TODO: loading state, this could take some time
if (results.some((result) => result.isPending)) return null

const mergedResults = tabularizeSiloMetrics(results)

return (
<Table className="w-full">
<Table.Header>
<Table.HeaderRow>
<Table.HeadCell>Silo</Table.HeadCell>
{/* data-test-ignore makes the row asserts work in the e2e tests */}
<Table.HeadCell colSpan={3} data-test-ignore>
Provisioned
</Table.HeadCell>
</Table.HeaderRow>
<Table.HeaderRow>
<Table.HeadCell data-test-ignore></Table.HeadCell>
<Table.HeadCell>CPU</Table.HeadCell>
<Table.HeadCell>Disk</Table.HeadCell>
<Table.HeadCell>Memory</Table.HeadCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{mergedResults.map((result) => (
<Table.Row key={result.siloName}>
<Table.Cell width="25%">{result.siloName}</Table.Cell>
<Table.Cell width="25%">{result.metrics.cpus_provisioned}</Table.Cell>
<Table.Cell width="25%">
{bytesToTiB(result.metrics.virtual_disk_space_provisioned)}
<span className="ml-1 inline-block text-quaternary">TiB</span>
</Table.Cell>
<Table.Cell width="25%">
{bytesToGiB(result.metrics.ram_provisioned)}
<span className="ml-1 inline-block text-quaternary">GiB</span>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)
}
65 changes: 65 additions & 0 deletions app/pages/system/metrics-util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { expect, test } from 'vitest'

import type { SystemMetricName } from '@oxide/api'

import type { MetricsResult } from './metrics-util'
import { tabularizeSiloMetrics } from './metrics-util'

function makeResult(
silo: string,
metricName: SystemMetricName,
value: number
): MetricsResult {
return {
data: {
items: [
{
datum: { type: 'i64', datum: value },
timestamp: new Date(),
},
],
params: { query: { silo }, path: { metricName } },
},
}
}

test('tabularizeSiloMetrics', () => {
expect(tabularizeSiloMetrics([])).toEqual([])
expect(
tabularizeSiloMetrics([
makeResult('a', 'virtual_disk_space_provisioned', 1),
makeResult('b', 'virtual_disk_space_provisioned', 2),
makeResult('a', 'cpus_provisioned', 3),
makeResult('b', 'cpus_provisioned', 4),
makeResult('a', 'ram_provisioned', 5),
makeResult('b', 'ram_provisioned', 6),
// here to make sure it gets ignored and doesn't break anything
// @ts-expect-error
{ error: 'whoops' },
])
).toEqual([
{
siloName: 'a',
metrics: {
virtual_disk_space_provisioned: 1,
cpus_provisioned: 3,
ram_provisioned: 5,
},
},
{
siloName: 'b',
metrics: {
virtual_disk_space_provisioned: 2,
cpus_provisioned: 4,
ram_provisioned: 6,
},
},
])
})
50 changes: 50 additions & 0 deletions app/pages/system/metrics-util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import type {
MeasurementResultsPage,
SystemMetricName,
SystemMetricPathParams,
SystemMetricQueryParams,
} from '@oxide/api'
import { groupBy } from '@oxide/util'

export type MetricsResult = {
data?: MeasurementResultsPage & {
params: {
path: SystemMetricPathParams
query?: SystemMetricQueryParams
}
}
}

type SiloMetric = {
siloName: string
metrics: Record<SystemMetricName, number>
}

/**
* Turn the big list of query results into something we can display in a table.
* See the tests for an example.
*/
export function tabularizeSiloMetrics(results: MetricsResult[]): SiloMetric[] {
const processed = results
// filter mostly to ensure we don't try to pull data off an error response
.filter((r) => r.data?.params.query?.silo)
.map((r) => {
const metricName = r.data!.params.path.metricName
const value = r.data!.items[0].datum.datum as number
return {
siloName: r.data!.params.query!.silo!,
metrics: { [metricName]: value },
}
})

return groupBy(processed, (r) => r.siloName).map(([siloName, results]) => {
return { siloName, metrics: Object.assign({}, ...results.map((r) => r.metrics)) }
})
}
6 changes: 3 additions & 3 deletions app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ import { NetworkingTab } from './pages/project/instances/instance/tabs/Networkin
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 { SiloImagesPage } from './pages/system/SiloImagesPage'
import { SiloPage } from './pages/system/SiloPage'
import SilosPage from './pages/system/SilosPage'
import { SystemUtilizationPage } from './pages/system/UtilizationPage'
import { DisksTab } from './pages/system/inventory/DisksTab'
import { InventoryPage } from './pages/system/inventory/InventoryPage'
import { SledsTab } from './pages/system/inventory/SledsTab'
Expand Down Expand Up @@ -137,8 +137,8 @@ export const routes = createRoutesFromElements(
<Route path="issues" element={null} />
<Route
path="utilization"
element={<CapacityUtilizationPage />}
loader={CapacityUtilizationPage.loader}
element={<SystemUtilizationPage />}
loader={SystemUtilizationPage.loader}
handle={{ crumb: 'Utilization' }}
/>
<Route
Expand Down
4 changes: 2 additions & 2 deletions app/test/e2e/authz.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { expect, getPageAsUser, test } from './utils'
test.describe('Silo/system picker', () => {
test('appears for fleet viewer', async ({ page }) => {
await page.goto('/projects')
await expect(page.getByRole('link', { name: 'SILO default-silo' })).toBeVisible()
await expect(page.getByRole('link', { name: 'SILO maze-war' })).toBeVisible()
await expect(
page.getByRole('button', { name: 'Switch between system and silo' })
).toBeVisible()
Expand All @@ -19,7 +19,7 @@ test.describe('Silo/system picker', () => {
test('does not appear to for dev user', async ({ browser }) => {
const page = await getPageAsUser(browser, 'Hans Jonas')
await page.goto('/projects')
await expect(page.getByRole('link', { name: 'SILO default-silo' })).toBeVisible()
await expect(page.getByRole('link', { name: 'SILO maze-war' })).toBeVisible()
await expect(
page.getByRole('button', { name: 'Switch between system and silo' })
).toBeHidden()
Expand Down
2 changes: 1 addition & 1 deletion app/test/e2e/inventory.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ test('Sled inventory page', async ({ page }) => {

const instancesTable = page.getByRole('table')
await expectRowVisible(instancesTable, {
name: 'default-silo / mock-projectdb1',
name: 'maze-war / mock-projectdb1',
})
})

Expand Down
Loading

0 comments on commit a50777c

Please sign in to comment.