Skip to content

Commit 342aa04

Browse files
authored
Polling on instance list (#2391)
* first pass at instance list polling * do it cleaner * add instance state to key * poll for 30 seconds on a 3 second interval * rework logic, use set diff instead of !setEq to trigger clock restart * add updated time. poll fast vs. slow, not fast vs. not at all * test polling in e2es * forgot that we only want to bother polling fast if there are any instances transitioning * comment tweak * fix typo in test. guitly * stray test.only
1 parent 9ff6ac6 commit 342aa04

File tree

7 files changed

+162
-35
lines changed

7 files changed

+162
-35
lines changed

app/pages/project/instances/InstancesPage.tsx

+80-6
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
*/
88
import { createColumnHelper } from '@tanstack/react-table'
99
import { filesize } from 'filesize'
10-
import { useMemo } from 'react'
10+
import { useMemo, useRef } from 'react'
1111
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1212

1313
import { apiQueryClient, usePrefetchedApiQuery, type Instance } from '@oxide/api'
1414
import { Instances24Icon } from '@oxide/design-system/icons/react'
1515

16+
import { instanceTransitioning } from '~/api/util'
1617
import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
1718
import { RefreshButton } from '~/components/RefreshButton'
1819
import { getProjectSelector, useProjectSelector, useQuickActions } from '~/hooks'
@@ -25,6 +26,9 @@ import { CreateLink } from '~/ui/lib/CreateButton'
2526
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
2627
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
2728
import { TableActions } from '~/ui/lib/Table'
29+
import { Tooltip } from '~/ui/lib/Tooltip'
30+
import { setDiff } from '~/util/array'
31+
import { toLocaleTimeString } from '~/util/date'
2832
import { pb } from '~/util/path-builder'
2933

3034
import { useMakeInstanceActions } from './actions'
@@ -51,6 +55,12 @@ InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => {
5155

5256
const refetchInstances = () => apiQueryClient.invalidateQueries('instanceList')
5357

58+
const sec = 1000 // ms, obviously
59+
const POLL_FAST_TIMEOUT = 30 * sec
60+
// a little slower than instance detail because this is a bigger response
61+
const POLL_INTERVAL_FAST = 3 * sec
62+
const POLL_INTERVAL_SLOW = 60 * sec
63+
5464
export function InstancesPage() {
5565
const { project } = useProjectSelector()
5666

@@ -59,9 +69,61 @@ export function InstancesPage() {
5969
{ onSuccess: refetchInstances, onDelete: refetchInstances }
6070
)
6171

62-
const { data: instances } = usePrefetchedApiQuery('instanceList', {
63-
query: { project, limit: PAGE_SIZE },
64-
})
72+
// this is a whole thing. sit down.
73+
74+
// We initialize this set as empty because we don't have the instances on hand
75+
// yet. This is fine because the first fetch will recognize the presence of
76+
// any transitioning instances as a change in state and initiate polling
77+
const transitioningInstances = useRef<Set<string>>(new Set())
78+
const pollingStartTime = useRef<number>(Date.now())
79+
80+
const { data: instances, dataUpdatedAt } = usePrefetchedApiQuery(
81+
'instanceList',
82+
{ query: { project, limit: PAGE_SIZE } },
83+
{
84+
// The point of all this is to poll quickly for a certain amount of time
85+
// after some instance in the current page enters a transitional state
86+
// like starting or stopping. After that, it will keep polling, but more
87+
// slowly. For example, if you stop an instance, its state will change to
88+
// `stopping`, which will cause this logic to start polling the list until
89+
// it lands in `stopped`, at which point it will poll only slowly because
90+
// `stopped` is not considered transitional.
91+
refetchInterval({ state: { data } }) {
92+
const prevTransitioning = transitioningInstances.current
93+
const nextTransitioning = new Set(
94+
// Data will never actually be undefined because of the prefetch but whatever
95+
(data?.items || [])
96+
.filter(instanceTransitioning)
97+
// These are strings of instance ID + current state. This is done because
98+
// of the case where an instance is stuck in starting (for example), polling
99+
// times out, and then you manually stop it. Without putting the state in the
100+
// the key, that stop action would not be registered as a change in the set
101+
// of transitioning instances.
102+
.map((i) => i.id + '|' + i.runState)
103+
)
104+
105+
// always update the ledger to the current state
106+
transitioningInstances.current = nextTransitioning
107+
108+
// We use this set difference logic instead of set equality because if
109+
// you have two transitioning instances and one stops transitioning,
110+
// then that's a change in the set, but you shouldn't start polling
111+
// fast because of it! What you want to look for is *new* transitioning
112+
// instances.
113+
const anyTransitioning = nextTransitioning.size > 0
114+
const anyNewTransitioning = setDiff(nextTransitioning, prevTransitioning).size > 0
115+
116+
// if there are new instances in transitioning, restart the timeout window
117+
if (anyNewTransitioning) pollingStartTime.current = Date.now()
118+
119+
// important that elapsed is calculated *after* potentially bumping start time
120+
const elapsed = Date.now() - pollingStartTime.current
121+
return anyTransitioning && elapsed < POLL_FAST_TIMEOUT
122+
? POLL_INTERVAL_FAST
123+
: POLL_INTERVAL_SLOW
124+
},
125+
}
126+
)
65127

66128
const navigate = useNavigate()
67129
useQuickActions(
@@ -132,8 +194,20 @@ export function InstancesPage() {
132194
<PageTitle icon={<Instances24Icon />}>Instances</PageTitle>
133195
<InstanceDocsPopover />
134196
</PageHeader>
135-
<TableActions>
136-
<RefreshButton onClick={refetchInstances} />
197+
{/* Avoid changing justify-end on TableActions for this one case. We can
198+
* fix this properly when we add refresh and filtering for all tables. */}
199+
<TableActions className="!-mt-6 !justify-between">
200+
<div className="flex items-center gap-2">
201+
<RefreshButton onClick={refetchInstances} />
202+
<Tooltip
203+
content="Auto-refresh is more frequent after instance actions"
204+
delay={150}
205+
>
206+
<span className="text-sans-sm text-tertiary">
207+
Updated {toLocaleTimeString(new Date(dataUpdatedAt))}
208+
</span>
209+
</Tooltip>
210+
</div>
137211
<CreateLink to={pb.instancesNew({ project })}>New Instance</CreateLink>
138212
</TableActions>
139213
<Table columns={columns} emptyState={<EmptyState />} />

app/pages/project/instances/instance/InstancePage.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ InstancePage.loader = async ({ params }: LoaderFunctionArgs) => {
8686
return null
8787
}
8888

89+
const POLL_INTERVAL = 1000
90+
8991
export function InstancePage() {
9092
const instanceSelector = useInstanceSelector()
9193

@@ -107,7 +109,7 @@ export function InstancePage() {
107109
},
108110
{
109111
refetchInterval: ({ state: { data: instance } }) =>
110-
instance && instanceTransitioning(instance) ? 1000 : false,
112+
instance && instanceTransitioning(instance) ? POLL_INTERVAL : false,
111113
}
112114
)
113115

@@ -169,7 +171,7 @@ export function InstancePage() {
169171
<div className="flex">
170172
<InstanceStatusBadge status={instance.runState} />
171173
{polling && (
172-
<Tooltip content="Auto-refreshing while state changes" delay={150}>
174+
<Tooltip content="Auto-refreshing while status changes" delay={150}>
173175
<button type="button">
174176
<Spinner className="ml-2" />
175177
</button>

app/ui/lib/Table.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Table.Cell = ({ height = 'small', className, children, ...props }: TableCellProp
115115
* Used _outside_ of the `Table`, this element wraps buttons that sit on top
116116
* of the table.
117117
*/
118-
export const TableActions = classed.div`-mt-11 mb-3 flex justify-end space-x-2`
118+
export const TableActions = classed.div`-mt-11 mb-3 flex justify-end gap-2`
119119

120120
export const TableEmptyBox = classed.div`flex h-full max-h-[480px] items-center justify-center rounded-lg border border-secondary p-4`
121121

app/util/array.spec.tsx

+21-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { type ReactElement } from 'react'
99
import { expect, test } from 'vitest'
1010

11-
import { groupBy, intersperse } from './array'
11+
import { groupBy, intersperse, isSetEqual, setDiff } from './array'
1212

1313
test('groupBy', () => {
1414
expect(
@@ -61,3 +61,23 @@ test('intersperse', () => {
6161
expect(result.map(getText)).toEqual(['a', ',', 'b', ',', 'or', 'c'])
6262
expect(result.map(getKey)).toEqual(['a', 'sep-1', 'b', 'sep-2', 'conj', 'c'])
6363
})
64+
65+
test('isSetEqual', () => {
66+
expect(isSetEqual(new Set(), new Set())).toBe(true)
67+
expect(isSetEqual(new Set(['a', 'b', 'c']), new Set(['a', 'b', 'c']))).toBe(true)
68+
69+
expect(isSetEqual(new Set(['a']), new Set(['b']))).toBe(false)
70+
expect(isSetEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
71+
expect(isSetEqual(new Set(['a', 'b']), new Set(['a']))).toBe(false)
72+
73+
expect(isSetEqual(new Set([{}]), new Set([{}]))).toBe(false)
74+
})
75+
76+
test('setDiff', () => {
77+
expect(setDiff(new Set(), new Set())).toEqual(new Set())
78+
expect(setDiff(new Set(['a']), new Set())).toEqual(new Set(['a']))
79+
expect(setDiff(new Set(), new Set(['a']))).toEqual(new Set())
80+
expect(setDiff(new Set(['b', 'a', 'c']), new Set(['b', 'd']))).toEqual(
81+
new Set(['a', 'c'])
82+
)
83+
})

app/util/array.ts

+15
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,18 @@ export function intersperse(
3838
return [sep0, item]
3939
})
4040
}
41+
42+
export function isSetEqual<T>(a: Set<T>, b: Set<T>): boolean {
43+
if (a.size !== b.size) return false
44+
for (const item of a) {
45+
if (!b.has(item)) {
46+
return false
47+
}
48+
}
49+
return true
50+
}
51+
52+
/** Set `a - b` */
53+
export function setDiff<T>(a: Set<T>, b: Set<T>): Set<T> {
54+
return new Set([...a].filter((x) => !b.has(x)))
55+
}

test/e2e/instance.e2e.ts

+40-19
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { expect, expectRowVisible, refreshInstance, sleep, test } from './utils'
8+
import { clickRowAction, expect, expectRowVisible, test } from './utils'
99

1010
test('can delete a failed instance', async ({ page }) => {
1111
await page.goto('/projects/mock-project/instances')
@@ -26,45 +26,66 @@ test('can delete a failed instance', async ({ page }) => {
2626
test('can stop and delete a running instance', async ({ page }) => {
2727
await page.goto('/projects/mock-project/instances')
2828

29+
const table = page.getByRole('table')
30+
await expectRowVisible(table, {
31+
name: 'db1',
32+
status: expect.stringContaining('running'),
33+
})
2934
const row = page.getByRole('row', { name: 'db1', exact: false })
30-
await expect(row).toBeVisible()
31-
await expect(row.getByRole('cell', { name: /running/ })).toBeVisible()
3235

3336
// can't delete, can stop
3437
await row.getByRole('button', { name: 'Row actions' }).click()
3538
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled()
3639
await page.getByRole('menuitem', { name: 'Stop' }).click()
3740
await page.getByRole('button', { name: 'Confirm' }).click()
3841

39-
await sleep(4000)
40-
await refreshInstance(page)
41-
42-
// now it's stopped
43-
await expect(row.getByRole('cell', { name: /stopped/ })).toBeVisible()
42+
// polling makes it go stopping and then stopped
43+
await expectRowVisible(table, {
44+
name: 'db1',
45+
status: expect.stringContaining('stopping'),
46+
})
47+
await expectRowVisible(table, {
48+
name: 'db1',
49+
status: expect.stringContaining('stopped'),
50+
})
4451

4552
// now delete
46-
await row.getByRole('button', { name: 'Row actions' }).click()
47-
await page.getByRole('menuitem', { name: 'Delete' }).click()
53+
await clickRowAction(page, 'db1', 'Delete')
4854
await page.getByRole('button', { name: 'Confirm' }).click()
4955

5056
await expect(row).toBeHidden() // bye
5157
})
5258

53-
test('can stop a starting instance', async ({ page }) => {
59+
test('can stop a starting instance, then start it again', async ({ page }) => {
5460
await page.goto('/projects/mock-project/instances')
5561

56-
const row = page.getByRole('row', { name: 'not-there-yet', exact: false })
57-
await expect(row).toBeVisible()
58-
await expect(row.getByRole('cell', { name: /starting/ })).toBeVisible()
62+
const table = page.getByRole('table')
63+
await expectRowVisible(table, {
64+
name: 'not-there-yet',
65+
status: expect.stringContaining('starting'),
66+
})
5967

60-
await row.getByRole('button', { name: 'Row actions' }).click()
61-
await page.getByRole('menuitem', { name: 'Stop' }).click()
68+
await clickRowAction(page, 'not-there-yet', 'Stop')
6269
await page.getByRole('button', { name: 'Confirm' }).click()
6370

64-
await sleep(4000)
65-
await refreshInstance(page)
71+
await expectRowVisible(table, {
72+
name: 'not-there-yet',
73+
status: expect.stringContaining('stopping'),
74+
})
75+
await expectRowVisible(table, {
76+
name: 'not-there-yet',
77+
status: expect.stringContaining('stopped'),
78+
})
6679

67-
await expect(row.getByRole('cell', { name: /stopped/ })).toBeVisible()
80+
await clickRowAction(page, 'not-there-yet', 'Start')
81+
await expectRowVisible(table, {
82+
name: 'not-there-yet',
83+
status: expect.stringContaining('starting'),
84+
})
85+
await expectRowVisible(table, {
86+
name: 'not-there-yet',
87+
status: expect.stringContaining('running'),
88+
})
6889
})
6990

7091
test('delete from instance detail', async ({ page }) => {

test/e2e/utils.ts

+1-6
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,10 @@ export async function stopInstance(page: Page) {
111111
await page.getByRole('menuitem', { name: 'Stop' }).click()
112112
await page.getByRole('button', { name: 'Confirm' }).click()
113113
await closeToast(page)
114-
await sleep(2000)
115-
await refreshInstance(page)
114+
// don't need to manually refresh because of polling
116115
await expect(page.getByText('statusstopped')).toBeVisible()
117116
}
118117

119-
export async function refreshInstance(page: Page) {
120-
await page.getByRole('button', { name: 'Refresh data' }).click()
121-
}
122-
123118
/**
124119
* Close toast and wait for it to fade out. For some reason it prevents things
125120
* from working, but only in tests as far as we can tell.

0 commit comments

Comments
 (0)