diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index 3259cf2895..cb12f83d5b 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -51,6 +51,7 @@ it('mock-api is only referenced in test files', () => { "app/main.tsx", "app/msw-mock-api.ts", "docs/mock-api-differences.md", + "mock-api/msw/util.ts", "package.json", "test/e2e/utils.ts", "test/unit/server.ts", diff --git a/app/api/util.ts b/app/api/util.ts index 9cbb13ba40..4018e4041b 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -229,7 +229,7 @@ export type ChartDatum = { // we're doing the x axis as timestamp ms instead of Date primarily to make // type=number work timestamp: number - value: number + value: number | null } /** fill in data points at start and end of range */ diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 63addffa1b..d4caa535a3 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import * as m from 'motion/react-m' import { useState, type ReactNode } from 'react' import { Success12Icon } from '@oxide/design-system/icons/react' @@ -13,29 +14,27 @@ import { Button } from '~/ui/lib/Button' import { Modal } from '~/ui/lib/Modal' import { useTimeout } from '~/ui/lib/use-timeout' -type CopyCodeProps = { +type CopyCodeModalProps = { code: string - modalButtonText: string copyButtonText: string modalTitle: string + footer?: ReactNode /** rendered code */ children?: ReactNode + isOpen: boolean + onDismiss: () => void } -export function CopyCode({ +export function CopyCodeModal({ + isOpen, + onDismiss, code, - modalButtonText, copyButtonText, modalTitle, children, -}: CopyCodeProps) { - const [isOpen, setIsOpen] = useState(false) + footer, +}: CopyCodeModalProps) { const [hasCopied, setHasCopied] = useState(false) - - function handleDismiss() { - setIsOpen(false) - } - useTimeout(() => setHasCopied(false), hasCopied ? 2000 : null) const handleCopy = () => { @@ -45,35 +44,44 @@ export function CopyCode({ } return ( - <> - - - -
-            {children}
-          
-
- - {copyButtonText} - + +
+          {children}
+        
+
+ + + {copyButtonText} + + + {hasCopied && ( + - - Copied -
- - } - /> -
- + + + )} + + } + > + {footer} + + ) } @@ -86,15 +94,23 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { `--instance ${instance}`, ] + const [isOpen, setIsOpen] = useState(false) + return ( - -
$
- {cmdParts.join(' \\\n')} -
+ <> + + setIsOpen(false)} + > + $ + {cmdParts.join(' \\\n ')} + + ) } diff --git a/app/components/MoreActionsMenu.tsx b/app/components/MoreActionsMenu.tsx index 9aa608ba1a..774ad7c257 100644 --- a/app/components/MoreActionsMenu.tsx +++ b/app/components/MoreActionsMenu.tsx @@ -5,6 +5,8 @@ * * Copyright Oxide Computer Company */ +import cn from 'classnames' + import { More12Icon } from '@oxide/design-system/icons/react' import type { MenuAction } from '~/table/columns/action-col' @@ -16,13 +18,21 @@ interface MoreActionsMenuProps { /** The accessible name for the menu button */ label: string actions: MenuAction[] + isSmall?: boolean } -export const MoreActionsMenu = ({ actions, label }: MoreActionsMenuProps) => { +export const MoreActionsMenu = ({ + actions, + label, + isSmall = false, +}: MoreActionsMenuProps) => { return ( diff --git a/app/components/RefetchIntervalPicker.tsx b/app/components/RefetchIntervalPicker.tsx index 246ce9ae06..e8402e7789 100644 --- a/app/components/RefetchIntervalPicker.tsx +++ b/app/components/RefetchIntervalPicker.tsx @@ -37,9 +37,19 @@ type Props = { enabled: boolean isLoading: boolean fn: () => void + showLastFetched?: boolean + className?: string + isSlim?: boolean } -export function useIntervalPicker({ enabled, isLoading, fn }: Props) { +export function useIntervalPicker({ + enabled, + isLoading, + fn, + showLastFetched = false, + className, + isSlim = false, +}: Props) { const [intervalPreset, setIntervalPreset] = useState('10s') const [lastFetched, setLastFetched] = useState(new Date()) @@ -53,11 +63,13 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) { return { intervalMs: (enabled && intervalPresets[intervalPreset]) || undefined, intervalPicker: ( -
-
- Refreshed{' '} - {toLocaleTimeString(lastFetched)} -
+
+ {showLastFetched && ( +
+ Refreshed{' '} + {toLocaleTimeString(lastFetched)} +
+ )}
diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx index a35272539e..ae82a9ba08 100644 --- a/app/components/RouteTabs.tsx +++ b/app/components/RouteTabs.tsx @@ -38,16 +38,35 @@ const selectTab = (e: React.KeyboardEvent) => { export interface RouteTabsProps { children: ReactNode fullWidth?: boolean + sideTabs?: boolean + tabListClassName?: string } -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, + tabListClassName, +}: RouteTabsProps) { + 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 @container', { 'ml-5 flex-grow': sideTabs }) return ( -
+
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} -
+
{children}
{/* TODO: Add aria-describedby for active tab */} -
+
@@ -57,14 +76,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 ( diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index 78f6ecd9ad..0b6568a7f5 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import React, { Suspense, useMemo, useRef } from 'react' +import { useMemo, useRef } from 'react' import { synthesizeData, @@ -16,7 +16,7 @@ import { import { Spinner } from '~/ui/lib/Spinner' -const TimeSeriesChart = React.lazy(() => import('./TimeSeriesChart')) +import { TimeSeriesChart } from './TimeSeriesChart' // The difference between system metric and silo metric is // 1. different endpoints @@ -99,20 +99,18 @@ export function SiloMetric({ {(inRange.isPending || beforeStart.isPending) && } {/* TODO: proper skeleton for empty chart */} - }> -
- -
-
+
+ +
) } @@ -177,20 +175,18 @@ export function SystemMetric({ {(inRange.isPending || beforeStart.isPending) && } {/* TODO: proper skeleton for empty chart */} - }> -
- -
-
+
+ +
) } diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 5fd8f6c2c4..e15b05bed9 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,7 +7,7 @@ */ import cn from 'classnames' import { format } from 'date-fns' -import { useMemo } from 'react' +import { useMemo, type ReactNode } from 'react' import { Area, AreaChart, @@ -20,6 +20,7 @@ import { import type { TooltipProps } from 'recharts/types/component/Tooltip' import type { ChartDatum } from '@oxide/api' +import { Error12Icon } from '@oxide/design-system/icons/react' // Recharts's built-in ticks behavior is useless and probably broken /** @@ -73,6 +74,12 @@ const textMonoMd = { fill: 'var(--content-quaternary)', } +// The length of a character in pixels at 11px with GT America Mono +// Used for dynamically sizing the yAxis. If this were to fallback +// the font would likely be thinner than the monospaced character +// and therefore not overflow +const TEXT_CHAR_WIDTH = 6.82 + function renderTooltip(props: TooltipProps, unit?: string) { const { payload } = props if (!payload || payload.length < 1) return null @@ -110,18 +117,55 @@ type TimeSeriesChartProps = { endTime: Date unit?: string yAxisTickFormatter?: (val: number) => string + hasBorder?: boolean + hasError?: boolean } const TICK_COUNT = 6 +const TICK_MARGIN = 8 +const TICK_SIZE = 6 /** Round `value` up to nearest number divisible by `divisor` */ function roundUpToDivBy(value: number, divisor: number) { return Math.ceil(value / divisor) * divisor } -// default export is most convenient for dynamic import -// eslint-disable-next-line import/no-default-export -export default function TimeSeriesChart({ +// this top margin is also in the chart, probably want a way of unifying the sizing between the two +const SkeletonMetric = ({ + children, + shimmer = false, + className, +}: { + children: ReactNode + shimmer?: boolean + className?: string +}) => ( +
+
+
+ {[...Array(4)].map((_e, i) => ( +
+ ))} +
+
+ {[...Array(8)].map((_e, i) => ( +
+ ))} +
+
+
+ {children} +
+
+) + +export function TimeSeriesChart({ className, data: rawData, title, @@ -132,13 +176,17 @@ export default function TimeSeriesChart({ endTime, unit, yAxisTickFormatter = (val) => val.toLocaleString(), + hasBorder = true, + hasError = false, }: TimeSeriesChartProps) { // We use the largest data point +20% for the graph scale. !rawData doesn't // mean it's empty (it will never be empty because we fill in artificial 0s at // beginning and end), it means the metrics requests haven't come back yet const maxY = useMemo(() => { if (!rawData) return null - const dataMax = Math.max(...rawData.map((datum) => datum.value)) + const dataMax = Math.max( + ...rawData.map((datum) => datum.value).filter((x) => x !== null) + ) return roundUpToDivBy(dataMax * 1.2, TICK_COUNT) // avoid uneven ticks }, [rawData]) @@ -149,18 +197,46 @@ export default function TimeSeriesChart({ ? { domain: [0, maxY], ticks: getVerticalTicks(TICK_COUNT, maxY) } : undefined + // We get the longest label length and multiply that with our `TICK_CHAR_WIDTH` + // and add the extra space for the tick stroke and spacing + // It's possible to get clever and calculate the width using the canvas or font metrics + // But our font is monospace so we can just use the length of the text * the baked width of the character + const maxLabelLength = yTicks + ? Math.max(...yTicks.ticks.map((tick) => yAxisTickFormatter(tick).length)) + : 0 + const maxLabelWidth = maxLabelLength * TEXT_CHAR_WIDTH + TICK_SIZE + TICK_MARGIN + // falling back here instead of in the parent lets us avoid causing a // re-render on every render of the parent when the data is undefined const data = useMemo(() => rawData || [], [rawData]) + const wrapperClass = cn(className, hasBorder && 'rounded-lg border border-default') + + if (hasError) { + return ( + + + + ) + } + + if (!data || data.length === 0) { + return ( + + + + ) + } + return (
- + {/* temporary until we migrate the old metrics to the new style */} + {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} @@ -211,3 +290,33 @@ export default function TimeSeriesChart({
) } + +const MetricsLoadingIndicator = () => ( +
+ + + +
+) + +const MetricsError = () => ( + <> +
+
+
+ +
+
Something went wrong
+
+ Please try again. If the problem persists, contact your administrator. +
+
+
+ +) diff --git a/app/components/form/fields/DateTimeRangePicker.tsx b/app/components/form/fields/DateTimeRangePicker.tsx index 5a52adb117..bde663f0cb 100644 --- a/app/components/form/fields/DateTimeRangePicker.tsx +++ b/app/components/form/fields/DateTimeRangePicker.tsx @@ -47,10 +47,12 @@ export function useDateTimeRangePicker({ initialPreset, minValue, maxValue, + items, }: { initialPreset: RangeKey minValue?: DateValue | undefined maxValue?: DateValue | undefined + items?: { label: string; value: RangeKeyAll }[] }) { const now = useMemo(() => getNow(getLocalTimeZone()), []) @@ -68,7 +70,16 @@ export function useDateTimeRangePicker({ } } - const props = { preset, setPreset, range, setRange, minValue, maxValue, onRangeChange } + const props = { + preset, + setPreset, + range, + setRange, + minValue, + maxValue, + onRangeChange, + items, + } return { startTime: range.start.toDate(getLocalTimeZone()), @@ -89,6 +100,7 @@ type DateTimeRangePickerProps = { onRangeChange?: (preset: RangeKeyAll) => void minValue?: DateValue | undefined maxValue?: DateValue | undefined + items?: { label: string; value: RangeKeyAll }[] } export function DateTimeRangePicker({ @@ -99,6 +111,7 @@ export function DateTimeRangePicker({ minValue, maxValue, onRangeChange, + items, }: DateTimeRangePickerProps) { return (
@@ -107,7 +120,7 @@ export function DateTimeRangePicker({ name="preset" selected={preset} aria-label="Choose a time range preset" - items={rangePresets} + items={items || rangePresets} onChange={(value) => { setPreset(value) onRangeChange?.(value) diff --git a/app/components/oxql-metrics/HighlightedOxqlQuery.spec.tsx b/app/components/oxql-metrics/HighlightedOxqlQuery.spec.tsx new file mode 100644 index 0000000000..ad06a6a5ff --- /dev/null +++ b/app/components/oxql-metrics/HighlightedOxqlQuery.spec.tsx @@ -0,0 +1,200 @@ +/* + * 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 { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { HighlightedOxqlQuery, toOxqlStr } from './HighlightedOxqlQuery' + +describe('toOxqlStr', () => { + const startTime = new Date('2024-01-01T00:00:00Z') + const endTime = new Date('2024-01-01T01:00:00Z') + it('generates a query for disk metrics without extra filters', () => { + const query = toOxqlStr({ + metricName: 'virtual_disk:bytes_read', + startTime, + endTime, + }) + expect(query).toBe( + 'get virtual_disk:bytes_read | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + ) + }) + + it('generates a query for vm metrics with instanceId filter', () => { + const query = toOxqlStr({ + metricName: 'virtual_machine:vcpu_usage', + startTime, + endTime, + eqFilters: { + instance_id: 'vm-123', + }, + }) + expect(query).toBe( + 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s)' + ) + }) + + it('generates a query for network metrics with interfaceId filter', () => { + const query = toOxqlStr({ + metricName: 'instance_network_interface:bytes_sent', + startTime, + endTime, + eqFilters: { + interface_id: 'eth0', + }, + }) + expect(query).toBe( + 'get instance_network_interface:bytes_sent | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' + ) + }) + + it('generates a query with vcpu state filter', () => { + const query = toOxqlStr({ + metricName: 'virtual_machine:vcpu_usage', + startTime, + endTime, + eqFilters: { + state: 'run', + }, + }) + expect(query).toBe( + 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && state == "run" | align mean_within(60s)' + ) + }) + + it('generates a query with group by instanceId', () => { + const query = toOxqlStr({ + metricName: 'virtual_disk:bytes_written', + startTime, + endTime, + eqFilters: { + instance_id: 'vm-123', + }, + groupBy: { cols: ['instance_id'], op: 'sum' }, + }) + expect(query).toBe( + 'get virtual_disk:bytes_written | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s) | group_by [instance_id], sum' + ) + }) + + it('generates a query with group by attachedInstanceId', () => { + const query = toOxqlStr({ + metricName: 'virtual_disk:io_latency', + startTime, + endTime, + eqFilters: { + attached_instance_id: 'attached-1', + }, + groupBy: { cols: ['attached_instance_id'], op: 'sum' }, + }) + expect(query).toBe( + 'get virtual_disk:io_latency | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && attached_instance_id == "attached-1" | align mean_within(60s) | group_by [attached_instance_id], sum' + ) + }) + + it('handles missing optional parameters gracefully', () => { + const query = toOxqlStr({ + metricName: 'virtual_disk:flushes', + startTime, + endTime, + }) + expect(query).toBe( + 'get virtual_disk:flushes | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + ) + }) + + it('correctly handles a range of disk and network metrics', () => { + const query = toOxqlStr({ + metricName: 'instance_network_interface:bytes_received', + startTime, + endTime, + eqFilters: { + interface_id: 'eth0', + }, + }) + expect(query).toBe( + 'get instance_network_interface:bytes_received | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' + ) + }) + + it('leaves out filters that are present but with falsy values', () => { + const query = toOxqlStr({ + metricName: 'virtual_machine:vcpu_usage', + startTime, + endTime, + eqFilters: { + state: undefined, + }, + }) + expect(query).toBe( + 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + ) + }) +}) + +describe('HighlightedOxqlQuery indentation', () => { + const startTime = new Date('2024-01-01T00:00:00Z') + const endTime = new Date('2024-01-01T01:00:00Z') + it('no filters', () => { + const pre = render( + + ) + // we have to do the assert this way because toHaveTextContent did not preserve the newlines + expect(pre.container.textContent).toMatchInlineSnapshot(` + "get virtual_machine:vcpu_usage + | filter timestamp >= @2023-12-31T23:58:00.000 + && timestamp < @2024-01-01T01:00:00.000 + | align mean_within(60s)" + `) + }) + + it('with filters', () => { + const pre = render( + + ) + expect(pre.container.textContent).toMatchInlineSnapshot(` + "get virtual_machine:vcpu_usage + | filter timestamp >= @2023-12-31T23:58:00.000 + && timestamp < @2024-01-01T01:00:00.000 + && instance_id == "an-instance-id" + && vcpu_id == "a-cpu-id" + | align mean_within(60s)" + `) + }) + + it('with groupby', () => { + const pre = render( + + ) + expect(pre.container.textContent).toMatchInlineSnapshot(` + "get virtual_machine:vcpu_usage + | filter timestamp >= @2023-12-31T23:58:00.000 + && timestamp < @2024-01-01T01:00:00.000 + && instance_id == "an-instance-id" + | align mean_within(60s) + | group_by [instance_id], sum" + `) + }) +}) diff --git a/app/components/oxql-metrics/HighlightedOxqlQuery.tsx b/app/components/oxql-metrics/HighlightedOxqlQuery.tsx new file mode 100644 index 0000000000..c0eb35614a --- /dev/null +++ b/app/components/oxql-metrics/HighlightedOxqlQuery.tsx @@ -0,0 +1,90 @@ +/* + * 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 { Fragment } from 'react' + +import { intersperse } from '~/util/array' +import { classed } from '~/util/classed' + +import { getTimePropsForOxqlQuery, oxqlTimestamp, type OxqlQuery } from './util' + +const Keyword = classed.span`text-[#C6A5EA]` // purple +const NewlinePipe = () => {'\n | '} // light green +const StringLit = classed.span`text-[#68D9A7]` // green +const NumLit = classed.span`text-[#EDD5A6]` // light yellow + +const FilterSep = () => '\n && ' + +export function HighlightedOxqlQuery({ + metricName, + startTime, + endTime, + groupBy, + eqFilters = {}, +}: OxqlQuery) { + const { meanWithinSeconds, adjustedStart } = getTimePropsForOxqlQuery(startTime, endTime) + const filters = [ + + timestamp >= @{oxqlTimestamp(adjustedStart)} + , + + timestamp < @{oxqlTimestamp(endTime)} + , + ...Object.entries(eqFilters) + .filter(([_, v]) => !!v) + .map(([k, v]) => ( + + {k} == "{v}" + + )), + ] + + return ( + <> + get {metricName} + + filter {intersperse(filters, )} + + align mean_within({meanWithinSeconds}s) + {groupBy && ( + <> + + group_by [{groupBy.cols.join(', ')}], {groupBy.op} + + )} + + ) +} + +export const toOxqlStr = ({ + metricName, + startTime, + endTime, + groupBy, + eqFilters = {}, +}: OxqlQuery) => { + const { meanWithinSeconds, adjustedStart } = getTimePropsForOxqlQuery(startTime, endTime) + const filters = [ + `timestamp >= @${oxqlTimestamp(adjustedStart)}`, + `timestamp < @${oxqlTimestamp(endTime)}`, + ...Object.entries(eqFilters) + // filter out key present but with falsy value. note that this will also + // filter out empty string, meaning we can't filter by value = "" + .filter(([_, v]) => !!v) + .map(([k, v]) => `${k} == "${v}"`), + ] + + const query = [ + `get ${metricName}`, + `filter ${filters.join(' && ')}`, + `align mean_within(${meanWithinSeconds}s)`, + ] + + if (groupBy) query.push(`group_by [${groupBy.cols.join(', ')}], ${groupBy.op}`) + return query.join(' | ') +} diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx new file mode 100644 index 0000000000..f16bbb746d --- /dev/null +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -0,0 +1,141 @@ +/* + * 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 + */ + +/* + * OxQL Metrics Schema: + * https://github.com/oxidecomputer/omicron/tree/main/oximeter/oximeter/schema + */ + +import { Children, useEffect, useMemo, useState, type ReactNode } from 'react' +import type { LoaderFunctionArgs } from 'react-router' + +import { apiQueryClient, useApiQuery } from '@oxide/api' + +import { CopyCodeModal } from '~/components/CopyCode' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { getInstanceSelector } from '~/hooks/use-params' +import { useMetricsContext } from '~/pages/project/instances/instance/tabs/common' +import { LearnMore } from '~/ui/lib/SettingsGroup' +import { classed } from '~/util/classed' +import { links } from '~/util/links' + +import { TimeSeriesChart } from '../TimeSeriesChart' +import { HighlightedOxqlQuery, toOxqlStr } from './HighlightedOxqlQuery' +import { + composeOxqlData, + getBytesChartProps, + getCountChartProps, + getUtilizationChartProps, + type OxqlQuery, +} from './util' + +export async function loader({ params }: LoaderFunctionArgs) { + const { project, instance } = getInstanceSelector(params) + await apiQueryClient.prefetchQuery('instanceView', { + path: { instance }, + query: { project }, + }) + return null +} + +export type OxqlMetricProps = OxqlQuery & { + title: string + description?: string + unit: 'Bytes' | '%' | 'Count' +} + +export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetricProps) { + // only start reloading data once an intial dataset has been loaded + const { setIsIntervalPickerEnabled } = useMetricsContext() + const query = toOxqlStr(queryObj) + const { data: metrics, error } = useApiQuery( + 'systemTimeseriesQuery', + { body: { query } } + // avoid graphs flashing blank while loading when you change the time + // { placeholderData: (x) => x } + ) + useEffect(() => { + if (metrics) { + // this is too slow right now; disabling until we can make it faster + // setIsIntervalPickerEnabled(true) + } + }, [metrics, setIsIntervalPickerEnabled]) + const { startTime, endTime } = queryObj + const { chartData, timeseriesCount } = useMemo(() => composeOxqlData(metrics), [metrics]) + const { data, label, unitForSet, yAxisTickFormatter } = useMemo(() => { + if (unit === 'Bytes') return getBytesChartProps(chartData) + if (unit === 'Count') return getCountChartProps(chartData) + return getUtilizationChartProps(chartData, timeseriesCount) + }, [unit, chartData, timeseriesCount]) + + const [modalOpen, setModalOpen] = useState(false) + + return ( +
+
+
+

+
{title}
+
{label}
+

+
{description}
+
+ { + const url = links.oxqlSchemaDocs(queryObj.metricName) + window.open(url, '_blank', 'noopener,noreferrer') + }, + }, + { + label: 'View OxQL query', + onActivate: () => setModalOpen(true), + }, + ]} + isSmall + /> + setModalOpen(false)} + code={query} + copyButtonText="Copy query" + modalTitle="OxQL query" + footer={} + > + + +
+
+ +
+
+ ) +} + +export const MetricHeader = ({ children }: { children: ReactNode }) => { + // If header has only one child, align it to the end of the container + const justify = Children.count(children) === 1 ? 'justify-end' : 'justify-between' + return
{children}
+} +export const MetricCollection = classed.div`mt-4 flex flex-col gap-4` + +export const MetricRow = classed.div`flex w-full flex-col gap-4 @[48rem]:flex-row` diff --git a/app/components/oxql-metrics/util.spec.ts b/app/components/oxql-metrics/util.spec.ts new file mode 100644 index 0000000000..9318cceb41 --- /dev/null +++ b/app/components/oxql-metrics/util.spec.ts @@ -0,0 +1,258 @@ +/* + * 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 { describe, expect, test } from 'vitest' + +import type { OxqlQueryResult } from '~/api' + +import { + composeOxqlData, + getLargestValue, + getMaxExponent, + getMeanWithinSeconds, + getTimePropsForOxqlQuery, + getUtilizationChartProps, + oxqlTimestamp, + sumValues, + yAxisLabelForCountChart, +} from './util' + +test('oxqlTimestamp', () => { + const date1 = new Date('2025-02-11T00:00:01.234Z') + expect(oxqlTimestamp(date1)).toEqual('2025-02-11T00:00:01.000') + const datePST = new Date('2025-02-11T00:00:00-08:00') + expect(oxqlTimestamp(datePST)).toEqual('2025-02-11T08:00:00.000') +}) + +describe('getMeanWithinSeconds', () => { + const start = new Date('2025-02-11T00:00:00Z') + test('calculates the mean window for a 10-minute range', () => { + const end = new Date('2025-02-11T00:10:00Z') // 10 minutes later + expect(getMeanWithinSeconds(start, end)).toBe(10) + }) + + test('calculates the mean window for a 1-hour range', () => { + const end = new Date('2025-02-11T01:00:00Z') // 60 minutes later + expect(getMeanWithinSeconds(start, end)).toBe(60) + }) + + test('calculates the mean window for a 24-hour range', () => { + const end = new Date('2025-02-12T00:00:00Z') // 24 hours later + expect(getMeanWithinSeconds(start, end)).toBe(1440) + }) + + test('calculates the mean window for a 1-week range', () => { + const end = new Date('2025-02-18T00:00:00Z') // 1 week later + expect(getMeanWithinSeconds(start, end)).toBe(10080) + }) + + test('calculates the mean window for a 10-minute range, but with only 5 datapoints', () => { + const end = new Date('2025-02-11T00:10:00Z') // 10 minutes later + const datapoints = 5 + expect(getMeanWithinSeconds(start, end, datapoints)).toBe(120) + }) + test('calculates the mean window for a 2-hour range, but with only 5 datapoints', () => { + const end = new Date('2025-02-11T02:00:00Z') // 120 minutes later + const datapoints = 5 + expect(getMeanWithinSeconds(start, end, datapoints)).toBe(1440) + }) + test('calculates the mean window for a 1-month range', () => { + const end = new Date('2025-03-11T00:00:00Z') // 28 days later + expect(getMeanWithinSeconds(start, end)).toBe(40320) + }) + test('calculates the mean window for a 1-month range, with only 20 datapoints', () => { + const end = new Date('2025-03-11T00:00:00Z') // 28 days later + expect(getMeanWithinSeconds(start, end, 20)).toBe(120960) + }) +}) + +test('getTimePropsForOxqlQuery', () => { + const startTime = new Date('2025-02-21T01:00:00Z') + const endTime = new Date('2025-02-21T02:00:00Z') + const { meanWithinSeconds, adjustedStart } = getTimePropsForOxqlQuery(startTime, endTime) + expect(meanWithinSeconds).toEqual(60) + expect(adjustedStart).toEqual(new Date('2025-02-21T00:58:00.000Z')) +}) + +test('getMaxExponent', () => { + expect(getMaxExponent(5, 1000)).toEqual(0) + expect(getMaxExponent(1000, 1000)).toEqual(1) + expect(getMaxExponent(1001, 1000)).toEqual(1) + expect(getMaxExponent(10 ** 6, 1000)).toEqual(2) + expect(getMaxExponent(10 ** 6 + 1, 1000)).toEqual(2) + expect(getMaxExponent(10 ** 9, 1000)).toEqual(3) + expect(getMaxExponent(10 ** 9 + 1, 1000)).toEqual(3) + + // Bytes + expect(getMaxExponent(5, 1024)).toEqual(0) + // KiBs + expect(getMaxExponent(1024, 1024)).toEqual(1) + expect(getMaxExponent(1025, 1024)).toEqual(1) + expect(getMaxExponent(2 ** 20 - 1, 1024)).toEqual(1) + // MiBs + expect(getMaxExponent(2 ** 20, 1024)).toEqual(2) + expect(getMaxExponent(2 ** 20 + 1, 1024)).toEqual(2) + expect(getMaxExponent(2 ** 30 - 1, 1024)).toEqual(2) + // GiBs + expect(getMaxExponent(2 ** 30, 1024)).toEqual(3) + expect(getMaxExponent(2 ** 30 + 1, 1024)).toEqual(3) + + expect(getMaxExponent(0, 1000)).toEqual(0) +}) + +test('yAxisLabelForCountChart', () => { + expect(yAxisLabelForCountChart(5, 0)).toEqual('5') + expect(yAxisLabelForCountChart(1000, 1)).toEqual('1k') + expect(yAxisLabelForCountChart(1001, 1)).toEqual('1k') + expect(yAxisLabelForCountChart(10 ** 6, 2)).toEqual('1M') + expect(yAxisLabelForCountChart(10 ** 6 + 1, 2)).toEqual('1M') + expect(yAxisLabelForCountChart(10 ** 9, 3)).toEqual('1B') + expect(yAxisLabelForCountChart(10 ** 9 + 1, 3)).toEqual('1B') + expect(yAxisLabelForCountChart(10 ** 12, 4)).toEqual('1T') + expect(yAxisLabelForCountChart(10 ** 12 + 1, 4)).toEqual('1T') +}) + +test('getLargestValue', () => { + const sampleData = [ + { timestamp: 1739232000000, value: 5 }, + { timestamp: 1739232060000, value: 10 }, + { timestamp: 1739232120000, value: 15 }, + ] + expect(getLargestValue(sampleData)).toEqual(15) + expect(getLargestValue([])).toEqual(0) +}) + +// Just including four datapoints for this test +const utilizationQueryResult1: OxqlQueryResult = { + tables: [ + { + name: 'virtual_machine:vcpu_usage', + timeseries: { + '16671618930358432507': { + fields: { + vcpuId: { + type: 'u32', + value: 0, + }, + }, + points: { + timestamps: [ + new Date('2025-02-21T19:28:43Z'), + new Date('2025-02-21T19:29:43Z'), + new Date('2025-02-21T19:30:43Z'), + new Date('2025-02-21T19:31:43Z'), + ], + values: [ + { + values: { + type: 'double', + // there is a bug in the client generator that makes this not allow nulls, + // but we can in fact get them from the API for these values + // @ts-expect-error + values: [4991154550.953981, 5002306111.529594, 5005747970.58788, null], + }, + metricType: 'gauge', + }, + ], + }, + }, + }, + }, + ], +} + +const timeseries1 = utilizationQueryResult1.tables[0].timeseries['16671618930358432507'] + +test('sumValues', () => { + expect(sumValues([], 0)).toEqual([]) + expect(sumValues([timeseries1], 4)).toEqual([ + 4991154550.953981, + 5002306111.529594, + 5005747970.58788, + null, + ]) + // we're just including this dataset twice to show that the numbers are getting added together + expect(sumValues([timeseries1, timeseries1], 4)).toEqual([ + 9982309101.907963, + 10004612223.059189, + 10011495941.17576, + null, + ]) + // and for good measure, we'll include it three times + expect(sumValues([timeseries1, timeseries1, timeseries1], 4)).toEqual([ + 14973463652.861944, + 15006918334.588783, + 15017243911.763641, + null, + ]) +}) + +// Note how this only has three values, where the original data had four, +// because we've intentionally removed the first one +const composedUtilizationData = { + chartData: [ + { + timestamp: 1740166183000, + value: 5002306111.529594, + }, + { + timestamp: 1740166243000, + value: 5005747970.58788, + }, + { + timestamp: 1740166303000, + value: null, + }, + ], + timeseriesCount: 1, +} + +// As above, we've removed the first value from the original data +const utilizationChartData = [ + { + timestamp: 1740166183000, + value: 100.04612223059189, + }, + { + timestamp: 1740166243000, + value: 100.11495941175761, + }, + { + timestamp: 1740166303000, + value: null, + }, +] + +// These are the exepcted values if the same data came back for a 4-vcpu instance +// As above, we've discarded the first value from the original data +const utilizationChartData4 = [ + { + timestamp: 1740166183000, + value: 25.011530557647973, + }, + { + timestamp: 1740166243000, + value: 25.028739852939403, + }, + { + timestamp: 1740166303000, + value: null, + }, +] + +test('get utilization chart data and process it for chart display', () => { + const composedData = composeOxqlData(utilizationQueryResult1) + expect(composedData).toEqual(composedUtilizationData) + const { data: chartData } = getUtilizationChartProps(composedUtilizationData.chartData, 1) + expect(chartData).toEqual(utilizationChartData) + // Testing the same data, but for a 4-vcpu instance + const { data: chartData4 } = getUtilizationChartProps( + composedUtilizationData.chartData, + 4 + ) + expect(chartData4).toEqual(utilizationChartData4) +}) diff --git a/app/components/oxql-metrics/util.ts b/app/components/oxql-metrics/util.ts new file mode 100644 index 0000000000..267a6e3eac --- /dev/null +++ b/app/components/oxql-metrics/util.ts @@ -0,0 +1,220 @@ +/* + * 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 { differenceInSeconds } from 'date-fns' +import * as R from 'remeda' + +import type { ChartDatum, OxqlQueryResult, Timeseries } from '~/api' + +/* + * OxQL Metrics Schema: + * https://github.com/oxidecomputer/omicron/tree/main/oximeter/oximeter/schema + */ + +type OxqlDiskMetricName = + | 'virtual_disk:bytes_read' + | 'virtual_disk:bytes_written' + | 'virtual_disk:failed_flushes' + | 'virtual_disk:failed_reads' + | 'virtual_disk:failed_writes' + | 'virtual_disk:flushes' + | 'virtual_disk:io_latency' + | 'virtual_disk:io_size' + | 'virtual_disk:reads' + | 'virtual_disk:writes' + +type OxqlVmMetricName = 'virtual_machine:vcpu_usage' + +export type OxqlNetworkMetricName = + | 'instance_network_interface:bytes_received' + | 'instance_network_interface:bytes_sent' + | 'instance_network_interface:errors_received' + | 'instance_network_interface:errors_sent' + | 'instance_network_interface:packets_dropped' + | 'instance_network_interface:packets_received' + | 'instance_network_interface:packets_sent' + +export type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetworkMetricName + +export type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' + +type FilterKey = + | 'instance_id' + // for cpu metrics + | 'vcpu_id' + | 'state' + // for disk metrics + | 'disk_id' + | 'attached_instance_id' + // for network metrics + | 'interface_id' + +type GroupByCol = 'instance_id' | 'attached_instance_id' | 'vcpu_id' + +type GroupBy = { + cols: NonEmptyArray + op: 'sum' | 'mean' +} + +export type OxqlQuery = { + metricName: OxqlMetricName + startTime: Date + endTime: Date + groupBy?: GroupBy + eqFilters?: Partial> +} + +// the interval (in seconds) at which oximeter polls for new data; a constant in Propolis +// in time, we'll need to make this dynamic +// https://github.com/oxidecomputer/propolis/blob/42b87359/bin/propolis-server/src/lib/stats/mod.rs#L54-L57 +export const VCPU_KSTAT_INTERVAL_SEC = 5 + +// Returns 0 if there are no data points +export const getLargestValue = (data: ChartDatum[]) => + data.length ? Math.max(0, ...data.map((d) => d.value).filter((x) => x !== null)) : 0 + +export const getMaxExponent = (largestValue: number, base: number) => + Math.max(Math.floor(Math.log(largestValue) / Math.log(base)), 0) + +/** convert to UTC and return the timezone-free format required by OxQL, without milliseconds */ +export const oxqlTimestamp = (date: Date) => date.toISOString().replace(/\.\d+Z$/, '.000') + +/** determine the mean window, in seconds, for the given time range; + * datapoints = the number of datapoints we want to see in the chart + * (default is 60, to show 1 point per minute on a 1-hour chart) + * */ +export const getMeanWithinSeconds = (start: Date, end: Date, datapoints = 60) => { + const duration = differenceInSeconds(end, start) + // 5 second minimum, to handle oximeter logging interval for CPU data + return Math.max(VCPU_KSTAT_INTERVAL_SEC, Math.round(duration / datapoints)) +} + +export const getTimePropsForOxqlQuery = ( + startTime: Date, + endTime: Date, + datapoints = 60 +) => { + const meanWithinSeconds = getMeanWithinSeconds(startTime, endTime, datapoints) + // we adjust the start time back by 2x the mean window so that we can + // 1) drop the first datapoint (the cumulative sum of all previous datapoints) + // 2) ensure that the first datapoint we display on the chart matches the actual start time + const secondsToAdjust = meanWithinSeconds * 2 + const adjustedStart = new Date(startTime.getTime() - secondsToAdjust * 1000) + return { meanWithinSeconds, adjustedStart } +} + +export const sumValues = (timeseries: Timeseries[], arrLen: number): (number | null)[] => + Array.from({ length: arrLen }).map((_, i) => + R.pipe( + timeseries, + // get point at that index for each timeseries + R.map((ts) => ts.points.values.at(0)?.values.values?.[i]), + // filter out nulls (undefined shouldn't happen) + R.filter((p) => typeof p === 'number'), + // null if no timeseries has a data point at that idx, so empty parts of + // the chart stay empty + (points) => (points.length > 0 ? R.sum(points) : null) + ) + ) + +// Take the OxQL Query Result and return the data in a format that the chart can use +// We'll do this by creating two arrays: one for the timestamps and one for the values +// We'll then combine these into an array of objects, each with a timestamp and a value +// Note that this data will need to be processed further, based on the kind of chart we're creating +export const composeOxqlData = (data: OxqlQueryResult | undefined) => { + let timeseriesCount = 0 + if (!data) return { chartData: [], timeseriesCount } + const timeseriesData = Object.values(data.tables[0].timeseries) + timeseriesCount = timeseriesData.length + if (!timeseriesCount) return { chartData: [], timeseriesCount } + // Extract timestamps (all series should have the same timestamps) + const timestamps = + timeseriesData[0]?.points.timestamps.map((t) => new Date(t).getTime()) || [] + // Sum up the values across all time series + const summedValues = sumValues(timeseriesData, timestamps.length) + const chartData = timestamps + .map((timestamp, idx) => ({ timestamp, value: summedValues[idx] })) + // Drop the first datapoint, which — for delta metric types — is the cumulative sum of all previous + // datapoints (like CPU utilization). We've accounted for this by adjusting the start time earlier; + // We could use a more elegant approach to this down the road + .slice(1) + + return { chartData, timeseriesCount } +} + +// What each function will return +type OxqlMetricChartProps = { + data: ChartDatum[] + label: string + unitForSet: string + yAxisTickFormatter: (n: number) => string +} + +// OxQL Metric Charts are currently one of three kinds: Bytes, Count, or Utilization +// Each of these will require different processing to get the data into the right format +// The helper functions below will take the data from the OxQL Query Result and return +// the data in the appropriate format for the chart + +export const getBytesChartProps = (chartData: ChartDatum[]): OxqlMetricChartProps => { + // Bytes charts use 1024 as the base + const base = 1024 + const byteUnits = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'] + const largestValue = getLargestValue(chartData) + const maxExponent = getMaxExponent(largestValue, base) + const bytesChartDivisor = base ** maxExponent + const data = chartData.map((d) => ({ + ...d, + value: d.value !== null ? d.value / bytesChartDivisor : null, + })) + const unitForSet = byteUnits[maxExponent] + return { + data, + label: `(${unitForSet})`, + unitForSet, + yAxisTickFormatter: (n: number) => n.toLocaleString(), + } +} + +export const yAxisLabelForCountChart = (val: number, maxExponent: number) => { + const tickValue = val / 1_000 ** maxExponent + const formattedTickValue = tickValue.toLocaleString(undefined, { + maximumSignificantDigits: 3, + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }) + return `${formattedTickValue}${['', 'k', 'M', 'B', 'T'][maxExponent]}` +} + +/** + * The primary job of the Count chart components helper is to format the Y-axis ticks, + * which requires knowing the largest value in the dataset, to get a consistent scale. + * The data isn't modified for Count charts; the label and unitForSet are basic as well. + */ +export const getCountChartProps = (chartData: ChartDatum[]): OxqlMetricChartProps => { + const largestValue = getLargestValue(chartData) + const maxExponent = getMaxExponent(largestValue, 1_000) + const yAxisTickFormatter = (val: number) => yAxisLabelForCountChart(val, maxExponent) + return { data: chartData, label: '(Count)', unitForSet: '', yAxisTickFormatter } +} + +export const getUtilizationChartProps = ( + chartData: ChartDatum[], + nCPUs: number +): OxqlMetricChartProps => { + // The divisor is the oximeter logging interval for CPU data (5 seconds) * 1,000,000,000 (nanoseconds) * nCPUs + const divisor = VCPU_KSTAT_INTERVAL_SEC * 1000 * 1000 * 1000 * nCPUs + const data = + // dividing by 0 would blow it up, so on the off chance that timeSeriesCount is 0, data should be an empty array + divisor > 0 + ? chartData.map(({ timestamp, value }) => ({ + timestamp, + value: value !== null ? (value * 100) / divisor : null, + })) + : [] + return { data, label: '(%)', unitForSet: '%', yAxisTickFormatter: (n: number) => `${n}%` } +} diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx index df95181cd3..a4a33913d2 100644 --- a/app/pages/SiloUtilizationPage.tsx +++ b/app/pages/SiloUtilizationPage.tsx @@ -26,7 +26,9 @@ import { bytesToGiB, bytesToTiB } from '~/util/units' const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id }) -export async function loader() { +export const handle = { crumb: 'Utilization' } + +export async function clientLoader() { await Promise.all([ apiQueryClient.prefetchQuery('projectList', {}), apiQueryClient.prefetchQuery('utilizationView', {}), @@ -34,8 +36,7 @@ export async function loader() { return null } -Component.displayName = 'SiloUtilizationPage' -export function Component() { +export default function SiloUtilizationPage() { const { me } = useCurrentUser() const siloId = me.siloId @@ -62,6 +63,8 @@ export function Component() { isLoading: useIsFetching({ queryKey: ['siloMetric'] }) > 0, // sliding the range forward is sufficient to trigger a refetch fn: () => onRangeChange(preset), + showLastFetched: true, + className: 'mb-12', }) const commonProps = { diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 2975c7900d..601298ce99 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -259,8 +259,8 @@ export function InstancePage() { Storage - Metrics Networking + Metrics Connect Settings diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts deleted file mode 100644 index 220c63258e..0000000000 --- a/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 { getCycleCount } from './MetricsTab' - -test('getCycleCount', () => { - expect(getCycleCount(5, 1000)).toEqual(0) - expect(getCycleCount(1000, 1000)).toEqual(0) - expect(getCycleCount(1001, 1000)).toEqual(1) - expect(getCycleCount(10 ** 6, 1000)).toEqual(1) - expect(getCycleCount(10 ** 6 + 1, 1000)).toEqual(2) - expect(getCycleCount(10 ** 9, 1000)).toEqual(2) - expect(getCycleCount(10 ** 9 + 1, 1000)).toEqual(3) - - expect(getCycleCount(5, 1024)).toEqual(0) - expect(getCycleCount(1024, 1024)).toEqual(0) - expect(getCycleCount(1025, 1024)).toEqual(1) - expect(getCycleCount(2 ** 20, 1024)).toEqual(1) - expect(getCycleCount(2 ** 20 + 1, 1024)).toEqual(2) - expect(getCycleCount(2 ** 30, 1024)).toEqual(2) - expect(getCycleCount(2 ** 30 + 1, 1024)).toEqual(3) -}) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 0e2304fda7..fb8537f098 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -5,230 +5,69 @@ * * 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 { useIsFetching } from '@tanstack/react-query' +import { useState } 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 { useIntervalPicker } from '~/components/RefetchIntervalPicker' +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')) +import { MetricsContext } from './common' -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' - 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]) - - // 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) - - // 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}` - } +export const handle = { crumb: 'Metrics' } - return ( -
-

- {title}
{label}
- {isLoading && } -

- }> - - -
- ) -} - -// 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 default function MetricsTab() { const { project, instance } = useInstanceSelector() - const { data } = usePrefetchedApiQuery('instanceDiskList', { - path: { instance }, - query: { project }, + // this ensures the interval picker (which defaults to reloading every 10s) only kicks in + // once some initial data have loaded, to prevent requests from stacking up + const [isIntervalPickerEnabled, setIsIntervalPickerEnabled] = useState(false) + + const { preset, onRangeChange, startTime, endTime, dateTimeRangePicker } = + useDateTimeRangePicker({ + items: [ + { label: 'Last hour', value: 'lastHour' }, + { label: 'Last 3 hours', value: 'last3Hours' }, + { label: 'Last 24 hours', value: 'lastDay' }, + { label: 'Custom', value: 'custom' }, + ], + initialPreset: 'lastHour', + }) + + const { intervalPicker } = useIntervalPicker({ + enabled: isIntervalPickerEnabled && preset !== 'custom', + isLoading: useIsFetching({ queryKey: ['systemTimeseriesQuery'] }) > 0, + // sliding the range forward is sufficient to trigger a refetch + fn: () => onRangeChange(preset), + isSlim: true, }) - const disks = useMemo(() => data?.items || [], [data]) - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastDay', - }) - - // 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(disks[0]?.name || '') - const diskItems = disks.map(({ name }) => ({ label: name, value: name })) - - if (disks.length === 0) { - return ( - - } - title="No metrics available" - body="Metrics are only available if there are disks attached" - /> - - ) - } - - const commonProps = { + // memoizing here would be redundant because the only things that cause a + // render are these values, which would all be in the dep array anyway + const context = { startTime, endTime, - diskSelector: { project, disk: diskName }, + dateTimeRangePicker, + intervalPicker, + setIsIntervalPickerEnabled, } + // Find the relevant in RouteTabs return ( - <> -
- { - if (val) setDiskName(val) - }} - /> - {dateTimeRangePicker} -
- -
- {/* 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 */} -
- - -
- -
- - -
- -
- -
-
- + + + + CPU + + + Disk + + + Network + + + ) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx new file mode 100644 index 0000000000..37c3d64d95 --- /dev/null +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -0,0 +1,115 @@ +/* + * 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 { useState } from 'react' + +import { usePrefetchedApiQuery } from '@oxide/api' + +import { + MetricCollection, + MetricHeader, + MetricRow, + OxqlMetric, +} from '~/components/oxql-metrics/OxqlMetric' +import type { OxqlVcpuState } from '~/components/oxql-metrics/util' +import { useInstanceSelector } from '~/hooks/use-params' +import { Listbox } from '~/ui/lib/Listbox' + +import { useMetricsContext } from '../common' + +export const handle = { crumb: 'CPU' } + +export default function CpuMetricsTab() { + const { project, instance } = useInstanceSelector() + const { data: instanceData } = usePrefetchedApiQuery('instanceView', { + path: { instance }, + query: { project }, + }) + + const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() + + type CpuChartType = OxqlVcpuState | 'all' + + const queryBase = { + unit: '%' as const, + metricName: 'virtual_machine:vcpu_usage' as const, + startTime, + endTime, + groupBy: { cols: ['vcpu_id'], op: 'sum' } as const, + } + + const stateItems: { label: string; value: CpuChartType }[] = [ + { label: 'State: Running', value: 'run' }, + { label: 'State: Emulating', value: 'emulation' }, + { label: 'State: Idling', value: 'idle' }, + { label: 'State: Waiting', value: 'waiting' }, + { label: 'All states', value: 'all' }, + ] + + const [selectedState, setSelectedState] = useState(stateItems[0].value) + + const title = `CPU Utilization: ${stateItems + .find((i) => i.value === selectedState) + ?.label.replace('State: ', '')}` + return ( + <> + +
+ setSelectedState(value)} + /> +
+ {dateTimeRangePicker} +
+ + {selectedState === 'all' ? ( + <> + + + + + + + + + + + ) : ( + + + + )} + + + ) +} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx new file mode 100644 index 0000000000..2e17d27fbb --- /dev/null +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -0,0 +1,162 @@ +/* + * 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 { useMemo, useState } from 'react' +import { type LoaderFunctionArgs } from 'react-router' + +import { apiQueryClient, usePrefetchedApiQuery, type Disk, type Instance } from '@oxide/api' +import { Storage24Icon } from '@oxide/design-system/icons/react' + +import { + MetricCollection, + MetricHeader, + MetricRow, + OxqlMetric, +} from '~/components/oxql-metrics/OxqlMetric' +import type { OxqlQuery } from '~/components/oxql-metrics/util' +import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Listbox } from '~/ui/lib/Listbox' +import { TableEmptyBox } from '~/ui/lib/Table' + +import { useMetricsContext } from '../common' + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { project, instance } = getInstanceSelector(params) + await apiQueryClient.prefetchQuery('instanceDiskList', { + path: { instance }, + query: { project }, + }) + return null +} + +// out here so we don't have to memoize it +const groupByAttachedInstanceId = { cols: ['attached_instance_id'], op: 'sum' } as const + +export const handle = { crumb: 'Disk' } + +export default function DiskMetricsTab() { + const { project, instance } = useInstanceSelector() + const { data: disks } = usePrefetchedApiQuery('instanceDiskList', { + path: { instance }, + query: { project }, + }) + const { data: instanceData } = usePrefetchedApiQuery('instanceView', { + path: { instance }, + query: { project }, + }) + if (disks.items.length === 0) { + return ( + + } + title="No disk metrics available" + body="Disk metrics are only available if there are disks attached" + /> + + ) + } + + return +} + +/** Only rendered if there is at least one disk in the list */ +function DiskMetrics({ disks, instance }: { disks: Disk[]; instance: Instance }) { + const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() + + const diskItems = useMemo( + () => [ + { label: 'All disks', value: 'all' }, + ...disks.map(({ name, id }) => ({ label: name, value: id })), + ], + [disks] + ) + + const [selectedDisk, setSelectedDisk] = useState(diskItems.at(0)!.value) + + const queryBase: Omit = { + startTime, + endTime, + eqFilters: useMemo( + () => ({ + attached_instance_id: instance.id, + disk_id: selectedDisk === 'all' ? undefined : selectedDisk, + }), + [instance.id, selectedDisk] + ), + groupBy: selectedDisk === 'all' ? groupByAttachedInstanceId : undefined, + } + + return ( + <> + +
+ setSelectedDisk(value)} + /> +
+ {dateTimeRangePicker} +
+ + {/* 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 */} + + {/* TODO: Add virtual_disk:io_latency once you can render histograms */} + + + + + + + + + + + + + + + + + ) +} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx new file mode 100644 index 0000000000..57192c09a4 --- /dev/null +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -0,0 +1,156 @@ +/* + * 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 { useMemo, useState } from 'react' +import { type LoaderFunctionArgs } from 'react-router' + +import { + apiQueryClient, + usePrefetchedApiQuery, + type InstanceNetworkInterface, +} from '@oxide/api' +import { Networking24Icon } from '@oxide/design-system/icons/react' + +import { + MetricCollection, + MetricHeader, + MetricRow, + OxqlMetric, +} from '~/components/oxql-metrics/OxqlMetric' +import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Listbox } from '~/ui/lib/Listbox' +import { TableEmptyBox } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' + +import { useMetricsContext } from '../common' + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { project, instance } = getInstanceSelector(params) + await apiQueryClient.prefetchQuery('instanceNetworkInterfaceList', { + query: { project, instance, limit: ALL_ISH }, + }) + return null +} + +const groupByInstanceId = { cols: ['instance_id'], op: 'sum' } as const + +export const handle = { crumb: 'Network' } + +export default function NetworkMetricsTab() { + const { project, instance } = useInstanceSelector() + const { data: nics } = usePrefetchedApiQuery('instanceNetworkInterfaceList', { + query: { project, instance, limit: ALL_ISH }, + }) + + if (nics.items.length === 0) { + return ( + + } + title="No network metrics available" + body="Network metrics are only available if there are network interfaces attached" + /> + + ) + } + + return +} + +function NetworkMetrics({ nics }: { nics: InstanceNetworkInterface[] }) { + const { project, instance } = useInstanceSelector() + const { data: instanceData } = usePrefetchedApiQuery('instanceView', { + path: { instance }, + query: { project }, + }) + const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() + + const nicItems = useMemo( + () => [ + { label: 'All NICs', value: 'all' }, + ...nics.map((n) => ({ label: n.name, value: n.id })), + ], + [nics] + ) + + const [selectedNic, setSelectedNic] = useState(nicItems[0].value) + + const queryBase = { + startTime, + endTime, + eqFilters: useMemo( + () => ({ + instance_id: instanceData.id, + interface_id: selectedNic === 'all' ? undefined : selectedNic, + }), + [instanceData.id, selectedNic] + ), + groupBy: selectedNic === 'all' ? groupByInstanceId : undefined, + } + + return ( + <> + +
+ setSelectedNic(val)} + /> +
+ {dateTimeRangePicker} +
+ + + + + + + + + + + + + + + ) +} diff --git a/app/pages/project/instances/instance/tabs/common.tsx b/app/pages/project/instances/instance/tabs/common.tsx index ba45fe169d..9f9f43e679 100644 --- a/app/pages/project/instances/instance/tabs/common.tsx +++ b/app/pages/project/instances/instance/tabs/common.tsx @@ -5,7 +5,10 @@ * * Copyright Oxide Computer Company */ +import { createContext, useContext, type ReactNode } from 'react' + import { intersperse } from '~/util/array' +import { invariant } from '~/util/invariant' const white = (s: string) => ( @@ -15,3 +18,23 @@ const white = (s: string) => ( export const fancifyStates = (states: string[]) => intersperse(states.map(white), <>, , <> or ) + +type MetricsContextValue = { + startTime: Date + endTime: Date + dateTimeRangePicker: ReactNode + intervalPicker: ReactNode + setIsIntervalPickerEnabled: (enabled: boolean) => void +} + +/** + * Using context lets the selected time window persist across route tab navs. + */ +export const MetricsContext = createContext(null) + +// this lets us init with a null value but rule it out in the consumers +export function useMetricsContext() { + const value = useContext(MetricsContext) + invariant(value, 'useMetricsContext can only be called inside a MetricsContext') + return value +} diff --git a/app/pages/system/UtilizationPage.tsx b/app/pages/system/UtilizationPage.tsx index 3c77268897..c1d9854188 100644 --- a/app/pages/system/UtilizationPage.tsx +++ b/app/pages/system/UtilizationPage.tsx @@ -44,7 +44,7 @@ const siloUtilList = getListQFn('siloUtilizationList', { query: { limit: ALL_ISH }, }) -export async function loader() { +export async function clientLoader() { await Promise.all([ queryClient.prefetchQuery(siloList.optionsFn()), queryClient.prefetchQuery(siloUtilList.optionsFn()), @@ -52,8 +52,9 @@ export async function loader() { return null } -Component.displayName = 'SystemUtilizationPage' -export function Component() { +export const handle = { crumb: 'Utilization' } + +export default function SystemUtilizationPage() { const { data: siloUtilizationList } = usePrefetchedQuery(siloUtilList.optionsFn()) const { totalAllocated, totalProvisioned } = totalUtilization(siloUtilizationList.items) @@ -113,6 +114,8 @@ const MetricsTab = () => { isLoading: useIsFetching({ queryKey: ['systemMetric'] }) > 0, // sliding the range forward is sufficient to trigger a refetch fn: () => onRangeChange(preset), + showLastFetched: true, + className: 'mb-12', }) const commonProps = { diff --git a/app/routes.tsx b/app/routes.tsx index 9165face2f..ecbbb2b691 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -59,7 +59,6 @@ import { FloatingIpsPage } from './pages/project/floating-ips/FloatingIpsPage' import { ImagesPage } from './pages/project/images/ImagesPage' import { InstancePage } from './pages/project/instances/instance/InstancePage' import * as ConnectTab from './pages/project/instances/instance/tabs/ConnectTab' -import * as MetricsTab from './pages/project/instances/instance/tabs/MetricsTab' import * as NetworkingTab from './pages/project/instances/instance/tabs/NetworkingTab' import * as SettingsTab from './pages/project/instances/instance/tabs/SettingsTab' import * as StorageTab from './pages/project/instances/instance/tabs/StorageTab' @@ -76,7 +75,6 @@ import * as Projects from './pages/ProjectsPage' import { ProfilePage } from './pages/settings/ProfilePage' import * as SSHKeysPage from './pages/settings/SSHKeysPage' import * as SiloAccess from './pages/SiloAccessPage' -import * as SiloUtilization from './pages/SiloUtilizationPage' import * as DisksTab from './pages/system/inventory/DisksTab' import { InventoryPage } from './pages/system/inventory/InventoryPage' import * as SledInstances from './pages/system/inventory/sled/SledInstancesTab' @@ -87,7 +85,6 @@ import * as IpPools from './pages/system/networking/IpPoolsPage' import * as SiloImages from './pages/system/SiloImagesPage' import * as SiloPage from './pages/system/silos/SiloPage' import * as SilosPage from './pages/system/silos/SilosPage' -import * as SystemUtilization from './pages/system/UtilizationPage' import { truncate } from './ui/lib/Truncate' import { pb } from './util/path-builder' @@ -98,6 +95,10 @@ type RouteModule = { shouldRevalidate?: () => boolean ErrorBoundary?: () => ReactElement handle?: Crumb + // trick to get a nice type error when we forget to convert loader to + // clientLoader in the module + loader?: never + Component?: never } function convert(m: RouteModule) { @@ -159,8 +160,7 @@ export const routes = createRoutesFromElements( import('./pages/system/UtilizationPage').then(convert)} /> - + import('./pages/SiloUtilizationPage').then(convert)} + /> {/* let's do both. what could go wrong*/} - + + import('./pages/project/instances/instance/tabs/MetricsTab').then( + convert + ) + } + > + } /> + + import( + './pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab' + ).then(convert) + } + path="cpu" + /> + + import( + './pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab' + ).then(convert) + } + path="disk" + /> + + import( + './pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab' + ).then(convert) + } + path="network" + handle={{ crumb: 'Network' }} + /> + diff --git a/app/ui/lib/DatePicker.tsx b/app/ui/lib/DatePicker.tsx index 8205caf74b..ae50282ff2 100644 --- a/app/ui/lib/DatePicker.tsx +++ b/app/ui/lib/DatePicker.tsx @@ -35,7 +35,7 @@ export function DatePicker(props: DatePickerProps) { const formatter = useDateFormatter({ dateStyle: 'short', timeStyle: 'short', - hourCycle: 'h24', + hourCycle: 'h23', }) const label = useMemo(() => { diff --git a/app/ui/lib/DateRangePicker.tsx b/app/ui/lib/DateRangePicker.tsx index 72a091c349..0392c0a069 100644 --- a/app/ui/lib/DateRangePicker.tsx +++ b/app/ui/lib/DateRangePicker.tsx @@ -35,7 +35,7 @@ export function DateRangePicker(props: DateRangePickerProps) { const formatter = useDateFormatter({ dateStyle: 'short', timeStyle: 'short', - hourCycle: 'h24', + hourCycle: 'h23', }) const label = useMemo(() => { @@ -46,10 +46,10 @@ export function DateRangePicker(props: DateRangePickerProps) { if (!state.dateRange.start) return 'No start date selected' if (!state.dateRange.end) return 'No end date selected' - return formatter.formatRange( - state.dateRange.start.toDate(getLocalTimeZone()), - state.dateRange.end.toDate(getLocalTimeZone()) - ) + const from = state.dateRange.start.toDate(getLocalTimeZone()) + const to = state.dateRange.end.toDate(getLocalTimeZone()) + + return formatter.formatRange(from, to) }, [state.dateRange, formatter]) return ( @@ -69,8 +69,8 @@ export function DateRangePicker(props: DateRangePickerProps) { : 'border-default ring-accent-secondary' )} > -
- {label} +
+
{label}
{state.isInvalid && (
diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index 2668c4dbc3..71aef4a94f 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -43,6 +43,7 @@ export interface ListboxProps { /** Necessary if you want RHF to be able to focus it on error */ buttonRef?: Ref hideOptionalTag?: boolean + hideSelected?: boolean } export const Listbox = ({ @@ -61,6 +62,7 @@ export const Listbox = ({ isLoading = false, buttonRef, hideOptionalTag, + hideSelected = false, ...props }: ListboxProps) => { const selectedItem = selected && items.find((i) => i.value === selected) @@ -99,7 +101,7 @@ export const Listbox = ({ id={id} name={name} className={cn( - `flex h-11 w-full items-center justify-between rounded border text-sans-md`, + `flex h-11 items-center justify-between rounded border text-sans-md`, hasError ? 'focus-error border-error-secondary hover:border-error' : 'border-default hover:border-hover', @@ -108,32 +110,44 @@ export const Listbox = ({ isDisabled ? 'cursor-not-allowed text-disabled bg-disabled !border-default' : 'bg-default', - isDisabled && hasError && '!border-error-secondary' + isDisabled && hasError && '!border-error-secondary', + hideSelected ? 'w-auto' : 'w-full' )} ref={buttonRef} {...props} > -
- {selectedItem ? ( - // selectedLabel is one line, which is what we need when label is a ReactNode - selectedItem.selectedLabel || selectedItem.label - ) : ( - - {noItems ? noItemsPlaceholder : placeholder} - - )} -
- {!isDisabled && } + {!hideSelected && ( + <> +
+ {selectedItem ? ( + // selectedLabel is one line, which is what we need when label is a ReactNode + selectedItem.selectedLabel || selectedItem.label + ) : ( + + {noItems ? noItemsPlaceholder : placeholder} + + )} +
+ {!isDisabled && } + + )}
( -