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')