Skip to content

Commit 33b7a50

Browse files
authored
Poll instance state when instance is transitioning (#2360)
* poll instance state on instance detail if instance is transitioning * make instance list polling not do weirdo stuff to the row actions menu * poll on the serial console page and try to connect * show a fun little message while auto-refreshing * Revert "poll on the serial console page and try to connect" 7f5c752 * don't poll on list view, use spinner with tooltip as loading indicator * e2es caught a real bug! * fix overzealous dep arrays * try to make serial test less flaky
1 parent 1a2cb52 commit 33b7a50

File tree

8 files changed

+110
-61
lines changed

8 files changed

+110
-61
lines changed

app/api/util.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,20 @@ const instanceActions: Record<string, InstanceState[]> = {
108108
// while also making the states available directly
109109

110110
export const instanceCan = R.mapValues(instanceActions, (states) => {
111-
const test = (i: Instance) => states.includes(i.runState)
111+
const test = (i: { runState: InstanceState }) => states.includes(i.runState)
112112
test.states = states
113113
return test
114114
})
115115

116+
export function instanceTransitioning({ runState }: Instance) {
117+
return (
118+
runState === 'creating' ||
119+
runState === 'starting' ||
120+
runState === 'stopping' ||
121+
runState === 'rebooting'
122+
)
123+
}
124+
116125
const diskActions: Record<string, DiskState['state'][]> = {
117126
// https://github.com/oxidecomputer/omicron/blob/4970c71e/nexus/db-queries/src/db/datastore/disk.rs#L578-L582.
118127
delete: ['detached', 'creating', 'faulted'],

app/pages/project/instances/actions.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +29,35 @@ type Options = {
2929
}
3030

3131
export const useMakeInstanceActions = (
32-
projectSelector: { project: string },
32+
{ project }: { project: string },
3333
options: Options = {}
3434
): MakeActions<Instance> => {
3535
const navigate = useNavigate()
3636

3737
// if you also pass onSuccess to mutate(), this one is not overridden — this
38-
// one runs first, then the one passed to mutate()
38+
// one runs first, then the one passed to mutate().
39+
//
40+
// We pull out the mutate functions because they are referentially stable,
41+
// while the whole useMutation result object is not. The async ones are used
42+
// when we need to confirm because the confirm modals want that.
3943
const opts = { onSuccess: options.onSuccess }
40-
const startInstance = useApiMutation('instanceStart', opts)
41-
const stopInstance = useApiMutation('instanceStop', opts)
42-
const rebootInstance = useApiMutation('instanceReboot', opts)
44+
const { mutate: startInstance } = useApiMutation('instanceStart', opts)
45+
const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', opts)
46+
const { mutate: rebootInstance } = useApiMutation('instanceReboot', opts)
4347
// delete has its own
44-
const deleteInstance = useApiMutation('instanceDelete', { onSuccess: options.onDelete })
48+
const { mutateAsync: deleteInstanceAsync } = useApiMutation('instanceDelete', {
49+
onSuccess: options.onDelete,
50+
})
4551

4652
return useCallback(
4753
(instance) => {
48-
const instanceSelector = { ...projectSelector, instance: instance.name }
49-
const instanceParams = { path: { instance: instance.name }, query: projectSelector }
54+
const instanceSelector = { project, instance: instance.name }
55+
const instanceParams = { path: { instance: instance.name }, query: { project } }
5056
return [
5157
{
5258
label: 'Start',
5359
onActivate() {
54-
startInstance.mutate(instanceParams, {
60+
startInstance(instanceParams, {
5561
onSuccess: () => addToast({ title: `Starting instance '${instance.name}'` }),
5662
onError: (error) =>
5763
addToast({
@@ -71,7 +77,7 @@ export const useMakeInstanceActions = (
7177
confirmAction({
7278
actionType: 'danger',
7379
doAction: () =>
74-
stopInstance.mutateAsync(instanceParams, {
80+
stopInstanceAsync(instanceParams, {
7581
onSuccess: () =>
7682
addToast({ title: `Stopping instance '${instance.name}'` }),
7783
}),
@@ -93,7 +99,7 @@ export const useMakeInstanceActions = (
9399
{
94100
label: 'Reboot',
95101
onActivate() {
96-
rebootInstance.mutate(instanceParams, {
102+
rebootInstance(instanceParams, {
97103
onSuccess: () => addToast({ title: `Rebooting instance '${instance.name}'` }),
98104
onError: (error) =>
99105
addToast({
@@ -117,7 +123,7 @@ export const useMakeInstanceActions = (
117123
label: 'Delete',
118124
onActivate: confirmDelete({
119125
doDelete: () =>
120-
deleteInstance.mutateAsync(instanceParams, {
126+
deleteInstanceAsync(instanceParams, {
121127
onSuccess: () =>
122128
addToast({ title: `Deleting instance '${instance.name}'` }),
123129
}),
@@ -132,6 +138,13 @@ export const useMakeInstanceActions = (
132138
},
133139
]
134140
},
135-
[projectSelector, deleteInstance, navigate, rebootInstance, startInstance, stopInstance]
141+
[
142+
project,
143+
navigate,
144+
deleteInstanceAsync,
145+
rebootInstance,
146+
startInstance,
147+
stopInstanceAsync,
148+
]
136149
)
137150
}

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@oxide/api'
1818
import { Instances16Icon, Instances24Icon } from '@oxide/design-system/icons/react'
1919

20+
import { instanceTransitioning } from '~/api/util'
2021
import { DocsPopover } from '~/components/DocsPopover'
2122
import { ExternalIps } from '~/components/ExternalIps'
2223
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
@@ -28,6 +29,8 @@ import { EmptyCell } from '~/table/cells/EmptyCell'
2829
import { DateTime } from '~/ui/lib/DateTime'
2930
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
3031
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
32+
import { Spinner } from '~/ui/lib/Spinner'
33+
import { Tooltip } from '~/ui/lib/Tooltip'
3134
import { Truncate } from '~/ui/lib/Truncate'
3235
import { docLinks } from '~/util/links'
3336
import { pb } from '~/util/path-builder'
@@ -94,10 +97,19 @@ export function InstancePage() {
9497
onDelete: () => navigate(pb.instances(instanceSelector)),
9598
})
9699

97-
const { data: instance } = usePrefetchedApiQuery('instanceView', {
98-
path: { instance: instanceSelector.instance },
99-
query: { project: instanceSelector.project },
100-
})
100+
const { data: instance } = usePrefetchedApiQuery(
101+
'instanceView',
102+
{
103+
path: { instance: instanceSelector.instance },
104+
query: { project: instanceSelector.project },
105+
},
106+
{
107+
refetchInterval: ({ state: { data: instance } }) =>
108+
instance && instanceTransitioning(instance) ? 1000 : false,
109+
}
110+
)
111+
112+
const polling = instanceTransitioning(instance)
101113

102114
const { data: nics } = usePrefetchedApiQuery('instanceNetworkInterfaceList', {
103115
query: {
@@ -157,7 +169,16 @@ export function InstancePage() {
157169
<span className="ml-1 text-quaternary"> {memory.unit}</span>
158170
</PropertiesTable.Row>
159171
<PropertiesTable.Row label="status">
160-
<InstanceStatusBadge status={instance.runState} />
172+
<div className="flex">
173+
<InstanceStatusBadge status={instance.runState} />
174+
{polling && (
175+
<Tooltip content="Auto-refreshing while state changes" delay={150}>
176+
<button type="button">
177+
<Spinner className="ml-2" />
178+
</button>
179+
</Tooltip>
180+
)}
181+
</div>
161182
</PropertiesTable.Row>
162183
<PropertiesTable.Row label="vpc">
163184
{vpc ? (

app/pages/project/instances/instance/tabs/NetworkingTab.tsx

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,31 @@ const staticCols = [
133133

134134
const updateNicStates = fancifyStates(instanceCan.updateNic.states)
135135

136+
const ipColHelper = createColumnHelper<ExternalIp>()
137+
const staticIpCols = [
138+
ipColHelper.accessor('ip', {
139+
cell: (info) => <CopyableIp ip={info.getValue()} />,
140+
}),
141+
ipColHelper.accessor('kind', {
142+
header: () => (
143+
<>
144+
Kind
145+
<TipIcon className="ml-2">
146+
Floating IPs can be detached from this instance and attached to another.
147+
</TipIcon>
148+
</>
149+
),
150+
cell: (info) => <Badge color="neutral">{info.getValue()}</Badge>,
151+
}),
152+
ipColHelper.accessor('name', {
153+
cell: (info) => (info.getValue() ? info.getValue() : <EmptyCell />),
154+
}),
155+
ipColHelper.accessor((row) => ('description' in row ? row.description : undefined), {
156+
header: 'description',
157+
cell: (info) => <DescriptionCell text={info.getValue()} />,
158+
}),
159+
]
160+
136161
export function NetworkingTab() {
137162
const instanceSelector = useInstanceSelector()
138163
const { instance: instanceName, project } = instanceSelector
@@ -157,13 +182,13 @@ export function NetworkingTab() {
157182
setCreateModalOpen(false)
158183
},
159184
})
160-
const deleteNic = useApiMutation('instanceNetworkInterfaceDelete', {
185+
const { mutateAsync: deleteNic } = useApiMutation('instanceNetworkInterfaceDelete', {
161186
onSuccess() {
162187
queryClient.invalidateQueries('instanceNetworkInterfaceList')
163188
addToast({ content: 'Network interface deleted' })
164189
},
165190
})
166-
const editNic = useApiMutation('instanceNetworkInterfaceUpdate', {
191+
const { mutate: editNic } = useApiMutation('instanceNetworkInterfaceUpdate', {
167192
onSuccess() {
168193
queryClient.invalidateQueries('instanceNetworkInterfaceList')
169194
},
@@ -180,7 +205,7 @@ export function NetworkingTab() {
180205
{
181206
label: 'Make primary',
182207
onActivate() {
183-
editNic.mutate({
208+
editNic({
184209
path: { interface: nic.name },
185210
query: instanceSelector,
186211
body: { ...nic, primary: true },
@@ -211,7 +236,7 @@ export function NetworkingTab() {
211236
label: 'Delete',
212237
onActivate: confirmDelete({
213238
doDelete: () =>
214-
deleteNic.mutateAsync({
239+
deleteNic({
215240
path: { interface: nic.name },
216241
query: instanceSelector,
217242
}),
@@ -243,32 +268,7 @@ export function NetworkingTab() {
243268
query: { project },
244269
})
245270

246-
const ipColHelper = createColumnHelper<ExternalIp>()
247-
const staticIpCols = [
248-
ipColHelper.accessor('ip', {
249-
cell: (info) => <CopyableIp ip={info.getValue()} />,
250-
}),
251-
ipColHelper.accessor('kind', {
252-
header: () => (
253-
<>
254-
Kind
255-
<TipIcon className="ml-2">
256-
Floating IPs can be detached from this instance and attached to another.
257-
</TipIcon>
258-
</>
259-
),
260-
cell: (info) => <Badge color="neutral">{info.getValue()}</Badge>,
261-
}),
262-
ipColHelper.accessor('name', {
263-
cell: (info) => (info.getValue() ? info.getValue() : <EmptyCell />),
264-
}),
265-
ipColHelper.accessor((row) => ('description' in row ? row.description : undefined), {
266-
header: 'description',
267-
cell: (info) => <DescriptionCell text={info.getValue()} />,
268-
}),
269-
]
270-
271-
const ephemeralIpDetach = useApiMutation('instanceEphemeralIpDetach', {
271+
const { mutateAsync: ephemeralIpDetach } = useApiMutation('instanceEphemeralIpDetach', {
272272
onSuccess() {
273273
queryClient.invalidateQueries('instanceExternalIpList')
274274
addToast({ content: 'Your ephemeral IP has been detached' })
@@ -278,7 +278,7 @@ export function NetworkingTab() {
278278
},
279279
})
280280

281-
const floatingIpDetach = useApiMutation('floatingIpDetach', {
281+
const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', {
282282
onSuccess() {
283283
queryClient.invalidateQueries('floatingIpList')
284284
queryClient.invalidateQueries('instanceExternalIpList')
@@ -301,12 +301,12 @@ export function NetworkingTab() {
301301
const doAction =
302302
externalIp.kind === 'floating'
303303
? () =>
304-
floatingIpDetach.mutateAsync({
304+
floatingIpDetach({
305305
path: { floatingIp: externalIp.name },
306306
query: { project },
307307
})
308308
: () =>
309-
ephemeralIpDetach.mutateAsync({
309+
ephemeralIpDetach({
310310
path: { instance: instanceName },
311311
query: { project },
312312
})

app/pages/project/instances/instance/tabs/StorageTab.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export function StorageTab() {
7373
[instanceName, project]
7474
)
7575

76-
const detachDisk = useApiMutation('instanceDiskDetach', {
76+
const { mutate: detachDisk } = useApiMutation('instanceDiskDetach', {
7777
onSuccess() {
7878
queryClient.invalidateQueries('instanceDiskList')
7979
addToast({ content: 'Disk detached' })
@@ -86,7 +86,7 @@ export function StorageTab() {
8686
})
8787
},
8888
})
89-
const createSnapshot = useApiMutation('snapshotCreate', {
89+
const { mutate: createSnapshot } = useApiMutation('snapshotCreate', {
9090
onSuccess() {
9191
queryClient.invalidateQueries('snapshotList')
9292
addToast({ content: 'Snapshot created' })
@@ -112,7 +112,7 @@ export function StorageTab() {
112112
</>
113113
),
114114
onActivate() {
115-
createSnapshot.mutate({
115+
createSnapshot({
116116
query: { project },
117117
body: {
118118
name: genName(disk.name),
@@ -124,18 +124,20 @@ export function StorageTab() {
124124
},
125125
{
126126
label: 'Detach',
127-
disabled: !instanceCan.detachDisk(instance) && (
127+
disabled: !instanceCan.detachDisk({ runState: instance.runState }) && (
128128
<>
129129
Instance must be <span className="text-default">stopped</span> before disk can
130130
be detached
131131
</>
132132
),
133133
onActivate() {
134-
detachDisk.mutate({ body: { disk: disk.name }, ...instancePathQuery })
134+
detachDisk({ body: { disk: disk.name }, ...instancePathQuery })
135135
},
136136
},
137137
],
138-
[detachDisk, instance, instancePathQuery, createSnapshot, project]
138+
// important to pass instance.runState because instance is not referentially
139+
// stable when we are polling when instance is in transition
140+
[detachDisk, instance.runState, instancePathQuery, createSnapshot, project]
139141
)
140142

141143
const attachDisk = useApiMutation('instanceDiskAttach', {

app/ui/styles/components/spinner.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
stroke: var(--content-default);
4747
}
4848

49+
.spinner-secondary .path {
50+
stroke: var(--content-secondary);
51+
}
52+
4953
.spinner-primary .bg {
5054
stroke: var(--content-accent);
5155
}

mock-api/msw/handlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,7 @@ export const handlers = makeHandlers({
673673

674674
setTimeout(() => {
675675
instance.run_state = 'running'
676-
}, 1000)
676+
}, 3000)
677677

678678
return json(instance, { status: 202 })
679679
},

test/e2e/instance-serial.e2e.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ test('serial console can connect while starting', async ({ page }) => {
2222
await page.getByRole('link', { name: 'Connect' }).click()
2323

2424
// The message goes from creating to starting and then disappears once
25-
// the instance is running
26-
await expect(page.getByText('The instance is creating')).toBeVisible()
25+
// the instance is running. skip the check for "creating" because it can
26+
// go by quickly
2727
await expect(page.getByText('Waiting for the instance to start')).toBeVisible()
2828
await expect(page.getByText('The instance is starting')).toBeVisible()
2929
await expect(page.getByText('The instance is')).toBeHidden()

0 commit comments

Comments
 (0)