Skip to content

Commit e7bfb23

Browse files
authored
Instance affinity groups on settings tab (#2767)
* instance affinity tab * group name -> name * move instance affinity group list to settings tab * go full width on settings tab * remove failure domain column and both tooltips * use apiq instead of getListQFn
1 parent 59e6976 commit e7bfb23

File tree

6 files changed

+303
-154
lines changed

6 files changed

+303
-154
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
10+
import { useMemo } from 'react'
11+
12+
import {
13+
apiq,
14+
usePrefetchedQuery,
15+
type AffinityGroup,
16+
type AntiAffinityGroup,
17+
} from '@oxide/api'
18+
import { Affinity24Icon } from '@oxide/design-system/icons/react'
19+
20+
import { useInstanceSelector } from '~/hooks/use-params'
21+
import { makeLinkCell } from '~/table/cells/LinkCell'
22+
import { Columns } from '~/table/columns/common'
23+
import { Table } from '~/table/Table'
24+
import { Badge } from '~/ui/lib/Badge'
25+
import { CardBlock } from '~/ui/lib/CardBlock'
26+
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
27+
import { TableEmptyBox } from '~/ui/lib/Table'
28+
import { ALL_ISH } from '~/util/consts'
29+
import { pb } from '~/util/path-builder'
30+
import type * as PP from '~/util/path-params'
31+
32+
export const antiAffinityGroupList = ({ project, instance }: PP.Instance) =>
33+
apiq('instanceAntiAffinityGroupList', {
34+
path: { instance },
35+
query: { project, limit: ALL_ISH },
36+
})
37+
38+
const colHelper = createColumnHelper<AffinityGroup | AntiAffinityGroup>()
39+
const staticCols = [
40+
colHelper.accessor('description', Columns.description),
41+
colHelper.accessor('policy', {
42+
cell: (info) => <Badge color="neutral">{info.getValue()}</Badge>,
43+
}),
44+
]
45+
46+
export function AntiAffinityCard() {
47+
const instanceSelector = useInstanceSelector()
48+
const { project } = instanceSelector
49+
50+
const { data: antiAffinityGroups } = usePrefetchedQuery(
51+
antiAffinityGroupList(instanceSelector)
52+
)
53+
54+
const antiAffinityCols = useMemo(
55+
() => [
56+
colHelper.accessor('name', {
57+
cell: makeLinkCell((antiAffinityGroup) =>
58+
pb.antiAffinityGroup({ project, antiAffinityGroup })
59+
),
60+
}),
61+
...staticCols,
62+
],
63+
[project]
64+
)
65+
66+
// Create tables for both types of groups
67+
const antiAffinityTable = useReactTable({
68+
columns: antiAffinityCols,
69+
data: antiAffinityGroups.items,
70+
getCoreRowModel: getCoreRowModel(),
71+
})
72+
73+
return (
74+
<CardBlock>
75+
<CardBlock.Header title="Anti-affinity groups" titleId="anti-affinity-groups-label" />
76+
<CardBlock.Body>
77+
{antiAffinityGroups.items.length > 0 ? (
78+
<Table
79+
aria-labelledby="anti-affinity-groups-label"
80+
table={antiAffinityTable}
81+
className="table-inline"
82+
/>
83+
) : (
84+
<TableEmptyBox border={false}>
85+
<EmptyMessage
86+
icon={<Affinity24Icon />}
87+
title="No anti-affinity groups"
88+
body="This instance is not a member of any anti-affinity groups"
89+
/>
90+
</TableEmptyBox>
91+
)}
92+
</CardBlock.Body>
93+
</CardBlock>
94+
)
95+
}
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>
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-lg"
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+
)

0 commit comments

Comments
 (0)