diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx
index c119712917..dc1d3287fd 100644
--- a/app/components/TopBar.tsx
+++ b/app/components/TopBar.tsx
@@ -62,11 +62,7 @@ export function TopBar({ children }: { children: React.ReactNode }) {
-
+
navigate(pb.profile())}>
Settings
diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx
index 2ec6340e5f..f936ca4389 100644
--- a/app/forms/firewall-rules-create.tsx
+++ b/app/forms/firewall-rules-create.tsx
@@ -7,7 +7,7 @@
*/
import { useMemo } from 'react'
import { useController, type Control } from 'react-hook-form'
-import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
+import { useNavigate, useParams, type LoaderFunctionArgs } from 'react-router-dom'
import * as R from 'remeda'
import {
@@ -74,7 +74,17 @@ export const valuesToRuleUpdate = (values: FirewallRuleValues): VpcFirewallRuleU
targets: values.targets,
})
-const defaultValues: FirewallRuleValues = {
+/** convert in the opposite direction for when we're creating from existing rule */
+const ruleToValues = (rule: VpcFirewallRule): FirewallRuleValues => ({
+ ...rule,
+ enabled: rule.status === 'enabled',
+ protocols: rule.filters.protocols || [],
+ ports: rule.filters.ports || [],
+ hosts: rule.filters.hosts || [],
+})
+
+/** Empty form for when we're not creating from an existing rule */
+const defaultValuesEmpty: FirewallRuleValues = {
enabled: true,
name: '',
description: '',
@@ -586,6 +596,18 @@ export function CreateFirewallRuleForm() {
})
const existingRules = useMemo(() => R.sortBy(data.rules, (r) => r.priority), [data])
+ // The :rule path param is optional. If it is present, we are creating a
+ // rule from an existing one, so we find that rule and copy it into the form
+ // values. Note that if we fail to find the rule by name (which should be
+ // very unlikely) we just pretend we never saw a name in the path and start
+ // from scratch.
+ const { rule: ruleName } = useParams()
+ const originalRule = existingRules.find((rule) => rule.name === ruleName)
+
+ const defaultValues: FirewallRuleValues = originalRule
+ ? ruleToValues({ ...originalRule, name: originalRule.name + '-copy' })
+ : defaultValuesEmpty
+
const form = useForm({ defaultValues })
return (
diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
index eb0c4d74df..e4fab8bc61 100644
--- a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
+++ b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
@@ -139,6 +139,12 @@ export function VpcFirewallRulesTab() {
navigate(pb.vpcFirewallRuleEdit({ ...vpcSelector, rule: rule.name }))
},
},
+ {
+ label: 'Clone',
+ onActivate() {
+ navigate(pb.vpcFirewallRuleClone({ ...vpcSelector, rule: rule.name }))
+ },
+ },
{
label: 'Delete',
onActivate: confirmDelete({
diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx
index 2ddf5df013..8c0a56dd51 100644
--- a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx
+++ b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx
@@ -85,8 +85,6 @@ export function VpcSubnetsTab() {
[vpcSelector, makeActions]
)
- // const columns = useColsWithActions(staticCols, makeActions)
-
const emptyState = (
}
loader={CreateFirewallRuleForm.loader}
handle={{ crumb: 'New Firewall Rule' }}
diff --git a/app/ui/styles/components/menu-button.css b/app/ui/styles/components/menu-button.css
index d362cae7b2..386fd7e67e 100644
--- a/app/ui/styles/components/menu-button.css
+++ b/app/ui/styles/components/menu-button.css
@@ -7,7 +7,7 @@
*/
.DropdownMenuContent {
- @apply z-30 min-w-[14rem] rounded border p-0 bg-raise border-secondary;
+ @apply z-30 min-w-36 rounded border p-0 bg-raise border-secondary;
& .DropdownMenuItem {
@apply block cursor-pointer select-none border-b py-2 pl-3 pr-6 text-left text-sans-md text-secondary border-secondary last-of-type:border-b-0;
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index eb42d38935..68b0197014 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -88,6 +88,7 @@ test('path builder', () => {
"systemUtilization": "/system/utilization",
"vpc": "/projects/p/vpcs/v/firewall-rules",
"vpcEdit": "/projects/p/vpcs/v/edit",
+ "vpcFirewallRuleClone": "/projects/p/vpcs/v/firewall-rules-new/fr",
"vpcFirewallRuleEdit": "/projects/p/vpcs/v/firewall-rules/fr/edit",
"vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules",
"vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new",
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 12e172018c..ce59f1d358 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -75,11 +75,12 @@ export const pb = {
// same deal as instance detail: go straight to first tab
vpc: (params: Vpc) => pb.vpcFirewallRules(params),
-
vpcEdit: (params: Vpc) => `${vpcBase(params)}/edit`,
vpcFirewallRules: (params: Vpc) => `${vpcBase(params)}/firewall-rules`,
vpcFirewallRulesNew: (params: Vpc) => `${vpcBase(params)}/firewall-rules-new`,
+ vpcFirewallRuleClone: (params: FirewallRule) =>
+ `${pb.vpcFirewallRulesNew(params)}/${params.rule}`,
vpcFirewallRuleEdit: (params: FirewallRule) =>
`${pb.vpcFirewallRules(params)}/${params.rule}/edit`,
vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`,
diff --git a/test/e2e/firewall-rules.e2e.ts b/test/e2e/firewall-rules.e2e.ts
index 0467c81ef6..3cbe630900 100644
--- a/test/e2e/firewall-rules.e2e.ts
+++ b/test/e2e/firewall-rules.e2e.ts
@@ -6,7 +6,7 @@
* Copyright Oxide Computer Company
*/
-import { expect, expectRowVisible, test } from './utils'
+import { clickRowAction, expect, expectRowVisible, test } from './utils'
const defaultRules = ['allow-internal-inbound', 'allow-ssh', 'allow-icmp', 'allow-rdp']
@@ -310,6 +310,37 @@ test('can update firewall rule', async ({ page }) => {
}
})
+test('create from existing rule', async ({ page }) => {
+ const url = '/projects/mock-project/vpcs/mock-vpc/firewall-rules'
+ await page.goto(url)
+
+ const modal = page.getByRole('dialog', { name: 'Add firewall rule' })
+ await expect(modal).toBeHidden()
+
+ await clickRowAction(page, 'allow-rdp', 'Clone')
+
+ await expect(page).toHaveURL(url + '-new/allow-rdp')
+ await expect(modal).toBeVisible()
+ await expect(modal.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue(
+ 'allow-rdp-copy'
+ )
+
+ await expect(modal.getByRole('checkbox', { name: 'TCP' })).toBeChecked()
+ await expect(modal.getByRole('checkbox', { name: 'UDP' })).not.toBeChecked()
+ await expect(modal.getByRole('checkbox', { name: 'ICMP' })).not.toBeChecked()
+
+ await expect(
+ modal
+ .getByRole('table', { name: 'Port filters' })
+ .getByRole('cell', { name: '3389', exact: true })
+ ).toBeVisible()
+ await expect(
+ modal
+ .getByRole('table', { name: 'Targets' })
+ .getByRole('row', { name: 'Name: default, Type: vpc' })
+ ).toBeVisible()
+})
+
const rulePath = '/projects/mock-project/vpcs/mock-vpc/firewall-rules/allow-icmp/edit'
test('can edit rule directly by URL', async ({ page }) => {