From 373d70ee9c579002dc28367fd6f003be5fafa14c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 26 Jul 2024 17:53:55 -0400 Subject: [PATCH 01/53] first stub of Routers and RouterRoutes pages --- app/api/hooks.ts | 9 ++- app/api/path-params.ts | 2 + app/hooks/use-params.ts | 4 + app/pages/project/vpcs/RouterRoutePage.tsx | 48 ++++++++++++ app/pages/project/vpcs/VpcPage/VpcPage.tsx | 1 + .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 65 ++++++++++++++++ app/routes.tsx | 10 ++- app/util/path-builder.ts | 3 + mock-api/msw/db.ts | 29 +++++++ mock-api/msw/handlers.ts | 23 +++++- mock-api/vpc.ts | 78 ++++++++++++++++++- 11 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 app/pages/project/vpcs/RouterRoutePage.tsx create mode 100644 app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx diff --git a/app/api/hooks.ts b/app/api/hooks.ts index a6c464f403..70c70a7778 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -174,7 +174,14 @@ export const getUsePrefetchedApiQuery = }) invariant( result.data, - `Expected query to be prefetched. Key: ${JSON.stringify(queryKey)}` + `Expected query to be prefetched. +Key: ${JSON.stringify(queryKey)} +Ensure the following: +• loader is running +• query matches in both the loader and the component +• request isn't erroring-out server-side (check the Networking tab) +• mock API endpoint is implemented in handlers.ts +` ) // TS infers non-nullable on a freestanding variable, but doesn't like to do // it on a property. So we give it a hint diff --git a/app/api/path-params.ts b/app/api/path-params.ts index a14c1c38c2..eaeab465aa 100644 --- a/app/api/path-params.ts +++ b/app/api/path-params.ts @@ -15,6 +15,8 @@ export type SiloImage = { image?: string } export type NetworkInterface = Merge export type Snapshot = Merge export type Vpc = Merge +export type VpcRouter = Merge +export type VpcRouterRoute = Merge export type VpcSubnet = Merge export type FirewallRule = Merge export type Silo = { silo?: string } diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 35932cce8d..b6d2e15d15 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -37,6 +37,8 @@ export const getFloatingIpSelector = requireParams('project', 'floatingIp') export const getInstanceSelector = requireParams('project', 'instance') export const getVpcSelector = requireParams('project', 'vpc') export const getFirewallRuleSelector = requireParams('project', 'vpc', 'rule') +export const getVpcRouterSelector = requireParams('project', 'vpc', 'router') +export const getRouterRouteSelector = requireParams('project', 'vpc', 'router', 'route') export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet') export const getSiloSelector = requireParams('silo') export const getSiloImageSelector = requireParams('image') @@ -79,6 +81,8 @@ export const useProjectSnapshotSelector = () => useSelectedParams(getProjectSnapshotSelector) export const useInstanceSelector = () => useSelectedParams(getInstanceSelector) export const useVpcSelector = () => useSelectedParams(getVpcSelector) +export const useVpcRouterSelector = () => useSelectedParams(getVpcRouterSelector) +export const useRouterRouteSelector = () => useSelectedParams(getRouterRouteSelector) export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector) export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector) export const useSiloSelector = () => useSelectedParams(getSiloSelector) diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx new file mode 100644 index 0000000000..7c80b0dd99 --- /dev/null +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -0,0 +1,48 @@ +/* + * 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 { type LoaderFunctionArgs } from 'react-router-dom' + +import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' +import { IpGlobal24Icon } from '@oxide/design-system/icons/react' + +import { + getRouterRouteSelector, + getVpcRouterSelector, + useRouterRouteSelector, +} from '~/hooks' +import { PAGE_SIZE } from '~/table/QueryTable' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' + +RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { + const { project, vpc, router } = getVpcRouterSelector(params) + const { route } = getRouterRouteSelector(params) + const query = { limit: PAGE_SIZE } + await apiQueryClient.prefetchQuery('vpcRouterRouteView', { + path: { route }, + query: { project, vpc, router, ...query }, + }) + return null +} + +export function RouterRoutePage() { + const query = { limit: PAGE_SIZE } + const { project, vpc, router, route } = useRouterRouteSelector() + const { data: routes } = usePrefetchedApiQuery('vpcRouterRouteView', { + path: { route }, + query: { project, vpc, router, ...query }, + }) + console.log({ routes }) + return ( + <> + + }>{routes.name} + + + ) +} diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 514401bc25..5bed62c366 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -59,6 +59,7 @@ export function VpcPage() { Firewall Rules Subnets + Routers ) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx new file mode 100644 index 0000000000..b26af77a97 --- /dev/null +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -0,0 +1,65 @@ +/* + * 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 } from '@tanstack/react-table' +import { useMemo } from 'react' +import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' + +import { apiQueryClient, usePrefetchedApiQuery, type VpcRouter } from '@oxide/api' + +import { getVpcSelector, useVpcSelector } from '~/hooks' +import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' + +const colHelper = createColumnHelper() + +VpcRoutersTab.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, vpc } = getVpcSelector(params) + await apiQueryClient.prefetchQuery('vpcRouterList', { + query: { project, vpc, limit: PAGE_SIZE }, + }) + return null +} + +export function VpcRoutersTab() { + const vpcSelector = useVpcSelector() + const { project, vpc } = vpcSelector + const routers = usePrefetchedApiQuery('vpcRouterList', { + query: { project, vpc, limit: PAGE_SIZE }, + }) + console.log({ routers }) + const { Table } = useQueryTable('vpcRouterList', { + query: { project, vpc, limit: PAGE_SIZE }, + }) + + const emptyState = ( + + ) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { + cell: (info) => info.getValue(), + }), + ], + [] + ) + + return ( + <> +
New router
+ + + + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 22e27ccb36..b2d4683a3d 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -62,7 +62,9 @@ import { NetworkingTab } from './pages/project/instances/instance/tabs/Networkin import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { InstancesPage } from './pages/project/instances/InstancesPage' import { SnapshotsPage } from './pages/project/snapshots/SnapshotsPage' +import { RouterRoutePage } from './pages/project/vpcs/RouterRoutePage' import { VpcFirewallRulesTab } from './pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab' +import { VpcRoutersTab } from './pages/project/vpcs/VpcPage/tabs/VpcRoutersTab' import { VpcSubnetsTab } from './pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab' import { VpcPage } from './pages/project/vpcs/VpcPage/VpcPage' import { VpcsPage } from './pages/project/vpcs/VpcsPage' @@ -390,10 +392,16 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Edit Subnet' }} /> + } loader={VpcRoutersTab.loader}> + + + + } loader={RouterRoutePage.loader}> + + {/* create page; follow IpPools example */} - } loader={FloatingIpsPage.loader}> type IpPool = Required type FloatingIp = Required type FirewallRule = Required +type VpcRouter = Required type VpcSubnet = Required // these are used as the basis for many routes but are not themselves routes we @@ -83,6 +84,8 @@ export const pb = { `${pb.vpcFirewallRulesNew(params)}/${params.rule}`, vpcFirewallRuleEdit: (params: FirewallRule) => `${pb.vpcFirewallRules(params)}/${params.rule}/edit`, + vpcRouters: (params: Vpc) => `${vpcBase(params)}/routers`, + vpcRouter: (params: VpcRouter) => `${pb.vpcRouters(params)}/${params.router}`, vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`, vpcSubnetsNew: (params: Vpc) => `${vpcBase(params)}/subnets-new`, vpcSubnetsEdit: (params: VpcSubnet) => `${pb.vpcSubnets(params)}/${params.subnet}/edit`, diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index fa8d5b6c42..c271f0d197 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -138,6 +138,33 @@ export const lookup = { return vpc }, + vpcRouter({ router: id, ...vpcSelector }: PP.VpcRouter): Json { + if (!id) throw notFoundErr('no router specified') + + if (isUuid(id)) return lookupById(db.vpcRouters, id) + + const vpc = lookup.vpc(vpcSelector) + const router = db.vpcRouters.find((r) => r.vpc_id === vpc.id && r.name === id) + if (!router) throw notFoundErr(`router '${id}'`) + + return router + }, + vpcRouterRoute({ + route: id, + ...routerSelector + }: PP.VpcRouterRoute): Json { + if (!id) throw notFoundErr('no route specified') + + if (isUuid(id)) return lookupById(db.vpcRouterRoutes, id) + + const router = lookup.vpcRouter(routerSelector) + const route = db.vpcRouterRoutes.find( + (r) => r.vpc_router_id === router.id && r.name === id + ) + if (!route) throw notFoundErr(`route '${id}'`) + + return route + }, vpcSubnet({ subnet: id, ...vpcSelector }: PP.VpcSubnet): Json { if (!id) throw notFoundErr('no subnet specified') @@ -326,6 +353,8 @@ const initDb = { sshKeys: [...mock.sshKeys], users: [...mock.users], vpcFirewallRules: [...mock.firewallRules], + vpcRouters: [...mock.vpcRouters], + vpcRouterRoutes: [...mock.routerRoutes], vpcs: [...mock.vpcs], vpcSubnets: [mock.vpcSubnet], } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index d88e250ad6..befc7a17e2 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1049,6 +1049,26 @@ export const handlers = makeHandlers({ return { rules: R.sortBy(rules, (r) => r.name) } }, + vpcRouterList({ query }) { + const vpc = lookup.vpc(query) + const routers = db.vpcRouters.filter((r) => r.vpc_id === vpc.id) + return paginated(query, routers) + }, + vpcRouterView: ({ path, query }) => lookup.vpcRouter({ ...path, ...query }), + // vpcRouterCreate({ body, query }) { + // const vpc = lookup.vpc(query) + // errIfExists(db.vpcRouters, { vpc_id: vpc.id, name: body.name }) + + // const newRouter: Json = { + // id: uuid(), + // vpc_id: vpc.id, + // ...body, + // ...getTimestamps(), + // } + // db.vpcRouters.push(newRouter) + // return json(newRouter, { status: 201 }) + // }, + vpcRouterRouteView: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }), vpcSubnetList({ query }) { const vpc = lookup.vpc(query) const subnets = db.vpcSubnets.filter((s) => s.vpc_id === vpc.id) @@ -1373,12 +1393,9 @@ export const handlers = makeHandlers({ userBuiltinView: NotImplemented, vpcRouterCreate: NotImplemented, vpcRouterDelete: NotImplemented, - vpcRouterList: NotImplemented, vpcRouterRouteCreate: NotImplemented, vpcRouterRouteDelete: NotImplemented, vpcRouterRouteList: NotImplemented, vpcRouterRouteUpdate: NotImplemented, - vpcRouterRouteView: NotImplemented, vpcRouterUpdate: NotImplemented, - vpcRouterView: NotImplemented, }) diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index 4d180daa97..ad548167c2 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import type { Vpc, VpcFirewallRule, VpcSubnet } from '@oxide/api' +import type { RouterRoute, Vpc, VpcFirewallRule, VpcRouter, VpcSubnet } from '@oxide/api' import type { Json } from './json-type' import { project, project2 } from './project' @@ -42,6 +42,82 @@ export const vpc2: Json = { export const vpcs: Json = [vpc, vpc2] +export const routerRoutes: Json> = [ + { + id: '51e50342-790f-4efb-8518-10bf01279514', + name: 'default', + description: "VPC Subnet route for 'default'", + time_created: '2024-07-11T17:46:21.161086Z', + time_modified: '2024-07-11T17:46:21.161086Z', + vpc_router_id: '6900c1ab-bc8c-4fed-8499-c8312a05d81f', + kind: 'vpc_subnet', + target: { + type: 'subnet', + value: 'default', + }, + destination: { + type: 'subnet', + value: 'default', + }, + }, + { + id: '4c98cd3b-37be-4754-954f-ca960f7a5c3f', + name: 'default-v4', + description: 'The default route of a vpc', + time_created: '2024-07-11T17:46:21.161086Z', + time_modified: '2024-07-11T17:46:21.161086Z', + vpc_router_id: '6900c1ab-bc8c-4fed-8499-c8312a05d81f', + kind: 'default', + target: { + type: 'internet_gateway', + value: 'outbound', + }, + destination: { + type: 'ip_net', + value: '0.0.0.0/0', + }, + }, + { + id: '83ee96a3-e418-47fd-912e-e5b22c6a29c6', + name: 'default-v6', + description: 'The default route of a vpc', + time_created: '2024-07-11T17:46:21.161086Z', + time_modified: '2024-07-11T17:46:21.161086Z', + vpc_router_id: '6900c1ab-bc8c-4fed-8499-c8312a05d81f', + kind: 'default', + target: { + type: 'internet_gateway', + value: 'outbound', + }, + destination: { + type: 'ip_net', + value: '::/0', + }, + }, +] + +export const vpcRouter: Json = { + id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', + name: 'mock-system-router', + description: 'a fake router', + time_created: new Date(2024, 0, 1).toISOString(), + time_modified: new Date(2024, 0, 2).toISOString(), + vpc_id: vpc.id, + kind: 'system', +} + +export const vpcRouter2: Json = { + id: '7ffc1613-8492-42f1-894b-9ef5c9ba2507', + name: 'mock-custom-router', + description: 'a fake custom router', + time_created: new Date(2024, 1, 1).toISOString(), + time_modified: new Date(2024, 1, 2).toISOString(), + vpc_id: vpc.id, + kind: 'custom', +} + +export const vpcRouters: Json = [vpcRouter, vpcRouter2] + export const vpcSubnet: Json = { // this is supposed to be flattened into the top level. will fix in API id: 'd12bf934-d2bf-40e9-8596-bb42a7793749', From 6422957f69739dfd7d042d99c767dd31ce17da57 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 30 Jul 2024 17:16:45 -0400 Subject: [PATCH 02/53] continuing stub of router routes pages --- app/pages/project/vpcs/AltRouterRoutePage.tsx | 391 ++++++++++++++++++ app/pages/project/vpcs/RouterRoutePage.tsx | 42 +- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 10 +- app/routes.tsx | 50 ++- app/util/links.ts | 6 + app/util/path-builder.ts | 2 +- 6 files changed, 473 insertions(+), 28 deletions(-) create mode 100644 app/pages/project/vpcs/AltRouterRoutePage.tsx diff --git a/app/pages/project/vpcs/AltRouterRoutePage.tsx b/app/pages/project/vpcs/AltRouterRoutePage.tsx new file mode 100644 index 0000000000..1c77e114d7 --- /dev/null +++ b/app/pages/project/vpcs/AltRouterRoutePage.tsx @@ -0,0 +1,391 @@ +/* + * 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 } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' + +import { + apiQueryClient, + parseIpUtilization, + useApiMutation, + useApiQuery, + useApiQueryClient, + usePrefetchedApiQuery, + type IpPoolRange, + type IpPoolSiloLink, +} from '@oxide/api' +import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' + +import { CapacityBar } from '~/components/CapacityBar' +import { DocsPopover } from '~/components/DocsPopover' +import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { HL } from '~/components/HL' +import { QueryParamTabs } from '~/components/QueryParamTabs' +import { getIpPoolSelector, useForm, useIpPoolSelector } from '~/hooks' +import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' +import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' +import { 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 { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { Tabs } from '~/ui/lib/Tabs' +import { TipIcon } from '~/ui/lib/TipIcon' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) { + const { pool } = getIpPoolSelector(params) + const query = { limit: PAGE_SIZE } + await Promise.all([ + apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }), + apiQueryClient.prefetchQuery('ipPoolSiloList', { path: { pool }, query }), + apiQueryClient.prefetchQuery('ipPoolRangeList', { path: { pool }, query }), + apiQueryClient.prefetchQuery('ipPoolUtilizationView', { path: { pool } }), + + // fetch silos and preload into RQ cache so fetches by ID in SiloNameFromId + // can be mostly instant yet gracefully fall back to fetching individually + // if we don't fetch them all here + apiQueryClient.fetchQuery('siloList', { query: { limit: 200 } }).then((silos) => { + for (const silo of silos.items) { + apiQueryClient.setQueryData('siloView', { path: { silo: silo.id } }, silo) + } + }), + ]) + return null +} + +export function IpPoolPage() { + const poolSelector = useIpPoolSelector() + const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) + return ( + <> + + }>{pool.name} + } + summary="IP pools are collections of external IPs you can assign to silos. When a pool is linked to a silo, users in that silo can allocate IPs from the pool for their instances." + links={[docLinks.systemIpPools]} + /> + + + + + IP ranges + Linked silos + + + + + + + + + {/* for add range form */} + + ) +} + +function UtilizationBars() { + const { pool } = useIpPoolSelector() + const { data } = usePrefetchedApiQuery('ipPoolUtilizationView', { path: { pool } }) + const { ipv4, ipv6 } = parseIpUtilization(data) + + if (ipv4.capacity === 0 && ipv6.capacity === 0n) return null + + return ( +
+ {ipv4.capacity > 0 && ( + } + title="IPv4" + provisioned={ipv4.allocated} + capacity={ipv4.capacity} + provisionedLabel="Allocated" + capacityLabel="Capacity" + unit="IPs" + includeUnit={false} + /> + )} + {ipv6.capacity > 0 && ( + } + title="IPv6" + provisioned={ipv6.allocated} + capacity={ipv6.capacity} + provisionedLabel="Allocated" + capacityLabel="Capacity" + unit="IPs" + includeUnit={false} + /> + )} +
+ ) +} + +const ipRangesColHelper = createColumnHelper() +const ipRangesStaticCols = [ + ipRangesColHelper.accessor('range.first', { header: 'First' }), + ipRangesColHelper.accessor('range.last', { header: 'Last' }), + ipRangesColHelper.accessor('timeCreated', Columns.timeCreated), +] + +function IpRangesTable() { + const { pool } = useIpPoolSelector() + const { Table } = useQueryTable('ipPoolRangeList', { path: { pool } }) + const queryClient = useApiQueryClient() + + const removeRange = useApiMutation('ipPoolRangeRemove', { + onSuccess() { + queryClient.invalidateQueries('ipPoolRangeList') + queryClient.invalidateQueries('ipPoolUtilizationView') + }, + }) + const emptyState = ( + } + title="No IP ranges" + body="Add a range to see it here" + buttonText="Add range" + buttonTo={pb.ipPoolRangeAdd({ pool })} + /> + ) + + const makeRangeActions = useCallback( + ({ range }: IpPoolRange): MenuAction[] => [ + { + label: 'Remove', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + removeRange.mutateAsync({ + path: { pool }, + body: range, + }), + errorTitle: 'Could not remove range', + modalTitle: 'Confirm remove range', + modalContent: ( +

+ Are you sure you want to remove range{' '} + + {range.first}–{range.last} + {' '} + from the pool? This will fail if the range has any addresses in use. +

+ ), + actionType: 'danger', + }), + }, + ], + [pool, removeRange] + ) + const columns = useColsWithActions(ipRangesStaticCols, makeRangeActions) + + return ( + <> +
+ Add range +
+
+ + ) +} + +function SiloNameFromId({ value: siloId }: { value: string }) { + const { data: silo } = useApiQuery('siloView', { path: { silo: siloId } }) + + if (!silo) return + + return {silo.name} +} + +const silosColHelper = createColumnHelper() +const silosStaticCols = [ + silosColHelper.accessor('siloId', { + header: 'Silo', + cell: (info) => , + }), + silosColHelper.accessor('isDefault', { + header: () => { + return ( + + Pool is silo default + + IPs are allocated from the default pool when users ask for an IP without + specifying a pool. + + + ) + }, + cell: (info) => , + }), +] + +function LinkedSilosTable() { + const poolSelector = useIpPoolSelector() + const queryClient = useApiQueryClient() + const { Table } = useQueryTable('ipPoolSiloList', { path: poolSelector }) + + const unlinkSilo = useApiMutation('ipPoolSiloUnlink', { + onSuccess() { + queryClient.invalidateQueries('ipPoolSiloList') + }, + }) + + const makeActions = useCallback( + (link: IpPoolSiloLink): MenuAction[] => [ + { + label: 'Unlink', + className: 'destructive', + onActivate() { + confirmAction({ + doAction: () => + unlinkSilo.mutateAsync({ path: { silo: link.siloId, pool: link.ipPoolId } }), + modalTitle: 'Confirm unlink silo', + // Would be nice to reference the silo by name like we reference the + // pool by name on unlink in the silo pools list, but it's a pain to + // get the name here. Could use useQueries to get all the names, and + // RQ would dedupe the requests since they're already being fetched + // for the table. Not worth it right now. + modalContent: ( +

+ Are you sure you want to unlink the silo? Users in this silo will no longer + be able to allocate IPs from this pool. Unlink will fail if there are any + IPs from the pool in use in this silo. +

+ ), + errorTitle: 'Could not unlink silo', + actionType: 'danger', + }) + }, + }, + ], + [unlinkSilo] + ) + + const [showLinkModal, setShowLinkModal] = useState(false) + + const emptyState = ( + } + title="No linked silos" + body="You can link this pool to a silo to see it here" + buttonText="Link silo" + onClick={() => setShowLinkModal(true)} + /> + ) + + const columns = useColsWithActions(silosStaticCols, makeActions) + return ( + <> +
+ setShowLinkModal(true)}>Link silo +
+
+ {showLinkModal && setShowLinkModal(false)} />} + + ) +} + +type LinkSiloFormValues = { + silo: string | undefined +} + +const defaultValues: LinkSiloFormValues = { silo: undefined } + +function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) { + const queryClient = useApiQueryClient() + const { pool } = useIpPoolSelector() + const { control, handleSubmit } = useForm({ defaultValues }) + + const linkSilo = useApiMutation('ipPoolSiloLink', { + onSuccess() { + queryClient.invalidateQueries('ipPoolSiloList') + }, + onError(err) { + addToast({ title: 'Could not link silo', content: err.message, variant: 'error' }) + }, + onSettled: onDismiss, + }) + + function onSubmit({ silo }: LinkSiloFormValues) { + if (!silo) return // can't happen, silo is required + linkSilo.mutate({ path: { pool }, body: { silo, isDefault: false } }) + } + + const linkedSilos = useApiQuery('ipPoolSiloList', { + path: { pool }, + query: { limit: 1000 }, + }) + const allSilos = useApiQuery('siloList', { query: { limit: 1000 } }) + + // in order to get the list of remaining unlinked silos, we have to get the + // list of all silos and remove the already linked ones + + const linkedSiloIds = useMemo( + () => + linkedSilos.data ? new Set(linkedSilos.data.items.map((s) => s.siloId)) : undefined, + [linkedSilos] + ) + const unlinkedSiloItems = useMemo( + () => + allSilos.data && linkedSiloIds + ? allSilos.data.items + .filter((s) => !linkedSiloIds.has(s.id)) + .map((s) => ({ value: s.name, label: s.name })) + : [], + [allSilos, linkedSiloIds] + ) + + return ( + + + +
{ + e.stopPropagation() + handleSubmit(onSubmit)(e) + }} + className="space-y-4" + > + + + + +
+
+ +
+ ) +} diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 7c80b0dd99..5064667c0a 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -6,43 +6,49 @@ * Copyright Oxide Computer Company */ -import { type LoaderFunctionArgs } from 'react-router-dom' +import type { LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' -import { IpGlobal24Icon } from '@oxide/design-system/icons/react' +import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' -import { - getRouterRouteSelector, - getVpcRouterSelector, - useRouterRouteSelector, -} from '~/hooks' +import { apiQueryClient, usePrefetchedApiQuery } from '~/api' +import { DocsPopover } from '~/components/DocsPopover' +import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' import { PAGE_SIZE } from '~/table/QueryTable' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { docLinks } from '~/util/links' RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { const { project, vpc, router } = getVpcRouterSelector(params) - const { route } = getRouterRouteSelector(params) + console.log({ project, vpc, router }) const query = { limit: PAGE_SIZE } - await apiQueryClient.prefetchQuery('vpcRouterRouteView', { - path: { route }, - query: { project, vpc, router, ...query }, + await apiQueryClient.prefetchQuery('vpcRouterView', { + path: { router }, + query: { project, vpc, ...query }, }) + console.log({ params }) return null } export function RouterRoutePage() { const query = { limit: PAGE_SIZE } - const { project, vpc, router, route } = useRouterRouteSelector() - const { data: routes } = usePrefetchedApiQuery('vpcRouterRouteView', { - path: { route }, - query: { project, vpc, router, ...query }, + const { project, vpc, router } = useVpcRouterSelector() + const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { + path: { router }, + query: { project, vpc, ...query }, }) - console.log({ routes }) + console.log({ routerData }) return ( <> - }>{routes.name} + }>{router} + } + summary="Routers summary copy TK" + links={[docLinks.routers]} + /> +

More to come here, based on IP Pools page

) } diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index b26af77a97..e693359ba8 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -12,8 +12,10 @@ import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, usePrefetchedApiQuery, type VpcRouter } from '@oxide/api' import { getVpcSelector, useVpcSelector } from '~/hooks' +import { LinkCell } from '~/table/cells/LinkCell' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { pb } from '~/util/path-builder' const colHelper = createColumnHelper() @@ -48,10 +50,14 @@ export function VpcRoutersTab() { const columns = useMemo( () => [ colHelper.accessor('name', { - cell: (info) => info.getValue(), + cell: (info) => ( + + {info.getValue()} + + ), }), ], - [] + [vpcSelector] ) return ( diff --git a/app/routes.tsx b/app/routes.tsx index b2d4683a3d..e4b2060bdf 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -392,16 +392,52 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Edit Subnet' }} /> - } loader={VpcRoutersTab.loader}> - - - - } loader={RouterRoutePage.loader}> - - {/* create page; follow IpPools example */} + {/* + + } /> + } + loader={IpPoolsPage.loader} + handle={{ crumb: 'IP pools' }} + > + + } /> + } + loader={EditIpPoolSideModalForm.loader} + handle={{ crumb: 'Edit IP pool' }} + /> + + + + } + loader={IpPoolPage.loader} + handle={{ crumb: poolCrumb }} + > + } /> + + */} + + } + loader={VpcRoutersTab.loader} + handle={{ crumb: 'Routers' }} + /> + } + loader={RouterRoutePage.loader} + handle={{ crumb: 'Routes' }} + path="vpcs/:vpc/routers/:router" + > + {/* create page; follow IpPools example */} + } loader={FloatingIpsPage.loader}> `${pb.vpcFirewallRules(params)}/${params.rule}/edit`, vpcRouters: (params: Vpc) => `${vpcBase(params)}/routers`, - vpcRouter: (params: VpcRouter) => `${pb.vpcRouters(params)}/${params.router}`, + vpcRouterEdit: (params: VpcRouter) => `${pb.vpcRouters(params)}/${params.router}`, vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`, vpcSubnetsNew: (params: Vpc) => `${vpcBase(params)}/subnets-new`, vpcSubnetsEdit: (params: VpcSubnet) => `${pb.vpcSubnets(params)}/${params.subnet}/edit`, From a7e6f44c01fc065fa4707b71492f45e41913a69d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 30 Jul 2024 21:22:26 -0400 Subject: [PATCH 03/53] Add more info to Router Route page --- app/pages/project/vpcs/RouterRoutePage.tsx | 142 ++++++++++++++++++--- mock-api/msw/db.ts | 15 +++ mock-api/msw/handlers.ts | 13 +- mock-api/vpc.ts | 6 +- 4 files changed, 154 insertions(+), 22 deletions(-) diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 5064667c0a..b81503d911 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -6,49 +6,155 @@ * Copyright Oxide Computer Company */ +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' import type { LoaderFunctionArgs } from 'react-router-dom' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' -import { apiQueryClient, usePrefetchedApiQuery } from '~/api' +import { apiQueryClient, usePrefetchedApiQuery, type RouterRoute } from '~/api' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' -import { PAGE_SIZE } from '~/table/QueryTable' +import { confirmAction } from '~/stores/confirm-action' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { useQueryTable } from '~/table/QueryTable' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { const { project, vpc, router } = getVpcRouterSelector(params) + console.log('loader') console.log({ project, vpc, router }) - const query = { limit: PAGE_SIZE } - await apiQueryClient.prefetchQuery('vpcRouterView', { - path: { router }, - query: { project, vpc, ...query }, - }) - console.log({ params }) + await Promise.all([ + apiQueryClient.prefetchQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }), + apiQueryClient.prefetchQuery('vpcRouterRouteList', { query: { project, router, vpc } }), + ]) return null } export function RouterRoutePage() { - const query = { limit: PAGE_SIZE } const { project, vpc, router } = useVpcRouterSelector() const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { path: { router }, - query: { project, vpc, ...query }, + query: { project, vpc }, + }) + // probably don't need to both set routerRoutes and use useQueryTable + const { data: routerRoutes } = usePrefetchedApiQuery('vpcRouterRouteList', { + query: { project, router, vpc }, }) - console.log({ routerData }) + console.log({ routerRoutes }) + + const actions = useMemo( + () => [ + { + label: 'Copy ID', + onActivate() { + window.navigator.clipboard.writeText(routerData.id || '') + }, + }, + ], + [routerData] + ) + const { Table } = useQueryTable('vpcRouterRouteList', { query: { project, router, vpc } }) + + const emptyState = ( + } + title="No router routes" + body="Add a route to see it here" + buttonText="Add route" + buttonTo="" + // TODO: "add route" button + // buttonTo={pb.ipPoolRangeAdd({ pool })} + /> + ) + + const routerRoutesColHelper = createColumnHelper() + + const routerRoutesStaticCols = [ + routerRoutesColHelper.accessor('name', { header: 'Name' }), + routerRoutesColHelper.accessor('kind', { header: 'Kind' }), + routerRoutesColHelper.accessor('target.type', { header: 'Target Type' }), + routerRoutesColHelper.accessor('target.value', { header: 'Target Value' }), + routerRoutesColHelper.accessor('destination.type', { + header: 'Destination Type', + }), + routerRoutesColHelper.accessor('destination.value', { + header: 'Destination Value', + }), + ] + + const makeRangeActions = useCallback( + ({ name }: RouterRoute): MenuAction[] => [ + { + label: 'Remove', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => { + // remove route + console.log('remove route') + // removeRange.mutateAsync({ + // path: { pool }, + // body: range, + // }), + return Promise.resolve() + }, + errorTitle: 'Could not remove route', + modalTitle: 'Confirm remove route', + modalContent: ( +

+ Are you sure you want to remove route {name}? +

+ ), + actionType: 'danger', + }), + }, + ], + [] + ) + const columns = useColsWithActions(routerRoutesStaticCols, makeRangeActions) + return ( <> }>{router} - } - summary="Routers summary copy TK" - links={[docLinks.routers]} - /> +
+ } + summary="Routers summary copy TK" + links={[docLinks.routers]} + /> + +
-

More to come here, based on IP Pools page

+ + + + {routerData.description || } + + {routerData.kind} + + + + + + + + + + +
) } diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index c271f0d197..693ea016af 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -165,6 +165,21 @@ export const lookup = { return route }, + vpcRouterRouteList({ project, router, vpc }: PP.VpcRouter): Json[] { + console.log(db.vpcRouterRoutes) + console.log({ project, router, vpc }) + + if (!project) throw notFoundErr('no project specified') + if (!router) throw notFoundErr('no router specified') + if (!vpc) throw notFoundErr('no VPC specified') + + const vpcRouter = lookup.vpcRouter({ vpc, router, project }) + console.log({ vpcRouter }) + return db.vpcRouterRoutes.filter( + // (rr) => rr.id === lookup.vpcRouter({ vpc, router, project }).id + (rr) => rr + ) + }, vpcSubnet({ subnet: id, ...vpcSelector }: PP.VpcSubnet): Json { if (!id) throw notFoundErr('no subnet specified') diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 0224d6e773..45fc354bd7 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1072,6 +1072,18 @@ export const handlers = makeHandlers({ // db.vpcRouters.push(newRouter) // return json(newRouter, { status: 201 }) // }, + + /* + the following needs to return a paginated list, rather than a spoofed object with { items } + */ + vpcRouterRouteList: ({ query: { project, router, vpc } }) => { + const vpcRouter = lookup.vpcRouter({ project, router, vpc }) + return { + items: lookup + .vpcRouterRouteList({ project, router, vpc }) + .filter((r) => r.vpc_router_id === vpcRouter.id), + } + }, vpcRouterRouteView: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }), vpcSubnetList({ query }) { const vpc = lookup.vpc(query) @@ -1399,7 +1411,6 @@ export const handlers = makeHandlers({ vpcRouterDelete: NotImplemented, vpcRouterRouteCreate: NotImplemented, vpcRouterRouteDelete: NotImplemented, - vpcRouterRouteList: NotImplemented, vpcRouterRouteUpdate: NotImplemented, vpcRouterUpdate: NotImplemented, }) diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index 33d2a88705..1e38132731 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -52,7 +52,7 @@ export const routerRoutes: Json> = [ description: "VPC Subnet route for 'default'", time_created: '2024-07-11T17:46:21.161086Z', time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: '6900c1ab-bc8c-4fed-8499-c8312a05d81f', + vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', kind: 'vpc_subnet', target: { type: 'subnet', @@ -69,7 +69,7 @@ export const routerRoutes: Json> = [ description: 'The default route of a vpc', time_created: '2024-07-11T17:46:21.161086Z', time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: '6900c1ab-bc8c-4fed-8499-c8312a05d81f', + vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', kind: 'default', target: { type: 'internet_gateway', @@ -86,7 +86,7 @@ export const routerRoutes: Json> = [ description: 'The default route of a vpc', time_created: '2024-07-11T17:46:21.161086Z', time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: '6900c1ab-bc8c-4fed-8499-c8312a05d81f', + vpc_router_id: '7ffc1613-8492-42f1-894b-9ef5c9ba2507', kind: 'default', target: { type: 'internet_gateway', From 588cffdfa11dca5e171b03b6774e3c573e2190ca Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 1 Aug 2024 17:17:38 -0400 Subject: [PATCH 04/53] adjustments; TypeValueCell --- app/pages/project/vpcs/RouterRoutePage.tsx | 45 +++++++++++++++---- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 8 ++-- app/util/path-builder.ts | 9 +++- mock-api/vpc.ts | 2 +- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index b81503d911..80d74301e7 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -12,20 +12,30 @@ import type { LoaderFunctionArgs } from 'react-router-dom' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' -import { apiQueryClient, usePrefetchedApiQuery, type RouterRoute } from '~/api' +import { + apiQueryClient, + usePrefetchedApiQuery, + type RouteDestination, + type RouterRoute, + type RouteTarget, +} from '~/api' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' import { confirmAction } from '~/stores/confirm-action' import { EmptyCell } from '~/table/cells/EmptyCell' +import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' +import { Badge } from '~/ui/lib/Badge' +import { CreateLink } from '~/ui/lib/CreateButton' import { DateTime } from '~/ui/lib/DateTime' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { const { project, vpc, router } = getVpcRouterSelector(params) @@ -41,6 +51,20 @@ RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { return null } +const RouterRouteTypeValueBadge = ({ + targetOrDestination, +}: { + // typed this way because of RouteTarget's `{ type: 'drop' }` + targetOrDestination: RouteDestination | (Omit & { value?: string }) +}) => { + const { type, value } = targetOrDestination + return value ? ( + + ) : ( + {type} + ) +} + export function RouterRoutePage() { const { project, vpc, router } = useVpcRouterSelector() const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { @@ -83,20 +107,20 @@ export function RouterRoutePage() { const routerRoutesStaticCols = [ routerRoutesColHelper.accessor('name', { header: 'Name' }), routerRoutesColHelper.accessor('kind', { header: 'Kind' }), - routerRoutesColHelper.accessor('target.type', { header: 'Target Type' }), - routerRoutesColHelper.accessor('target.value', { header: 'Target Value' }), - routerRoutesColHelper.accessor('destination.type', { - header: 'Destination Type', + routerRoutesColHelper.accessor('target', { + header: 'Target', + cell: (info) => , }), - routerRoutesColHelper.accessor('destination.value', { - header: 'Destination Value', + routerRoutesColHelper.accessor('destination', { + header: 'Destination', + cell: (info) => , }), ] const makeRangeActions = useCallback( ({ name }: RouterRoute): MenuAction[] => [ { - label: 'Remove', + label: 'Delete', className: 'destructive', onActivate: () => confirmAction({ @@ -154,6 +178,11 @@ export function RouterRoutePage() { +
+ + New route + +
) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index e693359ba8..f0c5f09af7 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -14,6 +14,7 @@ import { apiQueryClient, usePrefetchedApiQuery, type VpcRouter } from '@oxide/ap import { getVpcSelector, useVpcSelector } from '~/hooks' import { LinkCell } from '~/table/cells/LinkCell' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' @@ -51,7 +52,7 @@ export function VpcRoutersTab() { () => [ colHelper.accessor('name', { cell: (info) => ( - + {info.getValue()} ), @@ -62,8 +63,9 @@ export function VpcRoutersTab() { return ( <> -
New router
- +
+ New router +
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index a9ebd9f172..619da37239 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -23,6 +23,7 @@ type IpPool = Required type FloatingIp = Required type FirewallRule = Required type VpcRouter = Required +type VpcRouterRoute = Required type VpcSubnet = Required // these are used as the basis for many routes but are not themselves routes we @@ -85,7 +86,13 @@ export const pb = { vpcFirewallRuleEdit: (params: FirewallRule) => `${pb.vpcFirewallRules(params)}/${params.rule}/edit`, vpcRouters: (params: Vpc) => `${vpcBase(params)}/routers`, - vpcRouterEdit: (params: VpcRouter) => `${pb.vpcRouters(params)}/${params.router}`, + vpcRoutersNew: (params: Vpc) => `${vpcBase(params)}/routers-new`, + vpcRouter: (params: VpcRouter) => `${pb.vpcRouters(params)}/${params.router}`, + vpcRouterEdit: (params: VpcRouter) => `${pb.vpcRouter(params)}/edit`, + vpcRouterRouteEdit: (params: VpcRouterRoute) => + `${pb.vpcRouter(params)}/${params.route}/edit`, + vpcRouterRoutesNew: (params: VpcRouter) => `${pb.vpcRouter(params)}/routes-new`, + vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`, vpcSubnetsNew: (params: Vpc) => `${vpcBase(params)}/subnets-new`, vpcSubnetsEdit: (params: VpcSubnet) => `${pb.vpcSubnets(params)}/${params.subnet}/edit`, diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index 1e38132731..4f04ee283d 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -86,7 +86,7 @@ export const routerRoutes: Json> = [ description: 'The default route of a vpc', time_created: '2024-07-11T17:46:21.161086Z', time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: '7ffc1613-8492-42f1-894b-9ef5c9ba2507', + vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', kind: 'default', target: { type: 'internet_gateway', From 0507f0a530918be1ca3925d71718aaf37b868f91 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 2 Aug 2024 12:49:50 -0400 Subject: [PATCH 05/53] deleting a route works --- app/pages/project/vpcs/AltRouterRoutePage.tsx | 391 ------------------ app/pages/project/vpcs/RouterRoutePage.tsx | 54 +-- app/util/path-builder.ts | 2 + mock-api/msw/handlers.ts | 6 +- 4 files changed, 29 insertions(+), 424 deletions(-) delete mode 100644 app/pages/project/vpcs/AltRouterRoutePage.tsx diff --git a/app/pages/project/vpcs/AltRouterRoutePage.tsx b/app/pages/project/vpcs/AltRouterRoutePage.tsx deleted file mode 100644 index 1c77e114d7..0000000000 --- a/app/pages/project/vpcs/AltRouterRoutePage.tsx +++ /dev/null @@ -1,391 +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 { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' -import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' - -import { - apiQueryClient, - parseIpUtilization, - useApiMutation, - useApiQuery, - useApiQueryClient, - usePrefetchedApiQuery, - type IpPoolRange, - type IpPoolSiloLink, -} from '@oxide/api' -import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' - -import { CapacityBar } from '~/components/CapacityBar' -import { DocsPopover } from '~/components/DocsPopover' -import { ComboboxField } from '~/components/form/fields/ComboboxField' -import { HL } from '~/components/HL' -import { QueryParamTabs } from '~/components/QueryParamTabs' -import { getIpPoolSelector, useForm, useIpPoolSelector } from '~/hooks' -import { confirmAction } from '~/stores/confirm-action' -import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' -import { 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 { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' -import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { Message } from '~/ui/lib/Message' -import { Modal } from '~/ui/lib/Modal' -import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { Tabs } from '~/ui/lib/Tabs' -import { TipIcon } from '~/ui/lib/TipIcon' -import { docLinks } from '~/util/links' -import { pb } from '~/util/path-builder' - -IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) { - const { pool } = getIpPoolSelector(params) - const query = { limit: PAGE_SIZE } - await Promise.all([ - apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }), - apiQueryClient.prefetchQuery('ipPoolSiloList', { path: { pool }, query }), - apiQueryClient.prefetchQuery('ipPoolRangeList', { path: { pool }, query }), - apiQueryClient.prefetchQuery('ipPoolUtilizationView', { path: { pool } }), - - // fetch silos and preload into RQ cache so fetches by ID in SiloNameFromId - // can be mostly instant yet gracefully fall back to fetching individually - // if we don't fetch them all here - apiQueryClient.fetchQuery('siloList', { query: { limit: 200 } }).then((silos) => { - for (const silo of silos.items) { - apiQueryClient.setQueryData('siloView', { path: { silo: silo.id } }, silo) - } - }), - ]) - return null -} - -export function IpPoolPage() { - const poolSelector = useIpPoolSelector() - const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) - return ( - <> - - }>{pool.name} - } - summary="IP pools are collections of external IPs you can assign to silos. When a pool is linked to a silo, users in that silo can allocate IPs from the pool for their instances." - links={[docLinks.systemIpPools]} - /> - - - - - IP ranges - Linked silos - - - - - - - - - {/* for add range form */} - - ) -} - -function UtilizationBars() { - const { pool } = useIpPoolSelector() - const { data } = usePrefetchedApiQuery('ipPoolUtilizationView', { path: { pool } }) - const { ipv4, ipv6 } = parseIpUtilization(data) - - if (ipv4.capacity === 0 && ipv6.capacity === 0n) return null - - return ( -
- {ipv4.capacity > 0 && ( - } - title="IPv4" - provisioned={ipv4.allocated} - capacity={ipv4.capacity} - provisionedLabel="Allocated" - capacityLabel="Capacity" - unit="IPs" - includeUnit={false} - /> - )} - {ipv6.capacity > 0 && ( - } - title="IPv6" - provisioned={ipv6.allocated} - capacity={ipv6.capacity} - provisionedLabel="Allocated" - capacityLabel="Capacity" - unit="IPs" - includeUnit={false} - /> - )} -
- ) -} - -const ipRangesColHelper = createColumnHelper() -const ipRangesStaticCols = [ - ipRangesColHelper.accessor('range.first', { header: 'First' }), - ipRangesColHelper.accessor('range.last', { header: 'Last' }), - ipRangesColHelper.accessor('timeCreated', Columns.timeCreated), -] - -function IpRangesTable() { - const { pool } = useIpPoolSelector() - const { Table } = useQueryTable('ipPoolRangeList', { path: { pool } }) - const queryClient = useApiQueryClient() - - const removeRange = useApiMutation('ipPoolRangeRemove', { - onSuccess() { - queryClient.invalidateQueries('ipPoolRangeList') - queryClient.invalidateQueries('ipPoolUtilizationView') - }, - }) - const emptyState = ( - } - title="No IP ranges" - body="Add a range to see it here" - buttonText="Add range" - buttonTo={pb.ipPoolRangeAdd({ pool })} - /> - ) - - const makeRangeActions = useCallback( - ({ range }: IpPoolRange): MenuAction[] => [ - { - label: 'Remove', - className: 'destructive', - onActivate: () => - confirmAction({ - doAction: () => - removeRange.mutateAsync({ - path: { pool }, - body: range, - }), - errorTitle: 'Could not remove range', - modalTitle: 'Confirm remove range', - modalContent: ( -

- Are you sure you want to remove range{' '} - - {range.first}–{range.last} - {' '} - from the pool? This will fail if the range has any addresses in use. -

- ), - actionType: 'danger', - }), - }, - ], - [pool, removeRange] - ) - const columns = useColsWithActions(ipRangesStaticCols, makeRangeActions) - - return ( - <> -
- Add range -
-
- - ) -} - -function SiloNameFromId({ value: siloId }: { value: string }) { - const { data: silo } = useApiQuery('siloView', { path: { silo: siloId } }) - - if (!silo) return - - return {silo.name} -} - -const silosColHelper = createColumnHelper() -const silosStaticCols = [ - silosColHelper.accessor('siloId', { - header: 'Silo', - cell: (info) => , - }), - silosColHelper.accessor('isDefault', { - header: () => { - return ( - - Pool is silo default - - IPs are allocated from the default pool when users ask for an IP without - specifying a pool. - - - ) - }, - cell: (info) => , - }), -] - -function LinkedSilosTable() { - const poolSelector = useIpPoolSelector() - const queryClient = useApiQueryClient() - const { Table } = useQueryTable('ipPoolSiloList', { path: poolSelector }) - - const unlinkSilo = useApiMutation('ipPoolSiloUnlink', { - onSuccess() { - queryClient.invalidateQueries('ipPoolSiloList') - }, - }) - - const makeActions = useCallback( - (link: IpPoolSiloLink): MenuAction[] => [ - { - label: 'Unlink', - className: 'destructive', - onActivate() { - confirmAction({ - doAction: () => - unlinkSilo.mutateAsync({ path: { silo: link.siloId, pool: link.ipPoolId } }), - modalTitle: 'Confirm unlink silo', - // Would be nice to reference the silo by name like we reference the - // pool by name on unlink in the silo pools list, but it's a pain to - // get the name here. Could use useQueries to get all the names, and - // RQ would dedupe the requests since they're already being fetched - // for the table. Not worth it right now. - modalContent: ( -

- Are you sure you want to unlink the silo? Users in this silo will no longer - be able to allocate IPs from this pool. Unlink will fail if there are any - IPs from the pool in use in this silo. -

- ), - errorTitle: 'Could not unlink silo', - actionType: 'danger', - }) - }, - }, - ], - [unlinkSilo] - ) - - const [showLinkModal, setShowLinkModal] = useState(false) - - const emptyState = ( - } - title="No linked silos" - body="You can link this pool to a silo to see it here" - buttonText="Link silo" - onClick={() => setShowLinkModal(true)} - /> - ) - - const columns = useColsWithActions(silosStaticCols, makeActions) - return ( - <> -
- setShowLinkModal(true)}>Link silo -
-
- {showLinkModal && setShowLinkModal(false)} />} - - ) -} - -type LinkSiloFormValues = { - silo: string | undefined -} - -const defaultValues: LinkSiloFormValues = { silo: undefined } - -function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) { - const queryClient = useApiQueryClient() - const { pool } = useIpPoolSelector() - const { control, handleSubmit } = useForm({ defaultValues }) - - const linkSilo = useApiMutation('ipPoolSiloLink', { - onSuccess() { - queryClient.invalidateQueries('ipPoolSiloList') - }, - onError(err) { - addToast({ title: 'Could not link silo', content: err.message, variant: 'error' }) - }, - onSettled: onDismiss, - }) - - function onSubmit({ silo }: LinkSiloFormValues) { - if (!silo) return // can't happen, silo is required - linkSilo.mutate({ path: { pool }, body: { silo, isDefault: false } }) - } - - const linkedSilos = useApiQuery('ipPoolSiloList', { - path: { pool }, - query: { limit: 1000 }, - }) - const allSilos = useApiQuery('siloList', { query: { limit: 1000 } }) - - // in order to get the list of remaining unlinked silos, we have to get the - // list of all silos and remove the already linked ones - - const linkedSiloIds = useMemo( - () => - linkedSilos.data ? new Set(linkedSilos.data.items.map((s) => s.siloId)) : undefined, - [linkedSilos] - ) - const unlinkedSiloItems = useMemo( - () => - allSilos.data && linkedSiloIds - ? allSilos.data.items - .filter((s) => !linkedSiloIds.has(s.id)) - .map((s) => ({ value: s.name, label: s.name })) - : [], - [allSilos, linkedSiloIds] - ) - - return ( - - - -
{ - e.stopPropagation() - handleSubmit(onSubmit)(e) - }} - className="space-y-4" - > - - - - -
-
- -
- ) -} diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 80d74301e7..4acc02d6e5 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -14,16 +14,16 @@ import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/r import { apiQueryClient, + useApiMutation, usePrefetchedApiQuery, - type RouteDestination, type RouterRoute, - type RouteTarget, } from '~/api' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -34,13 +34,12 @@ import { DateTime } from '~/ui/lib/DateTime' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableControls, TableTitle } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { const { project, vpc, router } = getVpcRouterSelector(params) - console.log('loader') - console.log({ project, vpc, router }) await Promise.all([ apiQueryClient.prefetchQuery('vpcRouterView', { path: { router }, @@ -51,15 +50,10 @@ RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { return null } -const RouterRouteTypeValueBadge = ({ - targetOrDestination, -}: { - // typed this way because of RouteTarget's `{ type: 'drop' }` - targetOrDestination: RouteDestination | (Omit & { value?: string }) -}) => { - const { type, value } = targetOrDestination +const RouterRouteTypeValueBadge = ({ type, value }: { type: string; value?: string }) => { + const typeString = type.replace('_', ' ').replace('ip net', 'ip network') return value ? ( - + ) : ( {type} ) @@ -71,11 +65,13 @@ export function RouterRoutePage() { path: { router }, query: { project, vpc }, }) - // probably don't need to both set routerRoutes and use useQueryTable - const { data: routerRoutes } = usePrefetchedApiQuery('vpcRouterRouteList', { - query: { project, router, vpc }, + + const deleteRouterRoute = useApiMutation('vpcRouterRouteDelete', { + onSuccess() { + apiQueryClient.invalidateQueries('vpcRouterRouteList') + addToast({ content: 'Your route has been deleted' }) + }, }) - console.log({ routerRoutes }) const actions = useMemo( () => [ @@ -109,42 +105,34 @@ export function RouterRoutePage() { routerRoutesColHelper.accessor('kind', { header: 'Kind' }), routerRoutesColHelper.accessor('target', { header: 'Target', - cell: (info) => , + cell: (info) => , }), routerRoutesColHelper.accessor('destination', { header: 'Destination', - cell: (info) => , + cell: (info) => , }), ] const makeRangeActions = useCallback( - ({ name }: RouterRoute): MenuAction[] => [ + ({ name, id }: RouterRoute): MenuAction[] => [ { label: 'Delete', className: 'destructive', onActivate: () => confirmAction({ - doAction: () => { - // remove route - console.log('remove route') - // removeRange.mutateAsync({ - // path: { pool }, - // body: range, - // }), - return Promise.resolve() - }, + doAction: () => deleteRouterRoute.mutateAsync({ path: { route: id } }), errorTitle: 'Could not remove route', modalTitle: 'Confirm remove route', modalContent: (

- Are you sure you want to remove route {name}? + Are you sure you want to delete route {name}?

), actionType: 'danger', }), }, ], - [] + [deleteRouterRoute] ) const columns = useColsWithActions(routerRoutesStaticCols, makeRangeActions) @@ -178,11 +166,13 @@ export function RouterRoutePage() { -
+ + Routes + New route -
+
) diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 619da37239..6b7395bfee 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -91,6 +91,8 @@ export const pb = { vpcRouterEdit: (params: VpcRouter) => `${pb.vpcRouter(params)}/edit`, vpcRouterRouteEdit: (params: VpcRouterRoute) => `${pb.vpcRouter(params)}/${params.route}/edit`, + vpcRouterRouteDelete: (params: VpcRouterRoute) => + `${pb.vpcRouterRouteEdit(params)}/delete`, vpcRouterRoutesNew: (params: VpcRouter) => `${pb.vpcRouter(params)}/routes-new`, vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 45fc354bd7..097e5bc6b9 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1085,6 +1085,11 @@ export const handlers = makeHandlers({ } }, vpcRouterRouteView: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }), + vpcRouterRouteDelete: ({ path, query }) => { + const route = lookup.vpcRouterRoute({ ...path, ...query }) + db.vpcRouterRoutes = db.vpcRouterRoutes.filter((r) => r.id !== route.id) + return 204 + }, vpcSubnetList({ query }) { const vpc = lookup.vpc(query) const subnets = db.vpcSubnets.filter((s) => s.vpc_id === vpc.id) @@ -1410,7 +1415,6 @@ export const handlers = makeHandlers({ vpcRouterCreate: NotImplemented, vpcRouterDelete: NotImplemented, vpcRouterRouteCreate: NotImplemented, - vpcRouterRouteDelete: NotImplemented, vpcRouterRouteUpdate: NotImplemented, vpcRouterUpdate: NotImplemented, }) From d74f0c5e7a16c641f0bf1df7bdb63b47945b2de5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 2 Aug 2024 14:45:46 -0400 Subject: [PATCH 06/53] Updating strings in target / destination type-value rendering --- app/pages/project/vpcs/RouterRoutePage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 4acc02d6e5..97fc56447a 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -51,7 +51,10 @@ RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { } const RouterRouteTypeValueBadge = ({ type, value }: { type: string; value?: string }) => { - const typeString = type.replace('_', ' ').replace('ip net', 'ip network') + const typeString = type + .replace('_', ' ') + .replace('ip net', 'ip network') + .replace('internet gateway', 'gateway') return value ? ( ) : ( From 95b184fc2e84909e1f9d45745d08b9e52fc3e1bd Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 2 Aug 2024 17:15:59 -0400 Subject: [PATCH 07/53] Update based on feedback in oxide-product-eng --- app/forms/router-route-create.tsx | 71 ++++++++++++++++++++++ app/pages/project/vpcs/RouterRoutePage.tsx | 12 ++-- app/routes.tsx | 7 ++- mock-api/msw/db.ts | 11 +--- mock-api/vpc.ts | 16 +++++ 5 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 app/forms/router-route-create.tsx diff --git a/app/forms/router-route-create.tsx b/app/forms/router-route-create.tsx new file mode 100644 index 0000000000..ca1ae7b021 --- /dev/null +++ b/app/forms/router-route-create.tsx @@ -0,0 +1,71 @@ +/* + * 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 { useNavigate } from 'react-router-dom' + +import { useApiMutation, useApiQueryClient, type RouterRouteCreate } 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 { useForm, useVpcRouterSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import type { ListboxItem } from '~/ui/lib/Listbox' +import { pb } from '~/util/path-builder' + +const defaultValues: RouterRouteCreate = { + name: '', + description: '', + destination: { type: 'ip', value: '' }, + target: { type: 'ip', value: '' }, +} + +const destinationTypes: ListboxItem[] = [ + { value: 'ip', label: 'IP' }, + { value: 'ip_net', label: 'IP net' }, + { value: 'vpc', label: 'VPC' }, + { value: 'subnet', label: 'subnet' }, +] + +export function CreateRouterRouteSideModalForm() { + const queryClient = useApiQueryClient() + const routeSelector = useVpcRouterSelector() + const navigate = useNavigate() + + const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { + onSuccess() { + queryClient.invalidateQueries('vpcRouterRouteList') + addToast({ content: 'Your route has been created' }) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + navigate(pb.vpcRouter(routeSelector))} + onSubmit={(body) => createRouterRoute.mutate({ query: routeSelector, body })} + loading={createRouterRoute.isPending} + submitError={createRouterRoute.error} + > + + + + + + ) +} diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 97fc56447a..d661571e1c 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -55,6 +55,7 @@ const RouterRouteTypeValueBadge = ({ type, value }: { type: string; value?: stri .replace('_', ' ') .replace('ip net', 'ip network') .replace('internet gateway', 'gateway') + .replace('subnet', 'VPC subnet') return value ? ( ) : ( @@ -105,15 +106,18 @@ export function RouterRoutePage() { const routerRoutesStaticCols = [ routerRoutesColHelper.accessor('name', { header: 'Name' }), - routerRoutesColHelper.accessor('kind', { header: 'Kind' }), - routerRoutesColHelper.accessor('target', { - header: 'Target', - cell: (info) => , + routerRoutesColHelper.accessor('kind', { + header: 'Kind', + cell: (info) => {info.getValue()}, }), routerRoutesColHelper.accessor('destination', { header: 'Destination', cell: (info) => , }), + routerRoutesColHelper.accessor('target', { + header: 'Target', + cell: (info) => , + }), ] const makeRangeActions = useCallback( diff --git a/app/routes.tsx b/app/routes.tsx index e4b2060bdf..5420cc5fb6 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -28,6 +28,7 @@ import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { IpPoolAddRangeSideModalForm } from './forms/ip-pool-range-add' import { CreateProjectSideModalForm } from './forms/project-create' import { EditProjectSideModalForm } from './forms/project-edit' +import { CreateRouterRouteSideModalForm } from './forms/router-route-create' import { CreateSiloSideModalForm } from './forms/silo-create' import { CreateSnapshotSideModalForm } from './forms/snapshot-create' import { CreateSSHKeySideModalForm } from './forms/ssh-key-create' @@ -436,7 +437,11 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Routes' }} path="vpcs/:vpc/routers/:router" > - {/* create page; follow IpPools example */} + } + handle={{ crumb: 'New Route' }} + /> } loader={FloatingIpsPage.loader}> diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 693ea016af..a434f847cb 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -166,19 +166,10 @@ export const lookup = { return route }, vpcRouterRouteList({ project, router, vpc }: PP.VpcRouter): Json[] { - console.log(db.vpcRouterRoutes) - console.log({ project, router, vpc }) - if (!project) throw notFoundErr('no project specified') if (!router) throw notFoundErr('no router specified') if (!vpc) throw notFoundErr('no VPC specified') - - const vpcRouter = lookup.vpcRouter({ vpc, router, project }) - console.log({ vpcRouter }) - return db.vpcRouterRoutes.filter( - // (rr) => rr.id === lookup.vpcRouter({ vpc, router, project }).id - (rr) => rr - ) + return db.vpcRouterRoutes }, vpcSubnet({ subnet: id, ...vpcSelector }: PP.VpcSubnet): Json { if (!id) throw notFoundErr('no subnet specified') diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index 4f04ee283d..2f52a2d9bd 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -97,6 +97,22 @@ export const routerRoutes: Json> = [ value: '::/0', }, }, + { + id: '51e50342-790f-4efb-8518-10bf01279515', + name: 'drop-local', + description: 'Drop all local traffic', + time_created: '2024-07-11T17:46:21.161086Z', + time_modified: '2024-07-11T17:46:21.161086Z', + vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', + kind: 'default', + destination: { + type: 'ip', + value: '192.168.1.1', + }, + target: { + type: 'drop', + }, + }, ] export const vpcRouter: Json = { From 007a9611b899a800c1877ac080114fcfbddfb9be Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 2 Aug 2024 17:32:57 -0400 Subject: [PATCH 08/53] remove underscores in badge --- app/pages/project/vpcs/RouterRoutePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index d661571e1c..d9c7c8669c 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -108,7 +108,7 @@ export function RouterRoutePage() { routerRoutesColHelper.accessor('name', { header: 'Name' }), routerRoutesColHelper.accessor('kind', { header: 'Kind', - cell: (info) => {info.getValue()}, + cell: (info) => {info.getValue().replace('_', ' ')}, }), routerRoutesColHelper.accessor('destination', { header: 'Destination', From d224f1d1767518364c10f173b6a8c499b6ad15d5 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 3 Aug 2024 18:10:22 -0500 Subject: [PATCH 09/53] add limit to route list prefetch to match QueryTable fetch --- app/pages/project/vpcs/RouterRoutePage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index d9c7c8669c..2d3e84eeb2 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -27,7 +27,7 @@ import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { useQueryTable } from '~/table/QueryTable' +import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { DateTime } from '~/ui/lib/DateTime' @@ -45,7 +45,9 @@ RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { path: { router }, query: { project, vpc }, }), - apiQueryClient.prefetchQuery('vpcRouterRouteList', { query: { project, router, vpc } }), + apiQueryClient.prefetchQuery('vpcRouterRouteList', { + query: { project, router, vpc, limit: PAGE_SIZE }, + }), ]) return null } From 0a1c5ac56a9051923bf8b1c2942d099a9118375c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 5 Aug 2024 11:10:19 -0400 Subject: [PATCH 10/53] Progress on side modals to create router and route --- app/forms/router-create.tsx | 52 ++++++++++++++++++++++ app/pages/project/vpcs/RouterRoutePage.tsx | 3 +- app/routes.tsx | 15 ++++--- 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 app/forms/router-create.tsx diff --git a/app/forms/router-create.tsx b/app/forms/router-create.tsx new file mode 100644 index 0000000000..3210fe9664 --- /dev/null +++ b/app/forms/router-create.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useNavigate } from 'react-router-dom' + +import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { useForm, useVpcSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +const defaultValues: VpcRouterCreate = { + name: '', + description: '', +} + +export function CreateRouterSideModalForm() { + const queryClient = useApiQueryClient() + const vpcSelector = useVpcSelector() + const navigate = useNavigate() + + const createRouter = useApiMutation('vpcRouterCreate', { + onSuccess() { + queryClient.invalidateQueries('vpcRouterList') + addToast({ content: 'Your router has been created' }) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + navigate(pb.vpcRouters(vpcSelector))} + onSubmit={(body) => createRouter.mutate({ query: vpcSelector, body })} + loading={createRouter.isPending} + submitError={createRouter.error} + > + + + + ) +} diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 2d3e84eeb2..7a1eab93e0 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -8,7 +8,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' -import type { LoaderFunctionArgs } from 'react-router-dom' +import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' @@ -183,6 +183,7 @@ export function RouterRoutePage() {
+ ) } diff --git a/app/routes.tsx b/app/routes.tsx index 5420cc5fb6..af5ec89aaa 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -28,6 +28,7 @@ import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { IpPoolAddRangeSideModalForm } from './forms/ip-pool-range-add' import { CreateProjectSideModalForm } from './forms/project-create' import { EditProjectSideModalForm } from './forms/project-edit' +import { CreateRouterSideModalForm } from './forms/router-create' import { CreateRouterRouteSideModalForm } from './forms/router-route-create' import { CreateSiloSideModalForm } from './forms/silo-create' import { CreateSnapshotSideModalForm } from './forms/snapshot-create' @@ -422,12 +423,14 @@ export const routes = createRoutesFromElements( */} - } - loader={VpcRoutersTab.loader} - handle={{ crumb: 'Routers' }} - /> + } loader={VpcRoutersTab.loader}> + + } + handle={{ crumb: 'New Router' }} + /> + From 50187a86402ae4900304ad57be845743aae0fae6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 5 Aug 2024 16:33:04 -0400 Subject: [PATCH 11/53] Router create works --- app/forms/router-create.tsx | 11 ++++++++--- .../project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 6 +----- mock-api/msw/handlers.ts | 15 ++++++++++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/forms/router-create.tsx b/app/forms/router-create.tsx index 3210fe9664..ac71cb2249 100644 --- a/app/forms/router-create.tsx +++ b/app/forms/router-create.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useNavigate } from 'react-router-dom' +import { useNavigate, type NavigateFunction } from 'react-router-dom' import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/api' @@ -26,10 +26,15 @@ export function CreateRouterSideModalForm() { const vpcSelector = useVpcSelector() const navigate = useNavigate() + const onDismiss = (navigate: NavigateFunction) => { + navigate(pb.vpcRouters(vpcSelector)) + } + const createRouter = useApiMutation('vpcRouterCreate', { onSuccess() { queryClient.invalidateQueries('vpcRouterList') addToast({ content: 'Your router has been created' }) + onDismiss(navigate) }, }) @@ -39,8 +44,8 @@ export function CreateRouterSideModalForm() { navigate(pb.vpcRouters(vpcSelector))} + resourceName="router" + onDismiss={() => onDismiss(navigate)} onSubmit={(body) => createRouter.mutate({ query: vpcSelector, body })} loading={createRouter.isPending} submitError={createRouter.error} diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index f0c5f09af7..56db7b73ee 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, usePrefetchedApiQuery, type VpcRouter } from '@oxide/api' +import { apiQueryClient, type VpcRouter } from '@oxide/api' import { getVpcSelector, useVpcSelector } from '~/hooks' import { LinkCell } from '~/table/cells/LinkCell' @@ -31,10 +31,6 @@ VpcRoutersTab.loader = async ({ params }: LoaderFunctionArgs) => { export function VpcRoutersTab() { const vpcSelector = useVpcSelector() const { project, vpc } = vpcSelector - const routers = usePrefetchedApiQuery('vpcRouterList', { - query: { project, vpc, limit: PAGE_SIZE }, - }) - console.log({ routers }) const { Table } = useQueryTable('vpcRouterList', { query: { project, vpc, limit: PAGE_SIZE }, }) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 097e5bc6b9..f5a5574343 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1058,6 +1058,20 @@ export const handlers = makeHandlers({ const routers = db.vpcRouters.filter((r) => r.vpc_id === vpc.id) return paginated(query, routers) }, + vpcRouterCreate({ body, query }) { + const vpc = lookup.vpc(query) + errIfExists(db.vpcRouters, { vpc_id: vpc.id, name: body.name }) + + const newRouter: Json = { + id: uuid(), + vpc_id: vpc.id, + kind: 'custom', + ...body, + ...getTimestamps(), + } + db.vpcRouters.push(newRouter) + return json(newRouter, { status: 201 }) + }, vpcRouterView: ({ path, query }) => lookup.vpcRouter({ ...path, ...query }), // vpcRouterCreate({ body, query }) { // const vpc = lookup.vpc(query) @@ -1412,7 +1426,6 @@ export const handlers = makeHandlers({ timeseriesSchemaList: NotImplemented, userBuiltinList: NotImplemented, userBuiltinView: NotImplemented, - vpcRouterCreate: NotImplemented, vpcRouterDelete: NotImplemented, vpcRouterRouteCreate: NotImplemented, vpcRouterRouteUpdate: NotImplemented, From 266c9236be1c07785308831a2919218c7936cfd4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 6 Aug 2024 12:32:27 -0400 Subject: [PATCH 12/53] Route create working --- app/forms/router-route-create.tsx | 42 +++++++++++++++++++--- app/pages/project/vpcs/RouterRoutePage.tsx | 6 ++-- mock-api/msw/handlers.ts | 14 +++++++- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/app/forms/router-route-create.tsx b/app/forms/router-route-create.tsx index ca1ae7b021..84e238245c 100644 --- a/app/forms/router-route-create.tsx +++ b/app/forms/router-route-create.tsx @@ -5,13 +5,14 @@ * * Copyright Oxide Computer Company */ -import { useNavigate } from 'react-router-dom' +import { useNavigate, type NavigateFunction } from 'react-router-dom' import { useApiMutation, useApiQueryClient, type RouterRouteCreate } 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 { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' import { useForm, useVpcRouterSelector } from '~/hooks' import { addToast } from '~/stores/toast' @@ -32,15 +33,29 @@ const destinationTypes: ListboxItem[] = [ { value: 'subnet', label: 'subnet' }, ] +const targetTypes: ListboxItem[] = [ + { value: 'ip', label: 'IP' }, + { value: 'vpc', label: 'VPC' }, + { value: 'subnet', label: 'subnet' }, + { value: 'instance', label: 'instance' }, + { value: 'internet_gateway', label: 'Internet gateway' }, + { value: 'drop', label: 'Drop' }, +] + export function CreateRouterRouteSideModalForm() { const queryClient = useApiQueryClient() - const routeSelector = useVpcRouterSelector() + const routerSelector = useVpcRouterSelector() const navigate = useNavigate() + const onDismiss = (navigate: NavigateFunction) => { + navigate(pb.vpcRouter(routerSelector)) + } + const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { onSuccess() { queryClient.invalidateQueries('vpcRouterRouteList') addToast({ content: 'Your route has been created' }) + onDismiss(navigate) }, }) @@ -51,8 +66,8 @@ export function CreateRouterRouteSideModalForm() { form={form} formType="create" resourceName="route" - onDismiss={() => navigate(pb.vpcRouter(routeSelector))} - onSubmit={(body) => createRouterRoute.mutate({ query: routeSelector, body })} + onDismiss={() => navigate(pb.vpcRouter(routerSelector))} + onSubmit={(body) => createRouterRoute.mutate({ query: routerSelector, body })} loading={createRouterRoute.isPending} submitError={createRouterRoute.error} > @@ -66,6 +81,25 @@ export function CreateRouterRouteSideModalForm() { control={form.control} placeholder="Select a destination type" /> + + + ) } diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 7a1eab93e0..78e9eca232 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -95,12 +95,10 @@ export function RouterRoutePage() { const emptyState = ( } - title="No router routes" + title="No routes" body="Add a route to see it here" buttonText="Add route" - buttonTo="" - // TODO: "add route" button - // buttonTo={pb.ipPoolRangeAdd({ pool })} + buttonTo={pb.vpcRouterRoutesNew({ project, vpc, router })} /> ) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index f5a5574343..20bdccb9d3 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1098,6 +1098,19 @@ export const handlers = makeHandlers({ .filter((r) => r.vpc_router_id === vpcRouter.id), } }, + vpcRouterRouteCreate({ body, query }) { + const vpcRouter = lookup.vpcRouter(query) + const newRoute: Json = { + id: uuid(), + vpc_router_id: vpcRouter.id, + // TODO: look into proper setting of `kind` + kind: 'default', + ...body, + ...getTimestamps(), + } + db.vpcRouterRoutes.push(newRoute) + return json(newRoute, { status: 201 }) + }, vpcRouterRouteView: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }), vpcRouterRouteDelete: ({ path, query }) => { const route = lookup.vpcRouterRoute({ ...path, ...query }) @@ -1427,7 +1440,6 @@ export const handlers = makeHandlers({ userBuiltinList: NotImplemented, userBuiltinView: NotImplemented, vpcRouterDelete: NotImplemented, - vpcRouterRouteCreate: NotImplemented, vpcRouterRouteUpdate: NotImplemented, vpcRouterUpdate: NotImplemented, }) From a6114e6ce3b7a6def076e7e9b3aff82a3f7ffa17 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 6 Aug 2024 17:11:20 -0400 Subject: [PATCH 13/53] Router and route creation / editing working, mostly --- ...outer-create.tsx => vpc-router-create.tsx} | 0 app/forms/vpc-router-edit.tsx | 88 ++++++++++++++ ...create.tsx => vpc-router-route-create.tsx} | 20 ++-- app/forms/vpc-router-route-edit.tsx | 111 ++++++++++++++++++ app/pages/project/vpcs/RouterRoutePage.tsx | 7 ++ .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 1 + app/routes.tsx | 10 +- mock-api/msw/handlers.ts | 13 +- 8 files changed, 240 insertions(+), 10 deletions(-) rename app/forms/{router-create.tsx => vpc-router-create.tsx} (100%) create mode 100644 app/forms/vpc-router-edit.tsx rename app/forms/{router-route-create.tsx => vpc-router-route-create.tsx} (90%) create mode 100644 app/forms/vpc-router-route-edit.tsx diff --git a/app/forms/router-create.tsx b/app/forms/vpc-router-create.tsx similarity index 100% rename from app/forms/router-create.tsx rename to app/forms/vpc-router-create.tsx diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx new file mode 100644 index 0000000000..ea2a08f930 --- /dev/null +++ b/app/forms/vpc-router-edit.tsx @@ -0,0 +1,88 @@ +/* + * 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 { + useNavigate, + type LoaderFunctionArgs, + type NavigateFunction, +} from 'react-router-dom' + +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, + type VpcRouterUpdate, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { getVpcRouterSelector, useForm, useVpcRouterSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +EditRouterSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { router, project, vpc } = getVpcRouterSelector(params) + await apiQueryClient.prefetchQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }) + return null +} + +export function EditRouterSideModalForm() { + const queryClient = useApiQueryClient() + const routerSelector = useVpcRouterSelector() + const { project, vpc, router } = routerSelector + const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }) + const navigate = useNavigate() + + const onDismiss = (navigate: NavigateFunction) => { + navigate(pb.vpcRouter(routerSelector)) + } + + const editRouter = useApiMutation('vpcRouterUpdate', { + onSuccess(body) { + queryClient.invalidateQueries('vpcRouterView') + addToast({ content: 'Your router has been updated' }) + // navigation uses body.name instead of router in case the name changed + navigate(pb.vpcRouter({ project, vpc, router: body.name })) + }, + }) + + const defaultValues: VpcRouterUpdate = { + name: router, + description: routerData.description, + } + + const form = useForm({ defaultValues }) + + return ( + onDismiss(navigate)} + onSubmit={(body) => + editRouter.mutate({ + path: { router }, + query: { project, vpc }, + body, + }) + } + loading={editRouter.isPending} + submitError={editRouter.error} + > + + + + ) +} diff --git a/app/forms/router-route-create.tsx b/app/forms/vpc-router-route-create.tsx similarity index 90% rename from app/forms/router-route-create.tsx rename to app/forms/vpc-router-route-create.tsx index 84e238245c..08bd3a7e04 100644 --- a/app/forms/router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -60,6 +60,7 @@ export function CreateRouterRouteSideModalForm() { }) const form = useForm({ defaultValues }) + const targetType = form.watch('target.type') return ( - - + {targetType !== 'drop' && ( + + )} ) } diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx new file mode 100644 index 0000000000..08bd3a7e04 --- /dev/null +++ b/app/forms/vpc-router-route-edit.tsx @@ -0,0 +1,111 @@ +/* + * 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 { useNavigate, type NavigateFunction } from 'react-router-dom' + +import { useApiMutation, useApiQueryClient, type RouterRouteCreate } 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 { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { useForm, useVpcRouterSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import type { ListboxItem } from '~/ui/lib/Listbox' +import { pb } from '~/util/path-builder' + +const defaultValues: RouterRouteCreate = { + name: '', + description: '', + destination: { type: 'ip', value: '' }, + target: { type: 'ip', value: '' }, +} + +const destinationTypes: ListboxItem[] = [ + { value: 'ip', label: 'IP' }, + { value: 'ip_net', label: 'IP net' }, + { value: 'vpc', label: 'VPC' }, + { value: 'subnet', label: 'subnet' }, +] + +const targetTypes: ListboxItem[] = [ + { value: 'ip', label: 'IP' }, + { value: 'vpc', label: 'VPC' }, + { value: 'subnet', label: 'subnet' }, + { value: 'instance', label: 'instance' }, + { value: 'internet_gateway', label: 'Internet gateway' }, + { value: 'drop', label: 'Drop' }, +] + +export function CreateRouterRouteSideModalForm() { + const queryClient = useApiQueryClient() + const routerSelector = useVpcRouterSelector() + const navigate = useNavigate() + + const onDismiss = (navigate: NavigateFunction) => { + navigate(pb.vpcRouter(routerSelector)) + } + + const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { + onSuccess() { + queryClient.invalidateQueries('vpcRouterRouteList') + addToast({ content: 'Your route has been created' }) + onDismiss(navigate) + }, + }) + + const form = useForm({ defaultValues }) + const targetType = form.watch('target.type') + + return ( + navigate(pb.vpcRouter(routerSelector))} + onSubmit={(body) => createRouterRoute.mutate({ query: routerSelector, body })} + loading={createRouterRoute.isPending} + submitError={createRouterRoute.error} + > + + + + + + {targetType !== 'drop' && ( + + )} + + ) +} diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 78e9eca232..b0df885b3f 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -72,6 +72,13 @@ export function RouterRoutePage() { query: { project, vpc }, }) + // const editRouterRoute = useApiMutation('vpcRouterRouteUpdate', { + // onSuccess() { + // apiQueryClient.invalidateQueries('vpcRouterRouteList') + // addToast({ content: 'Your route has been updated' }) + // }, + // }) + const deleteRouterRoute = useApiMutation('vpcRouterRouteDelete', { onSuccess() { apiQueryClient.invalidateQueries('vpcRouterRouteList') diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 56db7b73ee..4479831a08 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -53,6 +53,7 @@ export function VpcRoutersTab() { ), }), + colHelper.accessor('description', { header: 'Description' }), ], [vpcSelector] ) diff --git a/app/routes.tsx b/app/routes.tsx index af5ec89aaa..0f4b1128f0 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -28,8 +28,6 @@ import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { IpPoolAddRangeSideModalForm } from './forms/ip-pool-range-add' import { CreateProjectSideModalForm } from './forms/project-create' import { EditProjectSideModalForm } from './forms/project-edit' -import { CreateRouterSideModalForm } from './forms/router-create' -import { CreateRouterRouteSideModalForm } from './forms/router-route-create' import { CreateSiloSideModalForm } from './forms/silo-create' import { CreateSnapshotSideModalForm } from './forms/snapshot-create' import { CreateSSHKeySideModalForm } from './forms/ssh-key-create' @@ -37,6 +35,9 @@ import { CreateSubnetForm } from './forms/subnet-create' import { EditSubnetForm } from './forms/subnet-edit' import { CreateVpcSideModalForm } from './forms/vpc-create' import { EditVpcSideModalForm } from './forms/vpc-edit' +import { CreateRouterSideModalForm } from './forms/vpc-router-create' +import { EditRouterSideModalForm } from './forms/vpc-router-edit' +import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create' import type { CrumbFunc } from './hooks/use-title' import { AuthenticatedLayout } from './layouts/AuthenticatedLayout' import { AuthLayout } from './layouts/AuthLayout' @@ -440,6 +441,11 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Routes' }} path="vpcs/:vpc/routers/:router" > + } + handle={{ crumb: 'Edit Router' }} + /> } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 20bdccb9d3..987a38f7c2 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1086,7 +1086,19 @@ export const handlers = makeHandlers({ // db.vpcRouters.push(newRouter) // return json(newRouter, { status: 201 }) // }, + vpcRouterUpdate({ body, path, query }) { + const router = lookup.vpcRouter({ ...path, ...query }) + if (body.name) { + router.name = body.name + } + + if (typeof body.description === 'string') { + router.description = body.description + } + + return router + }, /* the following needs to return a paginated list, rather than a spoofed object with { items } */ @@ -1441,5 +1453,4 @@ export const handlers = makeHandlers({ userBuiltinView: NotImplemented, vpcRouterDelete: NotImplemented, vpcRouterRouteUpdate: NotImplemented, - vpcRouterUpdate: NotImplemented, }) From 438464517b783f1f791a281f06db50569a177ce4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 6 Aug 2024 17:16:39 -0400 Subject: [PATCH 14/53] Move Router edit side modal to Routers overview page --- app/forms/vpc-router-edit.tsx | 7 +++---- app/routes.tsx | 14 ++++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index ea2a08f930..3e4ab425a0 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -50,11 +50,10 @@ export function EditRouterSideModalForm() { } const editRouter = useApiMutation('vpcRouterUpdate', { - onSuccess(body) { - queryClient.invalidateQueries('vpcRouterView') + onSuccess() { + queryClient.invalidateQueries('vpcRouterList') addToast({ content: 'Your router has been updated' }) - // navigation uses body.name instead of router in case the name changed - navigate(pb.vpcRouter({ project, vpc, router: body.name })) + navigate(pb.vpcRouters({ project, vpc })) }, }) diff --git a/app/routes.tsx b/app/routes.tsx index 0f4b1128f0..34ec8f6223 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -425,7 +425,14 @@ export const routes = createRoutesFromElements( */} } loader={VpcRoutersTab.loader}> - + + } + loader={EditRouterSideModalForm.loader} + handle={{ crumb: 'Edit Router' }} + /> + } @@ -441,11 +448,6 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Routes' }} path="vpcs/:vpc/routers/:router" > - } - handle={{ crumb: 'Edit Router' }} - /> } From ad203e8ed62b65318dbebc71ecc5b967b9f0bd05 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 7 Aug 2024 11:00:18 -0400 Subject: [PATCH 15/53] Add TopBar pickers for VPC and Router --- app/components/TopBarPicker.tsx | 52 ++++++++++++++++++++++++++++++++- app/layouts/ProjectLayout.tsx | 12 ++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index 48ecfa4073..f16bad3a83 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -15,7 +15,13 @@ import { Success12Icon, } from '@oxide/design-system/icons/react' -import { useInstanceSelector, useIpPoolSelector, useSiloSelector } from '~/hooks' +import { + useInstanceSelector, + useIpPoolSelector, + useSiloSelector, + useVpcRouterSelector, + useVpcSelector, +} from '~/hooks' import { useCurrentUser } from '~/layouts/AuthenticatedLayout' import { PAGE_SIZE } from '~/table/QueryTable' import { Button } from '~/ui/lib/Button' @@ -246,6 +252,50 @@ export function IpPoolPicker() { ) } +/** Used when drilling down into a VPC from the Silo view. */ +export function VpcPicker() { + // picker only shows up when a VPC is in scope + const { project, vpc } = useVpcSelector() + const { data } = useApiQuery('vpcList', { query: { project, limit: PAGE_SIZE } }) + const items = (data?.items || []).map((v) => ({ + label: v.name, + to: pb.vpc({ project, vpc: v.name }), + })) + + return ( + + ) +} + +/** Used when drilling down into a VPC Router from the Silo view. */ +export function VpcRouterPicker() { + // picker only shows up when a router is in scope + const { project, vpc, router } = useVpcRouterSelector() + const { data } = useApiQuery('vpcRouterList', { + query: { project, vpc, limit: PAGE_SIZE }, + }) + const items = (data?.items || []).map((r) => ({ + label: r.name, + to: pb.vpcRouter({ vpc, project, router: r.name }), + })) + + return ( + + ) +} + const NoProjectLogo = () => (
diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index db4c02b88a..ebb53d0079 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -26,7 +26,13 @@ import { } from '@oxide/design-system/icons/react' import { TopBar } from '~/components/TopBar' -import { InstancePicker, ProjectPicker, SiloSystemPicker } from '~/components/TopBarPicker' +import { + InstancePicker, + ProjectPicker, + SiloSystemPicker, + VpcPicker, + VpcRouterPicker, +} from '~/components/TopBarPicker' import { getProjectSelector, useProjectSelector, useQuickActions } from '~/hooks' import { Divider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -54,7 +60,7 @@ export function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { const projectSelector = useProjectSelector() const { data: project } = usePrefetchedApiQuery('projectView', { path: projectSelector }) - const { instance } = useParams() + const { instance, router, vpc } = useParams() const { pathname } = useLocation() useQuickActions( useMemo( @@ -85,6 +91,8 @@ export function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { {instance && } + {vpc && } + {router && } From 226814adcd3cd5ea39cbde0dc7599ac4be3cbc2d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 7 Aug 2024 12:15:59 -0400 Subject: [PATCH 16/53] vpcRouterDelete implemented --- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 59 +++++++++++++++++-- mock-api/msw/handlers.ts | 6 +- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 4479831a08..7f0c89abaf 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -6,13 +6,17 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useMemo } from 'react' -import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' +import { useCallback, useMemo } from 'react' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, type VpcRouter } from '@oxide/api' +import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' import { getVpcSelector, useVpcSelector } from '~/hooks' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { EmptyCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -30,6 +34,7 @@ VpcRoutersTab.loader = async ({ params }: LoaderFunctionArgs) => { export function VpcRoutersTab() { const vpcSelector = useVpcSelector() + const navigate = useNavigate() const { project, vpc } = vpcSelector const { Table } = useQueryTable('vpcRouterList', { query: { project, vpc, limit: PAGE_SIZE }, @@ -40,11 +45,11 @@ export function VpcRoutersTab() { title="No VPC routers" body="Create a router to see it here" buttonText="New router" - buttonTo={'adasd'} + buttonTo={pb.vpcRoutersNew({ project, vpc })} /> ) - const columns = useMemo( + const staticColumns = useMemo( () => [ colHelper.accessor('name', { cell: (info) => ( @@ -53,11 +58,53 @@ export function VpcRoutersTab() { ), }), - colHelper.accessor('description', { header: 'Description' }), + colHelper.accessor('description', { + header: 'Description', + cell: (info) => info.getValue() || , + }), ], [vpcSelector] ) + const deleteRouter = useApiMutation('vpcRouterDelete', { + onSuccess() { + apiQueryClient.invalidateQueries('vpcRouterList') + addToast({ content: 'Your router has been deleted' }) + }, + }) + + const makeActions = useCallback( + (router: VpcRouter): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => { + // the edit view has its own loader, but we can make the modal open + // instantaneously by preloading the fetch result + apiQueryClient.setQueryData( + 'vpcRouterView', + { path: { router: router.name } }, + router + ) + navigate(pb.vpcRouterEdit({ project, vpc, router: router.name })) + }, + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + deleteRouter.mutateAsync({ + path: { router: router.name }, + query: { project, vpc }, + }), + label: router.name, + }), + }, + ], + [deleteRouter, project, vpc, navigate] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + return ( <>
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 987a38f7c2..34e5c30f98 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1099,6 +1099,11 @@ export const handlers = makeHandlers({ return router }, + vpcRouterDelete({ path, query }) { + const router = lookup.vpcRouter({ ...path, ...query }) + db.vpcRouters = db.vpcRouters.filter((r) => r.id !== router.id) + return 204 + }, /* the following needs to return a paginated list, rather than a spoofed object with { items } */ @@ -1451,6 +1456,5 @@ export const handlers = makeHandlers({ timeseriesSchemaList: NotImplemented, userBuiltinList: NotImplemented, userBuiltinView: NotImplemented, - vpcRouterDelete: NotImplemented, vpcRouterRouteUpdate: NotImplemented, }) From 131c7f5260b9b98ac1ec497116561d4dcafc51e0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 7 Aug 2024 13:08:11 -0400 Subject: [PATCH 17/53] Updating a router route now works --- app/forms/vpc-router-route-edit.tsx | 65 +++++++++++++++------- app/hooks/use-params.ts | 4 +- app/pages/project/vpcs/RouterRoutePage.tsx | 32 +++++++---- app/routes.tsx | 7 +++ mock-api/msw/handlers.ts | 11 +++- 5 files changed, 84 insertions(+), 35 deletions(-) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 08bd3a7e04..7ab931495f 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -5,27 +5,30 @@ * * Copyright Oxide Computer Company */ -import { useNavigate, type NavigateFunction } from 'react-router-dom' +import { + useNavigate, + type LoaderFunctionArgs, + type NavigateFunction, +} from 'react-router-dom' -import { useApiMutation, useApiQueryClient, type RouterRouteCreate } from '@oxide/api' +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, + type RouterRouteUpdate, +} 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 { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' -import { useForm, useVpcRouterSelector } from '~/hooks' +import { getVpcRouterRouteSelector, useForm, useVpcRouterRouteSelector } from '~/hooks' import { addToast } from '~/stores/toast' import type { ListboxItem } from '~/ui/lib/Listbox' import { pb } from '~/util/path-builder' -const defaultValues: RouterRouteCreate = { - name: '', - description: '', - destination: { type: 'ip', value: '' }, - target: { type: 'ip', value: '' }, -} - const destinationTypes: ListboxItem[] = [ { value: 'ip', label: 'IP' }, { value: 'ip_net', label: 'IP net' }, @@ -42,19 +45,35 @@ const targetTypes: ListboxItem[] = [ { value: 'drop', label: 'Drop' }, ] -export function CreateRouterRouteSideModalForm() { +EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, vpc, router, route } = getVpcRouterRouteSelector(params) + await apiQueryClient.prefetchQuery('vpcRouterRouteView', { + path: { route }, + query: { project, vpc, router }, + }) + return null +} + +export function EditRouterRouteSideModalForm() { const queryClient = useApiQueryClient() - const routerSelector = useVpcRouterSelector() + const routeSelector = useVpcRouterRouteSelector() + const { project, vpc, router: routerName, route: routeName } = routeSelector const navigate = useNavigate() + const { data: route } = usePrefetchedApiQuery('vpcRouterRouteView', { + path: { route: routeName }, + query: { project, vpc, router: routerName }, + }) + + const defaultValues: RouterRouteUpdate = { ...route } const onDismiss = (navigate: NavigateFunction) => { - navigate(pb.vpcRouter(routerSelector)) + navigate(pb.vpcRouter({ project, vpc, router: routerName })) } - const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { + const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { onSuccess() { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been created' }) + addToast({ content: 'Your route has been updated' }) onDismiss(navigate) }, }) @@ -65,12 +84,18 @@ export function CreateRouterRouteSideModalForm() { return ( navigate(pb.vpcRouter(routerSelector))} - onSubmit={(body) => createRouterRoute.mutate({ query: routerSelector, body })} - loading={createRouterRoute.isPending} - submitError={createRouterRoute.error} + onDismiss={() => onDismiss(navigate)} + onSubmit={(body) => + updateRouterRoute.mutate({ + query: { project, vpc, router: routerName }, + path: { route: routeName }, + body, + }) + } + loading={updateRouterRoute.isPending} + submitError={updateRouterRoute.error} > diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index b6d2e15d15..348e48d3b1 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -38,7 +38,7 @@ export const getInstanceSelector = requireParams('project', 'instance') export const getVpcSelector = requireParams('project', 'vpc') export const getFirewallRuleSelector = requireParams('project', 'vpc', 'rule') export const getVpcRouterSelector = requireParams('project', 'vpc', 'router') -export const getRouterRouteSelector = requireParams('project', 'vpc', 'router', 'route') +export const getVpcRouterRouteSelector = requireParams('project', 'vpc', 'router', 'route') export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet') export const getSiloSelector = requireParams('silo') export const getSiloImageSelector = requireParams('image') @@ -82,7 +82,7 @@ export const useProjectSnapshotSelector = () => export const useInstanceSelector = () => useSelectedParams(getInstanceSelector) export const useVpcSelector = () => useSelectedParams(getVpcSelector) export const useVpcRouterSelector = () => useSelectedParams(getVpcRouterSelector) -export const useRouterRouteSelector = () => useSelectedParams(getRouterRouteSelector) +export const useVpcRouterRouteSelector = () => useSelectedParams(getVpcRouterRouteSelector) export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector) export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector) export const useSiloSelector = () => useSelectedParams(getSiloSelector) diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index b0df885b3f..721ec3ba67 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -8,7 +8,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' -import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' @@ -72,13 +72,6 @@ export function RouterRoutePage() { query: { project, vpc }, }) - // const editRouterRoute = useApiMutation('vpcRouterRouteUpdate', { - // onSuccess() { - // apiQueryClient.invalidateQueries('vpcRouterRouteList') - // addToast({ content: 'Your route has been updated' }) - // }, - // }) - const deleteRouterRoute = useApiMutation('vpcRouterRouteDelete', { onSuccess() { apiQueryClient.invalidateQueries('vpcRouterRouteList') @@ -108,6 +101,7 @@ export function RouterRoutePage() { buttonTo={pb.vpcRouterRoutesNew({ project, vpc, router })} /> ) + const navigate = useNavigate() const routerRoutesColHelper = createColumnHelper() @@ -128,25 +122,39 @@ export function RouterRoutePage() { ] const makeRangeActions = useCallback( - ({ name, id }: RouterRoute): MenuAction[] => [ + (routerRoute: RouterRoute): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => { + // the edit view has its own loader, but we can make the modal open + // instantaneously by preloading the fetch result + apiQueryClient.setQueryData( + 'vpcRouterRouteView', + { path: { route: routerRoute.name }, query: { project, vpc, router } }, + routerRoute + ) + navigate(pb.vpcRouterRouteEdit({ project, vpc, router, route: routerRoute.name })) + }, + }, { label: 'Delete', className: 'destructive', onActivate: () => confirmAction({ - doAction: () => deleteRouterRoute.mutateAsync({ path: { route: id } }), + doAction: () => + deleteRouterRoute.mutateAsync({ path: { route: routerRoute.id } }), errorTitle: 'Could not remove route', modalTitle: 'Confirm remove route', modalContent: (

- Are you sure you want to delete route {name}? + Are you sure you want to delete route {routerRoute.name}?

), actionType: 'danger', }), }, ], - [deleteRouterRoute] + [navigate, project, vpc, router, deleteRouterRoute] ) const columns = useColsWithActions(routerRoutesStaticCols, makeRangeActions) diff --git a/app/routes.tsx b/app/routes.tsx index 34ec8f6223..80ed4d268a 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -38,6 +38,7 @@ import { EditVpcSideModalForm } from './forms/vpc-edit' import { CreateRouterSideModalForm } from './forms/vpc-router-create' import { EditRouterSideModalForm } from './forms/vpc-router-edit' import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create' +import { EditRouterRouteSideModalForm } from './forms/vpc-router-route-edit' import type { CrumbFunc } from './hooks/use-title' import { AuthenticatedLayout } from './layouts/AuthenticatedLayout' import { AuthLayout } from './layouts/AuthLayout' @@ -453,6 +454,12 @@ export const routes = createRoutesFromElements( element={} handle={{ crumb: 'New Route' }} /> + } + loader={EditRouterRouteSideModalForm.loader} + handle={{ crumb: 'Edit Route' }} + /> } loader={FloatingIpsPage.loader}> diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 34e5c30f98..8b2a96f0eb 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1129,6 +1129,16 @@ export const handlers = makeHandlers({ return json(newRoute, { status: 201 }) }, vpcRouterRouteView: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }), + vpcRouterRouteUpdate({ body, path, query }) { + const route = lookup.vpcRouterRoute({ ...path, ...query }) + if (body.destination) { + route.destination = body.destination + } + if (body.target) { + route.target = body.target + } + return route + }, vpcRouterRouteDelete: ({ path, query }) => { const route = lookup.vpcRouterRoute({ ...path, ...query }) db.vpcRouterRoutes = db.vpcRouterRoutes.filter((r) => r.id !== route.id) @@ -1456,5 +1466,4 @@ export const handlers = makeHandlers({ timeseriesSchemaList: NotImplemented, userBuiltinList: NotImplemented, userBuiltinView: NotImplemented, - vpcRouterRouteUpdate: NotImplemented, }) From ef29208d7d3cffb945a76befcd8de594c80ff7da Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 7 Aug 2024 16:34:44 -0400 Subject: [PATCH 18/53] Update tests --- mock-api/vpc.ts | 2 +- test/e2e/vpcs.e2e.ts | 88 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index 2f52a2d9bd..f50f2dc751 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -48,7 +48,7 @@ export const vpcs: Json = [vpc, vpc2] export const routerRoutes: Json> = [ { id: '51e50342-790f-4efb-8518-10bf01279514', - name: 'default', + name: 'default1', description: "VPC Subnet route for 'default'", time_created: '2024-07-11T17:46:21.161086Z', time_modified: '2024-07-11T17:46:21.161086Z', diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index d34f579ade..92a590bc0a 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -47,7 +47,7 @@ test('can create and delete subnet', async ({ page }) => { // only one row in table, the default mock-subnet const rows = page.locator('tbody >> tr') await expect(rows).toHaveCount(1) - await expect(rows.nth(0).locator('text="mock-subnet"')).toBeVisible() + await expect(rows.nth(0).getByText('mock-subnet')).toBeVisible() // open modal, fill out form, submit await page.click('text=New subnet') @@ -57,11 +57,11 @@ test('can create and delete subnet', async ({ page }) => { await expect(rows).toHaveCount(2) - await expect(rows.nth(0).locator('text="mock-subnet"')).toBeVisible() - await expect(rows.nth(0).locator('text="10.1.1.1/24"')).toBeVisible() + await expect(rows.nth(0).getByText('mock-subnet')).toBeVisible() + await expect(rows.nth(0).getByText('10.1.1.1/24')).toBeVisible() - await expect(rows.nth(1).locator('text="mock-subnet-2"')).toBeVisible() - await expect(rows.nth(1).locator('text="10.1.1.2/24"')).toBeVisible() + await expect(rows.nth(1).getByText('mock-subnet-2')).toBeVisible() + await expect(rows.nth(1).getByText('10.1.1.2/24')).toBeVisible() // click more button on row to get menu, then click Delete await page @@ -74,3 +74,81 @@ test('can create and delete subnet', async ({ page }) => { await expect(rows).toHaveCount(1) }) + +test('can create, update, and delete Router', async ({ page }) => { + // load the VPC page for mock-vpc, to the firewall-rules tab + await page.goto('/projects/mock-project/vpcs/mock-vpc') + await page.getByRole('tab', { name: 'Routers' }).click() + + // expect to see the list of routers, including mock-system-router and mock-custom-router + const tbody = page.getByRole('table').locator('tbody') + const rows = tbody.locator('tr') + await expect(rows).toHaveCount(2) + await expect(rows.getByText('mock-system-router')).toBeVisible() + await expect(rows.getByText('mock-custom-router')).toBeVisible() + + // delete mock-custom-router + const row = page.getByRole('row', { name: 'mock-custom-router' }) + await row.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(rows).toHaveCount(1) + await expect(rows.getByText('mock-custom-router')).toBeHidden() + + // create a new router + await page.click('text=New router') + await page.fill('input[name=name]', 'mock-custom-router-2') + await page.click('button:has-text("Create router")') + await expect(rows).toHaveCount(2) + await expect(rows.getByText('mock-custom-router-2')).toBeVisible() + + // click on mock-system-router to go to the router detail page + await page.getByText('mock-system-router').click() + await expect(page).toHaveURL( + '/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router' + ) +}) +test('can create, update, and delete Route', async ({ page }) => { + // load the router + await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router') + + // expect to see table of routes + const table = page.getByRole('table') + const routeRows = table.locator('tbody >> tr') + await expect(routeRows).toHaveCount(4) + await expect(routeRows.getByText('default1')).toBeVisible() + await expect(routeRows.getByText('default-v4')).toBeVisible() + await expect(routeRows.getByText('default-v6')).toBeVisible() + await expect(routeRows.getByText('drop-local')).toBeVisible() + + // create a new route + await page.click('text=New route') + await page.getByRole('textbox', { name: 'name' }).fill('new-route') + await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.0') + await page.getByRole('textbox', { name: 'Target value' }).fill('1.1.1.1') + await page.getByRole('button', { name: 'Create route' }).click() + await expect(routeRows).toHaveCount(5) + const newRow = page.getByRole('row', { name: 'new-route' }) + await expect(newRow).toBeVisible() + + // see the destination value of 0.0.0.0 + await expect(newRow.getByText('0.0.0.0')).toBeVisible() + + // update the route by clicking the edit button + await newRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Edit' }).click() + await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.1') + await page.getByRole('button', { name: 'Update route' }).click() + await expect(routeRows).toHaveCount(5) + await expect(routeRows.getByText('new-route')).toBeVisible() + + // see the destination value of 0.0.0.1 + await expect(routeRows.getByText('0.0.0.1')).toBeVisible() + + // delete the route + await newRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(routeRows).toHaveCount(4) + await expect(newRow).toBeHidden() +}) From 240524f67918c8a4442ec3d66aee60722dfc72b6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Aug 2024 10:45:05 -0400 Subject: [PATCH 19/53] Update to routes, path-builder spec --- app/forms/vpc-router-edit.tsx | 2 +- app/routes.tsx | 2 +- app/util/path-builder.spec.ts | 7 +++++++ app/util/path-builder.ts | 4 +--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 3e4ab425a0..eff66fd6b5 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -46,7 +46,7 @@ export function EditRouterSideModalForm() { const navigate = useNavigate() const onDismiss = (navigate: NavigateFunction) => { - navigate(pb.vpcRouter(routerSelector)) + navigate(pb.vpcRouters({ project, vpc })) } const editRouter = useApiMutation('vpcRouterUpdate', { diff --git a/app/routes.tsx b/app/routes.tsx index 80ed4d268a..d57baa58cb 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -455,7 +455,7 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'New Route' }} /> } loader={EditRouterRouteSideModalForm.loader} handle={{ crumb: 'Edit Route' }} diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 68b0197014..744323cf19 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -24,6 +24,8 @@ const params = { pool: 'pl', rule: 'fr', subnet: 'su', + router: 'r', + route: 'rr', } test('path builder', () => { @@ -95,6 +97,11 @@ test('path builder', () => { "vpcSubnets": "/projects/p/vpcs/v/subnets", "vpcSubnetsEdit": "/projects/p/vpcs/v/subnets/su/edit", "vpcSubnetsNew": "/projects/p/vpcs/v/subnets-new", + "vpcRouters": "/projects/p/vpcs/v/routers", + "vpcRouterEdit": "/projects/p/vpcs/v/routers/r/edit", + "vpcRouterRouteEdit": "/projects/p/vpcs/v/routers/r/routes/rr/edit", + "vpcRouterRoutesNew": "/projects/p/vpcs/v/routers/r/routes-new", + "vpcRoutersNew": "/projects/p/vpcs/v/routers-new", "vpcs": "/projects/p/vpcs", "vpcsNew": "/projects/p/vpcs-new", } diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 6b7395bfee..1709b19c59 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -90,9 +90,7 @@ export const pb = { vpcRouter: (params: VpcRouter) => `${pb.vpcRouters(params)}/${params.router}`, vpcRouterEdit: (params: VpcRouter) => `${pb.vpcRouter(params)}/edit`, vpcRouterRouteEdit: (params: VpcRouterRoute) => - `${pb.vpcRouter(params)}/${params.route}/edit`, - vpcRouterRouteDelete: (params: VpcRouterRoute) => - `${pb.vpcRouterRouteEdit(params)}/delete`, + `${pb.vpcRouter(params)}/routes/${params.route}/edit`, vpcRouterRoutesNew: (params: VpcRouter) => `${pb.vpcRouter(params)}/routes-new`, vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`, From ff12b753921edc413e6f518ced68232dee6977a3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Aug 2024 10:52:48 -0400 Subject: [PATCH 20/53] alphabetize routes --- app/util/path-builder.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 744323cf19..4e03f9ffe3 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -94,14 +94,14 @@ test('path builder', () => { "vpcFirewallRuleEdit": "/projects/p/vpcs/v/firewall-rules/fr/edit", "vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules", "vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new", - "vpcSubnets": "/projects/p/vpcs/v/subnets", - "vpcSubnetsEdit": "/projects/p/vpcs/v/subnets/su/edit", - "vpcSubnetsNew": "/projects/p/vpcs/v/subnets-new", - "vpcRouters": "/projects/p/vpcs/v/routers", "vpcRouterEdit": "/projects/p/vpcs/v/routers/r/edit", "vpcRouterRouteEdit": "/projects/p/vpcs/v/routers/r/routes/rr/edit", "vpcRouterRoutesNew": "/projects/p/vpcs/v/routers/r/routes-new", + "vpcRouters": "/projects/p/vpcs/v/routers", "vpcRoutersNew": "/projects/p/vpcs/v/routers-new", + "vpcSubnets": "/projects/p/vpcs/v/subnets", + "vpcSubnetsEdit": "/projects/p/vpcs/v/subnets/su/edit", + "vpcSubnetsNew": "/projects/p/vpcs/v/subnets-new", "vpcs": "/projects/p/vpcs", "vpcsNew": "/projects/p/vpcs-new", } From 6afd4159235b7dd8dd196faef84d9c747566cb47 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Aug 2024 11:00:55 -0400 Subject: [PATCH 21/53] add in a missing route to the spec --- app/util/path-builder.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 4e03f9ffe3..76e39846ef 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -94,6 +94,7 @@ test('path builder', () => { "vpcFirewallRuleEdit": "/projects/p/vpcs/v/firewall-rules/fr/edit", "vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules", "vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new", + "vpcRouter": "/projects/p/vpcs/v/routers/r", "vpcRouterEdit": "/projects/p/vpcs/v/routers/r/edit", "vpcRouterRouteEdit": "/projects/p/vpcs/v/routers/r/routes/rr/edit", "vpcRouterRoutesNew": "/projects/p/vpcs/v/routers/r/routes-new", From 7440f84383e7ed1a13730060400171051fdb9958 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Aug 2024 11:45:47 -0400 Subject: [PATCH 22/53] Update test to match new error text in console --- test/e2e/error-pages.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/error-pages.e2e.ts b/test/e2e/error-pages.e2e.ts index 26f6a5982e..d697e5cf5b 100644 --- a/test/e2e/error-pages.e2e.ts +++ b/test/e2e/error-pages.e2e.ts @@ -31,7 +31,7 @@ test('Shows something went wrong page on other errors', async ({ page }) => { // but we do see it in the browser console const error = - 'Invariant failed: Expected query to be prefetched. Key: ["projectView",{"path":{"project":"error-503"}}]' + 'Expected query to be prefetched.\nKey: ["projectView",{"path":{"project":"error-503"}}]' expect(errors.some((e) => e.message.includes(error))).toBeTruthy() // test clicking sign out From 136b129784a04812219ae5a38d1b8125da0f4b1b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Aug 2024 13:57:51 -0400 Subject: [PATCH 23/53] Update app/forms/vpc-router-create.tsx Co-authored-by: David Crespo --- app/forms/vpc-router-create.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index ac71cb2249..19fdf05476 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -26,9 +26,7 @@ export function CreateRouterSideModalForm() { const vpcSelector = useVpcSelector() const navigate = useNavigate() - const onDismiss = (navigate: NavigateFunction) => { - navigate(pb.vpcRouters(vpcSelector)) - } + const onDismiss = () => navigate(pb.vpcRouters(vpcSelector)) const createRouter = useApiMutation('vpcRouterCreate', { onSuccess() { From ccfb037c8e54103997e8fe09de5bac698bda528f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Aug 2024 14:07:51 -0400 Subject: [PATCH 24/53] Slight refactor on onDismiss --- app/forms/vpc-router-create.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index 19fdf05476..089d47aa6f 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useNavigate, type NavigateFunction } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/api' @@ -32,7 +32,7 @@ export function CreateRouterSideModalForm() { onSuccess() { queryClient.invalidateQueries('vpcRouterList') addToast({ content: 'Your router has been created' }) - onDismiss(navigate) + onDismiss() }, }) @@ -43,7 +43,7 @@ export function CreateRouterSideModalForm() { form={form} formType="create" resourceName="router" - onDismiss={() => onDismiss(navigate)} + onDismiss={onDismiss} onSubmit={(body) => createRouter.mutate({ query: vpcSelector, body })} loading={createRouter.isPending} submitError={createRouter.error} From 8bfaf3b972fd3c2b12ff2e0a1d9bc0d1712e44d7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Aug 2024 18:29:07 -0400 Subject: [PATCH 25/53] Clean up some duplicated code --- app/components/TopBarPicker.tsx | 2 +- app/forms/vpc-router-route-create.tsx | 25 ++++++------------------- app/forms/vpc-router-route-edit.tsx | 24 ++++++------------------ app/forms/vpc-router-route/shared.tsx | 27 +++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 38 deletions(-) create mode 100644 app/forms/vpc-router-route/shared.tsx diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index f16bad3a83..c15c9f45a1 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -235,7 +235,7 @@ export function SiloPicker() { export function IpPoolPicker() { // picker only shows up when a pool is in scope const { pool: poolName } = useIpPoolSelector() - const { data } = useApiQuery('ipPoolList', { query: { limit: 10 } }) + const { data } = useApiQuery('ipPoolList', { query: { limit: PAGE_SIZE } }) const items = (data?.items || []).map((pool) => ({ label: pool.name, to: pb.ipPool({ pool: pool.name }), diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 08bd3a7e04..de3ad5fc2d 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -14,9 +14,12 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { + routerRouteDestinationTypes, + routerRouteTargetTypes, +} from '~/forms/vpc-router-route/shared' import { useForm, useVpcRouterSelector } from '~/hooks' import { addToast } from '~/stores/toast' -import type { ListboxItem } from '~/ui/lib/Listbox' import { pb } from '~/util/path-builder' const defaultValues: RouterRouteCreate = { @@ -26,22 +29,6 @@ const defaultValues: RouterRouteCreate = { target: { type: 'ip', value: '' }, } -const destinationTypes: ListboxItem[] = [ - { value: 'ip', label: 'IP' }, - { value: 'ip_net', label: 'IP net' }, - { value: 'vpc', label: 'VPC' }, - { value: 'subnet', label: 'subnet' }, -] - -const targetTypes: ListboxItem[] = [ - { value: 'ip', label: 'IP' }, - { value: 'vpc', label: 'VPC' }, - { value: 'subnet', label: 'subnet' }, - { value: 'instance', label: 'instance' }, - { value: 'internet_gateway', label: 'Internet gateway' }, - { value: 'drop', label: 'Drop' }, -] - export function CreateRouterRouteSideModalForm() { const queryClient = useApiQueryClient() const routerSelector = useVpcRouterSelector() @@ -76,7 +63,7 @@ export function CreateRouterRouteSideModalForm() { { const { project, vpc, router, route } = getVpcRouterRouteSelector(params) @@ -101,7 +89,7 @@ export function EditRouterRouteSideModalForm() { Date: Thu, 8 Aug 2024 18:32:25 -0400 Subject: [PATCH 26/53] Removed old stubbed code --- mock-api/msw/handlers.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 8b2a96f0eb..e6cc0cd53c 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1073,19 +1073,6 @@ export const handlers = makeHandlers({ return json(newRouter, { status: 201 }) }, vpcRouterView: ({ path, query }) => lookup.vpcRouter({ ...path, ...query }), - // vpcRouterCreate({ body, query }) { - // const vpc = lookup.vpc(query) - // errIfExists(db.vpcRouters, { vpc_id: vpc.id, name: body.name }) - - // const newRouter: Json = { - // id: uuid(), - // vpc_id: vpc.id, - // ...body, - // ...getTimestamps(), - // } - // db.vpcRouters.push(newRouter) - // return json(newRouter, { status: 201 }) - // }, vpcRouterUpdate({ body, path, query }) { const router = lookup.vpcRouter({ ...path, ...query }) From 50c5b728bda8d6f3feb73d64f07c5d051cfcc3d5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Aug 2024 10:39:35 -0400 Subject: [PATCH 27/53] Simpler table construction; add link to VPC in TopBar --- app/components/TopBarPicker.tsx | 1 + .../project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 15 ++++----------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index c15c9f45a1..f6d1ffd6b9 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -269,6 +269,7 @@ export function VpcPicker() { current={vpc} items={items} noItemsText="No VPCs found" + to={pb.vpc({ project, vpc })} /> ) } diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 7f0c89abaf..4f3bd35cc1 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -14,9 +14,9 @@ import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' import { getVpcSelector, useVpcSelector } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { EmptyCell } from '~/table/cells/EmptyCell' -import { LinkCell } from '~/table/cells/LinkCell' +import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -52,16 +52,9 @@ export function VpcRoutersTab() { const staticColumns = useMemo( () => [ colHelper.accessor('name', { - cell: (info) => ( - - {info.getValue()} - - ), - }), - colHelper.accessor('description', { - header: 'Description', - cell: (info) => info.getValue() || , + cell: makeLinkCell((router) => pb.vpcRouter({ ...vpcSelector, router })), }), + colHelper.accessor('description', Columns.description), ], [vpcSelector] ) From a2e3a83a44210ff9ba8005b266198dfa59a5f871 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Aug 2024 10:54:49 -0400 Subject: [PATCH 28/53] refactoring --- mock-api/msw/handlers.ts | 3 +-- mock-api/vpc.ts | 24 +++++++++++------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index e6cc0cd53c..9df535ae77 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1107,8 +1107,7 @@ export const handlers = makeHandlers({ const newRoute: Json = { id: uuid(), vpc_router_id: vpcRouter.id, - // TODO: look into proper setting of `kind` - kind: 'default', + kind: 'custom', ...body, ...getTimestamps(), } diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index f50f2dc751..39576396fa 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -45,14 +45,18 @@ export const vpc2: Json = { export const vpcs: Json = [vpc, vpc2] +const routeBase = { + time_created: '2024-07-11T17:46:21.161086Z', + time_modified: '2024-07-11T17:46:21.161086Z', + vpc_router_id: vpc.id, +} + export const routerRoutes: Json> = [ { + ...routeBase, id: '51e50342-790f-4efb-8518-10bf01279514', name: 'default1', description: "VPC Subnet route for 'default'", - time_created: '2024-07-11T17:46:21.161086Z', - time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', kind: 'vpc_subnet', target: { type: 'subnet', @@ -64,12 +68,10 @@ export const routerRoutes: Json> = [ }, }, { + ...routeBase, id: '4c98cd3b-37be-4754-954f-ca960f7a5c3f', name: 'default-v4', description: 'The default route of a vpc', - time_created: '2024-07-11T17:46:21.161086Z', - time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', kind: 'default', target: { type: 'internet_gateway', @@ -81,12 +83,10 @@ export const routerRoutes: Json> = [ }, }, { + ...routeBase, id: '83ee96a3-e418-47fd-912e-e5b22c6a29c6', name: 'default-v6', description: 'The default route of a vpc', - time_created: '2024-07-11T17:46:21.161086Z', - time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', kind: 'default', target: { type: 'internet_gateway', @@ -98,13 +98,11 @@ export const routerRoutes: Json> = [ }, }, { + ...routeBase, id: '51e50342-790f-4efb-8518-10bf01279515', name: 'drop-local', description: 'Drop all local traffic', - time_created: '2024-07-11T17:46:21.161086Z', - time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', - kind: 'default', + kind: 'custom', destination: { type: 'ip', value: '192.168.1.1', From 2f67e0db2f783bd0c3e5a77b05bca3dae2d43e39 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Aug 2024 16:20:44 -0400 Subject: [PATCH 29/53] Optimize route fields --- app/forms/vpc-router-route-create.tsx | 39 +++-------------- app/forms/vpc-router-route-edit.tsx | 40 +++--------------- app/forms/vpc-router-route/shared.tsx | 61 ++++++++++++++++++++------- 3 files changed, 55 insertions(+), 85 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index de3ad5fc2d..bbe371d58c 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -14,10 +14,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' -import { - routerRouteDestinationTypes, - routerRouteTargetTypes, -} from '~/forms/vpc-router-route/shared' +import { fields } from '~/forms/vpc-router-route/shared' import { useForm, useVpcRouterSelector } from '~/hooks' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -61,37 +58,11 @@ export function CreateRouterRouteSideModalForm() { > - - - + + + {targetType !== 'drop' && ( - + )}
) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index f9fe499238..39f6f1aecb 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -24,15 +24,11 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { fields } from '~/forms/vpc-router-route/shared' import { getVpcRouterRouteSelector, useForm, useVpcRouterRouteSelector } from '~/hooks' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' -import { - routerRouteDestinationTypes, - routerRouteTargetTypes, -} from './vpc-router-route/shared' - EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { project, vpc, router, route } = getVpcRouterRouteSelector(params) await apiQueryClient.prefetchQuery('vpcRouterRouteView', { @@ -87,37 +83,11 @@ export function EditRouterRouteSideModalForm() { > - - - + + + {targetType !== 'drop' && ( - + )} ) diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index c169152238..20173ce771 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -8,20 +8,49 @@ import type { RouteDestination, RouteTarget } from '~/api' -type DestinationItem = { value: RouteDestination['type']; label: string } -export const routerRouteDestinationTypes: DestinationItem[] = [ - { value: 'ip', label: 'IP' }, - { value: 'ip_net', label: 'IP net' }, - { value: 'vpc', label: 'VPC' }, - { value: 'subnet', label: 'subnet' }, -] +const destTypes: Record = { + ip: 'IP', + ip_net: 'IP net', + vpc: 'VPC', + subnet: 'subnet', +} +const targetTypes: Record = { + ip: 'IP', + vpc: 'VPC', + subnet: 'subnet', + instance: 'instance', + internet_gateway: 'Internet gateway', + drop: 'Drop', +} -type TargetItem = { value: RouteTarget['type']; label: string } -export const routerRouteTargetTypes: TargetItem[] = [ - { value: 'ip', label: 'IP' }, - { value: 'vpc', label: 'VPC' }, - { value: 'subnet', label: 'subnet' }, - { value: 'instance', label: 'instance' }, - { value: 'internet_gateway', label: 'Internet gateway' }, - { value: 'drop', label: 'Drop' }, -] +const toItems = (mapping: Record) => + Object.entries(mapping).map(([value, label]) => ({ value, label })) + +export const fields = { + destType: { + name: 'destination.type' as const, + items: toItems(destTypes), + label: 'Destination type', + placeholder: 'Select a destination type', + required: true, + }, + destValue: { + name: 'destination.value' as const, + label: 'Destination value', + placeholder: 'Enter a destination value', + required: true, + }, + targetType: { + name: 'target.type' as const, + items: toItems(targetTypes), + label: 'Target type', + placeholder: 'Select a target type', + required: true, + }, + targetValue: { + name: 'target.value' as const, + label: 'Target value', + placeholder: 'Enter a target value', + required: true, + }, +} From ac75b10c3d0563d9ada7428b1fdbfe2744c63039 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Aug 2024 16:28:25 -0400 Subject: [PATCH 30/53] Adjust mock db values --- mock-api/vpc.ts | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index 39576396fa..ab2babec9e 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -45,10 +45,32 @@ export const vpc2: Json = { export const vpcs: Json = [vpc, vpc2] +export const vpcRouter: Json = { + id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', + name: 'mock-system-router', + description: 'a fake router', + time_created: new Date(2024, 0, 1).toISOString(), + time_modified: new Date(2024, 0, 2).toISOString(), + vpc_id: vpc.id, + kind: 'system', +} + +export const vpcRouter2: Json = { + id: '7ffc1613-8492-42f1-894b-9ef5c9ba2507', + name: 'mock-custom-router', + description: 'a fake custom router', + time_created: new Date(2024, 1, 1).toISOString(), + time_modified: new Date(2024, 1, 2).toISOString(), + vpc_id: vpc.id, + kind: 'custom', +} + +export const vpcRouters: Json = [vpcRouter, vpcRouter2] + const routeBase = { time_created: '2024-07-11T17:46:21.161086Z', time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: vpc.id, + vpc_router_id: vpcRouter.id, } export const routerRoutes: Json> = [ @@ -113,28 +135,6 @@ export const routerRoutes: Json> = [ }, ] -export const vpcRouter: Json = { - id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', - name: 'mock-system-router', - description: 'a fake router', - time_created: new Date(2024, 0, 1).toISOString(), - time_modified: new Date(2024, 0, 2).toISOString(), - vpc_id: vpc.id, - kind: 'system', -} - -export const vpcRouter2: Json = { - id: '7ffc1613-8492-42f1-894b-9ef5c9ba2507', - name: 'mock-custom-router', - description: 'a fake custom router', - time_created: new Date(2024, 1, 1).toISOString(), - time_modified: new Date(2024, 1, 2).toISOString(), - vpc_id: vpc.id, - kind: 'custom', -} - -export const vpcRouters: Json = [vpcRouter, vpcRouter2] - export const vpcSubnet: Json = { // this is supposed to be flattened into the top level. will fix in API id: 'd12bf934-d2bf-40e9-8596-bb42a7793749', From d52df65cd0fc394015cb1c148595b122837258af Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Aug 2024 16:38:42 -0400 Subject: [PATCH 31/53] Refactor --- mock-api/msw/db.ts | 6 ------ mock-api/msw/handlers.ts | 4 +--- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index a434f847cb..c271f0d197 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -165,12 +165,6 @@ export const lookup = { return route }, - vpcRouterRouteList({ project, router, vpc }: PP.VpcRouter): Json[] { - if (!project) throw notFoundErr('no project specified') - if (!router) throw notFoundErr('no router specified') - if (!vpc) throw notFoundErr('no VPC specified') - return db.vpcRouterRoutes - }, vpcSubnet({ subnet: id, ...vpcSelector }: PP.VpcSubnet): Json { if (!id) throw notFoundErr('no subnet specified') diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 9df535ae77..0572e312c7 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1097,9 +1097,7 @@ export const handlers = makeHandlers({ vpcRouterRouteList: ({ query: { project, router, vpc } }) => { const vpcRouter = lookup.vpcRouter({ project, router, vpc }) return { - items: lookup - .vpcRouterRouteList({ project, router, vpc }) - .filter((r) => r.vpc_router_id === vpcRouter.id), + items: db.vpcRouterRoutes.filter((r) => r.vpc_router_id === vpcRouter.id), } }, vpcRouterRouteCreate({ body, query }) { From 3a16b0b88272eecd65f22027ff721955cd8ab98e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Aug 2024 16:45:51 -0400 Subject: [PATCH 32/53] Paginate routes --- mock-api/msw/handlers.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 0572e312c7..f0f8341a75 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1091,14 +1091,11 @@ export const handlers = makeHandlers({ db.vpcRouters = db.vpcRouters.filter((r) => r.id !== router.id) return 204 }, - /* - the following needs to return a paginated list, rather than a spoofed object with { items } - */ - vpcRouterRouteList: ({ query: { project, router, vpc } }) => { + vpcRouterRouteList: ({ query }) => { + const { project, router, vpc } = query const vpcRouter = lookup.vpcRouter({ project, router, vpc }) - return { - items: db.vpcRouterRoutes.filter((r) => r.vpc_router_id === vpcRouter.id), - } + const routes = db.vpcRouterRoutes.filter((r) => r.vpc_router_id === vpcRouter.id) + return paginated(query, routes) }, vpcRouterRouteCreate({ body, query }) { const vpcRouter = lookup.vpcRouter(query) From 572a136fb34e47a865bea6329356dd8d56989988 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Aug 2024 19:20:43 -0400 Subject: [PATCH 33/53] Adding some error handling for issues unearthed when using this branch with dogfood --- app/forms/vpc-router-route-create.tsx | 6 ++++++ app/forms/vpc-router-route-edit.tsx | 25 ++++++++++++++++------ app/forms/vpc-router-route/shared.tsx | 10 +++++++-- app/pages/project/vpcs/RouterRoutePage.tsx | 2 ++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index bbe371d58c..0f1f2f9210 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useEffect } from 'react' import { useNavigate, type NavigateFunction } from 'react-router-dom' import { useApiMutation, useApiQueryClient, type RouterRouteCreate } from '@oxide/api' @@ -46,6 +47,11 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const targetType = form.watch('target.type') + // Clear target value when targetType changes to 'drop' + useEffect(() => { + targetType === 'drop' && form.setValue('target.value', '') + }, [targetType, form]) + return ( { @@ -65,6 +66,15 @@ export function EditRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const targetType = form.watch('target.type') + let isDisabled = false + let disabledReason = '' + + // Can simplify this if there aren't other disabling reasons + if (route?.kind === 'vpc_subnet') { + isDisabled = true + disabledReason = routeError.vpcSubnetNotModifiable + } + return ( - - - - - + {isDisabled && } + + + + + {targetType !== 'drop' && ( - + )} ) diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index 20173ce771..4e08ccb6f7 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -14,10 +14,11 @@ const destTypes: Record = { vpc: 'VPC', subnet: 'subnet', } -const targetTypes: Record = { + +// Subnets cannot be used as a target in custom routers +const targetTypes: Record, string> = { ip: 'IP', vpc: 'VPC', - subnet: 'subnet', instance: 'instance', internet_gateway: 'Internet gateway', drop: 'Drop', @@ -54,3 +55,8 @@ export const fields = { required: true, }, } + +export const routeError = { + vpcSubnetNotModifiable: + 'Routes of type VPC Subnet within the system router are not modifiable', +} diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 721ec3ba67..98c6bc3224 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -21,6 +21,7 @@ import { import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { routeError } from '~/forms/vpc-router-route/shared' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' @@ -135,6 +136,7 @@ export function RouterRoutePage() { ) navigate(pb.vpcRouterRouteEdit({ project, vpc, router, route: routerRoute.name })) }, + disabled: routerRoute.kind === 'vpc_subnet' && routeError.vpcSubnetNotModifiable, }, { label: 'Delete', From 9c876be5c2aec62eb2cd72f83a6a90867c6e8eaa Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Aug 2024 11:00:02 -0400 Subject: [PATCH 34/53] VPCs can not be used as destinations or targets in cusom routes --- app/forms/vpc-router-route/shared.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index 4e08ccb6f7..c72bdf6e71 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -8,17 +8,16 @@ import type { RouteDestination, RouteTarget } from '~/api' -const destTypes: Record = { +// VPCs can not be specified as a destination in custom routers +const destTypes: Record, string> = { ip: 'IP', ip_net: 'IP net', - vpc: 'VPC', subnet: 'subnet', } -// Subnets cannot be used as a target in custom routers -const targetTypes: Record, string> = { +// Subnets and VPCs cannot be used as a target in custom routers +const targetTypes: Record, string> = { ip: 'IP', - vpc: 'VPC', instance: 'instance', internet_gateway: 'Internet gateway', drop: 'Drop', From 027e275f0c8d21fb9bc498cda16523e6912f3a0c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Aug 2024 14:08:59 -0400 Subject: [PATCH 35/53] additional validations in forms; better comments --- app/forms/vpc-router-route-create.tsx | 14 ++++++++++--- app/forms/vpc-router-route-edit.tsx | 24 +++++++++++++++++++--- app/forms/vpc-router-route/shared.tsx | 11 +++++++++- app/pages/project/vpcs/RouterRoutePage.tsx | 5 +++-- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 0f1f2f9210..ce6a53a22d 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -15,7 +15,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' -import { fields } from '~/forms/vpc-router-route/shared' +import { fields, targetValueDescription } from '~/forms/vpc-router-route/shared' import { useForm, useVpcRouterSelector } from '~/hooks' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -47,9 +47,11 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const targetType = form.watch('target.type') - // Clear target value when targetType changes to 'drop' useEffect(() => { + // Clear target value when targetType changes to 'drop' targetType === 'drop' && form.setValue('target.value', '') + // 'outbound' is only valid option when targetType is 'internet_gateway' + targetType === 'internet_gateway' && form.setValue('target.value', 'outbound') }, [targetType, form]) return ( @@ -68,7 +70,13 @@ export function CreateRouterRouteSideModalForm() { {targetType !== 'drop' && ( - + )} ) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 6171411752..bcc3af482e 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useEffect } from 'react' import { useNavigate, type LoaderFunctionArgs, @@ -24,7 +25,11 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' -import { fields, routeError } from '~/forms/vpc-router-route/shared' +import { + fields, + routeFormMessage, + targetValueDescription, +} from '~/forms/vpc-router-route/shared' import { getVpcRouterRouteSelector, useForm, useVpcRouterRouteSelector } from '~/hooks' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' @@ -66,13 +71,20 @@ export function EditRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const targetType = form.watch('target.type') + useEffect(() => { + // Clear target value when targetType changes to 'drop' + targetType === 'drop' && form.setValue('target.value', '') + // 'outbound' is only valid option when targetType is 'internet_gateway' + targetType === 'internet_gateway' && form.setValue('target.value', 'outbound') + }, [targetType, form]) + let isDisabled = false let disabledReason = '' // Can simplify this if there aren't other disabling reasons if (route?.kind === 'vpc_subnet') { isDisabled = true - disabledReason = routeError.vpcSubnetNotModifiable + disabledReason = routeFormMessage.vpcSubnetNotModifiable } return ( @@ -98,7 +110,13 @@ export function EditRouterRouteSideModalForm() { {targetType !== 'drop' && ( - + )} ) diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index c72bdf6e71..3162ea5878 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -9,6 +9,7 @@ import type { RouteDestination, RouteTarget } from '~/api' // VPCs can not be specified as a destination in custom routers +// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L363 const destTypes: Record, string> = { ip: 'IP', ip_net: 'IP net', @@ -16,6 +17,7 @@ const destTypes: Record, string> = { } // Subnets and VPCs cannot be used as a target in custom routers +// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L362-L368 const targetTypes: Record, string> = { ip: 'IP', instance: 'instance', @@ -55,7 +57,14 @@ export const fields = { }, } -export const routeError = { +export const routeFormMessage = { vpcSubnetNotModifiable: 'Routes of type VPC Subnet within the system router are not modifiable', + internetGatewayTargetValue: + 'For ‘Internet gateway’ targets, the value must be ‘outbound’', } + +export const targetValueDescription = (targetType: RouteTarget['type']) => + targetType === 'internet_gateway' + ? routeFormMessage.internetGatewayTargetValue + : undefined diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 98c6bc3224..39e7a9f7c3 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -21,7 +21,7 @@ import { import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' -import { routeError } from '~/forms/vpc-router-route/shared' +import { routeFormMessage } from '~/forms/vpc-router-route/shared' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' @@ -136,7 +136,8 @@ export function RouterRoutePage() { ) navigate(pb.vpcRouterRouteEdit({ project, vpc, router, route: routerRoute.name })) }, - disabled: routerRoute.kind === 'vpc_subnet' && routeError.vpcSubnetNotModifiable, + disabled: + routerRoute.kind === 'vpc_subnet' && routeFormMessage.vpcSubnetNotModifiable, }, { label: 'Delete', From 4ff67ed592750f1637046db163c724db86e05308 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Aug 2024 15:40:51 -0400 Subject: [PATCH 36/53] small test improvement --- test/e2e/vpcs.e2e.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 92a590bc0a..ced143fb1a 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -125,6 +125,14 @@ test('can create, update, and delete Route', async ({ page }) => { await page.click('text=New route') await page.getByRole('textbox', { name: 'name' }).fill('new-route') await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.0') + + // we'll set the target in a second, but first verify that selecting internet gateway disables the value + await page.getByRole('button', { name: 'Target type' }).click() + await page.getByRole('option', { name: 'Internet gateway' }).click() + await expect(page.getByRole('textbox', { name: 'Target value' })).toBeDisabled() + await expect(page.getByRole('textbox', { name: 'Target value' })).toHaveValue('outbound') + await page.getByRole('button', { name: 'Target type' }).click() + await page.getByRole('option', { name: 'IP' }).click() await page.getByRole('textbox', { name: 'Target value' }).fill('1.1.1.1') await page.getByRole('button', { name: 'Create route' }).click() await expect(routeRows).toHaveCount(5) From 812e010d3c8110744061ec226d45ba334fd29430 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Aug 2024 18:22:14 -0400 Subject: [PATCH 37/53] Additional restrictions on routes / routers --- app/forms/vpc-router-route/shared.tsx | 4 ++++ app/pages/project/vpcs/RouterRoutePage.tsx | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index 3162ea5878..04eb0ba6e9 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -62,6 +62,10 @@ export const routeFormMessage = { 'Routes of type VPC Subnet within the system router are not modifiable', internetGatewayTargetValue: 'For ‘Internet gateway’ targets, the value must be ‘outbound’', + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L204 + noNewRoutesOnSystemRouter: 'user-provided routes cannot be added to a system router', + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L300-L304 + noDeletingRoutesOnSystemRouter: 'DELETE not allowed on system routes', } export const targetValueDescription = (targetType: RouteTarget['type']) => diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 39e7a9f7c3..486bb62561 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -155,11 +155,16 @@ export function RouterRoutePage() { ), actionType: 'danger', }), + disabled: + routerData.kind === 'system' && routeFormMessage.noDeletingRoutesOnSystemRouter, }, ], - [navigate, project, vpc, router, deleteRouterRoute] + [navigate, project, vpc, router, deleteRouterRoute, routerData] ) const columns = useColsWithActions(routerRoutesStaticCols, makeRangeActions) + // user-provided routes cannot be added to a system router + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L205 + const canCreateNewRoute = routerData.kind === 'custom' return ( <> @@ -194,9 +199,11 @@ export function RouterRoutePage() { Routes - - New route - + {canCreateNewRoute && ( + + New route + + )}
From 442fa0719f078582a4af96d7bda8295721a54378 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 11:23:55 -0400 Subject: [PATCH 38/53] Update tests --- mock-api/vpc.ts | 11 ++++++----- test/e2e/vpcs.e2e.ts | 26 ++++++++++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index ab2babec9e..585e511cf9 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -45,7 +45,7 @@ export const vpc2: Json = { export const vpcs: Json = [vpc, vpc2] -export const vpcRouter: Json = { +export const defaultRouter: Json = { id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', name: 'mock-system-router', description: 'a fake router', @@ -55,7 +55,7 @@ export const vpcRouter: Json = { kind: 'system', } -export const vpcRouter2: Json = { +export const customRouter: Json = { id: '7ffc1613-8492-42f1-894b-9ef5c9ba2507', name: 'mock-custom-router', description: 'a fake custom router', @@ -65,19 +65,19 @@ export const vpcRouter2: Json = { kind: 'custom', } -export const vpcRouters: Json = [vpcRouter, vpcRouter2] +export const vpcRouters: Json = [defaultRouter, customRouter] const routeBase = { time_created: '2024-07-11T17:46:21.161086Z', time_modified: '2024-07-11T17:46:21.161086Z', - vpc_router_id: vpcRouter.id, + vpc_router_id: defaultRouter.id, } export const routerRoutes: Json> = [ { ...routeBase, id: '51e50342-790f-4efb-8518-10bf01279514', - name: 'default1', + name: 'default', description: "VPC Subnet route for 'default'", kind: 'vpc_subnet', target: { @@ -121,6 +121,7 @@ export const routerRoutes: Json> = [ }, { ...routeBase, + vpc_router_id: customRouter.id, id: '51e50342-790f-4efb-8518-10bf01279515', name: 'drop-local', description: 'Drop all local traffic', diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index ced143fb1a..1d4b25c676 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -108,17 +108,31 @@ test('can create, update, and delete Router', async ({ page }) => { '/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router' ) }) -test('can create, update, and delete Route', async ({ page }) => { + +test('can’t create or delete Routes on system routers', async ({ page }) => { // load the router await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router') + // verify that the "new route" link isn't present, since users can't add routes to system routers + await expect(page.getByRole('link', { name: 'New route' })).toBeHidden() + // expect to see table of routes const table = page.getByRole('table') const routeRows = table.locator('tbody >> tr') - await expect(routeRows).toHaveCount(4) - await expect(routeRows.getByText('default1')).toBeVisible() + await expect(routeRows).toHaveCount(3) + await expect(routeRows.getByText('default').first()).toBeVisible() await expect(routeRows.getByText('default-v4')).toBeVisible() await expect(routeRows.getByText('default-v6')).toBeVisible() + await routeRows.first().getByRole('button', { name: 'Row actions' }).click() + // can't delete default routes + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled() +}) + +test('can create, update, and delete Route', async ({ page }) => { + // go to the custom-router-2 page + await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router') + const table = page.getByRole('table') + const routeRows = table.locator('tbody >> tr') await expect(routeRows.getByText('drop-local')).toBeVisible() // create a new route @@ -135,7 +149,7 @@ test('can create, update, and delete Route', async ({ page }) => { await page.getByRole('option', { name: 'IP' }).click() await page.getByRole('textbox', { name: 'Target value' }).fill('1.1.1.1') await page.getByRole('button', { name: 'Create route' }).click() - await expect(routeRows).toHaveCount(5) + await expect(routeRows).toHaveCount(2) const newRow = page.getByRole('row', { name: 'new-route' }) await expect(newRow).toBeVisible() @@ -147,7 +161,7 @@ test('can create, update, and delete Route', async ({ page }) => { await page.getByRole('menuitem', { name: 'Edit' }).click() await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.1') await page.getByRole('button', { name: 'Update route' }).click() - await expect(routeRows).toHaveCount(5) + await expect(routeRows).toHaveCount(2) await expect(routeRows.getByText('new-route')).toBeVisible() // see the destination value of 0.0.0.1 @@ -157,6 +171,6 @@ test('can create, update, and delete Route', async ({ page }) => { await newRow.getByRole('button', { name: 'Row actions' }).click() await page.getByRole('menuitem', { name: 'Delete' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - await expect(routeRows).toHaveCount(4) + await expect(routeRows).toHaveCount(1) await expect(newRow).toBeHidden() }) From 75ba267423f2431867fc4483b71cd0dfc97d02d7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 14:36:36 -0400 Subject: [PATCH 39/53] switch to expectRowVisible --- test/e2e/vpcs.e2e.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 1d4b25c676..db6db925ef 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -81,11 +81,12 @@ test('can create, update, and delete Router', async ({ page }) => { await page.getByRole('tab', { name: 'Routers' }).click() // expect to see the list of routers, including mock-system-router and mock-custom-router - const tbody = page.getByRole('table').locator('tbody') + const table = page.getByRole('table') + const tbody = table.locator('tbody') const rows = tbody.locator('tr') await expect(rows).toHaveCount(2) - await expect(rows.getByText('mock-system-router')).toBeVisible() - await expect(rows.getByText('mock-custom-router')).toBeVisible() + await expectRowVisible(table, { name: 'mock-system-router' }) + await expectRowVisible(table, { name: 'mock-custom-router' }) // delete mock-custom-router const row = page.getByRole('row', { name: 'mock-custom-router' }) @@ -100,7 +101,7 @@ test('can create, update, and delete Router', async ({ page }) => { await page.fill('input[name=name]', 'mock-custom-router-2') await page.click('button:has-text("Create router")') await expect(rows).toHaveCount(2) - await expect(rows.getByText('mock-custom-router-2')).toBeVisible() + await expectRowVisible(table, { name: 'mock-custom-router-2' }) // click on mock-system-router to go to the router detail page await page.getByText('mock-system-router').click() @@ -120,9 +121,9 @@ test('can’t create or delete Routes on system routers', async ({ page }) => { const table = page.getByRole('table') const routeRows = table.locator('tbody >> tr') await expect(routeRows).toHaveCount(3) - await expect(routeRows.getByText('default').first()).toBeVisible() - await expect(routeRows.getByText('default-v4')).toBeVisible() - await expect(routeRows.getByText('default-v6')).toBeVisible() + await expectRowVisible(table, { Name: 'default' }) + await expectRowVisible(table, { Name: 'default-v4' }) + await expectRowVisible(table, { Name: 'default-v6' }) await routeRows.first().getByRole('button', { name: 'Row actions' }).click() // can't delete default routes await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled() @@ -133,7 +134,7 @@ test('can create, update, and delete Route', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router') const table = page.getByRole('table') const routeRows = table.locator('tbody >> tr') - await expect(routeRows.getByText('drop-local')).toBeVisible() + await expectRowVisible(table, { Name: 'drop-local' }) // create a new route await page.click('text=New route') @@ -151,10 +152,10 @@ test('can create, update, and delete Route', async ({ page }) => { await page.getByRole('button', { name: 'Create route' }).click() await expect(routeRows).toHaveCount(2) const newRow = page.getByRole('row', { name: 'new-route' }) - await expect(newRow).toBeVisible() + await expectRowVisible(table, { Name: 'new-route' }) // see the destination value of 0.0.0.0 - await expect(newRow.getByText('0.0.0.0')).toBeVisible() + await expectRowVisible(table, { Destination: 'ip0.0.0.0' }) // update the route by clicking the edit button await newRow.getByRole('button', { name: 'Row actions' }).click() @@ -162,10 +163,10 @@ test('can create, update, and delete Route', async ({ page }) => { await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.1') await page.getByRole('button', { name: 'Update route' }).click() await expect(routeRows).toHaveCount(2) - await expect(routeRows.getByText('new-route')).toBeVisible() + await expectRowVisible(table, { Name: 'new-route' }) // see the destination value of 0.0.0.1 - await expect(routeRows.getByText('0.0.0.1')).toBeVisible() + await expectRowVisible(table, { Destination: 'ip0.0.0.1' }) // delete the route await newRow.getByRole('button', { name: 'Row actions' }).click() From 7e62450c3ab556d3de68dd15e6bc59cc011a4982 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 14:39:52 -0400 Subject: [PATCH 40/53] use clickRowAction --- test/e2e/vpcs.e2e.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index db6db925ef..583e574c0d 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -7,7 +7,7 @@ */ import { expect, test } from '@playwright/test' -import { expectRowVisible } from './utils' +import { clickRowAction, expectRowVisible } from './utils' test('can nav to VpcPage from /', async ({ page }) => { await page.goto('/') @@ -89,9 +89,7 @@ test('can create, update, and delete Router', async ({ page }) => { await expectRowVisible(table, { name: 'mock-custom-router' }) // delete mock-custom-router - const row = page.getByRole('row', { name: 'mock-custom-router' }) - await row.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await clickRowAction(page, 'mock-custom-router', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() await expect(rows).toHaveCount(1) await expect(rows.getByText('mock-custom-router')).toBeHidden() @@ -151,15 +149,13 @@ test('can create, update, and delete Route', async ({ page }) => { await page.getByRole('textbox', { name: 'Target value' }).fill('1.1.1.1') await page.getByRole('button', { name: 'Create route' }).click() await expect(routeRows).toHaveCount(2) - const newRow = page.getByRole('row', { name: 'new-route' }) await expectRowVisible(table, { Name: 'new-route' }) // see the destination value of 0.0.0.0 await expectRowVisible(table, { Destination: 'ip0.0.0.0' }) // update the route by clicking the edit button - await newRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Edit' }).click() + await clickRowAction(page, 'new-route', 'Edit') await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.1') await page.getByRole('button', { name: 'Update route' }).click() await expect(routeRows).toHaveCount(2) @@ -169,9 +165,8 @@ test('can create, update, and delete Route', async ({ page }) => { await expectRowVisible(table, { Destination: 'ip0.0.0.1' }) // delete the route - await newRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await clickRowAction(page, 'new-route', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() await expect(routeRows).toHaveCount(1) - await expect(newRow).toBeHidden() + await expect(page.getByRole('row', { name: 'new-route' })).toBeHidden() }) From 0673c21437b4e02a92b59b4828439d86a2fe6476 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 15:30:39 -0400 Subject: [PATCH 41/53] Refactoring post-review --- app/forms/vpc-router-route-create.tsx | 8 ++++---- app/forms/vpc-router-route-edit.tsx | 12 ++++-------- .../project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 1 + 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index ce6a53a22d..9caac379bb 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { useEffect } from 'react' -import { useNavigate, type NavigateFunction } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { useApiMutation, useApiQueryClient, type RouterRouteCreate } from '@oxide/api' @@ -32,7 +32,7 @@ export function CreateRouterRouteSideModalForm() { const routerSelector = useVpcRouterSelector() const navigate = useNavigate() - const onDismiss = (navigate: NavigateFunction) => { + const onDismiss = () => { navigate(pb.vpcRouter(routerSelector)) } @@ -40,7 +40,7 @@ export function CreateRouterRouteSideModalForm() { onSuccess() { queryClient.invalidateQueries('vpcRouterRouteList') addToast({ content: 'Your route has been created' }) - onDismiss(navigate) + onDismiss() }, }) @@ -59,7 +59,7 @@ export function CreateRouterRouteSideModalForm() { form={form} formType="create" resourceName="route" - onDismiss={() => navigate(pb.vpcRouter(routerSelector))} + onDismiss={onDismiss} onSubmit={(body) => createRouterRoute.mutate({ query: routerSelector, body })} loading={createRouterRoute.isPending} submitError={createRouterRoute.error} diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index bcc3af482e..b763bd2f71 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -6,11 +6,7 @@ * Copyright Oxide Computer Company */ import { useEffect } from 'react' -import { - useNavigate, - type LoaderFunctionArgs, - type NavigateFunction, -} from 'react-router-dom' +import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, @@ -56,7 +52,7 @@ export function EditRouterRouteSideModalForm() { const defaultValues: RouterRouteUpdate = { ...route } - const onDismiss = (navigate: NavigateFunction) => { + const onDismiss = () => { navigate(pb.vpcRouter({ project, vpc, router: routerName })) } @@ -64,7 +60,7 @@ export function EditRouterRouteSideModalForm() { onSuccess() { queryClient.invalidateQueries('vpcRouterRouteList') addToast({ content: 'Your route has been updated' }) - onDismiss(navigate) + onDismiss() }, }) @@ -92,7 +88,7 @@ export function EditRouterRouteSideModalForm() { form={form} formType="edit" resourceName="route" - onDismiss={() => onDismiss(navigate)} + onDismiss={onDismiss} onSubmit={(body) => updateRouterRoute.mutate({ query: { project, vpc, router: routerName }, diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 4f3bd35cc1..bc8d98c667 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -55,6 +55,7 @@ export function VpcRoutersTab() { cell: makeLinkCell((router) => pb.vpcRouter({ ...vpcSelector, router })), }), colHelper.accessor('description', Columns.description), + colHelper.accessor('timeCreated', Columns.timeCreated), ], [vpcSelector] ) From fe2df7d112c94ad25ad26aa0e41a89088e487dcb Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 17:31:16 -0400 Subject: [PATCH 42/53] More refactoring --- app/forms/subnet-edit.tsx | 2 +- app/forms/vpc-router-route-edit.tsx | 8 +++++- app/forms/vpc-router-route/shared.tsx | 6 ++-- app/pages/project/vpcs/RouterRoutePage.tsx | 32 ++++++++++++++++------ test/e2e/vpcs.e2e.ts | 18 ++++++------ 5 files changed, 44 insertions(+), 22 deletions(-) diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 734f0197b4..5ae5fbd5bd 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -50,7 +50,7 @@ export function EditSubnetForm() { }, }) - const defaultValues = R.pick(subnet, ['name', 'description']) satisfies VpcSubnetUpdate + const defaultValues: VpcSubnetUpdate = R.pick(subnet, ['name', 'description']) const form = useForm({ defaultValues }) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index b763bd2f71..ba51abc95f 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -7,6 +7,7 @@ */ import { useEffect } from 'react' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' +import * as R from 'remeda' import { apiQueryClient, @@ -50,7 +51,12 @@ export function EditRouterRouteSideModalForm() { query: { project, vpc, router: routerName }, }) - const defaultValues: RouterRouteUpdate = { ...route } + const defaultValues: RouterRouteUpdate = R.pick(route, [ + 'name', + 'description', + 'target', + 'destination', + ]) const onDismiss = () => { navigate(pb.vpcRouter({ project, vpc, router: routerName })) diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index 04eb0ba6e9..bd8d20c359 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -12,15 +12,15 @@ import type { RouteDestination, RouteTarget } from '~/api' // https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L363 const destTypes: Record, string> = { ip: 'IP', - ip_net: 'IP net', - subnet: 'subnet', + ip_net: 'IP network', + subnet: 'Subnet', } // Subnets and VPCs cannot be used as a target in custom routers // https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L362-L368 const targetTypes: Record, string> = { ip: 'IP', - instance: 'instance', + instance: 'Instance', internet_gateway: 'Internet gateway', drop: 'Drop', } diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx index 486bb62561..84e4b4847c 100644 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ b/app/pages/project/vpcs/RouterRoutePage.tsx @@ -16,7 +16,9 @@ import { apiQueryClient, useApiMutation, usePrefetchedApiQuery, + type RouteDestination, type RouterRoute, + type RouteTarget, } from '~/api' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' @@ -53,16 +55,28 @@ RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { return null } -const RouterRouteTypeValueBadge = ({ type, value }: { type: string; value?: string }) => { - const typeString = type - .replace('_', ' ') - .replace('ip net', 'ip network') - .replace('internet gateway', 'gateway') - .replace('subnet', 'VPC subnet') +const routeTypes = { + drop: 'Drop', + ip: 'IP', + ip_net: 'IP network', + instance: 'Instance', + internet_gateway: 'Gateway', + subnet: 'VPC subnet', + vpc: 'VPC', +} + +// All will have a type and a value except `Drop`, which only has a type +const RouterRouteTypeValueBadge = ({ + type, + value, +}: { + type: (RouteDestination | RouteTarget)['type'] + value?: string +}) => { return value ? ( - + ) : ( - {type} + {routeTypes[type]} ) } @@ -177,7 +191,7 @@ export function RouterRoutePage() { summary="Routers summary copy TK" links={[docLinks.routers]} /> - + diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 583e574c0d..3e1b5f7f63 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -149,20 +149,22 @@ test('can create, update, and delete Route', async ({ page }) => { await page.getByRole('textbox', { name: 'Target value' }).fill('1.1.1.1') await page.getByRole('button', { name: 'Create route' }).click() await expect(routeRows).toHaveCount(2) - await expectRowVisible(table, { Name: 'new-route' }) - - // see the destination value of 0.0.0.0 - await expectRowVisible(table, { Destination: 'ip0.0.0.0' }) + await expectRowVisible(table, { + Name: 'new-route', + Destination: 'IP0.0.0.0', + Target: 'IP1.1.1.1', + }) // update the route by clicking the edit button await clickRowAction(page, 'new-route', 'Edit') await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.1') await page.getByRole('button', { name: 'Update route' }).click() await expect(routeRows).toHaveCount(2) - await expectRowVisible(table, { Name: 'new-route' }) - - // see the destination value of 0.0.0.1 - await expectRowVisible(table, { Destination: 'ip0.0.0.1' }) + await expectRowVisible(table, { + Name: 'new-route', + Destination: 'IP0.0.0.1', + Target: 'IP1.1.1.1', + }) // delete the route await clickRowAction(page, 'new-route', 'Delete') From f4975c7bb3d65adeddf2fe76d80db44ef0e68a48 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 17:36:03 -0400 Subject: [PATCH 43/53] Rename to RouterPage --- app/pages/project/vpcs/RouterRoutePage.tsx | 226 --------------------- app/routes.tsx | 35 +--- 2 files changed, 3 insertions(+), 258 deletions(-) delete mode 100644 app/pages/project/vpcs/RouterRoutePage.tsx diff --git a/app/pages/project/vpcs/RouterRoutePage.tsx b/app/pages/project/vpcs/RouterRoutePage.tsx deleted file mode 100644 index 84e4b4847c..0000000000 --- a/app/pages/project/vpcs/RouterRoutePage.tsx +++ /dev/null @@ -1,226 +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 { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' -import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' - -import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' - -import { - apiQueryClient, - useApiMutation, - usePrefetchedApiQuery, - type RouteDestination, - type RouterRoute, - type RouteTarget, -} from '~/api' -import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { MoreActionsMenu } from '~/components/MoreActionsMenu' -import { routeFormMessage } from '~/forms/vpc-router-route/shared' -import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' -import { confirmAction } from '~/stores/confirm-action' -import { addToast } from '~/stores/toast' -import { EmptyCell } from '~/table/cells/EmptyCell' -import { TypeValueCell } from '~/table/cells/TypeValueCell' -import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' -import { Badge } from '~/ui/lib/Badge' -import { CreateLink } from '~/ui/lib/CreateButton' -import { DateTime } from '~/ui/lib/DateTime' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { PropertiesTable } from '~/ui/lib/PropertiesTable' -import { TableControls, TableTitle } from '~/ui/lib/Table' -import { docLinks } from '~/util/links' -import { pb } from '~/util/path-builder' - -RouterRoutePage.loader = async function ({ params }: LoaderFunctionArgs) { - const { project, vpc, router } = getVpcRouterSelector(params) - await Promise.all([ - apiQueryClient.prefetchQuery('vpcRouterView', { - path: { router }, - query: { project, vpc }, - }), - apiQueryClient.prefetchQuery('vpcRouterRouteList', { - query: { project, router, vpc, limit: PAGE_SIZE }, - }), - ]) - return null -} - -const routeTypes = { - drop: 'Drop', - ip: 'IP', - ip_net: 'IP network', - instance: 'Instance', - internet_gateway: 'Gateway', - subnet: 'VPC subnet', - vpc: 'VPC', -} - -// All will have a type and a value except `Drop`, which only has a type -const RouterRouteTypeValueBadge = ({ - type, - value, -}: { - type: (RouteDestination | RouteTarget)['type'] - value?: string -}) => { - return value ? ( - - ) : ( - {routeTypes[type]} - ) -} - -export function RouterRoutePage() { - const { project, vpc, router } = useVpcRouterSelector() - const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { - path: { router }, - query: { project, vpc }, - }) - - const deleteRouterRoute = useApiMutation('vpcRouterRouteDelete', { - onSuccess() { - apiQueryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been deleted' }) - }, - }) - - const actions = useMemo( - () => [ - { - label: 'Copy ID', - onActivate() { - window.navigator.clipboard.writeText(routerData.id || '') - }, - }, - ], - [routerData] - ) - const { Table } = useQueryTable('vpcRouterRouteList', { query: { project, router, vpc } }) - - const emptyState = ( - } - title="No routes" - body="Add a route to see it here" - buttonText="Add route" - buttonTo={pb.vpcRouterRoutesNew({ project, vpc, router })} - /> - ) - const navigate = useNavigate() - - const routerRoutesColHelper = createColumnHelper() - - const routerRoutesStaticCols = [ - routerRoutesColHelper.accessor('name', { header: 'Name' }), - routerRoutesColHelper.accessor('kind', { - header: 'Kind', - cell: (info) => {info.getValue().replace('_', ' ')}, - }), - routerRoutesColHelper.accessor('destination', { - header: 'Destination', - cell: (info) => , - }), - routerRoutesColHelper.accessor('target', { - header: 'Target', - cell: (info) => , - }), - ] - - const makeRangeActions = useCallback( - (routerRoute: RouterRoute): MenuAction[] => [ - { - label: 'Edit', - onActivate: () => { - // the edit view has its own loader, but we can make the modal open - // instantaneously by preloading the fetch result - apiQueryClient.setQueryData( - 'vpcRouterRouteView', - { path: { route: routerRoute.name }, query: { project, vpc, router } }, - routerRoute - ) - navigate(pb.vpcRouterRouteEdit({ project, vpc, router, route: routerRoute.name })) - }, - disabled: - routerRoute.kind === 'vpc_subnet' && routeFormMessage.vpcSubnetNotModifiable, - }, - { - label: 'Delete', - className: 'destructive', - onActivate: () => - confirmAction({ - doAction: () => - deleteRouterRoute.mutateAsync({ path: { route: routerRoute.id } }), - errorTitle: 'Could not remove route', - modalTitle: 'Confirm remove route', - modalContent: ( -

- Are you sure you want to delete route {routerRoute.name}? -

- ), - actionType: 'danger', - }), - disabled: - routerData.kind === 'system' && routeFormMessage.noDeletingRoutesOnSystemRouter, - }, - ], - [navigate, project, vpc, router, deleteRouterRoute, routerData] - ) - const columns = useColsWithActions(routerRoutesStaticCols, makeRangeActions) - // user-provided routes cannot be added to a system router - // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L205 - const canCreateNewRoute = routerData.kind === 'custom' - - return ( - <> - - }>{router} -
- } - summary="Routers summary copy TK" - links={[docLinks.routers]} - /> - -
-
- - - - {routerData.description || } - - {routerData.kind} - - - - - - - - - - - - Routes - - {canCreateNewRoute && ( - - New route - - )} - -
- - - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index d57baa58cb..27456e57b0 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -66,7 +66,7 @@ import { NetworkingTab } from './pages/project/instances/instance/tabs/Networkin import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { InstancesPage } from './pages/project/instances/InstancesPage' import { SnapshotsPage } from './pages/project/snapshots/SnapshotsPage' -import { RouterRoutePage } from './pages/project/vpcs/RouterRoutePage' +import { RouterPage } from './pages/project/vpcs/RouterPage' import { VpcFirewallRulesTab } from './pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab' import { VpcRoutersTab } from './pages/project/vpcs/VpcPage/tabs/VpcRoutersTab' import { VpcSubnetsTab } from './pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab' @@ -396,35 +396,6 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Edit Subnet' }} /> - {/* - - } /> - } - loader={IpPoolsPage.loader} - handle={{ crumb: 'IP pools' }} - > - - } /> - } - loader={EditIpPoolSideModalForm.loader} - handle={{ crumb: 'Edit IP pool' }} - /> - - - - } - loader={IpPoolPage.loader} - handle={{ crumb: poolCrumb }} - > - } /> - - */} - } loader={VpcRoutersTab.loader}> } - loader={RouterRoutePage.loader} + element={} + loader={RouterPage.loader} handle={{ crumb: 'Routes' }} path="vpcs/:vpc/routers/:router" > From 7d8853984009f84409e316117fb1ed198dad1514 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 18:16:34 -0400 Subject: [PATCH 44/53] git add womp womp --- app/pages/project/vpcs/RouterPage.tsx | 226 ++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 app/pages/project/vpcs/RouterPage.tsx diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx new file mode 100644 index 0000000000..d33df218d3 --- /dev/null +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -0,0 +1,226 @@ +/* + * 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 } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' + +import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' + +import { + apiQueryClient, + useApiMutation, + usePrefetchedApiQuery, + type RouteDestination, + type RouterRoute, + type RouteTarget, +} from '~/api' +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { routeFormMessage } from '~/forms/vpc-router-route/shared' +import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' +import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { TypeValueCell } from '~/table/cells/TypeValueCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { Badge } from '~/ui/lib/Badge' +import { CreateLink } from '~/ui/lib/CreateButton' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableControls, TableTitle } from '~/ui/lib/Table' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +RouterPage.loader = async function ({ params }: LoaderFunctionArgs) { + const { project, vpc, router } = getVpcRouterSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }), + apiQueryClient.prefetchQuery('vpcRouterRouteList', { + query: { project, router, vpc, limit: PAGE_SIZE }, + }), + ]) + return null +} + +const routeTypes = { + drop: 'Drop', + ip: 'IP', + ip_net: 'IP network', + instance: 'Instance', + internet_gateway: 'Gateway', + subnet: 'VPC subnet', + vpc: 'VPC', +} + +// All will have a type and a value except `Drop`, which only has a type +const RouterRouteTypeValueBadge = ({ + type, + value, +}: { + type: (RouteDestination | RouteTarget)['type'] + value?: string +}) => { + return value ? ( + + ) : ( + {routeTypes[type]} + ) +} + +export function RouterPage() { + const { project, vpc, router } = useVpcRouterSelector() + const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }) + + const deleteRouterRoute = useApiMutation('vpcRouterRouteDelete', { + onSuccess() { + apiQueryClient.invalidateQueries('vpcRouterRouteList') + addToast({ content: 'Your route has been deleted' }) + }, + }) + + const actions = useMemo( + () => [ + { + label: 'Copy ID', + onActivate() { + window.navigator.clipboard.writeText(routerData.id || '') + }, + }, + ], + [routerData] + ) + const { Table } = useQueryTable('vpcRouterRouteList', { query: { project, router, vpc } }) + + const emptyState = ( + } + title="No routes" + body="Add a route to see it here" + buttonText="Add route" + buttonTo={pb.vpcRouterRoutesNew({ project, vpc, router })} + /> + ) + const navigate = useNavigate() + + const routerRoutesColHelper = createColumnHelper() + + const routerRoutesStaticCols = [ + routerRoutesColHelper.accessor('name', { header: 'Name' }), + routerRoutesColHelper.accessor('kind', { + header: 'Kind', + cell: (info) => {info.getValue().replace('_', ' ')}, + }), + routerRoutesColHelper.accessor('destination', { + header: 'Destination', + cell: (info) => , + }), + routerRoutesColHelper.accessor('target', { + header: 'Target', + cell: (info) => , + }), + ] + + const makeRangeActions = useCallback( + (routerRoute: RouterRoute): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => { + // the edit view has its own loader, but we can make the modal open + // instantaneously by preloading the fetch result + apiQueryClient.setQueryData( + 'vpcRouterRouteView', + { path: { route: routerRoute.name }, query: { project, vpc, router } }, + routerRoute + ) + navigate(pb.vpcRouterRouteEdit({ project, vpc, router, route: routerRoute.name })) + }, + disabled: + routerRoute.kind === 'vpc_subnet' && routeFormMessage.vpcSubnetNotModifiable, + }, + { + label: 'Delete', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + deleteRouterRoute.mutateAsync({ path: { route: routerRoute.id } }), + errorTitle: 'Could not remove route', + modalTitle: 'Confirm remove route', + modalContent: ( +

+ Are you sure you want to delete route {routerRoute.name}? +

+ ), + actionType: 'danger', + }), + disabled: + routerData.kind === 'system' && routeFormMessage.noDeletingRoutesOnSystemRouter, + }, + ], + [navigate, project, vpc, router, deleteRouterRoute, routerData] + ) + const columns = useColsWithActions(routerRoutesStaticCols, makeRangeActions) + // user-provided routes cannot be added to a system router + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L205 + const canCreateNewRoute = routerData.kind === 'custom' + + return ( + <> + + }>{router} +
+ } + summary="Routers summary copy TK" + links={[docLinks.routers]} + /> + +
+
+ + + + {routerData.description || } + + {routerData.kind} + + + + + + + + + + + + Routes + + {canCreateNewRoute && ( + + New route + + )} + +
+ + + ) +} From 014fb897bcd19a9988e3c7bbd8928041d0acb89a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 13 Aug 2024 18:27:54 -0400 Subject: [PATCH 45/53] badge and IP Net updates --- app/pages/project/vpcs/RouterPage.tsx | 4 +++- mock-api/vpc.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index d33df218d3..1768912925 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -199,7 +199,9 @@ export function RouterPage() { {routerData.description || } - {routerData.kind} + + {routerData.kind} + diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index 585e511cf9..7e2670c9de 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -101,7 +101,7 @@ export const routerRoutes: Json> = [ }, destination: { type: 'ip_net', - value: '0.0.0.0/0', + value: '192.168.1.0/24', }, }, { @@ -116,7 +116,7 @@ export const routerRoutes: Json> = [ }, destination: { type: 'ip_net', - value: '::/0', + value: '2001:db8:abcd:12::/64', }, }, { From 7a81ef0ae74a111fd736068e29dc8064e521c908 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Aug 2024 10:13:41 -0400 Subject: [PATCH 46/53] Add disabled link for 'New route' on system router page --- app/forms/vpc-router-route/shared.tsx | 2 +- app/pages/project/vpcs/RouterPage.tsx | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index bd8d20c359..46d4e1b460 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -63,7 +63,7 @@ export const routeFormMessage = { internetGatewayTargetValue: 'For ‘Internet gateway’ targets, the value must be ‘outbound’', // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L204 - noNewRoutesOnSystemRouter: 'user-provided routes cannot be added to a system router', + noNewRoutesOnSystemRouter: 'User-provided routes cannot be added to a system router', // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L300-L304 noDeletingRoutesOnSystemRouter: 'DELETE not allowed on system routes', } diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index 1768912925..6110dfeb66 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -32,7 +32,7 @@ import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' -import { CreateLink } from '~/ui/lib/CreateButton' +import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import { DateTime } from '~/ui/lib/DateTime' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -214,11 +214,17 @@ export function RouterPage() { Routes - - {canCreateNewRoute && ( + {canCreateNewRoute ? ( New route + ) : ( + + New route + )}
From 8c139fc109f0ec8b93caee2f6580c546608fe9e4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Aug 2024 10:24:44 -0400 Subject: [PATCH 47/53] Add DescriptionCell file and update imports --- .../instances/instance/tabs/NetworkingTab.tsx | 3 ++- app/pages/project/vpcs/RouterPage.tsx | 6 +++--- app/pages/project/vpcs/VpcPage/VpcPage.tsx | 4 ++-- app/pages/system/silos/SiloPage.tsx | 4 ++-- app/table/cells/DescriptionCell.tsx | 13 +++++++++++++ app/table/columns/common.tsx | 7 +------ 6 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 app/table/cells/DescriptionCell.tsx diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 27fecb8c71..6cc8d7d85e 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -30,10 +30,11 @@ import { getInstanceSelector, useInstanceSelector, useProjectSelector } from '~/ import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { Columns, DescriptionCell } from '~/table/columns/common' +import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' import { CopyableIp } from '~/ui/lib/CopyableIp' diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index 6110dfeb66..640ce58ed2 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -27,7 +27,7 @@ import { routeFormMessage } from '~/forms/vpc-router-route/shared' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' -import { EmptyCell } from '~/table/cells/EmptyCell' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' @@ -197,7 +197,7 @@ export function RouterPage() { - {routerData.description || } + {routerData.kind} @@ -213,7 +213,7 @@ export function RouterPage() { - Routes + Routes {canCreateNewRoute ? ( New route diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 5bed62c366..b1bf4073e5 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -12,7 +12,7 @@ import { Networking24Icon } from '@oxide/design-system/icons/react' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks' -import { EmptyCell } from '~/table/cells/EmptyCell' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' @@ -42,7 +42,7 @@ export function VpcPage() { - {vpc.description || } + {vpc.dnsName} diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index 315f02ab9b..e564356a8c 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -13,7 +13,7 @@ import { Cloud16Icon, Cloud24Icon, NextArrow12Icon } from '@oxide/design-system/ import { DocsPopover } from '~/components/DocsPopover' import { QueryParamTabs } from '~/components/QueryParamTabs' import { getSiloSelector, useSiloSelector } from '~/hooks' -import { EmptyCell } from '~/table/cells/EmptyCell' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { PAGE_SIZE } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { DateTime } from '~/ui/lib/DateTime' @@ -68,7 +68,7 @@ export function SiloPage() { {silo.id} - {silo.description || } + diff --git a/app/table/cells/DescriptionCell.tsx b/app/table/cells/DescriptionCell.tsx new file mode 100644 index 0000000000..cdcc6365be --- /dev/null +++ b/app/table/cells/DescriptionCell.tsx @@ -0,0 +1,13 @@ +/* + * 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 { EmptyCell } from '~/table/cells/EmptyCell' +import { Truncate } from '~/ui/lib/Truncate' + +export const DescriptionCell = ({ text }: { text?: string }) => + text ? : diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index c8de914199..6bac4b2426 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -8,10 +8,8 @@ import { filesize } from 'filesize' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { DateTime } from '~/ui/lib/DateTime' -import { Truncate } from '~/ui/lib/Truncate' - -import { EmptyCell } from '../cells/EmptyCell' // the full type of the info arg is CellContext from RT, but in these // cells we only care about the return value of getValue @@ -30,9 +28,6 @@ function sizeCell(info: Info) { ) } -export const DescriptionCell = ({ text }: { text?: string }) => - text ? : - /** Columns used in a bunch of tables */ export const Columns = { /** Truncates text if too long, full text in tooltip */ From dcd1d7fa999b33627627b01ae78371257df2a7c0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Aug 2024 10:31:32 -0400 Subject: [PATCH 48/53] update msw handler for name/description for VPC Route --- mock-api/msw/handlers.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index f0f8341a75..366cbb9d7a 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1112,6 +1112,12 @@ export const handlers = makeHandlers({ vpcRouterRouteView: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }), vpcRouterRouteUpdate({ body, path, query }) { const route = lookup.vpcRouterRoute({ ...path, ...query }) + if (body.name) { + route.name = body.name + } + if (body.description) { + route.description = body.description + } if (body.destination) { route.destination = body.destination } From 80f94bb1b8c50097df8619f2405c29b63d8f6a5f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Aug 2024 11:37:28 -0400 Subject: [PATCH 49/53] Update how form handles drop target --- app/forms/vpc-router-route-create.tsx | 15 ++++++++++++--- app/forms/vpc-router-route-edit.tsx | 12 ++++++++---- app/forms/vpc-router-route/shared.tsx | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 9caac379bb..bf687b71c9 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -48,8 +48,6 @@ export function CreateRouterRouteSideModalForm() { const targetType = form.watch('target.type') useEffect(() => { - // Clear target value when targetType changes to 'drop' - targetType === 'drop' && form.setValue('target.value', '') // 'outbound' is only valid option when targetType is 'internet_gateway' targetType === 'internet_gateway' && form.setValue('target.value', 'outbound') }, [targetType, form]) @@ -60,7 +58,18 @@ export function CreateRouterRouteSideModalForm() { formType="create" resourceName="route" onDismiss={onDismiss} - onSubmit={(body) => createRouterRoute.mutate({ query: routerSelector, body })} + onSubmit={({ name, description, destination, target }) => + createRouterRoute.mutate({ + query: routerSelector, + body: { + name, + description, + destination, + // drop has no value + target: target.type === 'drop' ? { type: target.type } : target, + }, + }) + } loading={createRouterRoute.isPending} submitError={createRouterRoute.error} > diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index ba51abc95f..b5a67c4f16 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -74,8 +74,6 @@ export function EditRouterRouteSideModalForm() { const targetType = form.watch('target.type') useEffect(() => { - // Clear target value when targetType changes to 'drop' - targetType === 'drop' && form.setValue('target.value', '') // 'outbound' is only valid option when targetType is 'internet_gateway' targetType === 'internet_gateway' && form.setValue('target.value', 'outbound') }, [targetType, form]) @@ -95,11 +93,17 @@ export function EditRouterRouteSideModalForm() { formType="edit" resourceName="route" onDismiss={onDismiss} - onSubmit={(body) => + onSubmit={({ name, description, destination, target }) => updateRouterRoute.mutate({ query: { project, vpc, router: routerName }, path: { route: routeName }, - body, + body: { + name, + description, + destination, + // drop has no value + target: target.type === 'drop' ? { type: target.type } : target, + }, }) } loading={updateRouterRoute.isPending} diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx index 46d4e1b460..0063aa9544 100644 --- a/app/forms/vpc-router-route/shared.tsx +++ b/app/forms/vpc-router-route/shared.tsx @@ -65,7 +65,7 @@ export const routeFormMessage = { // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L204 noNewRoutesOnSystemRouter: 'User-provided routes cannot be added to a system router', // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L300-L304 - noDeletingRoutesOnSystemRouter: 'DELETE not allowed on system routes', + noDeletingRoutesOnSystemRouter: 'System routes can not be deleted', } export const targetValueDescription = (targetType: RouteTarget['type']) => From 8cc47444cb7096976b40b84fe09171d0e9a9a179 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Aug 2024 11:49:35 -0400 Subject: [PATCH 50/53] Move from useEffect to onChange for TargetType --- app/forms/vpc-router-route-create.tsx | 15 ++++++++++----- app/forms/vpc-router-route-edit.tsx | 20 +++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index bf687b71c9..871a2fe153 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useApiMutation, useApiQueryClient, type RouterRouteCreate } from '@oxide/api' @@ -47,10 +46,12 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const targetType = form.watch('target.type') - useEffect(() => { + const onChangeTargetType = (value: string | null | undefined) => { // 'outbound' is only valid option when targetType is 'internet_gateway' - targetType === 'internet_gateway' && form.setValue('target.value', 'outbound') - }, [targetType, form]) + if (value === 'internet_gateway') { + form.setValue('target.value', 'outbound') + } + } return ( - + {targetType !== 'drop' && ( { - // 'outbound' is only valid option when targetType is 'internet_gateway' - targetType === 'internet_gateway' && form.setValue('target.value', 'outbound') - }, [targetType, form]) - let isDisabled = false let disabledReason = '' @@ -87,6 +81,13 @@ export function EditRouterRouteSideModalForm() { disabledReason = routeFormMessage.vpcSubnetNotModifiable } + const onChangeTargetType = (value: string | null | undefined) => { + // 'outbound' is only valid option when targetType is 'internet_gateway' + if (value === 'internet_gateway') { + form.setValue('target.value', 'outbound') + } + } + return ( - + {targetType !== 'drop' && ( Date: Wed, 14 Aug 2024 12:38:51 -0400 Subject: [PATCH 51/53] refactor; pull onChange inline --- app/forms/vpc-router-route-create.tsx | 14 ++++++-------- app/forms/vpc-router-route-edit.tsx | 14 ++++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 871a2fe153..7a01b9d74e 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -46,13 +46,6 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const targetType = form.watch('target.type') - const onChangeTargetType = (value: string | null | undefined) => { - // 'outbound' is only valid option when targetType is 'internet_gateway' - if (value === 'internet_gateway') { - form.setValue('target.value', 'outbound') - } - } - return ( { + // 'outbound' is only valid option when targetType is 'internet_gateway' + if (value === 'internet_gateway') { + form.setValue('target.value', 'outbound') + } + }} /> {targetType !== 'drop' && ( { - // 'outbound' is only valid option when targetType is 'internet_gateway' - if (value === 'internet_gateway') { - form.setValue('target.value', 'outbound') - } - } - return ( { + // 'outbound' is only valid option when targetType is 'internet_gateway' + if (value === 'internet_gateway') { + form.setValue('target.value', 'outbound') + } + }} /> {targetType !== 'drop' && ( Date: Wed, 14 Aug 2024 13:08:31 -0500 Subject: [PATCH 52/53] Update handlers to error if route or router name already exists --- app/forms/vpc-router-route-create.tsx | 3 +++ app/forms/vpc-router-route-edit.tsx | 3 +++ mock-api/msw/handlers.ts | 26 +++++++++++++++++--------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 7a01b9d74e..0ca0241d1e 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -79,6 +79,9 @@ export function CreateRouterRouteSideModalForm() { if (value === 'internet_gateway') { form.setValue('target.value', 'outbound') } + if (value === 'drop') { + form.setValue('target.value', '') + } }} /> {targetType !== 'drop' && ( diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 1cd12c3427..7554fe9cce 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -117,6 +117,9 @@ export function EditRouterRouteSideModalForm() { if (value === 'internet_gateway') { form.setValue('target.value', 'outbound') } + if (value === 'drop') { + form.setValue('target.value', '') + } }} /> {targetType !== 'drop' && ( diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 2d1a35ef01..79f8cfc396 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1075,15 +1075,17 @@ export const handlers = makeHandlers({ vpcRouterView: ({ path, query }) => lookup.vpcRouter({ ...path, ...query }), vpcRouterUpdate({ body, path, query }) { const router = lookup.vpcRouter({ ...path, ...query }) - if (body.name) { + // Error if changing the router name and that router name already exists + if (body.name !== router.name) { + errIfExists(db.vpcRouters, { + id: router.id, + name: body.name, + }) + } router.name = body.name } - - if (typeof body.description === 'string') { - router.description = body.description - } - + updateDesc(router, body) return router }, vpcRouterDelete({ path, query }) { @@ -1099,6 +1101,7 @@ export const handlers = makeHandlers({ }, vpcRouterRouteCreate({ body, query }) { const vpcRouter = lookup.vpcRouter(query) + errIfExists(db.vpcRouterRoutes, { vpc_router_id: vpcRouter.id, name: body.name }) const newRoute: Json = { id: uuid(), vpc_router_id: vpcRouter.id, @@ -1113,11 +1116,16 @@ export const handlers = makeHandlers({ vpcRouterRouteUpdate({ body, path, query }) { const route = lookup.vpcRouterRoute({ ...path, ...query }) if (body.name) { + // Error if changing the route name and that route name already exists + if (body.name !== route.name) { + errIfExists(db.vpcRouterRoutes, { + vpc_router_id: route.vpc_router_id, + name: body.name, + }) + } route.name = body.name } - if (body.description) { - route.description = body.description - } + updateDesc(route, body) if (body.destination) { route.destination = body.destination } From e0813a11e063a767c869aeaef68da095677263eb Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Aug 2024 13:27:50 -0500 Subject: [PATCH 53/53] Use VPC ID for testing Router uniqueness on update --- mock-api/msw/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 79f8cfc396..c84bbdc03e 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1079,7 +1079,7 @@ export const handlers = makeHandlers({ // Error if changing the router name and that router name already exists if (body.name !== router.name) { errIfExists(db.vpcRouters, { - id: router.id, + vpc_id: router.vpc_id, name: body.name, }) }