From 05e8314ca2eecf1b4d032fd8442e8113dcb5b29f Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Mon, 17 Feb 2025 18:40:42 +0000 Subject: [PATCH 1/9] Stub out error and loading states --- app/components/TimeSeriesChart.tsx | 80 ++++++++++++++++--- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 16 +++- app/ui/lib/Spinner.tsx | 30 +++++-- app/ui/styles/components/spinner.css | 19 +++-- mock-api/msw/handlers.ts | 4 + tailwind.config.ts | 1 + 6 files changed, 126 insertions(+), 24 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 6a8e060ae7..1742092cc0 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' import { Spinner } from '~/ui/lib/Spinner' @@ -113,6 +114,7 @@ type TimeSeriesChartProps = { unit?: string yAxisTickFormatter?: (val: number) => string hasBorder?: boolean + hasError?: boolean } const TICK_COUNT = 6 @@ -122,6 +124,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({ @@ -136,6 +173,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 @@ -157,22 +195,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 */} - +
@@ -360,9 +368,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/ui/lib/Spinner.tsx b/app/ui/lib/Spinner.tsx index e9d6d9dadc..5d828a1407 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) return handleOxqlMetrics(params.body) }, diff --git a/tailwind.config.ts b/tailwind.config.ts index 8f081c1a8f..48add3d7cc 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: [ From 307023809c1ef2a58a760a5f2b498e7103ddb039 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 24 Feb 2025 15:46:21 -0800 Subject: [PATCH 2/9] Copy tweak --- .../instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index 82fb9f831b..1d42fa32f3 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 ( <> From bcd171cd93366d4fab00989990f44930fb217572 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 25 Feb 2025 11:51:34 +0000 Subject: [PATCH 3/9] Spacing tweaks --- app/components/TimeSeriesChart.tsx | 2 +- app/components/oxql-metrics/OxqlMetric.tsx | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 813f8384b0..c57e2957f8 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -142,7 +142,7 @@ const SkeletonMetric = ({ shimmer?: boolean className?: string }) => ( -
+
{ - // 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') }, @@ -114,10 +112,9 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps)
-
+
}> Date: Tue, 25 Feb 2025 11:51:55 +0000 Subject: [PATCH 4/9] Fix interval picker loading state (and more realistic handler) --- app/pages/project/instances/instance/tabs/MetricsTab.tsx | 2 +- mock-api/msw/handlers.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index bb6dd336ea..85adbc4c51 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/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 3f148873eb..9d9b04bcda 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1598,12 +1598,15 @@ export const handlers = makeHandlers({ requireFleetViewer(params.cookies) return handleMetrics(params) }, - systemTimeseriesQuery(params) { + async systemTimeseriesQuery(params) { // Randomly simulate a failure if (Math.random() > 0.95) { throw 500 } requireFleetViewer(params.cookies) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + return handleOxqlMetrics(params.body) }, siloMetric: handleMetrics, From f79e0c8cefc31a41e4af965019ded660ee185885 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 25 Feb 2025 11:52:13 +0000 Subject: [PATCH 5/9] Tighten up copy, remove "this" --- app/components/oxql-metrics/OxqlMetric.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 5ff9471b7b..8dd1a73a71 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -88,7 +88,7 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps) label="Instance actions" actions={[ { - label: 'About this metric', + label: 'About metric', onActivate: () => { const url = links.oxqlSchemaDocs(queryObj.metricName) window.open(url, '_blank', 'noopener,noreferrer') From ffa568bd682d6ec40148339d9e0a732149d8337d Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 25 Feb 2025 11:52:25 +0000 Subject: [PATCH 6/9] Reduce spinnergeddon --- app/components/TimeSeriesChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index c57e2957f8..e26a3c9e3d 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -241,7 +241,7 @@ export default function TimeSeriesChart({ } else if (!data || data.length === 0) { return ( - + ) } From 60b983e8f50c35ddb0497167d8229ac39bd0fb42 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 25 Feb 2025 12:17:53 +0000 Subject: [PATCH 7/9] Switch loading indicator --- app/components/TimeSeriesChart.tsx | 4 ++-- app/ui/styles/components/spinner.css | 36 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index e26a3c9e3d..6939249f83 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -229,7 +229,7 @@ export default function TimeSeriesChart({
- + ) } diff --git a/app/ui/styles/components/spinner.css b/app/ui/styles/components/spinner.css index aa5bdbde3c..495e45363e 100644 --- a/app/ui/styles/components/spinner.css +++ b/app/ui/styles/components/spinner.css @@ -82,3 +82,39 @@ stroke-dashoffset: calc(var(--circumference) * -1); } } + +.metrics-loading-indicator { + display: flex; + align-items: end; + justify-content: space-around; + width: 12px; + height: 10px; +} + +.metrics-loading-indicator span { + @apply block h-1 w-[3px] rounded-[1px] bg-[--theme-accent-500]; + animation: stretch 1.8s infinite both; +} + +.metrics-loading-indicator span:nth-child(1) { + animation-delay: -0.4s; +} + +.metrics-loading-indicator span:nth-child(2) { + animation-delay: -0.2s; +} + +@keyframes stretch { + 0%, + 60%, + 100% { + height: 4px; + } + 30% { + height: 10px; + } + 70%, + 90% { + height: 4px; + } +} From 045938aa1e9cd6f6cec303024d07f4e70c60e8fa Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 25 Feb 2025 12:18:09 +0000 Subject: [PATCH 8/9] Skeleton/loading colour tweaks --- app/components/TimeSeriesChart.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 6939249f83..a1834c0348 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -22,8 +22,6 @@ import type { TooltipProps } from 'recharts/types/component/Tooltip' import type { ChartDatum } from '@oxide/api' import { Error12Icon } from '@oxide/design-system/icons/react' -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 @@ -152,12 +150,12 @@ const SkeletonMetric = ({ >
{[...Array(4)].map((_e, i) => ( -
+
))}
{[...Array(8)].map((_e, i) => ( -
+
))}
@@ -216,7 +214,7 @@ export default function TimeSeriesChart({ if (hasError) { return ( - + <>
@@ -308,3 +306,11 @@ export default function TimeSeriesChart({
) } + +const MetricsLoadingIndicator = () => ( +
+ + + +
+) From 55d7fb14b5fdcb9d5058701cc02c0a924edd0c82 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 25 Feb 2025 12:21:36 +0000 Subject: [PATCH 9/9] Promise comment --- mock-api/msw/handlers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 9d9b04bcda..6a593dd310 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1605,6 +1605,8 @@ export const handlers = makeHandlers({ } 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)