diff --git a/app/api/util.ts b/app/api/util.ts index 22f61843f3..69594e9b2a 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -30,6 +30,8 @@ import type { VpcFirewallRuleUpdate, } from './__generated__/Api' +export { snakeify } from './__generated__/util' + // API limits encoded in https://github.com/oxidecomputer/omicron/blob/9dd23096de93c7d6d05ea21f6323de4410060652/nexus/src/app/mod.rs#L142 // These are not actually used in app code, just the mock server. In the app we diff --git a/app/components/AlertPayload.tsx b/app/components/AlertPayload.tsx index be11cf33eb..797594505f 100644 --- a/app/components/AlertPayload.tsx +++ b/app/components/AlertPayload.tsx @@ -31,7 +31,7 @@ export function AlertPayload({ payload }: { payload: Record })
-
+        
           
         
diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 3f0da93486..0221b7d1f0 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -11,6 +11,7 @@ import { useState, type ReactNode } from 'react' import { Success12Icon } from '@oxide/design-system/icons/react' import { Button } from '~/ui/lib/Button' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { Modal } from '~/ui/lib/Modal' import { useTimeout } from '~/ui/lib/use-timeout' @@ -114,3 +115,65 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { ) } + +type CodeBlock = { + label: string + copyAriaLabel: string + /** Plain text that gets copied to the clipboard */ + code: string + /** Optional rendered representation; falls back to `code` */ + rendered?: ReactNode +} + +type CliCommandModalProps = { + isOpen: boolean + onDismiss: () => void + title: string + description?: ReactNode + blocks: [CodeBlock, ...CodeBlock[]] + footer?: ReactNode +} + +/** + * Modal that stacks one or more code blocks, each with its own copy-to-clipboard + * button. + */ +export function CliCommandModal({ + isOpen, + onDismiss, + title, + description, + blocks, + footer, +}: CliCommandModalProps) { + return ( + + + {description && ( + +
{description}
+
+ )} + {blocks.map((block) => ( + +
+
{block.label}
+ +
+
+
{block.rendered ?? block.code}
+
+
+ ))} +
+ + {footer} + +
+ ) +} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index b37ae653af..4926623241 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -24,6 +24,7 @@ import { poolHasIpVersion, q, queryClient, + snakeify, useApiMutation, usePrefetchedQuery, type ExternalIpCreate, @@ -45,6 +46,7 @@ import { Storage16Icon, } from '@oxide/design-system/icons/react' +import { CliCommandModal } from '~/components/CopyCode' import { DocsPopover } from '~/components/DocsPopover' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { ComboboxField } from '~/components/form/fields/ComboboxField' @@ -63,6 +65,7 @@ import { RadioFieldDyn } from '~/components/form/fields/RadioField' import { SshKeysField } from '~/components/form/fields/SshKeysField' import { Form } from '~/components/form/Form' import { FullPageForm } from '~/components/form/FullPageForm' +import { HighlightJSON } from '~/components/HighlightJSON' import { HL } from '~/components/HL' import { toPoolItem } from '~/components/PoolListboxItem' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' @@ -71,6 +74,7 @@ import { Button } from '~/ui/lib/Button' import { toComboboxItems } from '~/ui/lib/Combobox' import { FormDivider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { InlineCode } from '~/ui/lib/InlineCode' import { ItemLabel } from '~/ui/lib/ItemLabel' import { Listbox } from '~/ui/lib/Listbox' import { Message } from '~/ui/lib/Message' @@ -128,6 +132,62 @@ const getBootDiskAttachment = ( } } +const buildInstanceCreateBody = ( + values: InstanceCreateInput, + images: Array, + userData: string | undefined +): InstanceCreate => { + // we should never have a presetId that's not in the list + const preset = PRESETS.find((option) => option.id === values.presetId)! + const { memory, ncpus } = + values.presetId === 'custom' + ? { memory: values.memory, ncpus: values.ncpus } + : { memory: preset.memory, ncpus: preset.ncpus } + + const externalIps: ExternalIpCreate[] = [] + if (values.ephemeralIpv4) { + externalIps.push({ + type: 'ephemeral', + poolSelector: { type: 'explicit', pool: values.ephemeralIpv4Pool }, + }) + } + if (values.ephemeralIpv6) { + externalIps.push({ + type: 'ephemeral', + poolSelector: { type: 'explicit', pool: values.ephemeralIpv6Pool }, + }) + } + for (const floatingIp of values.floatingIps) { + externalIps.push({ type: 'floating', floatingIp }) + } + + return { + name: values.name, + hostname: values.name, + description: values.description, + memory: memory * GiB, + ncpus, + disks: values.otherDisks.map( + (d): InstanceDiskAttachment => + d.action === 'attach' + ? { type: 'attach', name: d.name } + : { + type: 'create', + name: d.name, + description: d.description, + size: d.size, + diskBackend: d.diskBackend, + } + ), + bootDisk: getBootDiskAttachment(values, images), + externalIps, + start: values.start, + networkInterfaces: values.networkInterfaces, + sshPublicKeys: values.sshPublicKeys, + userData, + } +} + type BootDiskSourceType = 'siloImage' | 'projectImage' | 'disk' export type InstanceCreateInput = Assign< @@ -534,6 +594,34 @@ export default function CreateInstanceForm() { const bootDiskName = useWatch({ control, name: 'bootDiskName' }) + const [cliModal, setCliModal] = useState<{ + open: boolean + body: InstanceCreate | null + command: string + }>({ open: false, body: null, command: '' }) + + // snakeify only at the display boundary so state keeps the typed body. + // memoized because HighlightJSON's memo needs a stable object identity + const snakeBody = useMemo(() => snakeify(cliModal.body), [cliModal.body]) + + const openCliModal = form.handleSubmit((values) => { + // surface validation errors inline before opening the preview, so the + // generated JSON reflects a valid configuration + + // userData is a File; the CLI consumes the raw base64. Show a placeholder + // string instead of synchronously reading the file. + const userDataPlaceholder = values.userData + ? '' + : undefined + const body = buildInstanceCreateBody(values, allImages, userDataPlaceholder) + const command = [ + 'oxide instance create', + `--project ${project}`, + '--json-body instance.json', + ].join(' \\\n ') + setCliModal({ open: true, body, command }) + }) + return ( <> @@ -551,63 +639,12 @@ export default function CreateInstanceForm() { form={form} onSubmit={async (values) => { setIsSubmitting(true) - // we should never have a presetId that's not in the list - const preset = PRESETS.find((option) => option.id === values.presetId)! - const instance = - values.presetId === 'custom' - ? { memory: values.memory, ncpus: values.ncpus } - : { memory: preset.memory, ncpus: preset.ncpus } - - const bootDisk = getBootDiskAttachment(values, allImages) - - const externalIps: ExternalIpCreate[] = [] - if (values.ephemeralIpv4) { - externalIps.push({ - type: 'ephemeral', - poolSelector: { type: 'explicit', pool: values.ephemeralIpv4Pool }, - }) - } - if (values.ephemeralIpv6) { - externalIps.push({ - type: 'ephemeral', - poolSelector: { type: 'explicit', pool: values.ephemeralIpv6Pool }, - }) - } - for (const floatingIp of values.floatingIps) { - externalIps.push({ type: 'floating', floatingIp }) - } - const userData = values.userData ? await readBlobAsBase64(values.userData) : undefined - await createInstance.mutateAsync({ query: { project }, - body: { - name: values.name, - hostname: values.name, - description: values.description, - memory: instance.memory * GiB, - ncpus: instance.ncpus, - disks: values.otherDisks.map( - (d): InstanceDiskAttachment => - d.action === 'attach' - ? { type: 'attach', name: d.name } - : { - type: 'create', - name: d.name, - description: d.description, - size: d.size, - diskBackend: d.diskBackend, - } - ), - bootDisk, - externalIps, - start: values.start, - networkInterfaces: values.networkInterfaces, - sshPublicKeys: values.sshPublicKeys, - userData, - }, + body: buildInstanceCreateBody(values, allImages, userData), }) }} loading={createInstance.isPending || createInstance.isSuccess} @@ -844,8 +881,47 @@ export default function CreateInstanceForm() { Create instance navigate(pb.instances({ project }))} /> + + setCliModal((s) => ({ ...s, open: false }))} + title="CLI command" + description={ + <> + Save the JSON below as instance.json, then run the + command in the same directory to create this instance from the CLI. + + } + blocks={[ + { + label: 'instance.json', + copyAriaLabel: 'Copy instance JSON', + code: JSON.stringify(snakeBody, null, 2), + rendered: , + }, + { + label: 'command', + copyAriaLabel: 'Copy CLI command', + code: cliModal.command, + rendered: ( + <> + $ + {cliModal.command} + + ), + }, + ]} + /> ) } diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 9ba5e3c21f..bf9b763585 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -800,7 +800,7 @@ const ExpandedItem = ({
-
+