-
Notifications
You must be signed in to change notification settings - Fork 14
Instance Metrics using OxQL #2654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 22 commits
2c3d3bf
b7f8d9f
b313aac
4c61baf
3a94b31
503a47e
1bd508d
82368ed
b7267fb
8745bec
3faf22b
67eb979
26dfd35
4723da2
40bd0c5
d332180
77bd509
e3e2382
450fc98
36de1bc
dd96051
4ad4271
8d80808
dae5172
51ef360
0ad871e
6fd2397
c673784
2c4d130
ef48320
d35afe4
bb8d32b
bfc9714
c143e71
83be393
1437239
520ee8b
d1f8e85
a7ba787
b518077
17446b0
3d31bd2
4614ac8
aefe263
d476b3a
e8710a8
56502be
7016bee
dc8e38e
8f4aa9b
919ca2b
82096ba
9141b3f
7661b10
65f0f43
d552feb
2ee92f0
8cc6018
8a1e80f
9c5aae3
1a8d551
3758390
1cf9bca
ed338f9
bc8853c
cf3faf8
fa081a7
7375ac0
6fa81f4
8c537dd
8a0ca56
0e87365
f3207fb
2f38adc
13e82c0
42b22d3
47da2bf
e16b1d7
f448a0a
fa375f9
3263861
d5ceef4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feels a bit silly to have to put |
||
) | ||
} |
There was a problem hiding this comment.
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'
withtop
as default might be clearer than the booleansideTabs