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)}
-
+ SubnetsFirewall 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}
-
-
}>
- {/* 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.
+
+ 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 (
+
+
+
+
+
+
+
+
+ )
+}
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.
+
+ 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 (
+
+
+
+
+
+
+
+
+ )
+}
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.
+