diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 960bd29aab..6649012283 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -45,6 +45,7 @@ import { useInstanceSelector, useProjectSelector, } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -636,6 +637,53 @@ export default function NetworkingTab() { const subnetDisabledReason = availableSubnets.length === 0 ? 'No available external subnets' : null + useQuickActions( + () => [ + ...(!ephemeralDisabledReason + ? [ + { + value: 'Attach ephemeral IP', + navGroup: 'Actions', + action: () => setAttachEphemeralModalOpen(true), + }, + ] + : []), + ...(!floatingDisabledReason + ? [ + { + value: 'Attach floating IP', + navGroup: 'Actions', + action: () => setAttachFloatingModalOpen(true), + }, + ] + : []), + ...(instanceCan.updateNic({ runState: instance.runState }) + ? [ + { + value: 'Add network interface', + navGroup: 'Actions', + action: () => setCreateModalOpen(true), + }, + ] + : []), + ...(!subnetDisabledReason + ? [ + { + value: 'Attach external subnet', + navGroup: 'Actions', + action: () => setAttachSubnetModalOpen(true), + }, + ] + : []), + ], + [ + ephemeralDisabledReason, + floatingDisabledReason, + instance.runState, + subnetDisabledReason, + ] + ) + return (
diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index 748a13ca30..de8def507c 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -28,6 +28,7 @@ import { DiskStateBadge, DiskTypeBadge, ReadOnlyBadge } from '~/components/State import { AttachDiskModalForm } from '~/forms/disk-attach' import { CreateDiskSideModalForm } from '~/forms/disk-create' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { DiskDetailSideModal } from '~/pages/project/disks/DiskDetailSideModal' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' @@ -339,6 +340,26 @@ export default function StorageTab() { getCoreRowModel: getCoreRowModel(), }) + const canAttachDisk = instanceCan.attachDisk(instance) + useQuickActions( + () => + canAttachDisk + ? [ + { + value: 'Attach existing disk', + navGroup: 'Actions', + action: () => setShowDiskAttach(true), + }, + { + value: 'Create disk', + navGroup: 'Actions', + action: () => setShowDiskCreate(true), + }, + ] + : [], + [canAttachDisk] + ) + return (
diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index 70b01936f7..2b5b82c63a 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -31,6 +31,7 @@ import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { routeFormMessage } from '~/forms/vpc-router-route-common' import { makeCrumb } from '~/hooks/use-crumbs' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' import { TypeValueCell } from '~/table/cells/TypeValueCell' @@ -176,6 +177,20 @@ export default function RouterPage() { // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L205 const canCreateNewRoute = routerData.kind === 'custom' + useQuickActions( + () => + canCreateNewRoute + ? [ + { + value: 'Add route', + navGroup: 'Actions', + action: pb.vpcRouterRoutesNew({ project, vpc, router }), + }, + ] + : [], + [canCreateNewRoute, project, vpc, router] + ) + return ( <> @@ -205,14 +220,14 @@ export default function RouterPage() { {canCreateNewRoute ? ( - New route + Add route ) : ( - New route + Add route )} diff --git a/app/pages/project/vpcs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcFirewallRulesTab.tsx index 471aed5b96..bc8a2fd41c 100644 --- a/app/pages/project/vpcs/VpcFirewallRulesTab.tsx +++ b/app/pages/project/vpcs/VpcFirewallRulesTab.tsx @@ -22,6 +22,7 @@ import { import { ListPlusCell } from '~/components/ListPlusCell' import { ProtocolBadge } from '~/components/ProtocolBadge' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { EnabledCell } from '~/table/cells/EnabledCell' import { LinkCell } from '~/table/cells/LinkCell' @@ -169,6 +170,22 @@ export default function VpcFirewallRulesTab() { const table = useReactTable({ columns, data: rules, getCoreRowModel: getCoreRowModel() }) + useQuickActions( + () => [ + { + value: 'New firewall rule', + navGroup: 'Actions', + action: pb.vpcFirewallRulesNew(vpcSelector), + }, + ...rules.map((r) => ({ + value: r.name, + navGroup: 'Edit firewall rule', + action: pb.vpcFirewallRuleEdit({ ...vpcSelector, rule: r.name }), + })), + ], + [vpcSelector, rules] + ) + const emptyState = ( [ + { + value: 'New router', + navGroup: 'Actions', + action: pb.vpcRoutersNew({ project, vpc }), + }, + ...(query.data?.items || []).map((r) => ({ + value: r.name, + navGroup: 'Edit router', + action: pb.vpcRouterEdit({ project, vpc, router: r.name }), + })), + ], + [project, vpc, query.data] + ) + return ( <>
diff --git a/app/pages/project/vpcs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcSubnetsTab.tsx index 198a722ca6..0fd25b796e 100644 --- a/app/pages/project/vpcs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcSubnetsTab.tsx @@ -12,6 +12,7 @@ import { Outlet, type LoaderFunctionArgs } from 'react-router' import { api, getListQFn, queryClient, useApiMutation, type VpcSubnet } from '@oxide/api' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -96,13 +97,29 @@ export default function VpcSubnetsTab() { /> ) - const { table } = useQueryTable({ + const { table, query } = useQueryTable({ query: subnetList(vpcSelector), columns, emptyState, rowHeight: 'large', }) + useQuickActions( + () => [ + { + value: 'New VPC subnet', + navGroup: 'Actions', + action: pb.vpcSubnetsNew(vpcSelector), + }, + ...(query.data?.items || []).map((s) => ({ + value: s.name, + navGroup: 'Edit VPC subnet', + action: pb.vpcSubnetsEdit({ ...vpcSelector, subnet: s.name }), + })), + ], + [vpcSelector, query.data] + ) + return ( <>
diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 92013e9664..9ef576382b 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -16,6 +16,7 @@ import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { makeCrumb } from '~/hooks/use-crumbs' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -88,6 +89,11 @@ export default function SSHKeysPage() { onClick={() => navigate(pb.sshKeysNew())} /> ) + useQuickActions( + () => [{ value: 'Add SSH key', navGroup: 'Actions', action: pb.sshKeysNew() }], + [] + ) + const { table } = useQueryTable({ query: sshKeyList, columns, emptyState }) return ( diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index ec48e5063b..42909fbb4d 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -33,6 +33,7 @@ import { FleetAccessEditUserSideModal, } from '~/forms/fleet-access' import { useCurrentUser } from '~/hooks/use-current-user' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' @@ -238,6 +239,17 @@ export default function FleetAccessPage() { [fleetPolicy, updatePolicy, me, navigate] ) + useQuickActions( + () => [ + { + value: 'Add user or group', + navGroup: 'Actions', + action: () => setAddModalOpen(true), + }, + ], + [] + ) + const tableInstance = useReactTable({ columns, data: rows, diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 4177afbac4..d82928af6d 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -34,6 +34,7 @@ import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { QueryParamTabs } from '~/components/QueryParamTabs' import { makeCrumb } from '~/hooks/use-crumbs' import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -272,6 +273,13 @@ function IpRangesTable() { ], [pool, removeRange] ) + useQuickActions( + () => [ + { value: 'Add range', navGroup: 'Actions', action: pb.ipPoolRangeAdd({ pool }) }, + ], + [pool] + ) + const columns = useColsWithActions(ipRangesStaticCols, makeRangeActions) const { table } = useQueryTable({ query: ipPoolRangeList({ pool }), columns, emptyState }) @@ -414,6 +422,13 @@ function LinkedSilosTable() { const [showLinkModal, setShowLinkModal] = useState(false) + useQuickActions( + () => [ + { value: 'Link silo', navGroup: 'Actions', action: () => setShowLinkModal(true) }, + ], + [] + ) + const emptyState = ( } diff --git a/app/pages/system/networking/SubnetPoolPage.tsx b/app/pages/system/networking/SubnetPoolPage.tsx index 3403b133ed..6dd7d565a2 100644 --- a/app/pages/system/networking/SubnetPoolPage.tsx +++ b/app/pages/system/networking/SubnetPoolPage.tsx @@ -34,6 +34,7 @@ import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { QueryParamTabs } from '~/components/QueryParamTabs' import { makeCrumb } from '~/hooks/use-crumbs' import { getSubnetPoolSelector, useSubnetPoolSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -231,6 +232,17 @@ function MembersTable() { ], [subnetPool, removeMember] ) + useQuickActions( + () => [ + { + value: 'Add member', + navGroup: 'Actions', + action: pb.subnetPoolMemberAdd({ subnetPool }), + }, + ], + [subnetPool] + ) + const columns = useColsWithActions(membersStaticCols, makeMemberActions) const { table } = useQueryTable({ query: subnetPoolMemberList({ subnetPool }), @@ -396,6 +408,13 @@ function LinkedSilosTable() { const [showLinkModal, setShowLinkModal] = useState(false) + useQuickActions( + () => [ + { value: 'Link silo', navGroup: 'Actions', action: () => setShowLinkModal(true) }, + ], + [] + ) + const emptyState = ( } diff --git a/app/pages/system/silos/SiloIdpsTab.tsx b/app/pages/system/silos/SiloIdpsTab.tsx index 7a8bd00591..17122511d1 100644 --- a/app/pages/system/silos/SiloIdpsTab.tsx +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -15,6 +15,7 @@ import { Badge } from '@oxide/design-system/ui' import { api, getListQFn, queryClient, type IdentityProvider } from '~/api' import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { LinkCell } from '~/table/cells/LinkCell' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -59,6 +60,13 @@ export default function SiloIdpsTab() { [silo] ) + useQuickActions( + () => [ + { value: 'New provider', navGroup: 'Actions', action: pb.siloIdpsNew({ silo }) }, + ], + [silo] + ) + const { table } = useQueryTable({ query: siloIdpList(silo), columns, diff --git a/app/pages/system/silos/SiloScimTab.tsx b/app/pages/system/silos/SiloScimTab.tsx index 5aeeee375b..8d74b097d6 100644 --- a/app/pages/system/silos/SiloScimTab.tsx +++ b/app/pages/system/silos/SiloScimTab.tsx @@ -26,6 +26,7 @@ import { } from '~/api' import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -103,6 +104,20 @@ export default function SiloScimTab() { const [modalState, setModalState] = useState(false) + useQuickActions( + () => + tokensResult.type === 'success' + ? [ + { + value: 'Create token', + navGroup: 'Actions', + action: () => setModalState({ kind: 'create' }), + }, + ] + : [], + [tokensResult.type] + ) + return ( <> diff --git a/test/e2e/action-menu.e2e.ts b/test/e2e/action-menu.e2e.ts index 6f1aa9f464..47119aed74 100644 --- a/test/e2e/action-menu.e2e.ts +++ b/test/e2e/action-menu.e2e.ts @@ -7,7 +7,7 @@ */ import { expect, test, type Page } from '@playwright/test' -import { expectNotVisible } from './utils' +import { expectNotVisible, getPageAsUser, stopInstance } from './utils' const openActionMenu = async (page: Page) => { // open the action menu (use the sidenav button, as keyboard events aren't reliable in Playwright) @@ -131,3 +131,71 @@ test('dismiss with Escape', async ({ page }) => { await page.keyboard.press('Escape') await expect(page.getByText('Enterto submit')).toBeHidden() }) + +test('router quick action "Add route" hidden for system router, visible for custom', async ({ + page, +}) => { + // system router: action should not appear + await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router') + await openActionMenu(page) + await expect(page.getByRole('option', { name: 'Add route' })).toBeHidden() + await page.keyboard.press('Escape') + + // custom router: action should appear + await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router') + await openActionMenu(page) + await expect(page.getByRole('option', { name: 'Add route' })).toBeVisible() +}) + +test('storage tab quick actions hidden when instance running, visible when stopped', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances/db1/storage') + await openActionMenu(page) + // running: disk actions not available + await expect(page.getByRole('option', { name: 'Attach existing disk' })).toBeHidden() + await expect(page.getByRole('option', { name: 'Create disk' })).toBeHidden() + await page.keyboard.press('Escape') + + await stopInstance(page) + await openActionMenu(page) + await expect(page.getByRole('option', { name: 'Attach existing disk' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Create disk' })).toBeVisible() +}) + +test('networking tab quick actions: NIC gated by run state, IPs/subnets by availability', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances/db1') + await page.getByRole('tab', { name: 'Networking' }).click() + await openActionMenu(page) + // running: NIC creation unavailable + await expect(page.getByRole('option', { name: 'Add network interface' })).toBeHidden() + // floating IP, ephemeral IP, and external subnet all have available resources + await expect(page.getByRole('option', { name: 'Attach floating IP' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Attach ephemeral IP' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Attach external subnet' })).toBeVisible() + await page.keyboard.press('Escape') + + await stopInstance(page) + await page.getByRole('tab', { name: 'Networking' }).click() + await openActionMenu(page) + await expect(page.getByRole('option', { name: 'Add network interface' })).toBeVisible() +}) + +test('SCIM tab quick action "Create token" visible when permitted, hidden when not', async ({ + page, + browser, +}) => { + // default session (fleet admin): action should appear + await page.goto('/system/silos/maze-war/scim') + await openActionMenu(page) + await expect(page.getByRole('option', { name: 'Create token' })).toBeVisible() + await page.keyboard.press('Escape') + + // Jane Austen: fleet viewer, not silo admin on maze-war — gets 403, action should not appear + const janeAustenPage = await getPageAsUser(browser, 'Jane Austen') + await janeAustenPage.goto('/system/silos/maze-war/scim') + await openActionMenu(janeAustenPage) + await expect(janeAustenPage.getByRole('option', { name: 'Create token' })).toBeHidden() +}) diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 9361de732d..73237f5c5f 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -207,7 +207,7 @@ test('can’t create or delete Routes on system routers', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router') // verify that the "new route" link isn't present, since users can't add routes to system routers - await expect(page.getByRole('link', { name: 'New route' })).toBeHidden() + await expect(page.getByRole('link', { name: 'Add route' })).toBeHidden() // expect to see table of routes const table = page.getByRole('table') @@ -237,7 +237,7 @@ test('create router route', async ({ page }) => { ) // create a new route - await page.getByRole('link', { name: 'New route' }).click() + await page.getByRole('link', { name: 'Add route' }).click() await nameInput.fill('new-route') // Test IP validation for destination @@ -391,7 +391,7 @@ test('internet gateway shows proper list of routes targeting it', async ({ page await page.getByRole('link', { name: 'mock-custom-router' }).click() // create a new route - await page.getByRole('link', { name: 'New route' }).click() + await page.getByRole('link', { name: 'Add route' }).click() await page.getByRole('textbox', { name: 'Name' }).fill('new-route') await page.getByRole('textbox', { name: 'Destination value' }).fill('1.2.3.4') await selectOption(page, 'Target type', 'Internet gateway')