diff --git a/app/components/ExternalLink.tsx b/app/components/ExternalLink.tsx new file mode 100644 index 0000000000..c12a93f5f3 --- /dev/null +++ b/app/components/ExternalLink.tsx @@ -0,0 +1,27 @@ +/* + * 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 cn from 'classnames' + +type ExternalLinkProps = { + href: string + className?: string + children: React.ReactNode +} + +export function ExternalLink({ href, className, children }: ExternalLinkProps) { + return ( + + {children} + + ) +} diff --git a/app/components/HL.tsx b/app/components/HL.tsx new file mode 100644 index 0000000000..22b7ecfd6b --- /dev/null +++ b/app/components/HL.tsx @@ -0,0 +1,10 @@ +/* + * 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 { classed } from '@oxide/util' + +export const HL = classed.span`text-sans-semi-md text-default` 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/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx new file mode 100644 index 0000000000..41edaa1f0c --- /dev/null +++ b/app/forms/ip-pool-create.tsx @@ -0,0 +1,54 @@ +/* + * 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 IpPoolCreate } from '@oxide/api' + +import { DescriptionField, NameField, SideModalForm } from 'app/components/form' +import { useForm } from 'app/hooks' +import { addToast } from 'app/stores/toast' +import { pb } from 'app/util/path-builder' + +const defaultValues: IpPoolCreate = { + name: '', + description: '', +} + +export function CreateIpPoolSideModalForm() { + const navigate = useNavigate() + const queryClient = useApiQueryClient() + + const onDismiss = () => navigate(pb.ipPools()) + + const createPool = useApiMutation('ipPoolCreate', { + onSuccess(_pool) { + queryClient.invalidateQueries('ipPoolList') + addToast({ content: 'Your IP pool has been created' }) + navigate(pb.ipPools()) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + { + createPool.mutate({ body: { name, description } }) + }} + loading={createPool.isPending} + submitError={createPool.error} + > + + + + ) +} diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx new file mode 100644 index 0000000000..9a318e45c2 --- /dev/null +++ b/app/forms/ip-pool-edit.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 { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' + +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, +} from '@oxide/api' + +import { DescriptionField, NameField, SideModalForm } from 'app/components/form' +import { getIpPoolSelector, useForm, useIpPoolSelector, useToast } from 'app/hooks' +import { pb } from 'app/util/path-builder' + +EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { pool } = getIpPoolSelector(params) + await apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }) + return null +} + +export function EditIpPoolSideModalForm() { + const queryClient = useApiQueryClient() + const addToast = useToast() + const navigate = useNavigate() + + const poolSelector = useIpPoolSelector() + + const onDismiss = () => navigate(pb.ipPools()) + + const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) + + const editPool = useApiMutation('ipPoolUpdate', { + onSuccess(_pool) { + queryClient.invalidateQueries('ipPoolList') + addToast({ content: 'Your IP pool has been updated' }) + onDismiss() + }, + }) + + const form = useForm({ defaultValues: pool }) + + return ( + { + editPool.mutate({ path: poolSelector, body: { name, description } }) + }} + loading={editPool.isPending} + submitError={editPool.error} + submitLabel="Save changes" + > + + + + ) +} diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index c63f4355a1..5fb0d1c21b 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -36,8 +36,6 @@ export function CreateProjectSideModalForm() { }, }) - // TODO: RHF docs warn about the performance impact of validating on every - // change const form = useForm({ defaultValues }) return ( 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..5f786c2a58 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() @@ -60,6 +66,7 @@ export default function SystemLayout() { { value: 'Silos', path: pb.silos() }, { value: 'Utilization', path: pb.systemUtilization() }, { value: 'Inventory', path: pb.inventory() }, + { value: 'Networking', path: pb.ipPools() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -84,6 +91,7 @@ export default function SystemLayout() { {silo && } + {pool && } @@ -103,15 +111,9 @@ export default function SystemLayout() { Inventory - {/* - Health - - - System Update - - + Networking - */} + diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 7e6c83081e..67ea8aaa31 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -32,12 +32,13 @@ import { import { groupBy, isTruthy } from '@oxide/util' import { AccessNameCell } from 'app/components/AccessNameCell' +import { HL } from 'app/components/HL' import { RoleBadgeCell } from 'app/components/RoleBadgeCell' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from 'app/forms/silo-access' -import { confirmDelete, HL } from 'app/stores/confirm-delete' +import { confirmDelete } from 'app/stores/confirm-delete' const EmptyState = ({ onClick }: { onClick: () => void }) => ( diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 1026a5bddd..482b5cfbac 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -35,13 +35,14 @@ import { import { groupBy, isTruthy } from '@oxide/util' import { AccessNameCell } from 'app/components/AccessNameCell' +import { HL } from 'app/components/HL' import { RoleBadgeCell } from 'app/components/RoleBadgeCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from 'app/forms/project-access' import { getProjectSelector, useProjectSelector } from 'app/hooks' -import { confirmDelete, HL } from 'app/stores/confirm-delete' +import { confirmDelete } from 'app/stores/confirm-delete' const EmptyState = ({ onClick }: { onClick: () => void }) => ( diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index d65edee69e..061be94ce5 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -16,7 +16,14 @@ import { useApiQueryClient, type Disk, } from '@oxide/api' -import { DateCell, linkCell, SizeCell, useQueryTable, type MenuAction } from '@oxide/table' +import { + DateCell, + LinkCell, + SizeCell, + SkeletonCell, + useQueryTable, + type MenuAction, +} from '@oxide/table' import { buttonStyle, EmptyMessage, @@ -33,22 +40,22 @@ import { pb } from 'app/util/path-builder' import { fancifyStates } from '../instances/instance/tabs/common' -function AttachedInstance({ - instanceId, - ...projectSelector -}: { - project: string - instanceId: string -}) { - const { data: instance } = useApiQuery('instanceView', { - path: { instance: instanceId }, - }) - - const instanceLinkCell = linkCell((instanceName) => - pb.instancePage({ ...projectSelector, instance: instanceName }) +function InstanceNameFromId({ value: instanceId }: { value: string | null }) { + const { project } = useProjectSelector() + const { data: instance } = useApiQuery( + 'instanceView', + { path: { instance: instanceId! } }, + { enabled: !!instanceId } ) - return instance ? instanceLinkCell({ value: instance.name }) : null + if (!instanceId) return null + if (!instance) return + + return ( + + {instance.name} + + ) } const EmptyState = () => ( @@ -143,9 +150,7 @@ export function DisksPage() { // whether it has an instance field 'instance' in disk.state ? disk.state.instance : null } - cell={({ value }: { value: string | undefined }) => - value ? : null - } + cell={InstanceNameFromId} /> { const projectSelector = useProjectSelector() const { data: vpc, isError } = useApiQuery( @@ -55,17 +45,8 @@ const VpcNameFromId = ({ value }: { value: string }) => { // possible because you can't delete a VPC that has child resources, but let's // be safe if (isError) return Deleted - if (!vpc) return - return ( - - {/* Pushes out the link area to the entire cell for improved clickability™ */} -
-
{vpc.name}
- - ) + if (!vpc) return + return {vpc.name} } const SubnetNameFromId = ({ value }: { value: string }) => { @@ -77,7 +58,7 @@ const SubnetNameFromId = ({ value }: { value: string }) => { // same deal as VPC: probably not possible but let's be safe if (isError) return Deleted - if (!subnet) return // loading + if (!subnet) return // loading return {subnet.name} } diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/networking/VpcPage/VpcPage.tsx index 1b0fefb6a7..a6286429bc 100644 --- a/app/pages/project/networking/VpcPage/VpcPage.tsx +++ b/app/pages/project/networking/VpcPage/VpcPage.tsx @@ -49,16 +49,16 @@ export function VpcPage() { {vpc.dnsName} - - {vpc.timeCreated && formatDateTime(vpc.timeCreated)} + + {formatDateTime(vpc.timeCreated)} - {vpc.timeModified && formatDateTime(vpc.timeModified)} + {formatDateTime(vpc.timeModified)} - + Subnets Firewall Rules diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 7b57b03900..65495faddb 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -14,7 +14,13 @@ import { useApiQueryErrorsAllowed, type Snapshot, } from '@oxide/api' -import { DateCell, SizeCell, useQueryTable, type MenuAction } from '@oxide/table' +import { + DateCell, + SizeCell, + SkeletonCell, + useQueryTable, + type MenuAction, +} from '@oxide/table' import { Badge, buttonStyle, @@ -22,7 +28,6 @@ import { PageHeader, PageTitle, Snapshots24Icon, - Spinner, TableActions, } from '@oxide/ui' @@ -34,7 +39,7 @@ import { pb } from 'app/util/path-builder' const DiskNameFromId = ({ value }: { value: string }) => { const { data } = useApiQueryErrorsAllowed('diskView', { path: { disk: value } }) - if (!data) return + if (!data) return if (data.type === 'error') return Deleted return {data.data.name} } 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/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx new file mode 100644 index 0000000000..2e7ac1814b --- /dev/null +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -0,0 +1,315 @@ +/* + * 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, useState } from 'react' +import { type LoaderFunctionArgs } from 'react-router-dom' + +import { + apiQueryClient, + useApiMutation, + useApiQuery, + useApiQueryClient, + usePrefetchedApiQuery, + type IpPoolRange, + type IpPoolSiloLink, +} from '@oxide/api' +import { + DateCell, + LinkCell, + SkeletonCell, + useQueryTable, + type MenuAction, +} from '@oxide/table' +import { + Badge, + Button, + EmptyMessage, + Message, + Modal, + Networking24Icon, + PageHeader, + PageTitle, + Success12Icon, + Tabs, +} from '@oxide/ui' + +import { ExternalLink } from 'app/components/ExternalLink' +import { ListboxField } from 'app/components/form' +import { QueryParamTabs } from 'app/components/QueryParamTabs' +import { getIpPoolSelector, useForm, useIpPoolSelector } from 'app/hooks' +import { confirmAction } from 'app/stores/confirm-action' +import { addToast } from 'app/stores/toast' +import { links } from 'app/util/links' +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: 25 }, // match QueryTable + }), + apiQueryClient.prefetchQuery('ipPoolRangeList', { + path: { pool }, + query: { limit: 25 }, // match QueryTable + }), + ]) + return null +} + +export function IpPoolPage() { + const poolSelector = useIpPoolSelector() + const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) + return ( + <> + + }>{pool.name} + + + + IP ranges + Linked silos + + + + + + + + + + ) +} + +const RangesEmptyState = () => ( + } + title="No IP ranges" + body="Add a range to see it here" + // TODO: link add range button + // buttonText="Add range" + // buttonTo={pb.ipPoolNew()} + /> +) + +const makeRangeActions = (_range: IpPoolRange): MenuAction[] => [ + { + disabled: 'Coming soon. Use the CLI or API to remove a range.', + label: 'Remove', + onActivate() {}, + }, +] + +function IpRangesTable() { + const poolSelector = useIpPoolSelector() + const { Table, Column } = useQueryTable('ipPoolRangeList', { path: poolSelector }) + + return ( + <> +
+ +
+ } makeActions={makeRangeActions}> + {/* TODO: only showing the ID is ridiculous. we need names */} + + + +
+ + ) +} + +const SilosEmptyState = () => ( + } + title="No IP pool associations" + body="You need to link the IP pool to a silo to be able to see it here" + // TODO: link silo button + // buttonText="Link IP pool" + // buttonTo={pb.ipPoolNew()} + /> +) + +function SiloNameFromId({ value: siloId }: { value: string }) { + const { data: silo } = useApiQuery('siloView', { path: { silo: siloId } }) + + if (!silo) return + + return {silo.name} +} + +function LinkedSilosTable() { + const poolSelector = useIpPoolSelector() + const queryClient = useApiQueryClient() + const { Table, Column } = useQueryTable('ipPoolSiloList', { path: poolSelector }) + + const unlinkSilo = useApiMutation('ipPoolSiloUnlink', { + onSuccess() { + queryClient.invalidateQueries('ipPoolSiloList') + }, + }) + + // TODO: confirm action. make clear what linking means + const makeActions = (link: IpPoolSiloLink): MenuAction[] => [ + { + label: 'Unlink', + 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. +

+ ), + errorTitle: 'Could not unlink silo', + }) + }, + }, + ] + + const [showLinkModal, setShowLinkModal] = useState(false) + + return ( + <> +
+

+ Users in linked silos can allocate external IPs from this pool for their + instances. A silo can have at most one default pool. IPs are allocated from the + default pool when users ask for one without specifying a pool. Read the docs to + learn more about{' '} + managing IP pools. +

+ +
+ } makeActions={makeActions}> + + + value && ( + <> + + default + + ) + } + /> +
+ {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/system/networking/IpPoolsTab.tsx b/app/pages/system/networking/IpPoolsTab.tsx new file mode 100644 index 0000000000..523b6fd6cc --- /dev/null +++ b/app/pages/system/networking/IpPoolsTab.tsx @@ -0,0 +1,77 @@ +/* + * 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, useNavigate } from 'react-router-dom' + +import { apiQueryClient, useApiMutation, type IpPool } from '@oxide/api' +import { DateCell, linkCell, useQueryTable, type MenuAction } from '@oxide/table' +import { buttonStyle, EmptyMessage, Networking24Icon } from '@oxide/ui' + +import { confirmDelete } from 'app/stores/confirm-delete' +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()} + /> +) + +IpPoolsTab.loader = async function () { + await apiQueryClient.prefetchQuery('ipPoolList', { query: { limit: 25 } }) + return null +} + +export function IpPoolsTab() { + const navigate = useNavigate() + const { Table, Column } = useQueryTable('ipPoolList', {}) + + const deletePool = useApiMutation('ipPoolDelete', { + onSuccess() { + apiQueryClient.invalidateQueries('ipPoolList') + }, + }) + + const makeActions = (pool: IpPool): 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('ipPoolView', { path: { pool: pool.name } }, pool) + navigate(pb.ipPoolEdit({ pool: pool.name })) + }, + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => deletePool.mutateAsync({ path: { pool: pool.name } }), + label: pool.name, + }), + }, + ] + + return ( + <> +
+ + New IP Pool + +
+ } makeActions={makeActions}> + pb.ipPool({ pool }))} /> + + +
+ + + ) +} diff --git a/app/pages/system/networking/NetworkingPage.tsx b/app/pages/system/networking/NetworkingPage.tsx new file mode 100644 index 0000000000..3a6565cf28 --- /dev/null +++ b/app/pages/system/networking/NetworkingPage.tsx @@ -0,0 +1,24 @@ +/* + * 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 { Networking24Icon, PageHeader, PageTitle } from '@oxide/ui' + +import { RouteTabs, Tab } from 'app/components/RouteTabs' +import { pb } from 'app/util/path-builder' + +export function NetworkingPage() { + return ( + <> + + }>Networking + + + IP pools + + + ) +} diff --git a/app/pages/system/silos/SiloIdpsTab.tsx b/app/pages/system/silos/SiloIdpsTab.tsx new file mode 100644 index 0000000000..fac3fa0658 --- /dev/null +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -0,0 +1,66 @@ +/* + * 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 } from 'react-router-dom' + +import { DateCell, DefaultCell, linkCell, TruncateCell, useQueryTable } from '@oxide/table' +import { Badge, buttonStyle, Cloud24Icon, EmptyMessage } from '@oxide/ui' + +import { useSiloSelector } from 'app/hooks' +import { pb } from 'app/util/path-builder' + +const EmptyState = () => ( + } title="No identity providers" /> +) + +export function SiloIdpsTab() { + const siloSelector = useSiloSelector() + + const { Table, Column } = useQueryTable('siloIdentityProviderList', { + query: siloSelector, + }) + + return ( + <> +
+ + 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/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx new file mode 100644 index 0000000000..9b0668efac --- /dev/null +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -0,0 +1,259 @@ +/* + * 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, useState } from 'react' + +import { useApiMutation, useApiQuery, useApiQueryClient, type SiloIpPool } from '@oxide/api' +import { linkCell, useQueryTable, type MenuAction } from '@oxide/table' +import { + Badge, + Button, + EmptyMessage, + Message, + Modal, + Networking24Icon, + Success12Icon, +} from '@oxide/ui' + +import { ExternalLink } from 'app/components/ExternalLink' +import { ListboxField } from 'app/components/form' +import { HL } from 'app/components/HL' +import { useForm, useSiloSelector } from 'app/hooks' +import { confirmAction } from 'app/stores/confirm-action' +import { addToast } from 'app/stores/toast' +import { links } from 'app/util/links' +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()} + /> +) + +export function SiloIpPoolsTab() { + const { silo } = useSiloSelector() + const [showLinkModal, setShowLinkModal] = useState(false) + const { Table, Column } = useQueryTable('siloIpPoolList', { path: { silo } }) + const queryClient = useApiQueryClient() + + // Fetch 1000 to we can be sure to get them all. There should only be a few + // anyway. Not prefetched because the prefetched one only gets 25 to match the + // query table. This req is better to do async because they can't click make + // default that fast anyway. + const { data: allPools } = useApiQuery('siloIpPoolList', { + path: { silo }, + query: { limit: 1000 }, + }) + + // used in change default confirm modal + const defaultPool = useMemo( + () => (allPools ? allPools.items.find((p) => p.isDefault)?.name : undefined), + [allPools] + ) + + const updatePoolLink = useApiMutation('ipPoolSiloUpdate', { + onSuccess() { + queryClient.invalidateQueries('siloIpPoolList') + }, + }) + const unlinkPool = useApiMutation('ipPoolSiloUnlink', { + onSuccess() { + queryClient.invalidateQueries('siloIpPoolList') + }, + }) + + // this is all very extra. I'm sorry. it's for the users + const makeActions = (pool: SiloIpPool): MenuAction[] => [ + { + label: pool.isDefault ? 'Clear default' : 'Make default', + onActivate() { + if (pool.isDefault) { + confirmAction({ + doAction: () => + updatePoolLink.mutateAsync({ + path: { silo, pool: pool.id }, + body: { isDefault: false }, + }), + modalTitle: 'Confirm clear default', + modalContent: ( +

+ Are you sure you want to clear the default pool? If there is no default, + users in this silo will have to specify a pool when allocating IPs. +

+ ), + errorTitle: 'Could not clear default', + }) + } else { + const modalContent = defaultPool ? ( +

+ Are you sure you want to change the default pool from {defaultPool}{' '} + to {pool.name}? +

+ ) : ( +

+ Are you sure you want to make {pool.name} the default pool for this + silo? +

+ ) + const verb = defaultPool ? 'change' : 'make' + confirmAction({ + doAction: () => + updatePoolLink.mutateAsync({ + path: { silo, pool: pool.id }, + body: { isDefault: true }, + }), + modalTitle: `Confirm ${verb} default`, + modalContent, + errorTitle: `Could not ${verb} default`, + }) + } + }, + }, + { + label: 'Unlink', + onActivate() { + confirmAction({ + doAction: () => unlinkPool.mutateAsync({ path: { silo, pool: pool.id } }), + modalTitle: `Confirm unlink pool`, + modalContent: ( +

+ Are you sure you want to unlink {pool.name}? Users in this silo will + no longer be able to allocate IPs from this pool. +

+ ), + errorTitle: `Could not unlink pool`, + }) + }, + }, + ] + + return ( + <> +
+

+ Users in this silo can allocate external IPs from these pools for their instances. + A silo can have at most one default pool. IPs are allocated from the default pool + when users ask for one without specifying a pool. Read the docs to learn more + about managing IP pools. +

+ +
+ } makeActions={makeActions}> + pb.ipPool({ pool }))} /> + + + value && ( + <> + + default + + ) + } + /> +
+ {showLinkModal && setShowLinkModal(false)} />} + + ) +} + +type LinkPoolFormValues = { + pool: string | undefined +} + +const defaultValues: LinkPoolFormValues = { pool: undefined } + +function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) { + const queryClient = useApiQueryClient() + const { silo } = useSiloSelector() + const { control, handleSubmit } = useForm({ defaultValues }) + + const linkPool = useApiMutation('ipPoolSiloLink', { + onSuccess() { + queryClient.invalidateQueries('siloIpPoolList') + }, + onError(err) { + addToast({ title: 'Could not link pool', content: err.message, variant: 'error' }) + }, + onSettled: onDismiss, + }) + + function onSubmit({ pool }: LinkPoolFormValues) { + if (!pool) return // can't happen, silo is required + linkPool.mutate({ path: { pool }, body: { silo, isDefault: false } }) + } + + const linkedPools = useApiQuery('siloIpPoolList', { + path: { silo }, + query: { limit: 1000 }, + }) + const allPools = useApiQuery('ipPoolList', { query: { limit: 1000 } }) + + // in order to get the list of remaining unlinked pools, we have to get the + // list of all pools and remove the already linked ones + + const linkedPoolIds = useMemo( + () => (linkedPools.data ? new Set(linkedPools.data.items.map((p) => p.id)) : undefined), + [linkedPools] + ) + const unlinkedPoolItems = useMemo( + () => + allPools.data && linkedPoolIds + ? allPools.data.items + .filter((p) => !linkedPoolIds.has(p.id)) + .map((p) => ({ value: p.name, label: p.name })) + : [], + [allPools, linkedPoolIds] + ) + + return ( + + + +
{ + e.stopPropagation() + handleSubmit(onSubmit)(e) + }} + className="space-y-4" + > + + + + +
+
+ +
+ ) +} diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx new file mode 100644 index 0000000000..bdd4452bc2 --- /dev/null +++ b/app/pages/system/silos/SiloPage.tsx @@ -0,0 +1,118 @@ +/* + * 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 { + Badge, + Cloud24Icon, + EmptyMessage, + NextArrow12Icon, + PageHeader, + PageTitle, + PropertiesTable, + TableEmptyBox, + Tabs, +} from '@oxide/ui' +import { formatDateTime } from '@oxide/util' + +import { QueryParamTabs } from 'app/components/QueryParamTabs' +import { getSiloSelector, useSiloSelector } from 'app/hooks' + +import { SiloIdpsTab } from './SiloIdpsTab' +import { SiloIpPoolsTab } from './SiloIpPoolsTab' + +SiloPage.loader = async ({ params }: LoaderFunctionArgs) => { + const { silo } = getSiloSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('siloView', { path: { silo } }), + apiQueryClient.prefetchQuery('siloIdentityProviderList', { + query: { silo, limit: 25 }, // match QueryTable + }), + apiQueryClient.prefetchQuery('siloIpPoolList', { + query: { limit: 25 }, // match QueryTable + path: { silo }, + }), + ]) + 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]) + ) + + return ( + <> + + }>{silo.name} + + + + + {silo.id} + {silo.description} + + + + {formatDateTime(silo.timeCreated)} + + + {formatDateTime(silo.timeModified)} + + + + + + + Identity Providers + IP Pools + Fleet roles + + + + + + + + + {/* TODO: better empty state explaining that no roles are mapped so nothing will happen */} + {roleMapPairs.length === 0 ? ( + + } + title="Mapped fleet roles" + body="Silo roles can automatically grant a fleet role. This silo has no role mappings configured." + /> + + ) : ( + <> +

+ Silo roles can automatically grant a fleet role. +

+
    + {roleMapPairs.map(([siloRole, fleetRole]) => ( +
  • + Silo {siloRole} + + Fleet {fleetRole} +
  • + ))} +
+ + )} +
+
+ + ) +} diff --git a/app/pages/system/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx similarity index 100% rename from app/pages/system/SilosPage.tsx rename to app/pages/system/silos/SilosPage.tsx diff --git a/app/routes.tsx b/app/routes.tsx index afe77dfdac..7427ef6021 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -19,6 +19,8 @@ import { import { CreateImageFromSnapshotSideModalForm } from './forms/image-from-snapshot' import { CreateImageSideModalForm } from './forms/image-upload' import { CreateInstanceForm } from './forms/instance-create' +import { CreateIpPoolSideModalForm } from './forms/ip-pool-create' +import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { CreateProjectSideModalForm } from './forms/project-create' import { EditProjectSideModalForm } from './forms/project-edit' import { CreateSiloSideModalForm } from './forms/silo-create' @@ -65,9 +67,12 @@ 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 { IpPoolsTab } from './pages/system/networking/IpPoolsTab' +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' +import { SiloPage } from './pages/system/silos/SiloPage' +import SilosPage from './pages/system/silos/SilosPage' import { SystemUtilizationPage } from './pages/system/UtilizationPage' import { pb } from './util/path-builder' @@ -125,7 +130,6 @@ export const routes = createRoutesFromElements( loader={SiloPage.loader} handle={{ crumb: siloCrumb }} > - } /> - + } + handle={{ crumb: 'Networking' }} + > + } + loader={IpPoolsTab.loader} + handle={{ crumb: 'IP pools' }} + > + + } /> + } + loader={EditIpPoolSideModalForm.loader} + handle={{ crumb: 'Edit IP pool' }} + /> + + + } + loader={IpPoolPage.loader} + /> } /> diff --git a/app/stores/confirm-action.ts b/app/stores/confirm-action.ts index 642a2d87c4..4ddc4a86de 100644 --- a/app/stores/confirm-action.ts +++ b/app/stores/confirm-action.ts @@ -33,10 +33,7 @@ export const useConfirmAction = create(() => ({ // in the store need to the hook. // https://github.com/pmndrs/zustand/blob/a5343354/docs/guides/practice-with-no-store-actions.md -/** - * Note that this returns a function so we can save a line in the calling code. - */ -export const confirmAction = (actionConfig: ActionConfig) => () => { +export function confirmAction(actionConfig: ActionConfig) { useConfirmAction.setState({ actionConfig }) } diff --git a/app/stores/confirm-delete.tsx b/app/stores/confirm-delete.tsx index fd23271573..5e8d95ca0b 100644 --- a/app/stores/confirm-delete.tsx +++ b/app/stores/confirm-delete.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { classed } from '@oxide/util' +import { HL } from 'app/components/HL' import { useConfirmAction } from './confirm-action' @@ -25,8 +25,6 @@ type DeleteConfig = { label: React.ReactNode } -export const HL = classed.span`text-sans-semi-md text-default` - export const confirmDelete = ({ doDelete, label }: DeleteConfig) => () => { diff --git a/app/test/e2e/ip-pools.e2e.ts b/app/test/e2e/ip-pools.e2e.ts new file mode 100644 index 0000000000..a6795c3e76 --- /dev/null +++ b/app/test/e2e/ip-pools.e2e.ts @@ -0,0 +1,124 @@ +/* + * 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 { expect, test } from '@playwright/test' + +import { clickRowAction, expectRowVisible } from './utils' + +test('IP pool list', async ({ page }) => { + await page.goto('/system/networking/ip-pools') + + await expect(page.getByRole('heading', { name: 'Networking' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'IP pools' })).toBeVisible() + + const table = page.getByRole('table') + + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 rows + + await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeVisible() + await expect(page.getByRole('cell', { name: 'ip-pool-2' })).toBeVisible() + await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeVisible() +}) + +test('IP pool silo list', async ({ page }) => { + await page.goto('/system/networking/ip-pools') + await page.getByRole('link', { name: 'ip-pool-1' }).click() + await page.getByRole('tab', { name: 'Linked silos' }).click() + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war', 'Pool is silo default?': 'default' }) + + // clicking silo takes you to silo page + const siloLink = page.getByRole('link', { name: 'maze-war' }) + await siloLink.click() + await expect(page).toHaveURL('/system/silos/maze-war?tab=ip-pools') + await page.goBack() + + // unlink silo and the row is gone + await clickRowAction(page, 'maze-war', 'Unlink') + await expect(page.getByRole('dialog', { name: 'Confirm unlink' })).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(siloLink).toBeHidden() +}) + +test('IP pool link silo', async ({ page }) => { + await page.goto('/system/networking/ip-pools/ip-pool-1?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war', 'Pool is silo default?': 'default' }) + await expect(table.getByRole('row')).toHaveCount(2) // header and 1 row + + const modal = page.getByRole('dialog', { name: 'Link silo' }) + await expect(modal).toBeHidden() + + // open link modal + await page.getByRole('button', { name: 'Link silo' }).click() + await expect(modal).toBeVisible() + + // close modal works + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(modal).toBeHidden() + + // reopen + await page.getByRole('button', { name: 'Link silo' }).click() + await expect(modal).toBeVisible() + + // select silo in listbox and click link + await page.getByRole('button', { name: 'Select silo' }).click() + await page.getByRole('option', { name: 'myriad' }).click() + await modal.getByRole('button', { name: 'Link' }).click() + + // modal closes and we see the thing in the table + await expect(modal).toBeHidden() + await expectRowVisible(table, { Silo: 'myriad', 'Pool is silo default?': '' }) +}) + +test('IP pool delete', async ({ page }) => { + await page.goto('/system/networking/ip-pools') + + // can't delete a pool containing ranges + await clickRowAction(page, 'ip-pool-1', 'Delete') + await expect(page.getByRole('dialog', { name: 'Confirm delete' })).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + await expect(page.getByText('Could not delete resource').first()).toBeVisible() + await expect( + page.getByText('IP pool cannot be deleted while it contains IP ranges').first() + ).toBeVisible() + + await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeVisible() + + // can delete a pool with no ranges + await clickRowAction(page, 'ip-pool-3', 'Delete') + await expect(page.getByRole('dialog', { name: 'Confirm delete' })).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeHidden() +}) + +test('IP pool create', async ({ page }) => { + await page.goto('/system/networking/ip-pools') + await expect(page.getByRole('cell', { name: 'another-pool' })).toBeHidden() + + const modal = page.getByRole('dialog', { name: 'Create IP pool' }) + await expect(modal).toBeHidden() + + await page.getByRole('link', { name: 'New IP pool' }).click() + + await expect(modal).toBeVisible() + + await page.getByRole('textbox', { name: 'Name' }).fill('another-pool') + await page.getByRole('textbox', { name: 'Description' }).fill('whatever') + await page.getByRole('button', { name: 'Create IP pool' }).click() + + await expect(modal).toBeHidden() + await expectRowVisible(page.getByRole('table'), { + name: 'another-pool', + description: 'whatever', + }) +}) diff --git a/app/test/e2e/silos.e2e.ts b/app/test/e2e/silos.e2e.ts index 406c105bd8..567f84b271 100644 --- a/app/test/e2e/silos.e2e.ts +++ b/app/test/e2e/silos.e2e.ts @@ -9,7 +9,13 @@ import { expect, test } from '@playwright/test' import { MiB } from '@oxide/util' -import { chooseFile, expectNotVisible, expectRowVisible, expectVisible } from './utils' +import { + chooseFile, + clickRowAction, + expectNotVisible, + expectRowVisible, + expectVisible, +} from './utils' test('Create silo', async ({ page }) => { await page.goto('/system/silos') @@ -99,6 +105,7 @@ test('Create silo', async ({ page }) => { // click into detail view and check the fleet role map await otherSiloCell.getByRole('link').click() + await page.getByRole('tab', { name: 'Fleet roles' }).click() await expectVisible(page, [ page.getByRole('heading', { name: 'other-silo' }), page.getByText('Silo adminFleet admin'), @@ -120,6 +127,12 @@ test('Default silo', async ({ page }) => { await page.getByRole('link', { name: 'myriad' }).click() await expect(page.getByRole('heading', { name: 'myriad' })).toBeVisible() + await page.getByRole('tab', { name: 'Fleet roles' }).click() + + await expect( + page.getByText('Silo roles can automatically grant a fleet role.') + ).toBeVisible() + await expectNotVisible(page, [ page.getByText('Silo adminFleet admin'), page.getByText('Silo viewerFleet viewer'), @@ -144,3 +157,84 @@ test('Identity providers', async ({ page }) => { await page.getByRole('button', { name: 'Cancel' }).click() await expectNotVisible(page, ['role=dialog[name="Identity provider"]']) }) + +test('Silo IP pools', async ({ page }) => { + await page.goto('/system/silos/maze-war?tab=ip-pools') + + const table = page.getByRole('table') + await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) + await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + + // clicking on pool goes to pool detail + await page.getByRole('link', { name: 'ip-pool-1' }).click() + await expect(page).toHaveURL('/system/networking/ip-pools/ip-pool-1') + await page.goBack() + + // make default + await clickRowAction(page, 'ip-pool-2', 'Make default') + await expect( + page + .getByRole('dialog', { name: 'Confirm change default' }) + .getByText( + 'Are you sure you want to change the default pool from ip-pool-1 to ip-pool-2?' + ) + ).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { name: 'ip-pool-1', Default: '' }) + await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) + + // unlink + await clickRowAction(page, 'ip-pool-1', 'Unlink') + await expect( + page + .getByRole('dialog', { name: 'Confirm unlink pool' }) + .getByText('Are you sure you want to unlink ip-pool-1?') + ).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeHidden() + await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) + + // clear default + await clickRowAction(page, 'ip-pool-2', 'Clear default') + await expect( + page + .getByRole('dialog', { name: 'Confirm clear default' }) + .getByText('Are you sure you want to clear the default pool?') + ).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) +}) + +test('Silo IP pools link pool', async ({ page }) => { + await page.goto('/system/silos/maze-war?tab=ip-pools') + + const table = page.getByRole('table') + await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) + await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + + const modal = page.getByRole('dialog', { name: 'Link pool' }) + await expect(modal).toBeHidden() + + // open link modal + await page.getByRole('button', { name: 'Link pool' }).click() + await expect(modal).toBeVisible() + + // close modal works + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(modal).toBeHidden() + + // reopen + await page.getByRole('button', { name: 'Link pool' }).click() + await expect(modal).toBeVisible() + + // select silo in listbox and click link + await page.getByRole('button', { name: 'Select pool' }).click() + await page.getByRole('option', { name: 'ip-pool-3' }).click() + await modal.getByRole('button', { name: 'Link' }).click() + + // modal closes and we see the thing in the table + await expect(modal).toBeHidden() + await expectRowVisible(table, { name: 'ip-pool-3', Default: '' }) +}) diff --git a/app/util/links.ts b/app/util/links.ts index 7a2137633f..4418c90a62 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -8,4 +8,5 @@ export const links: Record = { cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html', cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html', + ipPoolsDocs: 'https://docs.oxide.computer/guides/operator/ip-pool-management', } diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index b82d541bf1..d2bd8f505c 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,10 @@ test('path builder', () => { "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", "inventory": "/system/inventory", + "ipPool": "/system/networking/ip-pools/pl", + "ipPoolEdit": "/system/networking/ip-pools/pl/edit", + "ipPoolNew": "/system/networking/ip-pools-new", + "ipPools": "/system/networking/ip-pools", "nics": "/projects/p/instances/i/network-interfaces", "profile": "/settings/profile", "project": "/projects/p", @@ -58,6 +63,7 @@ test('path builder', () => { "siloImage": "/images/im", "siloImageEdit": "/images/im/edit", "siloImages": "/images", + "siloIpPools": "/system/silos/s?tab=ip-pools", "siloNew": "/system/silos-new", "siloUtilization": "/utilization", "silos": "/system/silos", @@ -72,7 +78,6 @@ test('path builder', () => { "system": "/system", "systemHealth": "/system/health", "systemIssues": "/system/issues", - "systemNetworking": "/system/networking", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v", "vpcEdit": "/projects/p/vpcs/v/edit", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 2c88e678ce..512762998a 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`, @@ -77,7 +78,12 @@ export const pb = { systemUtilization: () => '/system/utilization', systemHealth: () => '/system/health', - systemNetworking: () => '/system/networking', + // there is only one tab on networking and it's IP pools, so we just treat + // that as the networking route for now + ipPools: () => '/system/networking/ip-pools', + ipPoolNew: () => '/system/networking/ip-pools-new', + ipPool: (params: IpPool) => `${pb.ipPools()}/${params.pool}`, + ipPoolEdit: (params: IpPool) => `${pb.ipPool(params)}/edit`, inventory: () => '/system/inventory', rackInventory: () => '/system/inventory/racks', @@ -89,6 +95,7 @@ export const pb = { silos: () => '/system/silos', siloNew: () => '/system/silos-new', silo: ({ silo }: Silo) => `/system/silos/${silo}`, + siloIpPools: (params: Silo) => `${pb.silo(params)}?tab=ip-pools`, siloIdpNew: (params: Silo) => `${pb.silo(params)}/idps-new`, samlIdp: (params: IdentityProvider) => `${pb.silo(params)}/idps/saml/${params.provider}`, diff --git a/libs/api-mocks/ip-pool.ts b/libs/api-mocks/ip-pool.ts index 51905e14e8..8da7f33af2 100644 --- a/libs/api-mocks/ip-pool.ts +++ b/libs/api-mocks/ip-pool.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { type IpPool, type IpPoolSiloLink } from '@oxide/api' +import { type IpPool, type IpPoolRange, type IpPoolSiloLink } from '@oxide/api' import type { Json } from './json-type' import { defaultSilo } from './silo' @@ -27,7 +27,14 @@ const ipPool2: Json = { time_modified: new Date().toISOString(), } -export const ipPools: Json[] = [ipPool1, ipPool2] +const ipPool3: Json = { + id: '8929a9ec-03d7-4027-8bf3-dda76627de07', + name: 'ip-pool-3', + description: '', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} +export const ipPools: Json[] = [ipPool1, ipPool2, ipPool3] export const ipPoolSilos: Json[] = [ { @@ -35,4 +42,39 @@ export const ipPoolSilos: Json[] = [ silo_id: defaultSilo.id, is_default: true, }, + { + ip_pool_id: ipPool2.id, + silo_id: defaultSilo.id, + is_default: false, + }, +] + +export const ipPoolRanges: Json = [ + { + id: 'bbfcf3f2-061e-4334-a0e7-dfcd8171f87e', + ip_pool_id: ipPool1.id, + range: { + first: '10.0.0.1', + last: '10.0.0.5', + }, + time_created: new Date().toISOString(), + }, + { + id: 'df05795b-cb88-4971-9865-ac2995c2b2d4', + ip_pool_id: ipPool1.id, + range: { + first: '10.0.0.20', + last: '10.0.0.22', + }, + time_created: new Date().toISOString(), + }, + { + id: '7e6e94b9-748e-4219-83a3-cec76253ec70', + ip_pool_id: ipPool2.id, + range: { + first: '10.0.0.33', + last: '10.0.0.38', + }, + time_created: new Date().toISOString(), + }, ] diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index b54b65e759..ff9b73b75f 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -251,6 +251,7 @@ const initDb = { instances: [...mock.instances], ipPools: [...mock.ipPools], ipPoolSilos: [...mock.ipPoolSilos], + ipPoolRanges: [...mock.ipPoolRanges], networkInterfaces: [mock.networkInterface], physicalDisks: [...mock.physicalDisks], projects: [...mock.projects], diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 4f2f22f5e0..7736f4013c 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -76,8 +76,11 @@ export const handlers = makeHandlers({ projectUpdate({ body, path }) { const project = lookup.project({ ...path }) if (body.name) { + // only check for existing name if it's being changed + if (body.name !== project.name) { + errIfExists(db.projects, { name: body.name }) + } project.name = body.name - errIfExists(db.projects, { name: body.name }) } project.description = body.description || '' @@ -562,6 +565,7 @@ export const handlers = makeHandlers({ return { ...pool, is_default: link.is_default } }, + // TODO: require admin permissions for system IP pool endpoints ipPoolView: ({ path }) => lookup.ipPool(path), ipPoolSiloList({ path /*query*/ }) { // TODO: paginated wants an id field, but this is a join table, so it has a @@ -621,6 +625,52 @@ export const handlers = makeHandlers({ return ipPoolSilo }, + ipPoolRangeAdd: NotImplemented, + ipPoolRangeList({ path, query }) { + const pool = lookup.ipPool(path) + const ranges = db.ipPoolRanges.filter((r) => r.ip_pool_id === pool.id) + return paginated(query, ranges) + }, + ipPoolRangeRemove: NotImplemented, + ipPoolCreate({ body }) { + errIfExists(db.ipPools, { name: body.name }, 'IP pool') + + const newPool: Json = { + id: uuid(), + ...body, + ...getTimestamps(), + } + db.ipPools.push(newPool) + + return json(newPool, { status: 201 }) + }, + ipPoolDelete({ path }) { + const pool = lookup.ipPool(path) + + if (db.ipPoolRanges.some((r) => r.ip_pool_id === pool.id)) { + throw 'IP pool cannot be deleted while it contains IP ranges' + } + + // delete pools and silo links + db.ipPools = db.ipPools.filter((p) => p.id !== pool.id) + db.ipPoolSilos = db.ipPoolSilos.filter((s) => s.ip_pool_id !== pool.id) + + return 204 + }, + ipPoolUpdate({ path, body }) { + const pool = lookup.ipPool(path) + + if (body.name) { + // only check for existing name if it's being changed + if (body.name !== pool.name) { + errIfExists(db.ipPools, { name: body.name }) + } + pool.name = body.name + } + pool.description = body.description || '' + + return pool + }, projectPolicyView({ path }) { const project = lookup.project(path) @@ -952,6 +1002,7 @@ export const handlers = makeHandlers({ requireFleetViewer(cookies) const silo = lookup.silo(path) db.silos = db.silos.filter((i) => i.id !== silo.id) + db.ipPoolSilos = db.ipPoolSilos.filter((i) => i.silo_id !== silo.id) return 204 }, siloIdentityProviderList({ query, cookies }) { @@ -1049,16 +1100,10 @@ export const handlers = makeHandlers({ instanceMigrate: NotImplemented, instanceSerialConsoleStream: NotImplemented, instanceSshPublicKeyList: NotImplemented, - ipPoolCreate: NotImplemented, - ipPoolDelete: NotImplemented, - ipPoolRangeAdd: NotImplemented, - ipPoolRangeList: NotImplemented, - ipPoolRangeRemove: NotImplemented, ipPoolServiceRangeAdd: NotImplemented, ipPoolServiceRangeList: NotImplemented, ipPoolServiceRangeRemove: NotImplemented, ipPoolServiceView: NotImplemented, - ipPoolUpdate: NotImplemented, localIdpUserCreate: NotImplemented, localIdpUserDelete: NotImplemented, localIdpUserSetPassword: NotImplemented, diff --git a/libs/api-mocks/silo.ts b/libs/api-mocks/silo.ts index dcccb2c02f..025cc52813 100644 --- a/libs/api-mocks/silo.ts +++ b/libs/api-mocks/silo.ts @@ -14,17 +14,19 @@ export const silos: Json = [ { id: '6d3a9c06-475e-4f75-b272-c0d0e3f980fa', name: 'maze-war', - description: 'a fake default silo', + description: 'a silo', time_created: new Date(2021, 3, 1).toISOString(), time_modified: new Date(2021, 4, 2).toISOString(), discoverable: true, identity_mode: 'saml_jit', - mapped_fleet_roles: {}, + mapped_fleet_roles: { + admin: ['admin'], + }, }, { id: '68b58556-15b9-4ccb-adff-9fd3c7de1f9a', name: 'myriad', - description: 'a fake default silo', + description: 'a second silo', time_created: new Date(2023, 1, 28).toISOString(), time_modified: new Date(2023, 6, 12).toISOString(), discoverable: true, diff --git a/libs/table/cells/LinkCell.tsx b/libs/table/cells/LinkCell.tsx index 00207c0a55..acded67796 100644 --- a/libs/table/cells/LinkCell.tsx +++ b/libs/table/cells/LinkCell.tsx @@ -20,14 +20,18 @@ const Pusher = classed.div`absolute inset-0 right-px group-hover:bg-raise` export const linkCell = (makeHref: (value: string) => string) => ({ value }: Cell) => { - return ( - - -
{value}
- - ) + return {value} } +export function LinkCell({ to, children }: { to: string; children: React.ReactNode }) { + return ( + + +
{children}
+ + ) +} + export const ButtonCell = ({ children, ...props }: React.ComponentProps<'button'>) => { return (