diff --git a/app/components/RadioCard.tsx b/app/components/RadioCard.tsx new file mode 100644 index 000000000..1afeac914 --- /dev/null +++ b/app/components/RadioCard.tsx @@ -0,0 +1,95 @@ +import cn from 'classnames' +import { useId } from 'react' +import type { ReactNode } from 'react' + +/* + * 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 { Warning12Icon } from '@oxide/design-system/icons/react' + +import { RadioIndicator } from '~/ui/lib/Radio' + +/** + * A radio button styled as a full-width card with a label, description, and + * an optional notice (e.g. explaining why the option is disabled). Compose a + * list of these sharing one `name` to build a set of mutually exclusive + * options, e.g. for `extraContent` in `confirmDelete`/`confirmAction`. + */ +export function RadioCard({ + name, + checked, + onChange, + disabled, + label, + description, + notice, +}: { + name: string + checked: boolean + onChange: () => void + disabled?: boolean + label: ReactNode + description: ReactNode + notice?: ReactNode +}) { + // htmlFor + id because the a11y lint rule can't see the nested input + const id = useId() + return ( + + ) +} diff --git a/app/forms/internet-gateway-create.tsx b/app/forms/internet-gateway-create.tsx new file mode 100644 index 000000000..577e78b54 --- /dev/null +++ b/app/forms/internet-gateway-create.tsx @@ -0,0 +1,63 @@ +/* + * 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 { useNavigate } from 'react-router' + +import { api, queryClient, useApiMutation, type InternetGatewayCreate } from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useVpcSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const defaultValues: InternetGatewayCreate = { + name: '', + description: '', +} + +export const handle = titleCrumb('New Internet Gateway') + +export default function InternetGatewayCreateForm() { + const vpcSelector = useVpcSelector() + const navigate = useNavigate() + + const onDismiss = () => navigate(pb.vpcInternetGateways(vpcSelector)) + + const createGateway = useApiMutation(api.internetGatewayCreate, { + onSuccess(gateway) { + queryClient.invalidateEndpoint('internetGatewayList') + // prettier-ignore + addToast(<>Internet gateway {gateway.name} created) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + createGateway.mutate({ query: vpcSelector, body })} + loading={createGateway.isPending} + submitError={createGateway.error} + > + + + + + ) +} diff --git a/app/forms/internet-gateway-ip-address-create.tsx b/app/forms/internet-gateway-ip-address-create.tsx new file mode 100644 index 000000000..4baea59cc --- /dev/null +++ b/app/forms/internet-gateway-ip-address-create.tsx @@ -0,0 +1,107 @@ +/* + * 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 { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + queryClient, + useApiMutation, + usePrefetchedQuery, + type InternetGatewayIpAddressCreate, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { noPasswordManager, TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' +import { gatewayIpAddressList } from '~/pages/project/vpcs/gateway-data' +import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { validateIp } from '~/util/ip' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +export const handle = titleCrumb('Attach IP Address') + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getInternetGatewaySelector(params) + await queryClient.prefetchQuery(gatewayIpAddressList(selector).optionsFn()) + return null +} + +const defaultValues: InternetGatewayIpAddressCreate = { + name: '', + description: '', + address: '', +} + +const alreadyAttachedMessage = + 'Internet gateways can have at most one IP address attached. Detach the existing address before attaching another.' + +export default function InternetGatewayIpAddressCreateForm() { + const { project, vpc, gateway } = useInternetGatewaySelector() + const navigate = useNavigate() + + const { data: addresses } = usePrefetchedQuery( + gatewayIpAddressList({ project, vpc, gateway }).optionsFn() + ) + // gateways can have at most one IP address attached: the unique index is on + // internet_gateway_id alone + // https://github.com/oxidecomputer/omicron/blob/99249b4/schema/crdb/dbinit.sql#L2314-L2317 + const alreadyAttached = addresses.items.length > 0 + + const onDismiss = () => navigate(pb.vpcInternetGateway({ project, vpc, gateway })) + + const attachAddress = useApiMutation(api.internetGatewayIpAddressCreate, { + onSuccess(address) { + queryClient.invalidateEndpoint('internetGatewayIpAddressList') + // prettier-ignore + addToast(<>IP address {address.name} attached) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + attachAddress.mutate({ query: { project, vpc, gateway }, body })} + loading={attachAddress.isPending} + submitError={attachAddress.error} + submitDisabled={alreadyAttached ? alreadyAttachedMessage : undefined} + > + {alreadyAttached && } + + + + + + ) +} diff --git a/app/forms/internet-gateway-ip-pool-create.tsx b/app/forms/internet-gateway-ip-pool-create.tsx new file mode 100644 index 000000000..05427f72a --- /dev/null +++ b/app/forms/internet-gateway-ip-pool-create.tsx @@ -0,0 +1,98 @@ +/* + * 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 { useNavigate } from 'react-router' + +import { + api, + q, + queryClient, + sortPools, + useApiMutation, + usePrefetchedQuery, + type InternetGatewayIpPoolCreate, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { toPoolItem } from '~/components/PoolListboxItem' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useInternetGatewaySelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const poolList = q(api.ipPoolList, { query: { limit: ALL_ISH } }) + +export async function clientLoader() { + await queryClient.prefetchQuery(poolList) + return null +} + +export const handle = titleCrumb('Attach IP Pool') + +const defaultValues: InternetGatewayIpPoolCreate = { + name: '', + description: '', + ipPool: '', +} + +export default function InternetGatewayIpPoolCreateForm() { + const { project, vpc, gateway } = useInternetGatewaySelector() + const navigate = useNavigate() + + const { data: pools } = usePrefetchedQuery(poolList) + + const onDismiss = () => navigate(pb.vpcInternetGateway({ project, vpc, gateway })) + + const attachPool = useApiMutation(api.internetGatewayIpPoolCreate, { + onSuccess(pool) { + queryClient.invalidateEndpoint('internetGatewayIpPoolList') + // prettier-ignore + addToast(<>IP pool {pool.name} attached) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + attachPool.mutate({ query: { project, vpc, gateway }, body })} + loading={attachPool.isPending} + submitError={attachPool.error} + > + + + + + + ) +} diff --git a/app/pages/project/vpcs/InternetGatewayPage.tsx b/app/pages/project/vpcs/InternetGatewayPage.tsx new file mode 100644 index 000000000..583a8b8c8 --- /dev/null +++ b/app/pages/project/vpcs/InternetGatewayPage.tsx @@ -0,0 +1,386 @@ +/* + * 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 { useCallback, useMemo } from 'react' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { Gateway16Icon, Gateway24Icon } from '@oxide/design-system/icons/react' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type InternetGatewayIpAddress, + type InternetGatewayIpPool, +} from '~/api' +import { CopyIdItem } from '~/components/CopyIdItem' +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' +import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' +import { LinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { Table } from '~/table/Table' +import { CardBlock } from '~/ui/lib/CardBlock' +import { CopyableIp } from '~/ui/lib/CopyableIp' +import { CreateLink } from '~/ui/lib/CreateButton' +import { Divider } from '~/ui/lib/Divider' +import * as DropdownMenu from '~/ui/lib/DropdownMenu' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableEmptyBox } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { + gatewayIpAddressList, + gatewayIpPoolList, + routeList, + routerList, + useGatewayRoutes, +} from './gateway-data' +import { confirmDeleteGateway } from './gateway-delete' + +export const handle = makeCrumb((p) => p.gateway!) + +const gatewayView = ({ project, vpc, gateway }: PP.VpcInternetGateway) => + q(api.internetGatewayView, { path: { gateway }, query: { project, vpc } }) + +const siloIpPoolList = getListQFn(api.ipPoolList, { query: { limit: ALL_ISH } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getInternetGatewaySelector(params) + const { project, vpc } = selector + await Promise.all([ + queryClient.prefetchQuery(gatewayView(selector)), + queryClient.prefetchQuery(gatewayIpPoolList(selector).optionsFn()), + queryClient.prefetchQuery(gatewayIpAddressList(selector).optionsFn()), + ...(await queryClient.fetchQuery(routerList({ project, vpc }).optionsFn())).items.map( + (router) => + queryClient.prefetchQuery( + routeList({ project, vpc, router: router.name }).optionsFn() + ) + ), + queryClient.fetchQuery(siloIpPoolList.optionsFn()).then((pools) => { + for (const pool of pools.items) { + // IpPoolCell uses the errors-allowed query shape, so seed that exact + // cache entry instead of the normal ipPoolView query. + const { queryKey } = ipPoolErrorsAllowedQuery(pool.id) + queryClient.setQueryData(queryKey, { type: 'success', data: pool }) + } + }), + ] satisfies Promise[]) + return null +} + +const poolColHelper = createColumnHelper() +const addressColHelper = createColumnHelper() + +type GatewayRoute = { router: string; route: string } +const routeColHelper = createColumnHelper() + +export default function InternetGatewayPage() { + const { project, vpc, gateway } = useInternetGatewaySelector() + const navigate = useNavigate() + const { data: gatewayData } = usePrefetchedQuery(gatewayView({ project, vpc, gateway })) + const { data: gatewayIpPools } = usePrefetchedQuery( + gatewayIpPoolList({ project, vpc, gateway }).optionsFn() + ) + const { data: gatewayIpAddresses } = usePrefetchedQuery( + gatewayIpAddressList({ project, vpc, gateway }).optionsFn() + ) + const matchingRoutes = useGatewayRoutes({ project, vpc, gateway }) + + const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, { + onSuccess() { + navigate(pb.vpcInternetGateways({ project, vpc })) + queryClient.invalidateEndpoint('internetGatewayList') + // prettier-ignore + addToast(<>Internet gateway {gateway} deleted) + }, + }) + + const { mutateAsync: detachPool } = useApiMutation(api.internetGatewayIpPoolDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('internetGatewayIpPoolList') + // prettier-ignore + addToast(<>IP pool {variables.path.pool} detached) + }, + }) + + const { mutateAsync: detachAddress } = useApiMutation( + api.internetGatewayIpAddressDelete, + { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('internetGatewayIpAddressList') + // prettier-ignore + addToast(<>IP address {variables.path.address} detached) + }, + } + ) + + const makePoolActions = useCallback( + (pool: InternetGatewayIpPool): MenuAction[] => [ + { + label: 'Detach', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + detachPool({ + path: { pool: pool.name }, + query: { project, vpc, gateway }, + }), + errorTitle: 'Could not detach IP pool', + modalTitle: 'Detach IP pool', + modalContent: ( +

+ Are you sure you want to detach IP pool {pool.name} from gateway{' '} + {gateway}? +

+ ), + actionType: 'danger', + }), + }, + ], + [detachPool, project, vpc, gateway] + ) + + const makeAddressActions = useCallback( + (address: InternetGatewayIpAddress): MenuAction[] => [ + { + label: 'Detach', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + detachAddress({ + path: { address: address.name }, + query: { project, vpc, gateway }, + }), + errorTitle: 'Could not detach IP address', + modalTitle: 'Detach IP address', + modalContent: ( +

+ Are you sure you want to detach IP address {address.name} from + gateway {gateway}? +

+ ), + actionType: 'danger', + }), + }, + ], + [detachAddress, project, vpc, gateway] + ) + + const poolsTable = useReactTable({ + columns: useColsWithActions( + [ + poolColHelper.accessor('name', {}), + poolColHelper.accessor('description', Columns.description), + poolColHelper.accessor('ipPoolId', { + header: 'IP pool', + cell: (info) => , + }), + poolColHelper.accessor('timeCreated', Columns.timeCreated), + ], + makePoolActions + ), + data: gatewayIpPools.items, + getCoreRowModel: getCoreRowModel(), + }) + + const addressesTable = useReactTable({ + columns: useColsWithActions( + [ + addressColHelper.accessor('name', {}), + addressColHelper.accessor('description', Columns.description), + addressColHelper.accessor('address', { + cell: (info) => , + }), + addressColHelper.accessor('timeCreated', Columns.timeCreated), + ], + makeAddressActions + ), + data: gatewayIpAddresses.items, + getCoreRowModel: getCoreRowModel(), + }) + + const routesData = useMemo( + () => (matchingRoutes || []).map(([router, route]) => ({ router, route: route.name })), + [matchingRoutes] + ) + const routesTable = useReactTable({ + columns: useMemo( + () => [ + routeColHelper.accessor('router', { + header: 'Router', + cell: (info) => ( + + {info.getValue()} + + ), + }), + routeColHelper.accessor('route', { header: 'Route' }), + ], + [project, vpc] + ), + data: routesData, + getCoreRowModel: getCoreRowModel(), + }) + + useQuickActions( + () => [ + { + value: 'Attach IP pool', + navGroup: 'Actions', + action: pb.vpcInternetGatewayIpPoolsNew({ project, vpc, gateway }), + }, + { + value: 'Attach IP address', + navGroup: 'Actions', + action: pb.vpcInternetGatewayIpAddressesNew({ project, vpc, gateway }), + }, + ], + [project, vpc, gateway] + ) + + return ( + <> + + }>{gateway} +
+ } + summary="An internet gateway connects a VPC to the internet, using addresses from an attached IP pool or an attached IP address." + links={[docLinks.gateways]} + /> + + + + +
+
+ + + + + + + +
+ + + + Attach IP pool + + + + {gatewayIpPools.items.length > 0 ? ( + + ) : ( + + + + )} + + + + + + + Attach IP address + + + + {gatewayIpAddresses.items.length > 0 ? ( +
+ ) : ( + + + + )} + + + + + + + {routesData.length > 0 ? ( +
+ ) : ( + + + + )} + + + + + + ) +} diff --git a/app/pages/project/vpcs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcGatewaysTab.tsx index bbb095afa..5e8c30007 100644 --- a/app/pages/project/vpcs/VpcGatewaysTab.tsx +++ b/app/pages/project/vpcs/VpcGatewaysTab.tsx @@ -8,17 +8,23 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' -import { api, getListQFn, queryClient, type InternetGateway } from '~/api' +import { api, getListQFn, queryClient, useApiMutation, type InternetGateway } from '~/api' +import { HL } from '~/components/HL' +import { ListPlusOverflow } from '~/components/ListPlusCell' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' import { LinkCell, makeLinkCell } 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 { CopyableIp } from '~/ui/lib/CopyableIp' +import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TipIcon } from '~/ui/lib/TipIcon' import { ALL_ISH } from '~/util/consts' @@ -32,6 +38,7 @@ import { routerList, useGatewayRoutes, } from './gateway-data' +import { confirmDeleteGateway } from './gateway-delete' export const handle = { crumb: 'Internet Gateways' } @@ -43,14 +50,33 @@ const projectIpPoolList = getListQFn(api.ipPoolList, { const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => { const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn()) - if (!addresses || addresses.items.length < 1) return - return + const address = addresses?.items[0] + if (!address) return + return +} + +// plain pool name for the +N tooltip, where IpPoolCell's interactive button +// wouldn't be usable +const IpPoolName = ({ ipPoolId }: { ipPoolId: string }) => { + const { data: result } = useQuery(ipPoolErrorsAllowedQuery(ipPoolId)) + if (!result || result.type === 'error') return null + return
{result.data.name}
} const GatewayIpPoolCell = (gatewaySelector: PP.VpcInternetGateway) => { - const { data: gateways } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn()) - if (!gateways || gateways.items.length < 1) return - return + const { data: pools } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn()) + const [first, ...rest] = pools?.items || [] + if (!first) return + return ( +
+ + + {rest.map((pool) => ( + + ))} + +
+ ) } const GatewayRoutes = ({ project, vpc, gateway }: PP.VpcInternetGateway) => { @@ -111,12 +137,36 @@ export default function VpcInternetGatewaysTab() { ) - const columns = useMemo( + const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('internetGatewayList') + // prettier-ignore + addToast(<>Internet gateway {variables.path.gateway} deleted) + }, + }) + + const makeActions = useCallback( + (gateway: InternetGateway): MenuAction[] => [ + { + label: 'Delete', + className: 'destructive', + onActivate: confirmDeleteGateway({ + project, + vpc, + gateway: gateway.name, + deleteGateway, + }), + }, + ], + [deleteGateway, project, vpc] + ) + + const staticColumns = useMemo( () => [ colHelper.accessor('name', { cell: makeLinkCell((gateway) => pb.vpcInternetGateway({ project, vpc, gateway })), @@ -151,14 +201,32 @@ export default function VpcInternetGatewaysTab() { [project, vpc] ) + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ query: gatewayList({ project, vpc }), columns, emptyState, }) + useQuickActions( + () => [ + { + value: 'New internet gateway', + navGroup: 'Actions', + action: pb.vpcInternetGatewaysNew({ project, vpc }), + }, + ], + [project, vpc] + ) + return ( <> +
+ + New internet gateway + +
{table} diff --git a/app/pages/project/vpcs/gateway-data.ts b/app/pages/project/vpcs/gateway-data.ts index bff27bfce..99ab700e6 100644 --- a/app/pages/project/vpcs/gateway-data.ts +++ b/app/pages/project/vpcs/gateway-data.ts @@ -35,17 +35,24 @@ export function useGatewayRoutes({ project, vpc, gateway }: PP.VpcInternetGatewa const { data: routers } = usePrefetchedQuery(routerList({ project, vpc }).optionsFn()) const routerNames = routers.items.map((r) => r.name) - const routesQueries = useQueries({ + return useQueries({ queries: routerNames.map((router) => routeList({ project, vpc, router }).optionsFn()), + // combine's result is structurally shared by React Query, so the returned + // array is referentially stable across renders — required by consumers + // that use it as table data or in dep arrays + combine: (results) => { + // loading. should never happen because of prefetches + if (!results.every((q) => !!q.data)) return null + return R.pipe( + R.zip( + routerNames, + results.map((q) => q.data.items) + ), + R.flatMap(([router, routes]) => routes.map((route) => [router, route] as const)), + R.filter( + ([_, r]) => r.target.type === 'internet_gateway' && r.target.value === gateway + ) + ) + }, }) - const loadedRoutesLists = routesQueries.filter((q) => !!q.data).map((q) => q.data.items) - - // loading. should never happen because of prefetches - if (loadedRoutesLists.length < routers.items.length) return null - - return R.pipe( - R.zip(routerNames, loadedRoutesLists), - R.flatMap(([router, routes]) => routes.map((route) => [router, route] as const)), - R.filter(([_, r]) => r.target.type === 'internet_gateway' && r.target.value === gateway) - ) } diff --git a/app/pages/project/vpcs/gateway-delete.tsx b/app/pages/project/vpcs/gateway-delete.tsx new file mode 100644 index 000000000..a70c20583 --- /dev/null +++ b/app/pages/project/vpcs/gateway-delete.tsx @@ -0,0 +1,119 @@ +/* + * 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 { useId, useState } from 'react' + +import { + queryClient, + type api, + type InternetGatewayIpAddressResultsPage, + type InternetGatewayIpPoolResultsPage, +} from '~/api' +import { RadioCard } from '~/components/RadioCard' +import { confirmDelete } from '~/stores/confirm-delete' +import type * as PP from '~/util/path-params' + +import { gatewayIpAddressList, gatewayIpPoolList } from './gateway-data' + +function attachmentsNotice(numPools: number, numAddresses: number) { + const parts = [] + if (numPools > 0) parts.push(`${numPools} IP ${numPools === 1 ? 'pool' : 'pools'}`) + if (numAddresses > 0) { + parts.push(`${numAddresses} IP ${numAddresses === 1 ? 'address' : 'addresses'}`) + } + if (parts.length === 0) return undefined + return `${parts.join(' and ')} must be detached first` +} + +function GatewayCascadeChoice({ + numPools, + numAddresses, + onChange, +}: { + numPools: number + numAddresses: number + onChange: (cascade: boolean) => void +}) { + const hasAttachments = numPools > 0 || numAddresses > 0 + const [cascade, setCascade] = useState(hasAttachments) + const name = useId() + + function select(value: boolean) { + setCascade(value) + onChange(value) + } + + return ( +
+ select(false)} + disabled={hasAttachments} + label="Delete gateway only" + description="The gateway is deleted, attached resources and routes are untouched" + notice={attachmentsNotice(numPools, numAddresses)} + /> + select(true)} + label="Delete gateway and detach resources" + description="IP pools and IP addresses are detached and routes targeting this gateway are deleted" + /> +
+ ) +} + +/** + * Shared "delete internet gateway" confirmation, used by both the gateways + * table and the gateway detail page. Returns a callback suitable for + * `onActivate`/`onSelect`. + */ +export function confirmDeleteGateway({ + project, + vpc, + gateway, + deleteGateway, +}: PP.VpcInternetGateway & { + deleteGateway: ( + params: Parameters[0] + ) => Promise +}) { + return () => { + // already fetched by the pages that list a gateway's pools/addresses, so + // this reads from cache rather than triggering a new request + const pools = queryClient.getQueryData( + gatewayIpPoolList({ project, vpc, gateway }).optionsFn().queryKey + ) + const addresses = queryClient.getQueryData( + gatewayIpAddressList({ project, vpc, gateway }).optionsFn().queryKey + ) + const numPools = pools?.items.length ?? 0 + const numAddresses = addresses?.items.length ?? 0 + + // when there are attachments, the non-cascading option is disabled, so + // cascade starts out selected + let cascade = numPools > 0 || numAddresses > 0 + confirmDelete({ + doDelete: () => + deleteGateway({ path: { gateway }, query: { project, vpc, cascade } }), + label: gateway, + resourceKind: 'internet gateway', + extraContent: ( + { + cascade = value + }} + /> + ), + })() + } +} diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx deleted file mode 100644 index 5d5ac415d..000000000 --- a/app/pages/project/vpcs/internet-gateway-edit.tsx +++ /dev/null @@ -1,207 +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 { useQuery } from '@tanstack/react-query' -import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' - -import { Gateway16Icon } from '@oxide/design-system/icons/react' - -import { api, q, queryClient, usePrefetchedQuery } from '~/api' -import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' -import { titleCrumb } from '~/hooks/use-crumbs' -import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' -import { IpPoolCell } from '~/table/cells/IpPoolCell' -import { CopyableIp } from '~/ui/lib/CopyableIp' -import { FormDivider } from '~/ui/lib/Divider' -import { Message } from '~/ui/lib/Message' -import { SideModalFormDocs } from '~/ui/lib/ModalLinks' -import { PropertiesTable } from '~/ui/lib/PropertiesTable' -import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' -import { Table } from '~/ui/lib/Table' -import { docLinks } from '~/util/links' -import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' - -import { - gatewayIpAddressList, - gatewayIpPoolList, - routeList, - routerList, - useGatewayRoutes, -} from './gateway-data' - -export const handle = titleCrumb('Edit Internet Gateway') - -const RoutesEmpty = () => ( - - - No VPC router routes target this gateway. - - -) - -function RouteRows({ project, vpc, gateway }: PP.VpcInternetGateway) { - const matchingRoutes = useGatewayRoutes({ project, vpc, gateway }) - - if (!matchingRoutes) return null - if (matchingRoutes.length === 0) return - - return matchingRoutes.map(([router, route]) => ( - - - - {router} - - - {route.name} - - )) -} - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const { project, vpc, gateway } = getInternetGatewaySelector(params) - await Promise.all([ - queryClient.prefetchQuery( - q(api.internetGatewayView, { - query: { project, vpc }, - path: { gateway }, - }) - ), - queryClient.prefetchQuery(gatewayIpPoolList({ project, vpc, gateway }).optionsFn()), - queryClient.prefetchQuery(gatewayIpAddressList({ project, vpc, gateway }).optionsFn()), - ...(await queryClient.fetchQuery(routerList({ project, vpc }).optionsFn())).items.map( - (router) => - queryClient.prefetchQuery( - routeList({ project, vpc, router: router.name }).optionsFn() - ) - ), - ] satisfies Promise[]) - return null -} - -export default function EditInternetGatewayForm() { - const navigate = useNavigate() - const { project, vpc, gateway } = useInternetGatewaySelector() - const onDismiss = () => navigate(pb.vpcInternetGateways({ project, vpc })) - const { data: internetGateway } = usePrefetchedQuery( - q(api.internetGatewayView, { - query: { project, vpc }, - path: { gateway }, - }) - ) - const { data: { items: gatewayIpPools } = {} } = useQuery( - gatewayIpPoolList({ project, vpc, gateway }).optionsFn() - ) - const { data: { items: gatewayIpAddresses } = {} } = useQuery( - gatewayIpAddressList({ project, vpc, gateway }).optionsFn() - ) - - const hasAttachedPool = gatewayIpPools && gatewayIpPools.length > 0 - - return ( - - {internetGateway.name} - - } - > - - - {internetGateway.name} - - - - - -
- - Internet gateway IP address - {gatewayIpAddresses && gatewayIpAddresses.length > 1 ? 'es' : ''} - - {gatewayIpAddresses && gatewayIpAddresses.length > 0 ? ( - gatewayIpAddresses.map((gatewayIpAddress) => ( - - - {gatewayIpAddress.name} - - - - - - - )) - ) : ( -
- {'This internet gateway does not have any IP addresses attached. '} - {hasAttachedPool - ? 'It will use an address from the attached IP pool.' - : 'Attach an IP pool or IP address via the CLI or API.'} -
- )} -
- - - -
- - Internet gateway IP pool - {gatewayIpPools && gatewayIpPools.length > 1 ? 's' : ''} - - {hasAttachedPool ? ( - gatewayIpPools.map((gatewayIpPool) => ( - - {gatewayIpPool.name} - - - - - - )) - ) : ( -
- This internet gateway does not have any IP pools attached. -
- )} -
- - - -
- Routes targeting this gateway -
- - - Router - Route - - - - - -
-
- - - - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22..8dff14d7a 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -485,14 +485,12 @@ export const routes = createRoutesFromElements( /> import('./pages/project/vpcs/VpcGatewaysTab').then(convert)} > + - import('./pages/project/vpcs/internet-gateway-edit').then(convert) - } + path="internet-gateways-new" + lazy={() => import('./forms/internet-gateway-create').then(convert)} /> @@ -518,6 +516,28 @@ export const routes = createRoutesFromElements( + + + import('./pages/project/vpcs/InternetGatewayPage').then(convert) + } + > + + + import('./forms/internet-gateway-ip-pool-create').then(convert) + } + /> + + import('./forms/internet-gateway-ip-address-create').then(convert) + } + /> + + ( + + + {/* the dot in the middle. hide by default, use peer-checked to show if checked */} +
+ +) + export const Radio = ({ children, className, ...inputProps }: RadioProps) => (