diff --git a/app/pages/project/floating-ips/AttachFloatingIpModal.tsx b/app/pages/project/floating-ips/AttachFloatingIpModal.tsx new file mode 100644 index 000000000..43ce1c090 --- /dev/null +++ b/app/pages/project/floating-ips/AttachFloatingIpModal.tsx @@ -0,0 +1,100 @@ +/* + * 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 { useForm } from 'react-hook-form' + +import { useApiMutation, useApiQueryClient, type FloatingIp, type Instance } from '~/api' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' + +function FloatingIpLabel({ fip }: { fip: FloatingIp }) { + return ( +
+
{fip.name}
+
+
{fip.ip}
+ {fip.description && ( + <> + / +
+ {fip.description} +
+ + )} +
+
+ ) +} + +export const AttachFloatingIpModal = ({ + floatingIps, + instance, + project, + onDismiss, +}: { + floatingIps: Array + instance: Instance + project: string + onDismiss: () => void +}) => { + const queryClient = useApiQueryClient() + const floatingIpAttach = useApiMutation('floatingIpAttach', { + onSuccess() { + queryClient.invalidateQueries('floatingIpList') + queryClient.invalidateQueries('instanceExternalIpList') + addToast({ content: 'Your floating IP has been attached' }) + onDismiss() + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + const form = useForm({ defaultValues: { floatingIp: '' } }) + const floatingIp = form.watch('floatingIp') + + return ( + + + + +
+ ({ + value: ip.id, + label: , + labelString: ip.name, + }))} + required + /> + +
+
+ + floatingIpAttach.mutate({ + path: { floatingIp }, + query: { project }, + body: { kind: 'instance', parent: instance.id }, + }) + } + onDismiss={onDismiss} + > +
+ ) +} diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 1466307c5..a4641dc33 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -238,7 +238,7 @@ const AttachFloatingIpModal = ({ const form = useForm({ defaultValues: { instanceId: '' } }) return ( - + Storage Metrics - Network Interfaces + Networking Connect diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 91fa54c83..f4b877dbe 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -5,8 +5,8 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useState } from 'react' +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' import { type LoaderFunctionArgs } from 'react-router-dom' import { @@ -16,27 +16,29 @@ import { useApiQuery, useApiQueryClient, usePrefetchedApiQuery, + type ExternalIp, type InstanceNetworkInterface, } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { HL } from '~/components/HL' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' import { EditNetworkInterfaceForm } from '~/forms/network-interface-edit' -import { - getInstanceSelector, - useInstanceSelector, - useProjectSelector, - useToast, -} from '~/hooks' +import { getInstanceSelector, useInstanceSelector, useProjectSelector } from '~/hooks' +import { AttachFloatingIpModal } from '~/pages/project/floating-ips/AttachFloatingIpModal' +import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' -import { SkeletonCell } from '~/table/cells/EmptyCell' +import { addToast } from '~/stores/toast' +import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable' +import { Columns, DescriptionCell } from '~/table/columns/common' +import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableControls, TableEmptyBox, TableTitle } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' import { pb } from '~/util/path-builder' import { fancifyStates } from './common' @@ -75,7 +77,16 @@ NetworkingTab.loader = async ({ params }: LoaderFunctionArgs) => { const { project, instance } = getInstanceSelector(params) await Promise.all([ apiQueryClient.prefetchQuery('instanceNetworkInterfaceList', { - query: { project, instance, limit: 25 }, + // we want this to cover all NICs; TODO: determine actual limit? + query: { project, instance, limit: 1000 }, + }), + apiQueryClient.prefetchQuery('floatingIpList', { + query: { project, limit: 1000 }, + }), + // dupe of page-level fetch but that's fine, RQ dedupes + apiQueryClient.prefetchQuery('instanceExternalIpList', { + path: { instance }, + query: { project }, }), // This is covered by the InstancePage loader but there's no downside to // being redundant. If it were removed there, we'd still want it here. @@ -89,17 +100,17 @@ NetworkingTab.loader = async ({ params }: LoaderFunctionArgs) => { const colHelper = createColumnHelper() const staticCols = [ - colHelper.accessor((i) => ({ name: i.name, primary: i.primary }), { + colHelper.accessor('name', { header: 'name', cell: (info) => ( <> - {info.getValue().name} - {info.getValue().primary ? primary : null} + {info.getValue()} + {info.row.original.primary && primary} ), }), colHelper.accessor('description', Columns.description), - colHelper.accessor('ip', {}), + colHelper.accessor('ip', { header: 'Private IP' }), colHelper.accessor('vpcId', { header: 'vpc', cell: (info) => , @@ -114,12 +125,20 @@ const updateNicStates = fancifyStates(instanceCan.updateNic.states) export function NetworkingTab() { const instanceSelector = useInstanceSelector() + const { instance: instanceName, project } = instanceSelector const queryClient = useApiQueryClient() - const addToast = useToast() const [createModalOpen, setCreateModalOpen] = useState(false) const [editing, setEditing] = useState(null) + const [attachModalOpen, setAttachModalOpen] = useState(false) + + // Fetch the floating IPs to show in the "Attach floating IP" modal + const { data: ips } = usePrefetchedApiQuery('floatingIpList', { + query: { project, limit: 1000 }, + }) + // Filter out the IPs that are already attached to an instance + const availableIps = useMemo(() => ips.items.filter((ip) => !ip.instanceId), [ips]) const createNic = useApiMutation('instanceNetworkInterfaceCreate', { onSuccess() { @@ -127,14 +146,12 @@ export function NetworkingTab() { setCreateModalOpen(false) }, }) - const deleteNic = useApiMutation('instanceNetworkInterfaceDelete', { onSuccess() { queryClient.invalidateQueries('instanceNetworkInterfaceList') addToast({ content: 'Network interface deleted' }) }, }) - const editNic = useApiMutation('instanceNetworkInterfaceUpdate', { onSuccess() { queryClient.invalidateQueries('instanceNetworkInterfaceList') @@ -142,8 +159,8 @@ export function NetworkingTab() { }) const { data: instance } = usePrefetchedApiQuery('instanceView', { - path: { instance: instanceSelector.instance }, - query: { project: instanceSelector.project }, + path: { instance: instanceName }, + query: { project }, }) const canUpdateNic = instanceCan.updateNic(instance) @@ -197,54 +214,169 @@ export function NetworkingTab() { [canUpdateNic, deleteNic, editNic, instanceSelector] ) - const emptyState = ( - } - title="No network interfaces" - body="You need to create a network interface to be able to see it here" - /> + const columns = useColsWithActions(staticCols, makeActions) + + const rows = usePrefetchedApiQuery('instanceNetworkInterfaceList', { + query: { ...instanceSelector, limit: 1000 }, + }).data.items + + const tableInstance = useReactTable({ + columns, + data: rows || [], + getCoreRowModel: getCoreRowModel(), + }) + + // Attached IPs Table + const { data: eips } = usePrefetchedApiQuery('instanceExternalIpList', { + path: { instance: instanceName }, + query: { project }, + }) + + const ipColHelper = createColumnHelper() + const staticIpCols = [ + ipColHelper.accessor('ip', {}), + ipColHelper.accessor('kind', { + header: () => ( + <> + Kind + + Floating IPs can be detached from this instance and attached to another. + + + ), + cell: (info) => {info.getValue()}, + }), + ipColHelper.accessor('name', { + cell: (info) => (info.getValue() ? info.getValue() : ), + }), + ipColHelper.accessor((row) => ('description' in row ? row.description : undefined), { + header: 'description', + cell: (info) => , + }), + ] + + const floatingIpDetach = useApiMutation('floatingIpDetach', { + onSuccess() { + queryClient.invalidateQueries('floatingIpList') + queryClient.invalidateQueries('instanceExternalIpList') + addToast({ content: 'Your floating IP has been detached' }) + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + + const makeIpActions = useCallback( + (externalIp: ExternalIp): MenuAction[] => { + const copyAction = { + label: 'Copy IP address', + onActivate: () => { + window.navigator.clipboard.writeText(externalIp.ip) + }, + } + + if (externalIp.kind === 'floating') { + return [ + copyAction, + { + label: 'Detach', + onActivate: () => + confirmAction({ + actionType: 'danger', + doAction: () => + floatingIpDetach.mutateAsync({ + path: { floatingIp: externalIp.name }, + query: { project }, + }), + modalTitle: 'Detach Floating IP', + modalContent: ( +

+ Are you sure you want to detach floating IP {externalIp.name}?{' '} + The instance {instanceName} will no longer be reachable at{' '} + {externalIp.ip}. +

+ ), + errorTitle: 'Error detaching floating IP', + }), + }, + ] + } + return [copyAction] + }, + [floatingIpDetach, instanceName, project] ) - const { Table } = useQueryTable('instanceNetworkInterfaceList', { - query: instanceSelector, + const ipTableInstance = useReactTable({ + columns: useColsWithActions(staticIpCols, makeIpActions), + data: eips?.items || [], + getCoreRowModel: getCoreRowModel(), }) - const columns = useColsWithActions(staticCols, makeActions) + const disabledReason = + eips.items.length >= 32 + ? 'IP address limit of 32 reached for this instance' + : availableIps.length === 0 + ? 'No available floating IPs' + : null return ( <> -

- Network Interfaces -

- -
-
- -
- {!canUpdateNic && ( - - A network interface cannot be created or edited unless the instance is{' '} - {updateNicStates}. - + + External IPs + + {attachModalOpen && ( + setAttachModalOpen(false)} + project={project} + /> + )} + +
+ + + Network interfaces + + {createModalOpen && ( + setCreateModalOpen(false)} + onSubmit={(body) => createNic.mutate({ query: instanceSelector, body })} + submitError={createNic.error} + /> )} - - - {createModalOpen && ( - setCreateModalOpen(false)} - onSubmit={(body) => createNic.mutate({ query: instanceSelector, body })} - submitError={createNic.error} - /> + + {rows?.length && rows.length > 0 ? ( +
+ ) : ( + + } + title="No network interfaces" + body="You need to create a network interface to be able to see it here" + /> + )} + {editing && ( setEditing(null)} /> )} diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index e0e3ae7b9..cdf3f066e 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -53,7 +53,7 @@ StorageTab.loader = async ({ params }: LoaderFunctionArgs) => { const colHelper = createColumnHelper() const staticCols = [ - colHelper.accessor('name', {}), + colHelper.accessor('name', { header: 'Disk' }), colHelper.accessor('size', Columns.size), colHelper.accessor((row) => row.state.state, { header: 'status', @@ -168,12 +168,7 @@ export function StorageTab() { return ( <> -

- Disks -

- {/* TODO: need 40px high rows. another table or a flag on Table (ew) */} - -
+