Skip to content

Commit 2382425

Browse files
Instance action buttons (#2508)
* Instance action buttons * Re-add serial console link * Remove test styles * Fix broken `stopInstance` test util * be fussy * Button tooltip position default bottom (also flips to top) * Use correct info icon size * Add tooltip padding from edge of viewport * Removed disabled cursor for disabled button * Add confirm start instance * fix e2es * Only "running" instances can be stopped * `disabled:cursor-default` on button * use helper for symmetry --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent 78e7e26 commit 2382425

File tree

10 files changed

+78
-43
lines changed

10 files changed

+78
-43
lines changed

app/components/DocsPopover.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
1010
import cn from 'classnames'
1111

12-
import { OpenLink12Icon, Question12Icon } from '@oxide/design-system/icons/react'
12+
import { Info16Icon, OpenLink12Icon } from '@oxide/design-system/icons/react'
1313

1414
import { buttonStyle } from '~/ui/lib/Button'
1515

@@ -45,7 +45,7 @@ export const DocsPopover = ({ heading, icon, summary, links }: DocsPopoverProps)
4545
return (
4646
<Popover>
4747
<PopoverButton className={cn(buttonStyle({ size: 'sm', variant: 'ghost' }), 'w-8')}>
48-
<Question12Icon aria-label="Links to docs" className="shrink-0" />
48+
<Info16Icon aria-label="Links to docs" className="shrink-0" />
4949
</PopoverButton>
5050
<PopoverPanel
5151
// DocsPopoverPanel needed for enter animation

app/pages/project/instances/InstancesPage.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ const POLL_INTERVAL_SLOW = 60 * sec
6464

6565
export function InstancesPage() {
6666
const { project } = useProjectSelector()
67-
68-
const makeActions = useMakeInstanceActions(
67+
const { makeButtonActions, makeMenuActions } = useMakeInstanceActions(
6968
{ project },
7069
{ onSuccess: refetchInstances, onDelete: refetchInstances }
7170
)
@@ -182,9 +181,12 @@ export function InstancesPage() {
182181
}
183182
),
184183
colHelper.accessor('timeCreated', Columns.timeCreated),
185-
getActionsCol(makeActions),
184+
getActionsCol((instance: Instance) => [
185+
...makeButtonActions(instance),
186+
...makeMenuActions(instance),
187+
]),
186188
],
187-
[project, makeActions]
189+
[project, makeButtonActions, makeMenuActions]
188190
)
189191

190192
if (!instances) return null

app/pages/project/instances/actions.tsx

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { HL } from '~/components/HL'
1414
import { confirmAction } from '~/stores/confirm-action'
1515
import { confirmDelete } from '~/stores/confirm-delete'
1616
import { addToast } from '~/stores/toast'
17-
import type { MakeActions } from '~/table/columns/action-col'
1817
import { pb } from '~/util/path-builder'
1918

2019
import { fancifyStates } from './instance/tabs/common'
@@ -31,40 +30,49 @@ type Options = {
3130
export const useMakeInstanceActions = (
3231
{ project }: { project: string },
3332
options: Options = {}
34-
): MakeActions<Instance> => {
33+
) => {
3534
const navigate = useNavigate()
36-
3735
// if you also pass onSuccess to mutate(), this one is not overridden — this
3836
// one runs first, then the one passed to mutate().
3937
//
4038
// We pull out the mutate functions because they are referentially stable,
4139
// while the whole useMutation result object is not. The async ones are used
4240
// when we need to confirm because the confirm modals want that.
4341
const opts = { onSuccess: options.onSuccess }
44-
const { mutate: startInstance } = useApiMutation('instanceStart', opts)
42+
const { mutateAsync: startInstanceAsync } = useApiMutation('instanceStart', opts)
4543
const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', opts)
4644
const { mutate: rebootInstance } = useApiMutation('instanceReboot', opts)
4745
// delete has its own
4846
const { mutateAsync: deleteInstanceAsync } = useApiMutation('instanceDelete', {
4947
onSuccess: options.onDelete,
5048
})
5149

52-
return useCallback(
53-
(instance) => {
54-
const instanceSelector = { project, instance: instance.name }
50+
const makeButtonActions = useCallback(
51+
(instance: Instance) => {
5552
const instanceParams = { path: { instance: instance.name }, query: { project } }
5653
return [
5754
{
5855
label: 'Start',
5956
onActivate() {
60-
startInstance(instanceParams, {
61-
onSuccess: () => addToast(<>Starting instance <HL>{instance.name}</HL></>), // prettier-ignore
62-
onError: (error) =>
63-
addToast({
64-
variant: 'error',
65-
title: `Error starting instance '${instance.name}'`,
66-
content: error.message,
57+
confirmAction({
58+
actionType: 'primary',
59+
doAction: () =>
60+
startInstanceAsync(instanceParams, {
61+
onSuccess: () => addToast(<>Starting instance <HL>{instance.name}</HL></>), // prettier-ignore
62+
onError: (error) =>
63+
addToast({
64+
variant: 'error',
65+
title: `Error starting instance '${instance.name}'`,
66+
content: error.message,
67+
}),
6768
}),
69+
modalTitle: 'Confirm start instance',
70+
modalContent: (
71+
<p>
72+
Are you sure you want to start <HL>{instance.name}</HL>?
73+
</p>
74+
),
75+
errorTitle: `Error starting ${instance.name}`,
6876
})
6977
},
7078
disabled: !instanceCan.start(instance) && (
@@ -97,9 +105,20 @@ export const useMakeInstanceActions = (
97105
})
98106
},
99107
disabled: !instanceCan.stop(instance) && (
100-
<>Only {fancifyStates(instanceCan.stop.states)} instances can be stopped</>
108+
// don't list all the states, it's overwhelming
109+
<>Only {fancifyStates(['running'])} instances can be stopped</>
101110
),
102111
},
112+
]
113+
},
114+
[project, startInstanceAsync, stopInstanceAsync]
115+
)
116+
117+
const makeMenuActions = useCallback(
118+
(instance: Instance) => {
119+
const instanceSelector = { project, instance: instance.name }
120+
const instanceParams = { path: { instance: instance.name }, query: { project } }
121+
return [
103122
{
104123
label: 'Reboot',
105124
onActivate() {
@@ -143,13 +162,8 @@ export const useMakeInstanceActions = (
143162
},
144163
]
145164
},
146-
[
147-
project,
148-
navigate,
149-
deleteInstanceAsync,
150-
rebootInstance,
151-
startInstance,
152-
stopInstanceAsync,
153-
]
165+
[project, deleteInstanceAsync, navigate, rebootInstance]
154166
)
167+
168+
return { makeButtonActions, makeMenuActions }
155169
}

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { RouteTabs, Tab } from '~/components/RouteTabs'
2626
import { InstanceStateBadge } from '~/components/StateBadge'
2727
import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
2828
import { EmptyCell } from '~/table/cells/EmptyCell'
29+
import { Button } from '~/ui/lib/Button'
2930
import { DateTime } from '~/ui/lib/DateTime'
3031
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
3132
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
@@ -92,7 +93,8 @@ export function InstancePage() {
9293
const instanceSelector = useInstanceSelector()
9394

9495
const navigate = useNavigate()
95-
const makeActions = useMakeInstanceActions(instanceSelector, {
96+
97+
const { makeButtonActions, makeMenuActions } = useMakeInstanceActions(instanceSelector, {
9698
onSuccess: refreshData,
9799
// go to project instances list since there's no more instance
98100
onDelete: () => {
@@ -132,17 +134,17 @@ export function InstancePage() {
132134
{ enabled: !!primaryVpcId }
133135
)
134136

135-
const actions = useMemo(
137+
const allMenuActions = useMemo(
136138
() => [
137139
{
138140
label: 'Copy ID',
139141
onActivate() {
140142
window.navigator.clipboard.writeText(instance.id || '')
141143
},
142144
},
143-
...makeActions(instance),
145+
...makeMenuActions(instance),
144146
],
145-
[instance, makeActions]
147+
[instance, makeMenuActions]
146148
)
147149

148150
const memory = filesize(instance.memory, { output: 'object', base: 2 })
@@ -152,9 +154,23 @@ export function InstancePage() {
152154
<PageHeader>
153155
<PageTitle icon={<Instances24Icon />}>{instance.name}</PageTitle>
154156
<div className="inline-flex gap-2">
155-
<InstanceDocsPopover />
156157
<RefreshButton onClick={refreshData} />
157-
<MoreActionsMenu label="Instance actions" actions={actions} />
158+
<InstanceDocsPopover />
159+
<div className="flex space-x-2 border-l pl-2 border-default">
160+
{makeButtonActions(instance).map((action) => (
161+
<Button
162+
key={action.label}
163+
variant="ghost"
164+
size="sm"
165+
onClick={action.onActivate}
166+
disabled={!!action.disabled}
167+
disabledReason={action.disabled}
168+
>
169+
{action.label}
170+
</Button>
171+
))}
172+
</div>
173+
<MoreActionsMenu label="Instance actions" actions={allMenuActions} />
158174
</div>
159175
</PageHeader>
160176
<PropertiesTable.Group className="-mt-8 mb-16">

app/table/columns/action-col.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { Tooltip } from '~/ui/lib/Tooltip'
1616
import { Wrap } from '~/ui/util/wrap'
1717
import { kebabCase } from '~/util/str'
1818

19-
export type MakeActions<Item> = (item: Item) => Array<MenuAction>
19+
type MakeActions<Item> = (item: Item) => Array<MenuAction>
2020

2121
export type MenuAction = {
2222
label: string

app/ui/lib/Button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const buttonStyle = ({
3535
variant = 'primary',
3636
}: ButtonStyleProps = {}) => {
3737
return cn(
38-
'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-not-allowed shrink-0',
38+
'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-default shrink-0',
3939
`btn-${variant}`,
4040
sizeStyle[size],
4141
variant === 'danger'
@@ -87,7 +87,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
8787
return (
8888
<Wrap
8989
when={isDisabled && disabledReason}
90-
with={<Tooltip content={disabledReason} ref={ref} />}
90+
with={<Tooltip content={disabledReason} ref={ref} placement="bottom" />}
9191
>
9292
<button
9393
className={cn(buttonStyle({ size, variant }), className, {

app/ui/lib/Tooltip.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
FloatingArrow,
1414
FloatingPortal,
1515
offset,
16+
shift,
1617
useDismiss,
1718
useFloating,
1819
useFocus,
@@ -68,6 +69,7 @@ export const Tooltip = forwardRef(
6869
*/
6970
placement ? flip() : autoPlacement(),
7071
offset(12),
72+
shift({ padding: 16 }),
7173
arrow({ element: arrowRef, padding: 12 }),
7274
],
7375
})

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/e2e/instance.e2e.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ test('can start a failed instance', async ({ page }) => {
4646
// now start the failed one
4747
await expectInstanceState(page, 'you-fail', 'failed')
4848
await clickRowAction(page, 'you-fail', 'Start')
49+
await page.getByRole('button', { name: 'Confirm' }).click()
4950
await expectInstanceState(page, 'you-fail', 'starting')
5051
})
5152

@@ -93,6 +94,7 @@ test('can stop a starting instance, then start it again', async ({ page }) => {
9394
await expectInstanceState(page, 'not-there-yet', 'stopped')
9495

9596
await clickRowAction(page, 'not-there-yet', 'Start')
97+
await page.getByRole('button', { name: 'Confirm' }).click()
9698
await expectInstanceState(page, 'not-there-yet', 'starting')
9799
await expectInstanceState(page, 'not-there-yet', 'running')
98100
})

test/e2e/utils.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,7 @@ export async function expectRowVisible(
107107
}
108108

109109
export async function stopInstance(page: Page) {
110-
await page.getByRole('button', { name: 'Instance actions' }).click()
111-
await page.getByRole('menuitem', { name: 'Stop' }).click()
110+
await page.getByRole('button', { name: 'Stop' }).click()
112111
await page.getByRole('button', { name: 'Confirm' }).click()
113112
await closeToast(page)
114113
// don't need to manually refresh because of polling

0 commit comments

Comments
 (0)