From de664257c33b53fe479f32c7244ac68d3edd67bb Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 23 Jan 2024 16:21:43 -0600 Subject: [PATCH 01/34] basic IP pools page and list silos for pool --- app/components/TopBarPicker.tsx | 23 ++++++- app/hooks/use-params.ts | 2 + app/layouts/SystemLayout.tsx | 19 ++++-- app/pages/system/networking/IpPoolPage.tsx | 62 +++++++++++++++++++ .../system/networking/NetworkingPage.tsx | 43 +++++++++++++ app/routes.tsx | 12 +++- app/util/path-builder.spec.ts | 4 ++ app/util/path-builder.ts | 4 ++ 8 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 app/pages/system/networking/IpPoolPage.tsx create mode 100644 app/pages/system/networking/NetworkingPage.tsx diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index 622cb175d4..cea6f3aa6d 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -20,7 +20,7 @@ import { Wrap, } from '@oxide/ui' -import { useInstanceSelector, useSiloSelector } from 'app/hooks' +import { useInstanceSelector, useIpPoolSelector, useSiloSelector } from 'app/hooks' import { useCurrentUser } from 'app/layouts/AuthenticatedLayout' import { pb } from 'app/util/path-builder' @@ -228,6 +228,27 @@ export function SiloPicker() { ) } +/** Used when drilling down into a pool from the System/Networking view. */ +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 items = (data?.items || []).map((pool) => ({ + label: pool.name, + to: pb.ipPool({ pool: pool.name }), + })) + + return ( + + ) +} + const NoProjectLogo = () => (
diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 21fb0da773..6a1293d8f1 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -42,6 +42,7 @@ export const getProjectImageSelector = requireParams('project', 'image') export const getProjectSnapshotSelector = requireParams('project', 'snapshot') export const requireSledParams = requireParams('sledId') export const requireUpdateParams = requireParams('version') +export const getIpPoolSelector = requireParams('pool') /** * Turn `getThingSelector`, a pure function on a params object, into a hook @@ -79,3 +80,4 @@ export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector export const useIdpSelector = () => useSelectedParams(getIdpSelector) export const useSledParams = () => useSelectedParams(requireSledParams) export const useUpdateParams = () => useSelectedParams(requireUpdateParams) +export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 74c4d9dbec..ad1b2742da 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -9,12 +9,18 @@ import { useMemo } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { apiQueryClient } from '@oxide/api' -import { Cloud16Icon, Divider, Metrics16Icon, Storage16Icon } from '@oxide/ui' +import { + Cloud16Icon, + Divider, + Metrics16Icon, + Networking16Icon, + Storage16Icon, +} from '@oxide/ui' import { trigger404 } from 'app/components/ErrorBoundary' import { DocsLinkItem, NavLinkItem, Sidebar } from 'app/components/Sidebar' import { TopBar } from 'app/components/TopBar' -import { SiloPicker, SiloSystemPicker } from 'app/components/TopBarPicker' +import { IpPoolPicker, SiloPicker, SiloSystemPicker } from 'app/components/TopBarPicker' import { useQuickActions } from 'app/hooks' import { pb } from 'app/util/path-builder' @@ -49,7 +55,7 @@ export default function SystemLayout() { // robust way of doing this would be to make a separate layout for the // silo-specific routes in the route config, but it's overkill considering // this is a one-liner. Switch to that approach at the first sign of trouble. - const { silo } = useParams() + const { silo, pool } = useParams() const navigate = useNavigate() const { pathname } = useLocation() @@ -84,6 +90,7 @@ export default function SystemLayout() { {silo && } + {pool && } @@ -108,10 +115,10 @@ export default function SystemLayout() { System Update - - - Networking */} + + Networking + diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx new file mode 100644 index 0000000000..1e26159ddf --- /dev/null +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -0,0 +1,62 @@ +/* + * 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 { BooleanCell, useQueryTable } from '@oxide/table' +import { EmptyMessage, Networking24Icon, PageHeader, PageTitle } from '@oxide/ui' + +import { getIpPoolSelector, useIpPoolSelector } from 'app/hooks' +import { pb } from 'app/util/path-builder' + +IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) { + const { pool } = getIpPoolSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }), + apiQueryClient.prefetchQuery('ipPoolSiloList', { + path: { pool }, + query: { limit: 10 }, + }), + ]) + return null +} + +const EmptyState = () => ( + } + title="No IP pool associations" + body="You need to link an IP pool to the fleet or a silo to be able to see it here" + buttonText="Link IP pool" + // TODO: correct link + buttonTo={pb.ipPoolNew()} + /> +) + +export function IpPoolPage() { + const poolSelector = useIpPoolSelector() + const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) + const { Table, Column } = useQueryTable('ipPoolSiloList', { path: poolSelector }) + return ( + <> + {/* TODO: I think this page needs a back to pools button. clicking + Networking again is not at all obvious */} + + }>IP Pool: {pool.name} + + + } aria-labelledby="links-label"> + + {/* TODO: we're going to want a tooltip to explain what the f this means */} + +
+ + ) +} diff --git a/app/pages/system/networking/NetworkingPage.tsx b/app/pages/system/networking/NetworkingPage.tsx new file mode 100644 index 0000000000..2d3260b7ce --- /dev/null +++ b/app/pages/system/networking/NetworkingPage.tsx @@ -0,0 +1,43 @@ +/* + * 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 { apiQueryClient } from '@oxide/api' +import { linkCell, useQueryTable } from '@oxide/table' +import { EmptyMessage, Networking24Icon, PageHeader, PageTitle } from '@oxide/ui' + +import { pb } from 'app/util/path-builder' + +const EmptyState = () => ( + } + title="No IP pools" + body="You need to create an IP pool to be able to see it here" + buttonText="New IP pool" + buttonTo={pb.ipPoolNew()} + /> +) + +NetworkingPage.loader = async function () { + await apiQueryClient.prefetchQuery('ipPoolList', { query: { limit: 10 } }) + return null +} + +export function NetworkingPage() { + const { Table, Column } = useQueryTable('ipPoolList', {}) + return ( + <> + + }>Networking + + }> + pb.ipPool({ pool }))} /> + +
+ + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index afe77dfdac..2f5a3524a2 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -65,6 +65,8 @@ import { InventoryPage } from './pages/system/inventory/InventoryPage' import { SledInstancesTab } from './pages/system/inventory/sled/SledInstancesTab' import { SledPage } from './pages/system/inventory/sled/SledPage' import { SledsTab } from './pages/system/inventory/SledsTab' +import { IpPoolPage } from './pages/system/networking/IpPoolPage' +import { NetworkingPage } from './pages/system/networking/NetworkingPage' import { SiloImagesPage } from './pages/system/SiloImagesPage' import { SiloPage } from './pages/system/SiloPage' import SilosPage from './pages/system/SilosPage' @@ -170,7 +172,15 @@ export const routes = createRoutesFromElements( - + + } /> + + {/* TODO: make this a tab on the networking page */} + + } loader={NetworkingPage.loader} /> + } loader={IpPoolPage.loader} /> + + } /> diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index b82d541bf1..d921b04263 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -20,6 +20,7 @@ const params = { sledId: 'sl', image: 'im', snapshot: 'sn', + pool: 'pl', } test('path builder', () => { @@ -38,6 +39,9 @@ test('path builder', () => { "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", "inventory": "/system/inventory", + "ipPool": "/system/networking/ip-pools/pl", + "ipPoolNew": "/system/networking/ip-pool-new", + "ipPools": "/system/networking/ip-pools", "nics": "/projects/p/instances/i/network-interfaces", "profile": "/settings/profile", "project": "/projects/p", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 2c88e678ce..cb7a592dde 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -19,6 +19,7 @@ type Sled = Required type Image = Required type Snapshot = Required type SiloImage = Required +type IpPool = Required export const pb = { projects: () => `/projects`, @@ -78,6 +79,9 @@ export const pb = { systemHealth: () => '/system/health', systemNetworking: () => '/system/networking', + ipPools: () => '/system/networking/ip-pools', + ipPoolNew: () => '/system/networking/ip-pool-new', + ipPool: (params: IpPool) => `${pb.ipPools()}/${params.pool}`, inventory: () => '/system/inventory', rackInventory: () => '/system/inventory/racks', From adef77bb44da1d2e1cee793b756aa06d63ffe5bb Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 24 Jan 2024 01:29:10 -0800 Subject: [PATCH 02/34] Stubbing out tabs for silo IP pools --- app/pages/system/SiloPage.tsx | 134 --------------------- app/pages/system/SilosPage.tsx | 118 ------------------ app/pages/system/networking/IpPoolPage.tsx | 2 +- app/routes.tsx | 14 ++- app/util/path-builder.ts | 2 + libs/api/__generated__/Api.ts | 16 +++ 6 files changed, 30 insertions(+), 256 deletions(-) delete mode 100644 app/pages/system/SiloPage.tsx delete mode 100644 app/pages/system/SilosPage.tsx diff --git a/app/pages/system/SiloPage.tsx b/app/pages/system/SiloPage.tsx deleted file mode 100644 index eb12f0d4ac..0000000000 --- a/app/pages/system/SiloPage.tsx +++ /dev/null @@ -1,134 +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 { Link, Outlet, type LoaderFunctionArgs } from 'react-router-dom' - -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' -import { - DateCell, - DefaultCell, - EmptyCell, - linkCell, - TruncateCell, - useQueryTable, -} from '@oxide/table' -import { - Badge, - buttonStyle, - Cloud16Icon, - Cloud24Icon, - Divider, - EmptyMessage, - NextArrow12Icon, - PageHeader, - PageTitle, - Question12Icon, - TableActions, - Tooltip, -} from '@oxide/ui' - -import { getSiloSelector, useSiloSelector } from 'app/hooks' -import { pb } from 'app/util/path-builder' - -const EmptyState = () => ( - } title="No identity providers" /> -) - -const RoleMappingTooltip = () => ( - - - -) - -SiloPage.loader = async ({ params }: LoaderFunctionArgs) => { - const { silo } = getSiloSelector(params) - await Promise.all([ - apiQueryClient.prefetchQuery('siloView', { path: { silo } }), - apiQueryClient.prefetchQuery('siloIdentityProviderList', { - query: { silo, limit: 25 }, // same as query table - }), - ]) - return null -} - -export function SiloPage() { - const siloSelector = useSiloSelector() - - const { data: silo } = usePrefetchedApiQuery('siloView', { path: siloSelector }) - - const roleMapPairs = Object.entries(silo.mappedFleetRoles).flatMap( - ([fleetRole, siloRoles]) => - siloRoles.map((siloRole) => [siloRole, fleetRole] as [string, string]) - ) - - const { Table, Column } = useQueryTable('siloIdentityProviderList', { - query: siloSelector, - }) - - return ( - <> - - }>{silo.name} - -

- Fleet role mapping -

- {roleMapPairs.length === 0 ? ( - - ) : ( -
    - {roleMapPairs.map(([siloRole, fleetRole]) => ( -
  • - Silo {siloRole} - - Fleet {fleetRole} -
  • - ))} -
- )} - -

Identity providers

- - - New provider - - - }> - {/* TODO: this link will only really work for saml IdPs. */} - ({ name, providerType })} - cell={({ value: { name, providerType } }) => - // Only SAML IdPs have a detail view API endpoint, so only SAML IdPs - // get a link to the detail view. This is a little awkward to do with - // linkCell as currently designed — probably worth a small rework - providerType === 'saml' ? ( - linkCell((provider) => pb.samlIdp({ ...siloSelector, provider }))({ - value: name, - }) - ) : ( - - ) - } - /> - } - /> - {value}} - /> - -
- - - ) -} diff --git a/app/pages/system/SilosPage.tsx b/app/pages/system/SilosPage.tsx deleted file mode 100644 index 68b07e4528..0000000000 --- a/app/pages/system/SilosPage.tsx +++ /dev/null @@ -1,118 +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 { useMemo } from 'react' -import { Link, Outlet, useNavigate } from 'react-router-dom' - -import { - apiQueryClient, - useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, - type Silo, -} from '@oxide/api' -import { - BooleanCell, - DateCell, - linkCell, - useQueryTable, - type MenuAction, -} from '@oxide/table' -import { - Badge, - buttonStyle, - Cloud24Icon, - EmptyMessage, - PageHeader, - PageTitle, - TableActions, -} from '@oxide/ui' - -import { useQuickActions } from 'app/hooks/use-quick-actions' -import { confirmDelete } from 'app/stores/confirm-delete' -import { pb } from 'app/util/path-builder' - -const EmptyState = () => ( - } - title="No silos" - body="You need to create a silo to be able to see it here" - buttonText="New silo" - buttonTo={pb.siloNew()} - /> -) - -SilosPage.loader = async () => { - await apiQueryClient.prefetchQuery('siloList', { query: { limit: 25 } }) - return null -} - -export default function SilosPage() { - const navigate = useNavigate() - - const { Table, Column } = useQueryTable('siloList', {}) - const queryClient = useApiQueryClient() - - const { data: silos } = usePrefetchedApiQuery('siloList', { - query: { limit: 25 }, - }) - - const deleteSilo = useApiMutation('siloDelete', { - onSuccess() { - queryClient.invalidateQueries('siloList') - }, - }) - - const makeActions = (silo: Silo): MenuAction[] => [ - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => deleteSilo.mutateAsync({ path: { silo: silo.name } }), - label: silo.name, - }), - }, - ] - - useQuickActions( - useMemo( - () => [ - { value: 'New silo', onSelect: () => navigate(pb.siloNew()) }, - ...silos.items.map((o) => ({ - value: o.name, - onSelect: () => navigate(pb.silo({ silo: o.name })), - navGroup: 'Silo detail', - })), - ], - [navigate, silos] - ) - ) - - return ( - <> - - }>Silos - - - - New silo - - - } makeActions={makeActions}> - pb.silo({ silo }))} /> - - - silo.identityMode} - cell={({ value }) => {value.replace('_', ' ')}} - /> - -
- - - ) -} diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 1e26159ddf..51d4903eb7 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -47,7 +47,7 @@ export function IpPoolPage() { {/* TODO: I think this page needs a back to pools button. clicking Networking again is not at all obvious */} - }>IP Pool: {pool.name} + }>{`IP Pool: ${pool.name}`}