diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f20828f46..a1834c034 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,8 +20,7 @@ import { import type { TooltipProps } from 'recharts/types/component/Tooltip' import type { ChartDatum } from '@oxide/api' - -import { Spinner } from '~/ui/lib/Spinner' +import { Error12Icon } from '@oxide/design-system/icons/react' // Recharts's built-in ticks behavior is useless and probably broken /** @@ -119,6 +118,7 @@ type TimeSeriesChartProps = { unit?: string yAxisTickFormatter?: (val: number) => string hasBorder?: boolean + hasError?: boolean } const TICK_COUNT = 6 @@ -130,6 +130,41 @@ function roundUpToDivBy(value: number, divisor: number) { return Math.ceil(value / divisor) * divisor } +// 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} +
+
+) + // default export is most convenient for dynamic import // eslint-disable-next-line import/no-default-export export default function TimeSeriesChart({ @@ -144,6 +179,7 @@ export default function TimeSeriesChart({ 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 @@ -174,22 +210,44 @@ 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) { + const wrapperClass = cn(className, hasBorder && 'rounded-lg border border-default') + + if (hasError) { return ( -
-
- -
-
+ + <> +
+
+
+ +
+
Something went wrong
+
+ Try refreshing the page, or contact your project admin +
+
+
+ + + ) + } else if (!data || data.length === 0) { + return ( + + + ) } return (
{/* temporary until we migrate the old metrics to the new style */} - + ) } + +const MetricsLoadingIndicator = () => ( +
+ + + +
+) diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 87bac5a66..8dd1a73a7 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -11,7 +11,15 @@ * https://github.com/oxidecomputer/omicron/tree/main/oximeter/oximeter/schema */ -import React, { Suspense, useEffect, useMemo, useState } from 'react' +import { + Children, + lazy, + Suspense, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' import { useApiQuery } from '@oxide/api' @@ -32,7 +40,7 @@ import { type OxqlQuery, } from './util' -const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) +const TimeSeriesChart = lazy(() => import('~/components/TimeSeriesChart')) export type OxqlMetricProps = OxqlQuery & { title: string @@ -43,7 +51,7 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps) // only start reloading data once an intial dataset has been loaded const { setIsIntervalPickerEnabled } = useMetricsContext() const query = toOxqlStr(queryObj) - const { data: metrics } = useApiQuery( + const { data: metrics, error } = useApiQuery( 'systemTimeseriesQuery', { body: { query } }, // avoid graphs flashing blank while loading when you change the time @@ -80,10 +88,8 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps) label="Instance actions" actions={[ { - label: 'About this metric', + label: 'About metric', onActivate: () => { - // Turn into a real link when this is fixed - // https://github.com/oxidecomputer/console/issues/1855 const url = links.oxqlSchemaDocs(queryObj.metricName) window.open(url, '_blank', 'noopener,noreferrer') }, @@ -106,10 +112,9 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps)
-
+
}>
@@ -126,9 +132,9 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps) ) } -export const MetricHeader = ({ children }: { children: React.ReactNode }) => { +export const MetricHeader = ({ children }: { children: ReactNode }) => { // If header has only one child, align it to the end of the container - const justify = React.Children.count(children) === 1 ? 'justify-end' : 'justify-between' + const justify = Children.count(children) === 1 ? 'justify-end' : 'justify-between' return (
{children} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index bb6dd336e..85adbc4c5 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -50,7 +50,7 @@ export const MetricsTab = () => { const { intervalPicker } = useIntervalPicker({ enabled: isIntervalPickerEnabled && preset !== 'custom', - isLoading: useIsFetching({ queryKey: ['siloMetric'] }) > 0, + isLoading: useIsFetching({ queryKey: ['systemTimeseriesQuery'] }) > 0, // sliding the range forward is sufficient to trigger a refetch fn: () => onRangeChange(preset), isSlim: true, diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index 82fb9f831..1d42fa32f 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -63,7 +63,7 @@ export function Component() { { label: 'State: Emulating', value: 'emulation' }, { label: 'State: Idling', value: 'idle' }, { label: 'State: Waiting', value: 'waiting' }, - { label: 'See all states', value: 'all' }, + { label: 'All states', value: 'all' }, ] const [selectedState, setSelectedState] = useState(stateItems[0].value) @@ -71,7 +71,7 @@ export function Component() { const title = `CPU Utilization: ${stateItems .find((i) => i.value === selectedState) ?.label.replace('State: ', '') - .replace('See all states', 'Total')}` + .replace('All states', 'Total')}` const state = selectedState === 'all' ? undefined : selectedState return ( <> diff --git a/app/ui/lib/Spinner.tsx b/app/ui/lib/Spinner.tsx index e9d6d9dad..5d828a140 100644 --- a/app/ui/lib/Spinner.tsx +++ b/app/ui/lib/Spinner.tsx @@ -8,7 +8,7 @@ import cn from 'classnames' import { useEffect, useRef, useState, type ReactNode } from 'react' -export const spinnerSizes = ['base', 'lg'] as const +export const spinnerSizes = ['base', 'md', 'lg'] as const export const spinnerVariants = ['primary', 'secondary', 'ghost', 'danger'] as const export type SpinnerSize = (typeof spinnerSizes)[number] export type SpinnerVariant = (typeof spinnerVariants)[number] @@ -19,15 +19,35 @@ interface SpinnerProps { variant?: SpinnerVariant } +const SPINNER_DIMENSIONS = { + base: { + frameSize: 12, + center: 6, + radius: 5, + strokeWidth: 2, + }, + md: { + frameSize: 24, + center: 12, + radius: 10, + strokeWidth: 2, + }, + lg: { + frameSize: 36, + center: 18, + radius: 16, + strokeWidth: 3, + }, +} as const + export const Spinner = ({ className, size = 'base', variant = 'primary', }: SpinnerProps) => { - const frameSize = size === 'lg' ? 36 : 12 - const center = size === 'lg' ? 18 : 6 - const radius = size === 'lg' ? 16 : 5 - const strokeWidth = size === 'lg' ? 3 : 2 + const dimensions = SPINNER_DIMENSIONS[size] + const { frameSize, center, radius, strokeWidth } = dimensions + return ( 0.95) { + throw 500 + } requireFleetViewer(params.cookies) + + // Add delay to more accurately simulate timeseries + // queries are slower than most other queries + await new Promise((resolve) => setTimeout(resolve, 1000)) + return handleOxqlMetrics(params.body) }, siloMetric: handleMetrics, diff --git a/tailwind.config.ts b/tailwind.config.ts index 8f081c1a8..48add3d7c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -64,6 +64,7 @@ export default { }, animation: { 'spin-slow': 'spin 5s linear infinite', + pulse: 'pulse 2s cubic-bezier(.4,0,.6,1) infinite', }, }, plugins: [