Skip to content

Commit 6186cff

Browse files
committed
move instance affinity group list to settings tab
1 parent a19c79d commit 6186cff

10 files changed

Lines changed: 196 additions & 207 deletions

File tree

app/pages/project/instances/AffinityTab.tsx renamed to app/pages/project/instances/AntiAffinityCard.tsx

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,21 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
89
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
910
import { useMemo } from 'react'
10-
import { type LoaderFunctionArgs } from 'react-router'
1111

1212
import {
13-
apiq,
1413
getListQFn,
15-
queryClient,
1614
usePrefetchedQuery,
1715
type AffinityGroup,
1816
type AntiAffinityGroup,
1917
} from '@oxide/api'
2018
import { Affinity24Icon } from '@oxide/design-system/icons/react'
2119

22-
import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
20+
import { useInstanceSelector } from '~/hooks/use-params'
21+
import { DescriptionCell } from '~/table/cells/DescriptionCell'
2322
import { makeLinkCell } from '~/table/cells/LinkCell'
24-
import { Columns } from '~/table/columns/common'
2523
import { Table } from '~/table/Table'
2624
import { Badge } from '~/ui/lib/Badge'
2725
import { CardBlock } from '~/ui/lib/CardBlock'
@@ -32,28 +30,17 @@ import { ALL_ISH } from '~/util/consts'
3230
import { pb } from '~/util/path-builder'
3331
import type * as PP from '~/util/path-params'
3432

35-
const instanceView = ({ project, instance }: PP.Instance) =>
36-
apiq('instanceView', { path: { instance }, query: { project } })
37-
const antiAffinityGroupList = ({ project, instance }: PP.Instance) =>
33+
export const antiAffinityGroupList = ({ project, instance }: PP.Instance) =>
3834
getListQFn('instanceAntiAffinityGroupList', {
3935
path: { instance },
4036
query: { project, limit: ALL_ISH },
4137
})
4238

43-
export async function clientLoader({ params }: LoaderFunctionArgs) {
44-
const instanceSelector = getInstanceSelector(params)
45-
await Promise.all([
46-
// This is covered by the InstancePage loader but there's no downside to
47-
// being redundant. If it were removed there, we'd still want it here.
48-
queryClient.prefetchQuery(instanceView(instanceSelector)),
49-
queryClient.prefetchQuery(antiAffinityGroupList(instanceSelector).optionsFn()),
50-
])
51-
return null
52-
}
53-
5439
const colHelper = createColumnHelper<AffinityGroup | AntiAffinityGroup>()
5540
const staticCols = [
56-
colHelper.accessor('description', Columns.description),
41+
colHelper.accessor('description', {
42+
cell: (info) => <DescriptionCell text={info.getValue()} maxLength={32} />,
43+
}),
5744
colHelper.accessor('policy', {
5845
header: () => (
5946
<>
@@ -81,9 +68,7 @@ const staticCols = [
8168
}),
8269
]
8370

84-
export const handle = { crumb: 'Affinity' }
85-
86-
export default function AffinityTab() {
71+
export function AntiAffinityCard() {
8772
const instanceSelector = useInstanceSelector()
8873
const { project } = instanceSelector
8974

@@ -111,9 +96,8 @@ export default function AffinityTab() {
11196
})
11297

11398
return (
114-
<CardBlock>
99+
<CardBlock width="medium">
115100
<CardBlock.Header title="Anti-affinity groups" titleId="anti-affinity-groups-label" />
116-
117101
<CardBlock.Body>
118102
{antiAffinityGroups.items.length > 0 ? (
119103
<Table
@@ -125,7 +109,7 @@ export default function AffinityTab() {
125109
<TableEmptyBox border={false}>
126110
<EmptyMessage
127111
icon={<Affinity24Icon />}
128-
title="No Anti-Affinity Groups"
112+
title="No anti-affinity groups"
129113
body="This instance is not a member of any anti-affinity groups"
130114
/>
131115
</TableEmptyBox>
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
9+
import { formatDistanceToNow } from 'date-fns'
10+
import { type ReactNode } from 'react'
11+
import { useForm } from 'react-hook-form'
12+
import { match } from 'ts-pattern'
13+
14+
import {
15+
apiQueryClient,
16+
instanceAutoRestartingSoon,
17+
useApiMutation,
18+
usePrefetchedApiQuery,
19+
} from '~/api'
20+
import { ListboxField } from '~/components/form/fields/ListboxField'
21+
import { useInstanceSelector } from '~/hooks/use-params'
22+
import { addToast } from '~/stores/toast'
23+
import { Button } from '~/ui/lib/Button'
24+
import { CardBlock, LearnMore } from '~/ui/lib/CardBlock'
25+
import { type ListboxItem } from '~/ui/lib/Listbox'
26+
import { TipIcon } from '~/ui/lib/TipIcon'
27+
import { toLocaleDateTimeString } from '~/util/date'
28+
import { links } from '~/util/links'
29+
30+
type FormPolicy = 'default' | 'never' | 'best_effort'
31+
32+
const restartPolicyItems: ListboxItem<FormPolicy>[] = [
33+
{ value: 'default', label: 'Default' },
34+
{ value: 'never', label: 'Never' },
35+
{ value: 'best_effort', label: 'Best effort' },
36+
]
37+
38+
type FormValues = {
39+
autoRestartPolicy: FormPolicy
40+
}
41+
42+
export function AutoRestartCard() {
43+
const instanceSelector = useInstanceSelector()
44+
45+
const { data: instance } = usePrefetchedApiQuery('instanceView', {
46+
path: { instance: instanceSelector.instance },
47+
query: { project: instanceSelector.project },
48+
})
49+
50+
const instanceUpdate = useApiMutation('instanceUpdate', {
51+
onSuccess() {
52+
apiQueryClient.invalidateQueries('instanceView')
53+
addToast({ content: 'Instance auto-restart policy updated' })
54+
},
55+
onError(err) {
56+
addToast({
57+
title: 'Could not update auto-restart policy',
58+
content: err.message,
59+
variant: 'error',
60+
})
61+
},
62+
})
63+
64+
const autoRestartPolicy = instance.autoRestartPolicy || 'default'
65+
const defaultValues: FormValues = { autoRestartPolicy }
66+
67+
const form = useForm({ defaultValues })
68+
69+
// note there are no instance state-based restrictions on updating auto
70+
// restart, so there is no instanceCan helper for it
71+
// https://github.com/oxidecomputer/omicron/blob/0c6ab099e/nexus/db-queries/src/db/datastore/instance.rs#L1050-L1058
72+
const disableSubmit = form.watch('autoRestartPolicy') === autoRestartPolicy
73+
74+
const onSubmit = form.handleSubmit((values) => {
75+
instanceUpdate.mutate({
76+
path: { instance: instanceSelector.instance },
77+
query: { project: instanceSelector.project },
78+
body: {
79+
ncpus: instance.ncpus,
80+
memory: instance.memory,
81+
bootDisk: instance.bootDiskId,
82+
autoRestartPolicy: match(values.autoRestartPolicy)
83+
.with('default', () => undefined)
84+
.with('never', () => 'never' as const)
85+
.with('best_effort', () => 'best_effort' as const)
86+
.exhaustive(),
87+
},
88+
})
89+
})
90+
91+
return (
92+
<form onSubmit={onSubmit}>
93+
<CardBlock width="medium">
94+
<CardBlock.Header
95+
title="Auto-restart"
96+
description="The auto-restart policy for this instance"
97+
/>
98+
<CardBlock.Body>
99+
<ListboxField
100+
control={form.control}
101+
name="autoRestartPolicy"
102+
label="Policy"
103+
description="The global default is currently best effort, but this may change in the future."
104+
items={restartPolicyItems}
105+
required
106+
className="max-w-none"
107+
/>
108+
<FormMeta
109+
label="Cooldown expiration"
110+
tip="When this instance will next restart (if in a failed state and the policy allows it). If N/A, then either the instance has never been automatically restarted, or the cooldown period has expired."
111+
>
112+
{instance.autoRestartCooldownExpiration ? (
113+
<>
114+
{toLocaleDateTimeString(instance.autoRestartCooldownExpiration)}{' '}
115+
{instance.runState === 'failed' && instance.autoRestartEnabled && (
116+
<span className="text-tertiary">
117+
(
118+
{instanceAutoRestartingSoon(instance)
119+
? 'restarting soon'
120+
: formatDistanceToNow(instance.autoRestartCooldownExpiration)}
121+
)
122+
</span>
123+
)}
124+
</>
125+
) : (
126+
<span className="text-tertiary">N/A</span>
127+
)}
128+
</FormMeta>
129+
<FormMeta
130+
label="Last auto-restarted"
131+
tip="When this instance was last automatically restarted. N/A if never auto-restarted."
132+
>
133+
{instance.timeLastAutoRestarted ? (
134+
toLocaleDateTimeString(instance.timeLastAutoRestarted)
135+
) : (
136+
<span className="text-tertiary">N/A</span>
137+
)}
138+
</FormMeta>
139+
</CardBlock.Body>
140+
<CardBlock.Footer>
141+
<LearnMore href={links.instanceUpdateDocs} text="Auto-Restart" />
142+
<Button size="sm" type="submit" disabled={disableSubmit}>
143+
Save
144+
</Button>
145+
</CardBlock.Footer>
146+
</CardBlock>
147+
</form>
148+
)
149+
}
150+
151+
type FormMetaProps = {
152+
label: string
153+
tip?: string
154+
children: ReactNode
155+
}
156+
157+
const FormMeta = ({ label, tip, children }: FormMetaProps) => (
158+
<div>
159+
<div className="mb-2 flex items-center gap-1 border-b pb-2 text-sans-md border-secondary">
160+
<div>{label}</div>
161+
{tip && <TipIcon>{tip}</TipIcon>}
162+
</div>
163+
{children}
164+
</div>
165+
)

app/pages/project/instances/InstancePage.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,6 @@ export default function InstancePage() {
273273
Metrics
274274
</Tab>
275275
<Tab to={pb.instanceConnect(instanceSelector)}>Connect</Tab>
276-
<Tab to={pb.instanceAffinity(instanceSelector)}>Affinity</Tab>
277276
<Tab to={pb.instanceSettings(instanceSelector)}>Settings</Tab>
278277
</RouteTabs>
279278
{resizeInstance && (

0 commit comments

Comments
 (0)