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
12 changes: 7 additions & 5 deletions app/pages/project/disks/DisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { DiskStatusBadge } from '~/components/StatusBadge'
import { getProjectSelector, useProjectSelector, useToast } from '~/hooks'
import { confirmDelete } from '~/stores/confirm-delete'
import { DateCell } from '~/table/cells/DateCell'
import { defaultCell } from '~/table/cells/DefaultCell'
import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell'
import { SizeCell } from '~/table/cells/SizeCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
Expand Down Expand Up @@ -74,13 +73,16 @@ DisksPage.loader = async ({ params }: LoaderFunctionArgs) => {
const colHelper = createColumnHelper<Disk>()

const staticCols = [
colHelper.accessor('name', { cell: defaultCell }),
colHelper.accessor('name', {}),
// sneaky: rather than looking at particular states, just look at
// whether it has an instance field
colHelper.accessor((disk) => ('instance' in disk.state ? disk.state.instance : null), {
colHelper.accessor(
(disk) => ('instance' in disk.state ? disk.state.instance : undefined),
{
header: 'Attached to',
cell: (props) => <InstanceLinkCell value={props.getValue()} />,
}),
cell: (props) => <InstanceLinkCell instanceId={props.getValue()} />,
}
),
colHelper.accessor('size', { cell: (props) => <SizeCell value={props.getValue()} /> }),
colHelper.accessor('state.state', {
header: 'Status',
Expand Down
71 changes: 44 additions & 27 deletions app/pages/project/floating-ips/FloatingIpsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
*
* Copyright Oxide Computer Company
*/
import { useState } from 'react'
import { createColumnHelper } from '@tanstack/react-table'
import { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom'

Expand All @@ -26,8 +27,9 @@ import { confirmAction } from '~/stores/confirm-action'
import { confirmDelete } from '~/stores/confirm-delete'
import { addToast } from '~/stores/toast'
import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell'
import type { MenuAction } from '~/table/columns/action-col'
import { useQueryTable } from '~/table/QueryTable'
import { makeLinkCell } from '~/table/cells/LinkCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { useQueryTable2 } from '~/table/QueryTable2'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Listbox } from '~/ui/lib/Listbox'
import { Message } from '~/ui/lib/Message'
Expand Down Expand Up @@ -68,8 +70,6 @@ export function FloatingIpsPage() {
query: { project },
})
const navigate = useNavigate()
const getInstanceName = (instanceId: string) =>
instances.items.find((i) => i.id === instanceId)?.name

const floatingIpDetach = useApiMutation('floatingIpDetach', {
onSuccess() {
Expand All @@ -88,7 +88,33 @@ export function FloatingIpsPage() {
},
})

const makeActions = (floatingIp: FloatingIp): MenuAction[] => {
const colHelper = createColumnHelper<FloatingIp>()

const staticCols = [
colHelper.accessor('name', {
cell: makeLinkCell((name) => pb.floatingIp({ floatingIp: name, project })),
}),
colHelper.accessor('description', {}),
colHelper.accessor('ip', {}),
colHelper.accessor('instanceId', {
cell: (props) => <InstanceLinkCell instanceId={props.getValue()} />,
header: 'Attached to instance',
}),
]

const makeActions = useCallback(
(floatingIp: FloatingIp): MenuAction[] => {
const instanceName = floatingIp.instanceId
? instances.items.find((i) => i.id === floatingIp.instanceId)?.name
: undefined
// handling the rather unlikely case where the instance is not in the 1000 we fetched
const fromInstance = instanceName ? (
<>
{' ' /* important */}
from instance <HL>{instanceName}</HL>
</>
) : null

const isAttachedToAnInstance = !!floatingIp.instanceId
const attachOrDetachAction = isAttachedToAnInstance
? {
Expand All @@ -102,17 +128,12 @@ export function FloatingIpsPage() {
query: { project },
}),
modalTitle: 'Detach Floating IP',
// instanceName! non-null because we only see this if there is an instance
modalContent: (
<p>
Are you sure you want to detach floating IP <HL>{floatingIp.name}</HL>{' '}
from instance{' '}
<HL>
{
// instanceId is guaranteed to be non-null here
getInstanceName(floatingIp.instanceId!)
}
</HL>
? The instance will no longer be reachable at <HL>{floatingIp.ip}</HL>.
Are you sure you want to detach floating IP <HL>{floatingIp.name}</HL>
{fromInstance}? The instance will no longer be reachable at{' '}
<HL>{floatingIp.ip}</HL>.
</p>
),
errorTitle: 'Error detaching floating IP',
Expand Down Expand Up @@ -155,9 +176,14 @@ export function FloatingIpsPage() {
}),
},
]
}
},
[deleteFloatingIp, floatingIpDetach, navigate, project, instances]
)

const { Table } = useQueryTable2('floatingIpList', { query: { project } })

const columns = useColsWithActions(staticCols, makeActions)

const { Table, Column } = useQueryTable('floatingIpList', { query: { project } })
return (
<>
<PageHeader>
Expand All @@ -173,16 +199,7 @@ export function FloatingIpsPage() {
New Floating IP
</TableControlsLink>
</TableControls>
<Table emptyState={<EmptyState />} makeActions={makeActions}>
<Column accessor="name" />
<Column accessor="description" />
<Column accessor="ip" />
<Column
accessor="instanceId"
header="Attached to instance"
cell={InstanceLinkCell}
/>
</Table>
<Table emptyState={<EmptyState />} columns={columns} />
<Outlet />
{floatingIpToModify && (
<AttachFloatingIpModal
Expand Down
3 changes: 1 addition & 2 deletions app/pages/system/networking/IpPoolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { IpUtilCell } from '~/components/IpPoolUtilization'
import { useQuickActions } from '~/hooks'
import { confirmDelete } from '~/stores/confirm-delete'
import { DateCell } from '~/table/cells/DateCell'
import { defaultCell } from '~/table/cells/DefaultCell'
import { SkeletonCell } from '~/table/cells/EmptyCell'
import { makeLinkCell } from '~/table/cells/LinkCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
Expand Down Expand Up @@ -53,7 +52,7 @@ const colHelper = createColumnHelper<IpPool>()

const staticColumns = [
colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }),
colHelper.accessor('description', { cell: defaultCell }),
colHelper.accessor('description', {}),
colHelper.accessor('name', {
id: 'Utilization',
header: 'Utilization',
Expand Down
61 changes: 35 additions & 26 deletions app/pages/system/silos/SiloIpPoolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* Copyright Oxide Computer Company
*/

import { useMemo, useState } from 'react'
import { createColumnHelper } from '@tanstack/react-table'
import { useCallback, useMemo, useState } from 'react'

import { useApiMutation, useApiQuery, useApiQueryClient, type SiloIpPool } from '@oxide/api'
import { Networking24Icon, Success12Icon } from '@oxide/design-system/icons/react'
Expand All @@ -17,9 +18,9 @@ import { HL } from '~/components/HL'
import { useForm, useSiloSelector } from '~/hooks'
import { confirmAction } from '~/stores/confirm-action'
import { addToast } from '~/stores/toast'
import { linkCell } from '~/table/cells/LinkCell'
import type { MenuAction } from '~/table/columns/action-col'
import { useQueryTable } from '~/table/QueryTable'
import { makeLinkCell } from '~/table/cells/LinkCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { useQueryTable2 } from '~/table/QueryTable2'
import { Badge } from '~/ui/lib/Badge'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Message } from '~/ui/lib/Message'
Expand All @@ -38,10 +39,28 @@ const EmptyState = () => (
/>
)

const DefaultBadge = () => (
<>
<Success12Icon className="mr-1 text-accent" />
<Badge>default</Badge>
</>
)

const colHelper = createColumnHelper<SiloIpPool>()

const staticCols = [
colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }),
colHelper.accessor('description', {}),
colHelper.accessor('isDefault', {
header: 'Default',
cell: (props) => (props.getValue() ? <DefaultBadge /> : null),
}),
]

export function SiloIpPoolsTab() {
const { silo } = useSiloSelector()
const [showLinkModal, setShowLinkModal] = useState(false)
const { Table, Column } = useQueryTable('siloIpPoolList', { path: { silo } })
const { Table } = useQueryTable2('siloIpPoolList', { path: { silo } })
const queryClient = useApiQueryClient()

// Fetch 1000 to we can be sure to get them all. There should only be a few
Expand Down Expand Up @@ -71,7 +90,8 @@ export function SiloIpPoolsTab() {
})

// this is all very extra. I'm sorry. it's for the users
const makeActions = (pool: SiloIpPool): MenuAction[] => [
const makeActions = useCallback(
(pool: SiloIpPool): MenuAction[] => [
{
label: pool.isDefault ? 'Clear default' : 'Make default',
className: pool.isDefault ? 'destructive' : undefined,
Expand Down Expand Up @@ -130,17 +150,21 @@ export function SiloIpPoolsTab() {
modalTitle: `Confirm unlink pool`,
modalContent: (
<p>
Are you sure you want to unlink <HL>{pool.name}</HL>? Users in this silo will
no longer be able to allocate IPs from this pool. Unlink will fail if there
are any IPs from <HL>{pool.name}</HL> in use in this silo.
Are you sure you want to unlink <HL>{pool.name}</HL>? Users in this silo
will no longer be able to allocate IPs from this pool. Unlink will fail if
there are any IPs from <HL>{pool.name}</HL> in use in this silo.
</p>
),
errorTitle: `Could not unlink pool`,
actionType: 'danger',
})
},
},
]
],
[defaultPool, silo, unlinkPool, updatePoolLink]
)

const columns = useColsWithActions(staticCols, makeActions)

return (
<>
Expand All @@ -155,22 +179,7 @@ export function SiloIpPoolsTab() {
Link pool
</TableControlsButton>
</TableControls>
<Table emptyState={<EmptyState />} makeActions={makeActions}>
<Column accessor="name" cell={linkCell((pool) => pb.ipPool({ pool }))} />
<Column accessor="description" />
<Column
accessor="isDefault"
header="Default"
cell={({ value }) =>
value && (
<>
<Success12Icon className="mr-1 text-accent" />
<Badge>default</Badge>
</>
)
}
/>
</Table>
<Table emptyState={<EmptyState />} columns={columns}></Table>
{showLinkModal && <LinkPoolModal onDismiss={() => setShowLinkModal(false)} />}
</>
)
Expand Down
6 changes: 0 additions & 6 deletions app/table/cells/DefaultCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,8 @@
*
* Copyright Oxide Computer Company
*/
import type { CellContext } from '@tanstack/react-table'

import type { Cell } from './Cell'

export const DefaultCell = ({ value }: Cell<string>) => (
<span className="text-secondary">{value}</span>
)

export const defaultCell = <T, U extends React.ReactNode>(props: CellContext<T, U>) => (
<span className="text-secondary">{props.getValue()}</span>
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Turns out we don't need this. Worth keeping an eye out for situations where it behaves differently because it's not in a span, but that seems pretty unlikely.

& th,
& td {
min-width: fit-content;
white-space: nowrap;
@apply text-secondary;
}

3 changes: 2 additions & 1 deletion app/table/cells/InstanceLinkCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import { pb } from '~/util/path-builder'
import { SkeletonCell } from './EmptyCell'
import { LinkCell } from './LinkCell'

export const InstanceLinkCell = ({ value: instanceId }: { value: string | null }) => {
export const InstanceLinkCell = ({ instanceId }: { instanceId?: string }) => {
const { project } = useProjectSelector()
const { data: instance } = useApiQuery(
'instanceView',
{ path: { instance: instanceId! } },
{ enabled: !!instanceId }
)

// has to be after the hooks because hooks can't be executed conditionally
if (!instanceId) return null
if (!instance) return <SkeletonCell />

Expand Down
6 changes: 6 additions & 0 deletions app/table/cells/LinkCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export const linkCell =
return <LinkCell to={makeHref(value)}>{value}</LinkCell>
}

/**
* Because this returns a component, it should only be used in a static context
* or memoized with useCallback. It should not be used unmemoized inside the
* render loop. It's probably better to inline the contents directly at the call
* site if it needs to be called inside render.
*/
export const makeLinkCell =
(makeHref: (value: string) => string) =>
<T, U extends string>(props: CellContext<T, U>) => {
Expand Down