Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions app/api/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions app/components/MoreActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 (
<DropdownMenu.Root>
<DropdownMenu.Trigger
aria-label={label}
className="flex h-8 w-8 items-center justify-center rounded border border-default hover:bg-tertiary"
className={cn(
'flex items-center justify-center rounded border border-default hover:bg-tertiary',
isSmall ? 'h-6 w-6' : 'h-8 w-8'
)}
>
<More12Icon />
</DropdownMenu.Trigger>
Expand Down
27 changes: 20 additions & 7 deletions app/components/RefetchIntervalPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,19 @@ type Props = {
enabled: boolean
isLoading: boolean
fn: () => void
showLastFetched?: boolean
className?: string
isSlim?: boolean
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This gives us a version that has the dropdown but not the number...perhaps we just scrap the selected interval in the listbox all together here and we can simplify the implementation. I don't think the interval necessarily needs to be visible anywhere.

}

export function useIntervalPicker({ enabled, isLoading, fn }: Props) {
export function useIntervalPicker({
enabled,
isLoading,
fn,
showLastFetched = false,
className,
isSlim = false,
}: Props) {
const [intervalPreset, setIntervalPreset] = useState<IntervalPreset>('10s')

const [lastFetched, setLastFetched] = useState(new Date())
Expand All @@ -53,11 +63,13 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) {
return {
intervalMs: (enabled && intervalPresets[intervalPreset]) || undefined,
intervalPicker: (
<div className="mb-12 flex items-center justify-between">
<div className="hidden items-center gap-2 text-right text-mono-sm text-tertiary lg+:flex">
<Time16Icon className="text-quaternary" /> Refreshed{' '}
{toLocaleTimeString(lastFetched)}
</div>
<div className={cn('flex items-center justify-between', className)}>
{showLastFetched && (
<div className="hidden items-center gap-2 text-right text-mono-sm text-tertiary lg+:flex">
<Time16Icon className="text-quaternary" /> Refreshed{' '}
{toLocaleTimeString(lastFetched)}
</div>
)}
<div className="flex">
<button
type="button"
Expand All @@ -75,10 +87,11 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) {
</button>
<Listbox
selected={enabled ? intervalPreset : 'Off'}
className="w-24 [&>button]:!rounded-l-none"
className={cn('[&>button]:!rounded-l-none', isSlim ? '' : 'w-24')}
items={intervalItems}
onChange={setIntervalPreset}
disabled={!enabled}
hideSelected={isSlim}
/>
</div>
</div>
Expand Down
16 changes: 13 additions & 3 deletions app/components/RouteTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,30 @@ export interface RouteTabsProps {
children: ReactNode
fullWidth?: boolean
sideTabs?: boolean
tabListClassName?: string
}
/** 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) {
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', { 'flex-grow': sideTabs })
const panelClasses = cn('ox-tabs-panel @container', { 'ml-5 flex-grow': sideTabs })
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Container query lets us move the datepicker onto the next line when we start running out of space. Flex wrap might work here, but eventually I think I'd like to do something cleverer with the layout when it reflows.

return (
<div className={wrapperClasses}>
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div role="tablist" className={tabListClasses} onKeyDown={selectTab}>
<div
role="tablist"
className={cn(tabListClasses, tabListClassName)}
onKeyDown={selectTab}
>
{children}
</div>
{/* TODO: Add aria-describedby for active tab */}
Expand Down
19 changes: 18 additions & 1 deletion app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import type { TooltipProps } from 'recharts/types/component/Tooltip'

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

import { Spinner } from '~/ui/lib/Spinner'

// Recharts's built-in ticks behavior is useless and probably broken
/**
* Split the data into n evenly spaced ticks, with one at the left end and one a
Expand Down Expand Up @@ -110,6 +112,7 @@ type TimeSeriesChartProps = {
endTime: Date
unit?: string
yAxisTickFormatter?: (val: number) => string
hasBorder?: boolean
}

const TICK_COUNT = 6
Expand All @@ -132,6 +135,7 @@ export default function TimeSeriesChart({
endTime,
unit,
yAxisTickFormatter = (val) => val.toLocaleString(),
hasBorder = true,
}: 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
Expand All @@ -153,9 +157,22 @@ export default function TimeSeriesChart({
// re-render on every render of the parent when the data is undefined
const data = useMemo(() => rawData || [], [rawData])

if (!data || data.length === 0) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Temporary empty state. We should handle errors and loading differently.

return (
<div className="flex h-[300px] w-full items-center justify-center">
<div className="m-4 flex max-w-[18rem] flex-col items-center text-center">
<Spinner variant="secondary" />
</div>
</div>
)
}

return (
<div className="h-[300px] w-full">
<ResponsiveContainer className={cn(className, 'rounded-lg border border-default')}>
{/* temporary until we migrate the old metrics to the new style */}
<ResponsiveContainer
className={cn(className, hasBorder && 'rounded-lg border border-default')}
>
<AreaChart
width={width}
height={height}
Expand Down
2 changes: 2 additions & 0 deletions app/pages/SiloUtilizationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,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 = {
Expand Down
24 changes: 19 additions & 5 deletions app/pages/project/instances/instance/tabs/MetricsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
* Copyright Oxide Computer Company
*/

import { useIsFetching } from '@tanstack/react-query'
import { createContext, useContext, type ReactNode } from 'react'

import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker'
import { useIntervalPicker } from '~/components/RefetchIntervalPicker'
import { RouteTabs, Tab } from '~/components/RouteTabs'
import { useInstanceSelector } from '~/hooks/use-params'
import { pb } from '~/util/path-builder'
Expand All @@ -23,21 +25,33 @@ const MetricsContext = createContext<{
startTime: Date
endTime: Date
dateTimeRangePicker: ReactNode
}>({ startTime, endTime, dateTimeRangePicker: <></> })
intervalPicker: ReactNode
}>({ startTime, endTime, dateTimeRangePicker: <></>, intervalPicker: <></> })

export const useMetricsContext = () => useContext(MetricsContext)

export const MetricsTab = () => {
const { project, instance } = useInstanceSelector()

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

const { intervalPicker } = useIntervalPicker({
enabled: preset !== 'custom',
isLoading: useIsFetching({ queryKey: ['siloMetric'] }) > 0,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Need to update with query keys

// sliding the range forward is sufficient to trigger a refetch
fn: () => onRangeChange(preset),
isSlim: true,
})

// Find the relevant <Outlet> in RouteTabs
return (
<MetricsContext.Provider value={{ startTime, endTime, dateTimeRangePicker }}>
<RouteTabs sideTabs>
<MetricsContext.Provider
value={{ startTime, endTime, dateTimeRangePicker, intervalPicker }}
>
<RouteTabs sideTabs tabListClassName="mt-24">
<Tab to={pb.instanceCpuMetrics({ project, instance })} sideTab>
CPU
</Tab>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function Component() {
query: { project },
})

const { startTime, endTime, dateTimeRangePicker } = useMetricsContext()
const { startTime, endTime, dateTimeRangePicker, intervalPicker } = useMetricsContext()

const getQuery = (metricName: OxqlVmMetricName, state?: OxqlVcpuState) =>
getOxqlQuery({
Expand All @@ -67,11 +67,14 @@ export function Component() {

return (
<>
<MetricHeader>{dateTimeRangePicker}</MetricHeader>
<MetricHeader>
{intervalPicker} {dateTimeRangePicker}
</MetricHeader>
<MetricCollection>
<MetricRow>
<OxqlMetric
title="CPU Utilization"
description="Cumulative time all vCPUs have spent in a state"
query={getQuery('virtual_machine:vcpu_usage')}
startTime={startTime}
endTime={endTime}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function Component() {
[diskData]
)

const { startTime, endTime, dateTimeRangePicker } = useMetricsContext()
const { startTime, endTime, dateTimeRangePicker, intervalPicker } = useMetricsContext()

// 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
Expand Down Expand Up @@ -107,21 +107,25 @@ export function Component() {
return (
<>
<MetricHeader>
{disks.length > 2 && (
<Listbox
className="w-64"
aria-label="Choose disk"
name="disk-name"
selected={disk.id}
items={items}
onChange={(val) => {
setDisk({
name: disks.find((n) => n.id === val)?.name || 'All disks',
id: val,
})
}}
/>
)}
<div className="flex gap-2">
{intervalPicker}

{disks.length > 2 && (
<Listbox
className="w-52"
aria-label="Choose disk"
name="disk-name"
selected={disk.id}
items={items}
onChange={(val) => {
setDisk({
name: disks.find((n) => n.id === val)?.name || 'All disks',
id: val,
})
}}
/>
)}
</div>
{dateTimeRangePicker}
</MetricHeader>
<MetricCollection>
Expand All @@ -131,12 +135,14 @@ export function Component() {
<MetricRow>
<OxqlMetric
title="Disk Reads"
description="Total number of read operations from the disk"
query={getQuery('virtual_disk:reads')}
startTime={startTime}
endTime={endTime}
/>
<OxqlMetric
title="Disk Writes"
description="Total number of write operations to the disk"
query={getQuery('virtual_disk:writes')}
startTime={startTime}
endTime={endTime}
Expand All @@ -146,12 +152,14 @@ export function Component() {
<MetricRow>
<OxqlMetric
title="Bytes Read"
description="Number of bytes read from the disk"
query={getQuery('virtual_disk:bytes_read')}
startTime={startTime}
endTime={endTime}
/>
<OxqlMetric
title="Bytes Written"
description="Number of bytes written to the disk"
query={getQuery('virtual_disk:bytes_written')}
startTime={startTime}
endTime={endTime}
Expand All @@ -161,6 +169,7 @@ export function Component() {
<MetricRow>
<OxqlMetric
title="Disk Flushes"
description="Total number of flush operations on the disk"
query={getQuery('virtual_disk:flushes')}
startTime={startTime}
endTime={endTime}
Expand Down
Loading
Loading