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 }) => {