Skip to content

Commit e1d8077

Browse files
authored
Fix metric loading and empty states (#2779)
* scenarios for mock oxql data * explicit loading state for timeserieschart * test it, switch back to project timeseries endpoint * also test group by error workaround
1 parent 30b1afd commit e1d8077

File tree

7 files changed

+148
-29
lines changed

7 files changed

+148
-29
lines changed

app/api/util.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,5 @@ export function parseIpUtilization({ ipv4, ipv6 }: IpPoolUtilization) {
288288
},
289289
}
290290
}
291+
292+
export const OXQL_GROUP_BY_ERROR = 'Input tables to a `group_by` must be aligned'

app/components/SystemMetric.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export function SiloMetric({
100100
startTime={startTime}
101101
endTime={endTime}
102102
unit={unit !== 'Count' ? unit : undefined}
103+
// note use of loading, not fetching, which is only true on first fetch.
104+
// otherwise we get loading states on refetches
105+
loading={inRange.isLoading || beforeStart.isLoading}
103106
/>
104107
</ChartContainer>
105108
)
@@ -168,6 +171,9 @@ export function SystemMetric({
168171
startTime={startTime}
169172
endTime={endTime}
170173
unit={unit !== 'Count' ? unit : undefined}
174+
// note use of loading, not fetching, which is only true on first fetch.
175+
// otherwise we get loading states on refetches
176+
loading={inRange.isLoading || beforeStart.isLoading}
171177
/>
172178
</ChartContainer>
173179
)

app/components/TimeSeriesChart.tsx

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ type TimeSeriesChartProps = {
118118
unit?: string
119119
yAxisTickFormatter?: (val: number) => string
120120
hasError?: boolean
121+
loading: boolean
121122
}
122123

123124
const TICK_COUNT = 6
@@ -173,6 +174,7 @@ export function TimeSeriesChart({
173174
unit,
174175
yAxisTickFormatter = (val) => val.toLocaleString(),
175176
hasError = false,
177+
loading,
176178
}: TimeSeriesChartProps) {
177179
// We use the largest data point +20% for the graph scale. !rawData doesn't
178180
// mean it's empty (it will never be empty because we fill in artificial 0s at
@@ -213,13 +215,20 @@ export function TimeSeriesChart({
213215
)
214216
}
215217

216-
if (!data || data.length === 0) {
218+
if (loading) {
217219
return (
218220
<SkeletonMetric shimmer>
219221
<MetricsLoadingIndicator />
220222
</SkeletonMetric>
221223
)
222224
}
225+
if (!data || data.length === 0) {
226+
return (
227+
<SkeletonMetric>
228+
<MetricsEmpty />
229+
</SkeletonMetric>
230+
)
231+
}
223232

224233
// ResponsiveContainer has default height and width of 100%
225234
// https://recharts.org/en-US/api/ResponsiveContainer
@@ -281,23 +290,28 @@ export function TimeSeriesChart({
281290
}
282291

283292
const MetricsLoadingIndicator = () => (
284-
<div className="metrics-loading-indicator">
293+
<div className="metrics-loading-indicator" aria-label="Chart loading">
285294
<span></span>
286295
<span></span>
287296
<span></span>
288297
</div>
289298
)
290299

291-
const MetricsError = () => (
300+
const MetricsMessage = ({
301+
icon,
302+
title,
303+
description,
304+
}: {
305+
icon?: React.ReactNode
306+
title: React.ReactNode
307+
description: React.ReactNode
308+
}) => (
292309
<>
293310
<div className="z-10 flex w-52 flex-col items-center justify-center gap-1">
294-
<div className="my-2 flex h-8 w-8 items-center justify-center">
295-
<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]" />
296-
<Error12Icon className="relative h-6 w-6 text-error-tertiary" />
297-
</div>
298-
<div className="text-semi-lg text-center text-raise">Something went wrong</div>
299-
<div className="text-center text-sans-md text-secondary">
300-
Please try again. If the problem persists, contact your administrator.
311+
{icon}
312+
<div className="text-semi-lg text-center text-raise">{title}</div>
313+
<div className="text-balance text-center text-sans-md text-secondary">
314+
{description}
301315
</div>
302316
</div>
303317
<div
@@ -310,6 +324,26 @@ const MetricsError = () => (
310324
</>
311325
)
312326

327+
const MetricsError = () => (
328+
<MetricsMessage
329+
icon={
330+
<div className="my-2 flex h-8 w-8 items-center justify-center">
331+
<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]" />
332+
<Error12Icon className="relative h-6 w-6 text-error-tertiary" />
333+
</div>
334+
}
335+
title="Something went wrong"
336+
description="Please try again. If the problem persists, contact your administrator."
337+
/>
338+
)
339+
340+
const MetricsEmpty = () => (
341+
<MetricsMessage
342+
// mt-3 is a shameful hack to get it vertically centered in the chart
343+
title={<div className="mt-3">No data</div>}
344+
description="There is no data for this time period."
345+
/>
346+
)
313347
export const ChartContainer = classed.div`flex w-full grow flex-col rounded-lg border border-default`
314348

315349
type ChartHeaderProps = {

app/components/oxql-metrics/OxqlMetric.tsx

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@
1212
*/
1313

1414
import { useQuery } from '@tanstack/react-query'
15-
import { Children, useEffect, useMemo, useState, type ReactNode } from 'react'
15+
import { Children, useMemo, useState, type ReactNode } from 'react'
1616
import type { LoaderFunctionArgs } from 'react-router'
1717

18-
import { apiq, queryClient } from '@oxide/api'
18+
import { apiq, OXQL_GROUP_BY_ERROR, queryClient } from '@oxide/api'
1919

2020
import { CopyCodeModal } from '~/components/CopyCode'
2121
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
2222
import { getInstanceSelector, useProjectSelector } from '~/hooks/use-params'
23-
import { useMetricsContext } from '~/pages/project/instances/common'
2423
import { LearnMore } from '~/ui/lib/CardBlock'
2524
import * as Dropdown from '~/ui/lib/DropdownMenu'
2625
import { classed } from '~/util/classed'
@@ -51,23 +50,34 @@ export type OxqlMetricProps = OxqlQuery & {
5150
}
5251

5352
export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetricProps) {
54-
// only start reloading data once an intial dataset has been loaded
55-
const { setIsIntervalPickerEnabled } = useMetricsContext()
56-
const { project } = useProjectSelector()
5753
const query = toOxqlStr(queryObj)
58-
const { data: metrics, error } = useQuery(
54+
const { project } = useProjectSelector()
55+
const {
56+
data: metrics,
57+
error,
58+
isLoading,
59+
} = useQuery(
5960
apiq('timeseriesQuery', { body: { query }, query: { project } })
6061
// avoid graphs flashing blank while loading when you change the time
6162
// { placeholderData: (x) => x }
6263
)
63-
useEffect(() => {
64-
if (metrics) {
65-
// this is too slow right now; disabling until we can make it faster
66-
// setIsIntervalPickerEnabled(true)
67-
}
68-
}, [metrics, setIsIntervalPickerEnabled])
64+
65+
// HACK: omicron has a bug where it blows up on an attempt to group by on
66+
// empty result set because it can't determine whether the data is aligned.
67+
// Most likely it should consider empty data sets trivially aligned and just
68+
// flow the emptiness on through, but in the meantime we have to detect
69+
// this error and pretend it is not an error.
70+
// See https://github.com/oxidecomputer/omicron/issues/7715
71+
const errorMeansEmpty = error?.message === OXQL_GROUP_BY_ERROR
72+
const hasError = !!error && !errorMeansEmpty
73+
6974
const { startTime, endTime } = queryObj
70-
const { chartData, timeseriesCount } = useMemo(() => composeOxqlData(metrics), [metrics])
75+
const { chartData, timeseriesCount } = useMemo(
76+
() =>
77+
errorMeansEmpty ? { chartData: [], timeseriesCount: 0 } : composeOxqlData(metrics),
78+
[metrics, errorMeansEmpty]
79+
)
80+
7181
const { data, label, unitForSet, yAxisTickFormatter } = useMemo(() => {
7282
if (unit === 'Bytes') return getBytesChartProps(chartData)
7383
if (unit === 'Count') return getCountChartProps(chartData)
@@ -103,7 +113,9 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric
103113
unit={unitForSet}
104114
data={data}
105115
yAxisTickFormatter={yAxisTickFormatter}
106-
hasError={!!error}
116+
hasError={hasError}
117+
// isLoading only covers first load --- future-proof against the reintroduction of interval refresh
118+
loading={isLoading}
107119
/>
108120
</ChartContainer>
109121
)

mock-api/msw/handlers.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
} from '@oxide/api'
2727

2828
import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers'
29-
import { instanceCan } from '~/api/util'
29+
import { instanceCan, OXQL_GROUP_BY_ERROR } from '~/api/util'
3030
import { parseIp } from '~/util/ip'
3131
import { commaSeries } from '~/util/str'
3232
import { GiB } from '~/util/units'
@@ -1614,7 +1614,24 @@ export const handlers = makeHandlers({
16141614

16151615
// timeseries queries are slower than most other queries
16161616
await delay(1000)
1617-
return handleOxqlMetrics(body)
1617+
const data = handleOxqlMetrics(body)
1618+
1619+
// we use other-project to test certain response cases
1620+
if (query.project === 'other-project') {
1621+
// 1. return only one data point
1622+
const points = Object.values(data.tables[0].timeseries)[0].points
1623+
if (body.query.includes('state == "run"')) {
1624+
points.timestamps = points.timestamps.slice(0, 2)
1625+
points.values = points.values.slice(0, 2)
1626+
} else if (body.query.includes('state == "emulation"')) {
1627+
points.timestamps = points.timestamps.slice(0, 1)
1628+
points.values = points.values.slice(0, 1)
1629+
} else if (body.query.includes('state == "idle"')) {
1630+
throw OXQL_GROUP_BY_ERROR
1631+
}
1632+
}
1633+
1634+
return data
16181635
},
16191636
async systemTimeseriesQuery({ cookies, body }) {
16201637
requireFleetViewer(cookies)

mock-api/oxql-metrics.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ export const getMockOxqlInstanceData = (
280280
state?: OxqlVcpuState
281281
): Json<OxqlQueryResult> => {
282282
const values = state ? mockOxqlVcpuStateValues[state] : mockOxqlValues[name]
283-
return {
283+
// structuredClone lets us mutate data in the calling code without messing up
284+
// the source data
285+
return structuredClone({
284286
tables: [
285287
{
286288
name: name,
@@ -310,5 +312,5 @@ export const getMockOxqlInstanceData = (
310312
},
311313
},
312314
],
313-
}
315+
})
314316
}

test/e2e/instance-metrics.e2e.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import { expect, test } from '@playwright/test'
1010

11+
import { OXQL_GROUP_BY_ERROR } from '~/api'
12+
1113
import { getPageAsUser } from './utils'
1214

1315
test('Click through instance metrics', async ({ page }) => {
@@ -41,3 +43,47 @@ test('Instance metrics work for non-fleet viewer', async ({ browser }) => {
4143
// we don't want an error, we want the data!
4244
await expect(page.getByText('Something went wrong')).toBeHidden()
4345
})
46+
47+
test('empty and loading states', async ({ page }) => {
48+
const messages: string[] = []
49+
page.on('console', (e) => messages.push(e.text()))
50+
51+
// we have special handling in the API to return special data for this project
52+
await page.goto('/projects/other-project/instances/failed-restarting-soon/metrics/cpu')
53+
54+
const loading = page.getByLabel('Chart loading') // aria-label on loading indicator
55+
const noData = page.getByText('No data', { exact: true })
56+
57+
// default running state returns two data points, which get turned into one by
58+
// the data munging
59+
await expect(loading).toBeVisible()
60+
await expect(loading).toBeHidden()
61+
await expect(noData).toBeHidden()
62+
63+
const statePicker = page.getByRole('button', { name: 'Choose state' })
64+
65+
// emulation state returns one data point
66+
await statePicker.click()
67+
await page.getByRole('option', { name: 'State: Emulation' }).click()
68+
await expect(loading).toBeVisible()
69+
await expect(loading).toBeHidden()
70+
await expect(noData).toBeVisible()
71+
72+
// idle state returns group_by must be aligned error, treated as empty
73+
const hasGroupByError = () => messages.some((m) => m.includes(OXQL_GROUP_BY_ERROR))
74+
75+
expect(hasGroupByError()).toBe(false) // error not in console
76+
await statePicker.click()
77+
await page.getByRole('option', { name: 'State: Idle' }).click()
78+
await expect(loading).toBeVisible()
79+
await expect(loading).toBeHidden()
80+
await expect(page.getByText('Something went wrong')).toBeHidden()
81+
await expect(noData).toBeVisible()
82+
expect(hasGroupByError()).toBe(true) // error present in console
83+
84+
// make sure empty state goes away again for the first one
85+
await statePicker.click()
86+
await page.getByRole('option', { name: 'State: Running' }).click()
87+
await expect(noData).toBeHidden()
88+
await expect(loading).toBeHidden()
89+
})

0 commit comments

Comments
 (0)