Skip to content

Commit 61438c6

Browse files
Experimental stop instance in modal
1 parent b4ce08d commit 61438c6

File tree

5 files changed

+133
-23
lines changed

5 files changed

+133
-23
lines changed

app/components/StopInstancePrompt.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useEffect, useState, type ReactNode } from 'react'
9+
10+
import {
11+
instanceTransitioning,
12+
useApiMutation,
13+
useApiQuery,
14+
useApiQueryClient,
15+
type Instance,
16+
} from '@oxide/api'
17+
18+
import { HL } from '~/components/HL'
19+
import { addToast } from '~/stores/toast'
20+
import { Button } from '~/ui/lib/Button'
21+
import { Message } from '~/ui/lib/Message'
22+
23+
const POLL_INTERVAL_FAST = 2000 // 2 seconds
24+
25+
type StopInstancePromptProps = {
26+
instance: Instance
27+
children: ReactNode
28+
}
29+
30+
export function StopInstancePrompt({ instance, children }: StopInstancePromptProps) {
31+
const queryClient = useApiQueryClient()
32+
const [isStoppingInstance, setIsStoppingInstance] = useState(false)
33+
34+
const { data } = useApiQuery(
35+
'instanceView',
36+
{
37+
path: { instance: instance.name },
38+
query: { project: instance.projectId },
39+
},
40+
{
41+
refetchInterval:
42+
isStoppingInstance || instanceTransitioning(instance) ? POLL_INTERVAL_FAST : false,
43+
}
44+
)
45+
46+
const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', {
47+
onSuccess: () => {
48+
setIsStoppingInstance(true)
49+
addToast(
50+
<>
51+
Stopping instance <HL>{instance.name}</HL>
52+
</>
53+
)
54+
},
55+
onError: (error) => {
56+
addToast({
57+
variant: 'error',
58+
title: `Error stopping instance '${instance.name}'`,
59+
content: error.message,
60+
})
61+
setIsStoppingInstance(false)
62+
},
63+
})
64+
65+
const handleStopInstance = () => {
66+
stopInstanceAsync({
67+
path: { instance: instance.name },
68+
query: { project: instance.projectId },
69+
})
70+
}
71+
72+
const currentInstance = data || instance
73+
74+
useEffect(() => {
75+
if (!data) {
76+
return
77+
}
78+
if (isStoppingInstance && data.runState === 'stopped') {
79+
queryClient.invalidateQueries('instanceView')
80+
setIsStoppingInstance(false)
81+
}
82+
}, [isStoppingInstance, data, queryClient])
83+
84+
if (
85+
!currentInstance ||
86+
(currentInstance.runState !== 'stopping' && currentInstance.runState !== 'running')
87+
) {
88+
return null
89+
}
90+
91+
return (
92+
<Message
93+
variant="notice"
94+
content={
95+
<>
96+
{children}{' '}
97+
<Button
98+
size="xs"
99+
className="mt-3"
100+
variant="notice"
101+
onClick={handleStopInstance}
102+
loading={isStoppingInstance}
103+
>
104+
Stop instance
105+
</Button>
106+
</>
107+
}
108+
/>
109+
)
110+
}

app/forms/disk-attach.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
import { useMemo } from 'react'
99
import { useForm } from 'react-hook-form'
1010

11-
import { useApiQuery, type ApiError } from '@oxide/api'
11+
import { useApiQuery, type ApiError, type Instance } from '@oxide/api'
1212

1313
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1414
import { ModalForm } from '~/components/form/ModalForm'
15+
import { StopInstancePrompt } from '~/components/StopInstancePrompt'
1516
import { useProjectSelector } from '~/hooks/use-params'
1617
import { toComboboxItems } from '~/ui/lib/Combobox'
1718
import { ALL_ISH } from '~/util/consts'
@@ -25,6 +26,7 @@ type AttachDiskProps = {
2526
diskNamesToExclude?: string[]
2627
loading?: boolean
2728
submitError?: ApiError | null
29+
instance: Instance
2830
}
2931

3032
/**
@@ -37,6 +39,7 @@ export function AttachDiskModalForm({
3739
diskNamesToExclude = [],
3840
loading = false,
3941
submitError = null,
42+
instance,
4043
}: AttachDiskProps) {
4144
const { project } = useProjectSelector()
4245

@@ -67,7 +70,13 @@ export function AttachDiskModalForm({
6770
title="Attach disk"
6871
onSubmit={onSubmit}
6972
width="medium"
73+
submitDisabled={
74+
instance.runState !== 'stopped' ? 'Instance must be stopped' : undefined
75+
}
7076
>
77+
<StopInstancePrompt instance={instance}>
78+
An instance must be stopped to attach a disk.
79+
</StopInstancePrompt>
7180
<ComboboxField
7281
label="Disk name"
7382
placeholder="Select a disk"

app/ui/lib/Button.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import { Spinner } from '~/ui/lib/Spinner'
1313
import { Tooltip } from '~/ui/lib/Tooltip'
1414
import { Wrap } from '~/ui/util/wrap'
1515

16-
export const buttonSizes = ['sm', 'icon', 'base'] as const
17-
export const variants = ['primary', 'secondary', 'ghost', 'danger'] as const
16+
export const buttonSizes = ['xs', 'sm', 'icon', 'base'] as const
17+
export const variants = ['primary', 'secondary', 'ghost', 'danger', 'notice'] as const
1818

1919
export type ButtonSize = (typeof buttonSizes)[number]
2020
export type Variant = (typeof variants)[number]
2121

2222
const sizeStyle: Record<ButtonSize, string> = {
23+
xs: 'h-6 px-2 text-mono-xs',
2324
sm: 'h-8 px-3 text-mono-sm [&>svg]:w-4',
2425
// meant for buttons that only contain a single icon
2526
icon: 'h-8 w-8 text-mono-sm [&>svg]:w-4',
@@ -115,7 +116,7 @@ export const Button = ({
115116
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
116117
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
117118
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
118-
className="absolute left-1/2 top-1/2"
119+
className="absolute left-1/2 top-1/2 flex items-center justify-center"
119120
>
120121
<Spinner variant={variant} />
121122
</m.span>

app/ui/styles/components/button.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
@apply hidden;
4545
}
4646

47+
.btn-notice.ox-button:after {
48+
@apply opacity-40;
49+
}
50+
4751
/**
4852
* A class to make it very visually obvious that a button style is missing
4953
*/

app/ui/styles/components/spinner.css

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
animation: rotate 5s linear infinite;
1414
}
1515

16+
.spinner .path,
17+
.spinner .bg {
18+
stroke: currentColor;
19+
}
20+
1621
.spinner.spinner-md {
1722
--radius: 8;
1823
--circumference: calc(var(--PI) * var(--radius) * 2px);
@@ -27,7 +32,6 @@
2732
stroke-dasharray: var(--circumference);
2833
transform-origin: center;
2934
animation: dash 8s ease-in-out infinite;
30-
stroke: var(--content-accent-tertiary);
3135
}
3236

3337
@media (prefers-reduced-motion) {
@@ -50,24 +54,6 @@
5054
}
5155
}
5256

53-
.spinner-ghost .bg,
54-
.spinner-secondary .bg {
55-
stroke: var(--content-default);
56-
}
57-
58-
.spinner-secondary .path {
59-
stroke: var(--content-secondary);
60-
}
61-
62-
.spinner-primary .bg {
63-
stroke: var(--content-accent);
64-
}
65-
66-
.spinner-danger .bg,
67-
.spinner-danger .path {
68-
stroke: var(--content-destructive-tertiary);
69-
}
70-
7157
@keyframes rotate {
7258
100% {
7359
transform: rotate(360deg);

0 commit comments

Comments
 (0)