diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index d4caa535a3..15187abe37 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -98,8 +98,8 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { return ( <> - @@ -236,7 +236,7 @@ export function TimeSeriesChart({ width={width} height={height} data={data} - margin={{ top: 0, right: hasBorder ? 16 : 0, bottom: 16, left: 0 }} + margin={{ top: 0, right: hasBorder ? 16 : 0, bottom: 0, left: 0 }} > -
+

{title}
@@ -113,7 +113,7 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric

-
+
- - - Serial console - Connect to your instance’s serial console - - -
- -
-
- - - Connect - -
-
-
- - - SSH -

- If your instance allows SSH access, connect with{' '} - ssh [username]@{externalIp || '[external IP]'}. -

- {!externalIp && ( -

- This instance has no external IP address. You can add one on the{' '} - - networking - {' '} - tab. -

- )} -
- -
- -
-
-
+
+ + + + + Connect + + + + + + + + + +
+ If your instance allows SSH access, connect with{' '} + ssh [username]@{externalIp || '[external IP]'}. +
+ {!externalIp && ( +
+ This instance has no external IP address. You can add one on the{' '} + + networking + {' '} + tab. +
+ )} + + } + /> + + + +
) } diff --git a/app/pages/project/instances/DiskMetricsTab.tsx b/app/pages/project/instances/DiskMetricsTab.tsx index db976e1ab2..ca81fe8eef 100644 --- a/app/pages/project/instances/DiskMetricsTab.tsx +++ b/app/pages/project/instances/DiskMetricsTab.tsx @@ -50,7 +50,7 @@ export default function DiskMetricsTab() { }) if (disks.items.length === 0) { return ( - + } title="No disk metrics available" diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 0a5c685c70..de2b8326ec 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -44,10 +44,11 @@ import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' +import { Button } from '~/ui/lib/Button' +import { CardBlock } from '~/ui/lib/CardBlock' import { CopyableIp } from '~/ui/lib/CopyableIp' -import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableControls, TableEmptyBox, TableTitle } from '~/ui/lib/Table' +import { TableEmptyBox } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' @@ -390,28 +391,49 @@ export default function NetworkingTab() { : null return ( - <> - - External IPs -
- {/* - We normally wouldn't hide this button and would just have a disabled state on it, - but it is very rare for this button to be necessary, and it would be disabled - most of the time, for most users. To reduce clutter on the screen, we're hiding it. - */} - {enableEphemeralAttachButton && ( - setAttachEphemeralModalOpen(true)}> - Attach ephemeral IP - +
+ + +
+ {/* + We normally wouldn't hide this button and would just have a disabled state on it, + but it is very rare for this button to be necessary, and it would be disabled + most of the time, for most users. To reduce clutter on the screen, we're hiding it. + */} + {enableEphemeralAttachButton && ( + + )} + +
+
+ + + {eips.items.length > 0 ? ( + + ) : ( + + } + title="No external IPs" + body="Attach an external IP to see it here" + /> + )} - setAttachFloatingModalOpen(true)} - disabled={!!floatingDisabledReason} - disabledReason={floatingDisabledReason} - > - Attach floating IP - - + + {attachEphemeralModalOpen && ( setAttachEphemeralModalOpen(false)} /> )} @@ -422,33 +444,43 @@ export default function NetworkingTab() { onDismiss={() => setAttachFloatingModalOpen(false)} /> )} - - {eips.items.length > 0 ? ( -
- ) : ( - - } - title="No external IPs" - body="Attach an external IP to see it here" - /> - - )} - - - Network interfaces - setCreateModalOpen(true)} - disabled={!instanceCan.updateNic(instance)} - disabledReason={ - <> - A network interface cannot be created or edited unless the instance is{' '} - {updateNicStates}. - - } - > - Add network interface - + + + + + + + + + {nics.length > 0 ? ( +
+ ) : ( + + } + title="No network interfaces" + body="Create a network interface to see it here" + /> + + )} + + {createModalOpen && ( setCreateModalOpen(false)} @@ -456,22 +488,11 @@ export default function NetworkingTab() { submitError={createNic.error} /> )} - - {nics.length > 0 ? ( -
- ) : ( - - } - title="No network interfaces" - body="Create a network interface to see it here" - /> - - )} - {editing && ( - setEditing(null)} /> - )} - + {editing && ( + setEditing(null)} /> + )} + + ) } diff --git a/app/pages/project/instances/SettingsTab.tsx b/app/pages/project/instances/SettingsTab.tsx index 9a20cca1bd..cd5968ce54 100644 --- a/app/pages/project/instances/SettingsTab.tsx +++ b/app/pages/project/instances/SettingsTab.tsx @@ -21,8 +21,8 @@ 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 { LearnMore, SettingsGroup } from '~/ui/lib/SettingsGroup' import { TipIcon } from '~/ui/lib/TipIcon' import { toLocaleDateTimeString } from '~/util/date' import { links } from '~/util/links' @@ -91,13 +91,13 @@ export default function SettingsTab() { }) return ( -
- - - Auto-restart -

The auto-restart policy for this instance

-
- + + + + N/A )} - - -
- -
+ + + -
-
+ + ) } diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index 20d51afeff..fb58644b78 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -34,9 +34,9 @@ import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Button } from '~/ui/lib/Button' -import { CreateButton } from '~/ui/lib/CreateButton' +import { CardBlock } from '~/ui/lib/CardBlock' import { EMBody, EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableControls, TableEmptyBox, TableTitle } from '~/ui/lib/Table' +import { TableEmptyBox } from '~/ui/lib/Table' import { links } from '~/util/links' import { fancifyStates } from './common' @@ -323,53 +323,66 @@ export default function StorageTab() { }) return ( - <> - - Boot disk - - {bootDisks.length > 0 ? ( -
- ) : ( - - )} - - - Other disks - - - {otherDisks.length > 0 ? ( -
- ) : ( - - )} +
+ + + + {bootDisks.length > 0 ? ( +
+ ) : ( + + )} + + -
- setShowDiskCreate(true)} - disabledReason={ - <> - Instance must be stopped to create and - attach a disk - - } - disabled={!instanceCan.attachDisk(instance)} - > - Create disk - - -
+ + +
+ + +
+
+ + {otherDisks.length > 0 ? ( +
+ ) : ( + + )} + + {showDiskCreate && ( )} - + ) } function BootDiskEmptyState({ otherDisks }: { otherDisks: Disk[] }) { return ( - + } title="No boot disk set" @@ -437,7 +450,7 @@ function BootDiskEmptyState({ otherDisks }: { otherDisks: Disk[] }) { function OtherDisksEmptyState() { return ( - + } title="No other disks" diff --git a/app/ui/lib/CardBlock.tsx b/app/ui/lib/CardBlock.tsx new file mode 100644 index 0000000000..560898960a --- /dev/null +++ b/app/ui/lib/CardBlock.tsx @@ -0,0 +1,78 @@ +/* + * 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 cn from 'classnames' +import { type ReactNode } from 'react' + +import { OpenLink12Icon } from '@oxide/design-system/icons/react' + +import { classed } from '~/util/classed' + +type Width = 'medium' | 'full' + +const widthClass: Record = { + medium: 'w-full max-w-[740px]', + full: 'w-full', +} + +export type CardBlockProps = { + children: ReactNode + width?: Width +} + +export function CardBlock({ children, width = 'full' }: CardBlockProps) { + return ( +
+ {children} +
+ ) +} + +type HeaderProps = { + title: string + description?: ReactNode + children?: ReactNode + titleId?: string +} + +CardBlock.Header = ({ title, description, children, titleId }: HeaderProps) => ( +
+
+
+ {title} +
+ {description &&
{description}
} +
+ +
{children}
+
+) + +// If there's a table with a scrollbar we want to avoid it adding extra padding at the bottom +CardBlock.Body = classed.div`px-5 pt-4 space-y-4 [&>*:last-child[data-simplebar]]:pb-3 [&>*:last-child[data-simplebar]]:-mb-3` + +CardBlock.Footer = classed.footer`flex items-center justify-between border-t px-5 pt-4 text-secondary border-secondary` + +export const LearnMore = ({ href, text }: { href: string; text: React.ReactNode }) => ( +
+ Learn more about{' '} + + {text} + + +
+) diff --git a/app/ui/lib/EmptyMessage.tsx b/app/ui/lib/EmptyMessage.tsx index 5673cc799a..8e46af3249 100644 --- a/app/ui/lib/EmptyMessage.tsx +++ b/app/ui/lib/EmptyMessage.tsx @@ -54,4 +54,4 @@ export function EmptyMessage(props: Props) { ) } -export const EMBody = classed.p`mt-1 text-balance text-sans-md text-default` +export const EMBody = classed.p`mt-0.5 text-balance text-sans-md text-default` diff --git a/app/ui/lib/SettingsGroup.tsx b/app/ui/lib/SettingsGroup.tsx deleted file mode 100644 index b2d5bb2280..0000000000 --- a/app/ui/lib/SettingsGroup.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 { OpenLink12Icon } from '@oxide/design-system/icons/react' - -import { classed } from '~/util/classed' - -export const LearnMore = ({ href, text }: { href: string; text: React.ReactNode }) => ( -
- Learn more about{' '} - - {text} - - -
-) - -/** Use size=sm on buttons and links! */ -export const SettingsGroup = { - Container: classed.div`w-full max-w-[660px] rounded-lg border text-sans-md text-default border-default`, - Header: classed.div`border-b px-6 py-5 border-default`, - Body: classed.div`p-6 space-y-5`, - Title: classed.div`mb-1 text-sans-lg text-raise`, - Description: classed.div`text-sans-md text-default`, - Footer: classed.div`flex items-center justify-between border-t px-6 py-4 border-default min-h-14`, -} diff --git a/app/ui/lib/Table.tsx b/app/ui/lib/Table.tsx index 6b5945e7b1..c4ab3a5e13 100644 --- a/app/ui/lib/Table.tsx +++ b/app/ui/lib/Table.tsx @@ -119,7 +119,20 @@ Table.Cell = ({ height = 'small', className, children, ...props }: TableCellProp */ export const TableActions = classed.div`-mt-6 mb-3 flex justify-end gap-2` -export const TableEmptyBox = classed.div`flex h-full max-h-[480px] items-center justify-center rounded-lg border border-secondary p-4` +type TableEmptyBoxProps = { + children: React.ReactNode + border?: boolean +} + +export const TableEmptyBox = ({ children, border = true }: TableEmptyBoxProps) => ( +
+ {children} +
+) /** * Used _outside_ of the `Table`, this element includes a soon-to-be-removed description of the resource inside the table, diff --git a/app/ui/styles/components/table.css b/app/ui/styles/components/table.css index 677da8bb35..3061c67d78 100644 --- a/app/ui/styles/components/table.css +++ b/app/ui/styles/components/table.css @@ -9,6 +9,12 @@ table.ox-table { border-spacing: 0px; + --table-border-radius: var(--border-radius-lg); + + &.table-inline { + --table-border-radius: var(--border-radius); + } + /* Adds borders to first and last in a row excluding table corners @@ -80,20 +86,20 @@ table.ox-table { } & tr:last-of-type td:last-of-type.action-col > div { - border-bottom-right-radius: var(--border-radius-lg); + border-bottom-right-radius: var(--table-border-radius); } & tr:last-of-type td:first-of-type > div { - border-bottom-left-radius: var(--border-radius-lg); + border-bottom-left-radius: var(--table-border-radius); } & tr:last-of-type td:last-of-type.action-col:after { - border-bottom-right-radius: var(--border-radius-lg); + border-bottom-right-radius: var(--table-border-radius); @apply pointer-events-none absolute bottom-0 left-0 right-0 top-0 border-b border-r content-[''] border-default; } & tr:last-of-type td:last-of-type:not(.action-col) { - border-bottom-right-radius: var(--border-radius-lg); + border-bottom-right-radius: var(--table-border-radius); } & tr:last-of-type td:first-of-type { @@ -101,7 +107,7 @@ table.ox-table { } & tr:last-of-type td:first-of-type:after { - border-bottom-left-radius: var(--border-radius-lg); + border-bottom-left-radius: var(--table-border-radius); @apply pointer-events-none absolute bottom-0 left-0 right-0 top-0 border-b border-l content-[''] border-default; } @@ -110,12 +116,12 @@ table.ox-table { } & th:first-of-type > div { - border-top-left-radius: var(--border-radius-lg); + border-top-left-radius: var(--table-border-radius); @apply w-[calc(100%+1px)] bg-secondary; } & th:first-of-type:after { - border-top-left-radius: var(--border-radius-lg); + border-top-left-radius: var(--table-border-radius); @apply pointer-events-none absolute bottom-0 left-0 right-0 top-0 border-l border-t content-[''] border-default; } @@ -124,17 +130,17 @@ table.ox-table { } & th:last-of-type.action-col > div { - border-top-right-radius: var(--border-radius-lg); + border-top-right-radius: var(--table-border-radius); @apply bg-secondary; } & th:last-of-type.action-col:after { - border-top-right-radius: var(--border-radius-lg); + border-top-right-radius: var(--table-border-radius); @apply pointer-events-none absolute bottom-0 left-0 right-0 top-0 border-r border-t content-[''] border-default; } & th:last-of-type:not(.action-col) { - border-top-right-radius: var(--border-radius-lg); + border-top-right-radius: var(--table-border-radius); @apply border-r; } diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index d680fa6497..878d67557d 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -276,7 +276,7 @@ test('can’t create a disk with a name that collides with the boot disk name', await expect(bootDiskTable.getByRole('cell', { name: 'disk-11' })).toBeVisible() // Find the Other Disks table and verify that disk-12 is there - const otherDisksTable = page.getByRole('table', { name: 'Other disks' }) + const otherDisksTable = page.getByRole('table', { name: 'Additional disks' }) await expect(otherDisksTable.getByRole('cell', { name: 'disk-12' })).toBeVisible() }) @@ -593,7 +593,7 @@ test('create instance with additional disks', async ({ page }) => { await expect(bootDiskTable.getByRole('cell', { name: /^more-disks-/ })).toBeVisible() // Check for the additional disks - const otherDisksTable = page.getByRole('table', { name: 'Other disks' }) + const otherDisksTable = page.getByRole('table', { name: 'Additional disks' }) await expectRowVisible(otherDisksTable, { Disk: 'new-disk-1', size: '5 GiB' }) await expectRowVisible(otherDisksTable, { Disk: 'disk-3', size: '6 GiB' }) }) diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index 0c6a080102..f6c59b063f 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -122,7 +122,7 @@ test('Create disk', async ({ page }) => { await createForm.getByRole('button', { name: 'Create disk' }).click() - const otherDisksTable = page.getByRole('table', { name: 'Other disks' }) + const otherDisksTable = page.getByRole('table', { name: 'Additional disks' }) await expectRowVisible(otherDisksTable, { Disk: 'created-disk', size: '20 GiB' }) }) @@ -169,7 +169,7 @@ test('Change boot disk', async ({ page }) => { // assert disk-1 is boot disk, disk-2 also there const bootDiskTable = page.getByRole('table', { name: 'Boot disk' }) - const otherDisksTable = page.getByRole('table', { name: 'Other disks' }) + const otherDisksTable = page.getByRole('table', { name: 'Additional disks' }) const confirm = page.getByRole('button', { name: 'Confirm' }) const noBootDisk = page.getByText('No boot disk set') const noOtherDisks = page.getByText('No other disks')