Skip to content
90 changes: 78 additions & 12 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
/**
Expand Down Expand Up @@ -119,6 +118,7 @@ type TimeSeriesChartProps = {
unit?: string
yAxisTickFormatter?: (val: number) => string
hasBorder?: boolean
hasError?: boolean
}

const TICK_COUNT = 6
Expand All @@ -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
}) => (
<div className="relative flex h-[300px] w-full items-center">
<div
className={cn(
shimmer && 'motion-safe:animate-pulse',
'absolute inset-0 bottom-11',
className
)}
>
<div className="flex h-full flex-col justify-between">
{[...Array(4)].map((_e, i) => (
<div key={i} className="h-px w-full bg-[--stroke-secondary]" />
))}
</div>
<div className="flex justify-between">
{[...Array(8)].map((_e, i) => (
<div key={i} className="h-1.5 w-px bg-[--stroke-secondary]" />
))}
</div>
</div>
<div className="relative flex h-full w-full items-center justify-center pb-11">
{children}
</div>
</div>
)

// default export is most convenient for dynamic import
// eslint-disable-next-line import/no-default-export
export default function TimeSeriesChart({
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<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>
<SkeletonMetric className={wrapperClass}>
<>
<div className="z-10 flex w-52 flex-col items-center justify-center gap-1">
<div className="my-2 flex h-8 w-8 items-center justify-center">
<div className="absolute h-8 w-8 rounded-full opacity-20 bg-destructive motion-safe:animate-[ping_2s_cubic-bezier(0,0,0.2,1)_infinite]" />
<Error12Icon className="relative h-6 w-6 text-error-tertiary" />
</div>
<div className="text-semi-lg text-center text-raise">Something went wrong</div>
<div className="text-center text-sans-md text-secondary">
Try refreshing the page, or contact your project admin
</div>
</div>
<div
className="absolute inset-x-0 bottom-12 top-1 bg-accent-secondary"
style={{
background:
'radial-gradient(197.76% 54.9% at 50% 50%, var(--surface-default) 0%, rgba(8, 15, 17, 0.00) 100%)',
}}
/>
</>
</SkeletonMetric>
)
} else if (!data || data.length === 0) {
return (
<SkeletonMetric shimmer className={wrapperClass}>
<MetricsLoadingIndicator />
</SkeletonMetric>
)
}

return (
<div className="h-[300px] w-full">
{/* temporary until we migrate the old metrics to the new style */}
<ResponsiveContainer
className={cn(className, hasBorder && 'rounded-lg border border-default')}
>
<ResponsiveContainer className={wrapperClass}>
<AreaChart
width={width}
height={height}
Expand Down Expand Up @@ -248,3 +306,11 @@ export default function TimeSeriesChart({
</div>
)
}

const MetricsLoadingIndicator = () => (
<div className="metrics-loading-indicator">
<span></span>
<span></span>
<span></span>
</div>
)
26 changes: 16 additions & 10 deletions app/components/oxql-metrics/OxqlMetric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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')
},
Expand All @@ -106,10 +112,9 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps)
<HighlightedOxqlQuery {...queryObj} />
</CopyCodeModal>
</div>
<div className="px-6 py-5">
<div className="px-6 py-5 pt-8">
<Suspense fallback={<div className="h-[300px]" />}>
<TimeSeriesChart
className="mt-3"
title={title}
startTime={startTime}
endTime={endTime}
Expand All @@ -119,16 +124,17 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps)
width={480}
height={240}
hasBorder={false}
hasError={!!error}
/>
</Suspense>
</div>
</div>
)
}

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 (
<div className={`flex flex-col gap-2 ${justify} mt-8 @[48rem]:flex-row`}>
{children}
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/instances/instance/tabs/MetricsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ 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)

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 (
<>
Expand Down
30 changes: 25 additions & 5 deletions app/ui/lib/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 (
<svg
width={frameSize}
Expand Down
55 changes: 50 additions & 5 deletions app/ui/styles/components/spinner.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* 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
*/

Expand All @@ -13,16 +13,21 @@
animation: rotate 5s linear infinite;
}

.spinner.spinner-md {
--radius: 8;
--circumference: calc(var(--PI) * var(--radius) * 2px);
}

.spinner.spinner-lg {
--radius: 10;
--circumference: calc(var(--PI) * var(--radius) * 3px);
--radius: 14;
--circumference: calc(var(--PI) * var(--radius) * 2px);
}

.spinner .path {
stroke-dasharray: var(--circumference);
transform-origin: center;
animation: dash 8s ease-in-out infinite;
stroke: var(--content-accent);
stroke: var(--content-accent-tertiary);
}

@media (prefers-reduced-motion) {
Expand All @@ -36,6 +41,10 @@
stroke-dashoffset: 100;
}

.spinner-md .path {
stroke-dasharray: 30;
}

.spinner-lg .path {
stroke-dasharray: 50;
}
Expand All @@ -56,7 +65,7 @@

.spinner-danger .bg,
.spinner-danger .path {
stroke: var(--content-destructive);
stroke: var(--content-destructive-tertiary);
}

@keyframes rotate {
Expand All @@ -73,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;
}
}
Loading
Loading