Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions app/pages/project/instances/AntiAffinityCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'

import {
apiq,
usePrefetchedQuery,
type AffinityGroup,
type AntiAffinityGroup,
} from '@oxide/api'
import { Affinity24Icon } from '@oxide/design-system/icons/react'

import { useInstanceSelector } from '~/hooks/use-params'
import { makeLinkCell } from '~/table/cells/LinkCell'
import { Columns } from '~/table/columns/common'
import { Table } from '~/table/Table'
import { Badge } from '~/ui/lib/Badge'
import { CardBlock } from '~/ui/lib/CardBlock'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { TableEmptyBox } from '~/ui/lib/Table'
import { ALL_ISH } from '~/util/consts'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'

export const antiAffinityGroupList = ({ project, instance }: PP.Instance) =>
apiq('instanceAntiAffinityGroupList', {
path: { instance },
query: { project, limit: ALL_ISH },
})

const colHelper = createColumnHelper<AffinityGroup | AntiAffinityGroup>()
const staticCols = [
colHelper.accessor('description', Columns.description),
colHelper.accessor('policy', {
cell: (info) => <Badge color="neutral">{info.getValue()}</Badge>,
}),
]

export function AntiAffinityCard() {
const instanceSelector = useInstanceSelector()
const { project } = instanceSelector

const { data: antiAffinityGroups } = usePrefetchedQuery(
antiAffinityGroupList(instanceSelector)
)

const antiAffinityCols = useMemo(
() => [
colHelper.accessor('name', {
cell: makeLinkCell((antiAffinityGroup) =>
pb.antiAffinityGroup({ project, antiAffinityGroup })
),
}),
...staticCols,
],
[project]
)

// Create tables for both types of groups
const antiAffinityTable = useReactTable({
columns: antiAffinityCols,
data: antiAffinityGroups.items,
getCoreRowModel: getCoreRowModel(),
})

return (
<CardBlock>
<CardBlock.Header title="Anti-affinity groups" titleId="anti-affinity-groups-label" />
<CardBlock.Body>
{antiAffinityGroups.items.length > 0 ? (
<Table
aria-labelledby="anti-affinity-groups-label"
table={antiAffinityTable}
className="table-inline"
/>
) : (
<TableEmptyBox border={false}>
<EmptyMessage
icon={<Affinity24Icon />}
title="No anti-affinity groups"
body="This instance is not a member of any anti-affinity groups"
/>
</TableEmptyBox>
)}
</CardBlock.Body>
</CardBlock>
)
}
165 changes: 165 additions & 0 deletions app/pages/project/instances/AutoRestartCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { formatDistanceToNow } from 'date-fns'
import { type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'

import {
apiQueryClient,
instanceAutoRestartingSoon,
useApiMutation,
usePrefetchedApiQuery,
} from '~/api'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { useInstanceSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Button } from '~/ui/lib/Button'
import { CardBlock, LearnMore } from '~/ui/lib/CardBlock'
import { type ListboxItem } from '~/ui/lib/Listbox'
import { TipIcon } from '~/ui/lib/TipIcon'
import { toLocaleDateTimeString } from '~/util/date'
import { links } from '~/util/links'

type FormPolicy = 'default' | 'never' | 'best_effort'

const restartPolicyItems: ListboxItem<FormPolicy>[] = [
{ value: 'default', label: 'Default' },
{ value: 'never', label: 'Never' },
{ value: 'best_effort', label: 'Best effort' },
]

type FormValues = {
autoRestartPolicy: FormPolicy
}

export function AutoRestartCard() {
const instanceSelector = useInstanceSelector()

const { data: instance } = usePrefetchedApiQuery('instanceView', {
path: { instance: instanceSelector.instance },
query: { project: instanceSelector.project },
})

const instanceUpdate = useApiMutation('instanceUpdate', {
onSuccess() {
apiQueryClient.invalidateQueries('instanceView')
addToast({ content: 'Instance auto-restart policy updated' })
},
onError(err) {
addToast({
title: 'Could not update auto-restart policy',
content: err.message,
variant: 'error',
})
},
})

const autoRestartPolicy = instance.autoRestartPolicy || 'default'
const defaultValues: FormValues = { autoRestartPolicy }

const form = useForm({ defaultValues })

// note there are no instance state-based restrictions on updating auto
// restart, so there is no instanceCan helper for it
// https://github.com/oxidecomputer/omicron/blob/0c6ab099e/nexus/db-queries/src/db/datastore/instance.rs#L1050-L1058
const disableSubmit = form.watch('autoRestartPolicy') === autoRestartPolicy

const onSubmit = form.handleSubmit((values) => {
instanceUpdate.mutate({
path: { instance: instanceSelector.instance },
query: { project: instanceSelector.project },
body: {
ncpus: instance.ncpus,
memory: instance.memory,
bootDisk: instance.bootDiskId,
autoRestartPolicy: match(values.autoRestartPolicy)
.with('default', () => undefined)
.with('never', () => 'never' as const)
.with('best_effort', () => 'best_effort' as const)
.exhaustive(),
},
})
})

return (
<form onSubmit={onSubmit}>
<CardBlock>
<CardBlock.Header
title="Auto-restart"
description="The auto-restart policy for this instance"
/>
<CardBlock.Body>
<ListboxField
control={form.control}
name="autoRestartPolicy"
label="Policy"
description="The global default is currently best effort, but this may change in the future."
items={restartPolicyItems}
required
className="max-w-lg"
/>
<FormMeta
label="Cooldown expiration"
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."
>
{instance.autoRestartCooldownExpiration ? (
<>
{toLocaleDateTimeString(instance.autoRestartCooldownExpiration)}{' '}
{instance.runState === 'failed' && instance.autoRestartEnabled && (
<span className="text-tertiary">
(
{instanceAutoRestartingSoon(instance)
? 'restarting soon'
: formatDistanceToNow(instance.autoRestartCooldownExpiration)}
)
</span>
)}
</>
) : (
<span className="text-tertiary">N/A</span>
)}
</FormMeta>
<FormMeta
label="Last auto-restarted"
tip="When this instance was last automatically restarted. N/A if never auto-restarted."
>
{instance.timeLastAutoRestarted ? (
toLocaleDateTimeString(instance.timeLastAutoRestarted)
) : (
<span className="text-tertiary">N/A</span>
)}
</FormMeta>
</CardBlock.Body>
<CardBlock.Footer>
<LearnMore href={links.instanceUpdateDocs} text="Auto-Restart" />
<Button size="sm" type="submit" disabled={disableSubmit}>
Save
</Button>
</CardBlock.Footer>
</CardBlock>
</form>
)
}

type FormMetaProps = {
label: string
tip?: string
children: ReactNode
}

const FormMeta = ({ label, tip, children }: FormMetaProps) => (
<div>
<div className="mb-2 flex items-center gap-1 border-b pb-2 text-sans-md border-secondary">
<div>{label}</div>
{tip && <TipIcon>{tip}</TipIcon>}
</div>
{children}
</div>
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately git can't tell this is almost entirely a move. The only change is taking off width="medium" and putting max-w-lg on the ListboxField.

Loading
Loading