Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
2c3d3bf
Add stub of Disk Metrics using OxQL
charliepark Jan 17, 2025
b7f8d9f
Merge branch 'main' into oxql_disk_metrics
charliepark Jan 23, 2025
b313aac
Refactoring; getting chart working; needs better default situation
charliepark Jan 27, 2025
4c61baf
refacotrs; remove old DiskMetrics; add writes and flushes charts
charliepark Jan 29, 2025
3a94b31
initial stub for CPU metrics
charliepark Jan 29, 2025
503a47e
file reorg
charliepark Jan 29, 2025
1bd508d
More CPU metrics, though we mgiht need to rethink the long-term plan
charliepark Jan 30, 2025
82368ed
working group_by
charliepark Jan 30, 2025
b7267fb
Updates to routes to handle sub-tabs
charliepark Jan 30, 2025
8745bec
Dropdown working on CPU charts
charliepark Jan 30, 2025
3faf22b
cleanup
charliepark Jan 30, 2025
67eb979
more work on charts; networking
charliepark Jan 31, 2025
26dfd35
Standardize wrapper components
charliepark Jan 31, 2025
4723da2
Reorder charts a bit
charliepark Jan 31, 2025
40bd0c5
getting side tabs into place
charliepark Jan 31, 2025
d332180
Update side tab CSS
charliepark Jan 31, 2025
77bd509
Consolidate SideTabs into legacy tabs, using props to control layout
charliepark Feb 1, 2025
e3e2382
refactoring; getting rollups working for disks and network interfaces
charliepark Feb 4, 2025
450fc98
Pass pre-formed query string to metric component
charliepark Feb 4, 2025
36de1bc
Move date selector up a level, using useContext
charliepark Feb 5, 2025
dd96051
Update routes in path-builder test
charliepark Feb 5, 2025
4ad4271
Small refactor to align approach to useState and dropdowns for networ…
charliepark Feb 5, 2025
8d80808
Add static values for metrics for testing and mock service worker
charliepark Feb 6, 2025
dae5172
Merge branch 'main' into oxql_disk_metrics
charliepark Feb 7, 2025
51ef360
Removes TS guard that was a bit onerous; relying on casting now, thou…
charliepark Feb 7, 2025
0ad871e
Updated mock data for disks
charliepark Feb 7, 2025
6fd2397
small refactor before integrating Ben's PR
charliepark Feb 7, 2025
c673784
Refactoring chart logic
charliepark Feb 11, 2025
2c4d130
Add tests for OxQL charts
charliepark Feb 11, 2025
ef48320
Better handle cumulative_u64 data with initial sum value
charliepark Feb 11, 2025
d35afe4
Merge branch 'main' into oxql_disk_metrics
charliepark Feb 12, 2025
bb8d32b
Instance metrics design tweaks (#2676)
benjaminleonard Feb 12, 2025
bfc9714
a little code cleanup
david-crespo Feb 13, 2025
c143e71
make getOxqlQuery args more generic and structured
david-crespo Feb 13, 2025
83be393
merge main
david-crespo Feb 13, 2025
1437239
view/copy oxql modal
david-crespo Feb 13, 2025
520ee8b
inline oxql query modal, remove comment about showing query
david-crespo Feb 13, 2025
d1f8e85
NonEmptyArray whaaaaaaat
david-crespo Feb 13, 2025
a7ba787
highlight oxql
david-crespo Feb 13, 2025
b518077
Add 'More about OxQL queries' button/link to modal
charliepark Feb 14, 2025
17446b0
test for rendered oxql in modal
david-crespo Feb 14, 2025
3d31bd2
Better link style for OxQL docs
charliepark Feb 14, 2025
4614ac8
slightly smaller text
charliepark Feb 14, 2025
aefe263
clean up my weird half-finished metrics props change
david-crespo Feb 14, 2025
d476b3a
CopyCode footer
david-crespo Feb 14, 2025
e8710a8
handle no nics case on network metrics
david-crespo Feb 14, 2025
56502be
small aria label fix
charliepark Feb 14, 2025
7016bee
Add restriction to only turn on query reloading once initial data hav…
charliepark Feb 15, 2025
dc8e38e
Simplify CPU utilization tab
charliepark Feb 18, 2025
8f4aa9b
Metrics more actions (#2700)
benjaminleonard Feb 18, 2025
919ca2b
tweak more actions menu copy one more time
david-crespo Feb 18, 2025
82096ba
Dynamic chart Y axis width (#2697)
benjaminleonard Feb 18, 2025
9141b3f
merge main (react 19)
david-crespo Feb 18, 2025
7661b10
Updates, and better logic on utilization chart; still accounting for …
charliepark Feb 20, 2025
65f0f43
Merge branch 'main' into oxql_disk_metrics
charliepark Feb 20, 2025
d552feb
Updates to incorporate nCPUs in utilization calculation
charliepark Feb 21, 2025
2ee92f0
small refactor
charliepark Feb 21, 2025
8cc6018
Updated test for utilization
charliepark Feb 21, 2025
8a1e80f
Move OxqlMetric files to own component directory
charliepark Feb 22, 2025
9c5aae3
A few more tests
charliepark Feb 22, 2025
1a8d551
update import
charliepark Feb 22, 2025
3758390
tests are easier to make sense of when you can see all the data at once
charliepark Feb 22, 2025
1cf9bca
Default to single state on CPU utiization tab; offer 'total' option
charliepark Feb 24, 2025
ed338f9
Update metrics schema URL
benjaminleonard Feb 25, 2025
bc8853c
Metrics error & loading states (#2698)
benjaminleonard Feb 25, 2025
cf3faf8
Move some loaders to parent component
charliepark Feb 25, 2025
fa081a7
Update dropdown to cap at 24 hours and handle minimum mean_within
charliepark Feb 26, 2025
7375ac0
Use seconds when determining durations
charliepark Feb 26, 2025
6fa81f4
remove intervalPicker until OxQL is faster
charliepark Feb 26, 2025
8c537dd
Less twitchy datepicker wrap
benjaminleonard Feb 26, 2025
8a0ca56
clean up chart loading states
david-crespo Feb 26, 2025
0e87365
init MetricsContext with null instead of dummy values
david-crespo Feb 26, 2025
f3207fb
Update mock numbers so CPU utilization range is normal
charliepark Feb 26, 2025
2f38adc
use lazy imports in the routes
david-crespo Feb 26, 2025
13e82c0
blarg lint
david-crespo Feb 27, 2025
42b22d3
Clean up CPU charts
charliepark Feb 27, 2025
47da2bf
revert CpuStateMetric component
charliepark Feb 27, 2025
e16b1d7
merge main
david-crespo Feb 27, 2025
f448a0a
utils file tweaks, abstract slightly less
david-crespo Feb 27, 2025
fa375f9
replace getUnit with explicit unit prop
david-crespo Feb 27, 2025
3263861
use date-fns
david-crespo Feb 27, 2025
d5ceef4
use delay function for sleeps
david-crespo Feb 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
InstanceState,
IpPoolUtilization,
Measurement,
OxqlQueryResult,
SiloUtilization,
Sled,
VpcFirewallRule,
Expand Down Expand Up @@ -259,3 +260,16 @@ export function parseIpUtilization({ ipv4, ipv6 }: IpPoolUtilization) {
},
}
}

export const getChartData = (data: OxqlQueryResult | undefined): ChartDatum[] => {
if (!data) return []
const ts = Object.values(data.tables[0].timeseries)
return ts.flatMap((t) => {
const { timestamps, values } = t.points
const v = values[0].values.values as number[]
return timestamps.map((timestamp, idx) => ({
timestamp: new Date(timestamp).getTime(),
value: v[idx],
}))
})
}
23 changes: 17 additions & 6 deletions app/components/RouteTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,25 @@ const selectTab = (e: React.KeyboardEvent<HTMLDivElement>) => {
export interface RouteTabsProps {
children: ReactNode
fullWidth?: boolean
sideTabs?: boolean
}
export function RouteTabs({ children, fullWidth }: RouteTabsProps) {
/** Tabbed views, controlling both the layout and functioning of tabs and the panel contents.
* sideTabs: Whether the tabs are displayed on the side of the panel. Default is false.
*/
export function RouteTabs({ children, fullWidth, sideTabs = false }: RouteTabsProps) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think tabPosition: 'side' | 'top' with top as default might be clearer than the boolean sideTabs

const wrapperClasses = sideTabs
? 'ox-side-tabs flex'
: cn('ox-tabs', { 'full-width': fullWidth })
const tabListClasses = sideTabs ? 'ox-side-tabs-list' : 'ox-tabs-list'
const panelClasses = cn('ox-tabs-panel', { 'flex-grow': sideTabs })
return (
<div className={cn('ox-tabs', { 'full-width': fullWidth })}>
<div className={wrapperClasses}>
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div role="tablist" className="ox-tabs-list" onKeyDown={selectTab}>
<div role="tablist" className={tabListClasses} onKeyDown={selectTab}>
{children}
</div>
{/* TODO: Add aria-describedby for active tab */}
<div className="ox-tabs-panel" role="tabpanel" tabIndex={0}>
<div className={panelClasses} role="tabpanel" tabIndex={0}>
<Outlet />
</div>
</div>
Expand All @@ -57,14 +66,16 @@ export function RouteTabs({ children, fullWidth }: RouteTabsProps) {
export interface TabProps {
to: string
children: ReactNode
sideTab?: boolean
}
export const Tab = ({ to, children }: TabProps) => {
export const Tab = ({ to, children, sideTab = false }: TabProps) => {
const isActive = useIsActivePath({ to })
const baseClass = sideTab ? 'ox-side-tab' : 'ox-tab'
return (
<Link
role="tab"
to={to}
className={cn('ox-tab', { 'is-selected': isActive })}
className={cn(baseClass, { 'is-selected': isActive })}
tabIndex={isActive ? 0 : -1}
aria-selected={isActive}
>
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/instances/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ export function InstancePage() {
</PropertiesTable.Group>
<RouteTabs fullWidth>
<Tab to={pb.instanceStorage(instanceSelector)}>Storage</Tab>
<Tab to={pb.instanceMetrics(instanceSelector)}>Metrics</Tab>
<Tab to={pb.instanceNetworking(instanceSelector)}>Networking</Tab>
<Tab to={pb.instanceMetrics(instanceSelector)}>Metrics</Tab>
<Tab to={pb.instanceConnect(instanceSelector)}>Connect</Tab>
</RouteTabs>
{resizeInstance && (
Expand Down
239 changes: 29 additions & 210 deletions app/pages/project/instances/instance/tabs/MetricsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,230 +5,49 @@
*
* Copyright Oxide Computer Company
*/
import React, { Suspense, useMemo, useState } from 'react'
import type { LoaderFunctionArgs } from 'react-router'

import {
apiQueryClient,
useApiQuery,
usePrefetchedApiQuery,
type Cumulativeint64,
type DiskMetricName,
} from '@oxide/api'
import { Storage24Icon } from '@oxide/design-system/icons/react'
import { createContext, useContext, type ReactNode } from 'react'

import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker'
import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Listbox } from '~/ui/lib/Listbox'
import { Spinner } from '~/ui/lib/Spinner'
import { TableEmptyBox } from '~/ui/lib/Table'
import { RouteTabs, Tab } from '~/components/RouteTabs'
import { useInstanceSelector } from '~/hooks/use-params'
import { pb } from '~/util/path-builder'

const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart'))
// useContext will need default values for startTime and endTime
const oneHourAgo = new Date()
oneHourAgo.setHours(oneHourAgo.getHours() - 1)
const startTime = oneHourAgo
const endTime = new Date()

export function getCycleCount(num: number, base: number) {
let cycleCount = 0
let transformedValue = num
while (transformedValue > base) {
transformedValue = transformedValue / base
cycleCount++
}
return cycleCount
}

type DiskMetricParams = {
title: string
unit: 'Bytes' | 'Count'
const MetricsContext = createContext<{
startTime: Date
endTime: Date
metric: DiskMetricName
diskSelector: {
project: string
disk: string
}
}

function DiskMetric({
title,
unit,
startTime,
endTime,
metric,
diskSelector: { project, disk },
}: DiskMetricParams) {
// TODO: we're only pulling the first page. Should we bump the cap to 10k?
// Fetch multiple pages if 10k is not enough? That's a bit much.
const { data: metrics, isLoading } = useApiQuery(
'diskMetricsList',
{
path: { disk, metric },
query: { project, startTime, endTime, limit: 3000 },
},
// avoid graphs flashing blank while loading when you change the time
{ placeholderData: (x) => x }
)

const isBytesChart = unit === 'Bytes'

const largestValue = useMemo(() => {
if (!metrics || metrics.items.length === 0) return 0
return Math.max(...metrics.items.map((m) => (m.datum.datum as Cumulativeint64).value))
}, [metrics])
dateTimeRangePicker: ReactNode
}>({ startTime, endTime, dateTimeRangePicker: <></> })

// We'll need to divide each number in the set by a consistent exponent
// of 1024 (for Bytes) or 1000 (for Counts)
const base = isBytesChart ? 1024 : 1000
// Figure out what that exponent is:
const cycleCount = getCycleCount(largestValue, base)
export const useMetricsContext = () => useContext(MetricsContext)

// Now that we know how many cycles of "divide by 1024 || 1000" to run through
// (via cycleCount), we can determine the proper unit for the set
let unitForSet = ''
let label = '(COUNT)'
if (isBytesChart) {
const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB']
unitForSet = byteUnits[cycleCount]
label = `(${unitForSet})`
}

const divisor = base ** cycleCount

const data = useMemo(
() =>
(metrics?.items || []).map(({ datum, timestamp }) => ({
timestamp: timestamp.getTime(),
// All of these metrics are cumulative ints.
// The value passed in is what will render in the tooltip.
value: isBytesChart
? // We pass a pre-divided value to the chart if the unit is Bytes
(datum.datum as Cumulativeint64).value / divisor
: // If the unit is Count, we pass the raw value
(datum.datum as Cumulativeint64).value,
})),
[metrics, isBytesChart, divisor]
)

// Create a label for the y-axis ticks. "Count" charts will be
// abbreviated and will have a suffix (e.g. "k") appended. Because
// "Bytes" charts will have already been divided by the divisor
// before the yAxis is created, we can use their given value.
const yAxisTickFormatter = (val: number) => {
if (isBytesChart) {
return val.toLocaleString()
}
const tickValue = (val / divisor).toFixed(2)
const countUnits = ['', 'k', 'M', 'B', 'T']
const unitForTick = countUnits[cycleCount]
return `${tickValue}${unitForTick}`
}

return (
<div className="flex w-1/2 grow flex-col">
<h2 className="ml-3 flex items-center text-mono-xs text-default">
{title} <div className="ml-1 normal-case text-tertiary">{label}</div>
{isLoading && <Spinner className="ml-2" />}
</h2>
<Suspense fallback={<div className="mt-3 h-[300px]" />}>
<TimeSeriesChart
className="mt-3"
data={data}
title={title}
unit={unitForSet}
width={480}
height={240}
startTime={startTime}
endTime={endTime}
yAxisTickFormatter={yAxisTickFormatter}
/>
</Suspense>
</div>
)
}

// 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.

export async function loader({ params }: LoaderFunctionArgs) {
const { project, instance } = getInstanceSelector(params)
await apiQueryClient.prefetchQuery('instanceDiskList', {
path: { instance },
query: { project },
})
return null
}

Component.displayName = 'MetricsTab'
export function Component() {
export const MetricsTab = () => {
const { project, instance } = useInstanceSelector()
const { data } = usePrefetchedApiQuery('instanceDiskList', {
path: { instance },
query: { project },
})
const disks = useMemo(() => data?.items || [], [data])

const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({
initialPreset: 'lastDay',
initialPreset: 'lastHour',
})

// The fallback here is kind of silly — it is only invoked when there are no
// disks, in which case we show the fallback UI and diskName is never used. We
// only need to do it this way because hooks cannot be called conditionally.
const [diskName, setDiskName] = useState<string>(disks[0]?.name || '')
const diskItems = disks.map(({ name }) => ({ label: name, value: name }))

if (disks.length === 0) {
return (
<TableEmptyBox>
<EmptyMessage
icon={<Storage24Icon />}
title="No metrics available"
body="Metrics are only available if there are disks attached"
/>
</TableEmptyBox>
)
}

const commonProps = {
startTime,
endTime,
diskSelector: { project, disk: diskName },
}

// Find the relevant <Outlet> in RouteTabs
return (
<>
<div className="mb-4 flex justify-between">
<Listbox
className="w-64"
aria-label="Choose disk"
name="disk-name"
selected={diskName}
items={diskItems}
onChange={(val) => {
if (val) setDiskName(val)
}}
/>
{dateTimeRangePicker}
</div>

<div className="mt-8 space-y-12">
{/* 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 */}
<div className="flex w-full space-x-4">
<DiskMetric {...commonProps} title="Reads" unit="Count" metric="read" />
<DiskMetric {...commonProps} title="Read" unit="Bytes" metric="read_bytes" />
</div>

<div className="flex w-full space-x-4">
<DiskMetric {...commonProps} title="Writes" unit="Count" metric="write" />
<DiskMetric {...commonProps} title="Write" unit="Bytes" metric="write_bytes" />
</div>

<div className="flex w-full space-x-4">
<DiskMetric {...commonProps} title="Flushes" unit="Count" metric="flush" />
</div>
</div>
</>
<MetricsContext.Provider value={{ startTime, endTime, dateTimeRangePicker }}>
<RouteTabs sideTabs>
<Tab to={pb.instanceCpuMetrics({ project, instance })} sideTab>
CPU
</Tab>
<Tab to={pb.instanceDiskMetrics({ project, instance })} sideTab>
Disk
</Tab>
<Tab to={pb.instanceNetworkMetrics({ project, instance })} sideTab>
Network
</Tab>
</RouteTabs>
</MetricsContext.Provider>
Copy link
Collaborator

@david-crespo david-crespo Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels a bit silly to have to put sideTabs on both the parent and the children. We could fix this either by taking the list of entries as a prop on <RouteTabs> and having it render the children and passing through the right prop, OR we could do it entirely in CSS, allowing the class on the parent to determine the styling on the children. I think either of those would be fine, but because they require changing all existing RouteTabs call sites, let's make it a followup: #2709

)
}
Loading
Loading