+
>
)
}
diff --git a/app/routes.tsx b/app/routes.tsx
index 16b6239e79..9c558733a7 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -10,6 +10,8 @@ import { createRoutesFromElements, Navigate, Route } from 'react-router-dom'
import { RouterDataErrorBoundary } from './components/ErrorBoundary'
import { NotFound } from './components/ErrorPage'
import { CreateDiskSideModalForm } from './forms/disk-create'
+import { CreateFirewallRuleForm } from './forms/firewall-rules-create'
+import { EditFirewallRuleForm } from './forms/firewall-rules-edit'
import { CreateFloatingIpSideModalForm } from './forms/floating-ip-create'
import { EditFloatingIpSideModalForm } from './forms/floating-ip-edit'
import { CreateIdpSideModalForm } from './forms/idp/create'
@@ -29,6 +31,8 @@ import { EditProjectSideModalForm } from './forms/project-edit'
import { CreateSiloSideModalForm } from './forms/silo-create'
import { CreateSnapshotSideModalForm } from './forms/snapshot-create'
import { CreateSSHKeySideModalForm } from './forms/ssh-key-create'
+import { CreateSubnetForm } from './forms/subnet-create'
+import { EditSubnetForm } from './forms/subnet-edit'
import { CreateVpcSideModalForm } from './forms/vpc-create'
import { EditVpcSideModalForm } from './forms/vpc-edit'
import type { CrumbFunc } from './hooks/use-title'
@@ -58,6 +62,8 @@ import { NetworkingTab } from './pages/project/instances/instance/tabs/Networkin
import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab'
import { InstancesPage } from './pages/project/instances/InstancesPage'
import { SnapshotsPage } from './pages/project/snapshots/SnapshotsPage'
+import { VpcFirewallRulesTab } from './pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab'
+import { VpcSubnetsTab } from './pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab'
import { VpcPage } from './pages/project/vpcs/VpcPage/VpcPage'
import { VpcsPage } from './pages/project/vpcs/VpcsPage'
import { ProjectsPage } from './pages/ProjectsPage'
@@ -343,12 +349,40 @@ export const routes = createRoutesFromElements(
- }
- loader={VpcPage.loader}
- handle={{ crumb: vpcCrumb }}
- />
+
+ } />
+ } loader={VpcPage.loader}>
+ } loader={VpcFirewallRulesTab.loader}>
+
+ }
+ loader={CreateFirewallRuleForm.loader}
+ handle={{ crumb: 'New Firewall Rule' }}
+ />
+ }
+ loader={EditFirewallRuleForm.loader}
+ handle={{ crumb: 'Edit Firewall Rule' }}
+ />
+
+ } loader={VpcSubnetsTab.loader}>
+
+ }
+ handle={{ crumb: 'New Subnet' }}
+ />
+ }
+ loader={EditSubnetForm.loader}
+ handle={{ crumb: 'Edit Subnet' }}
+ />
+
+
+
} loader={FloatingIpsPage.loader}>
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index c08f072473..46bb65295c 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -22,6 +22,8 @@ const params = {
image: 'im',
snapshot: 'sn',
pool: 'pl',
+ firewallRule: 'fr',
+ subnet: 'su',
}
test('path builder', () => {
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index bb1de149d7..dc2bfb345a 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -21,6 +21,8 @@ type Snapshot = Required
type SiloImage = Required
type IpPool = Required
type FloatingIp = Required
+type VpcFirewallRule = Required
+type VpcSubnet = Required
// this is used as the basis for many routes, but is itself not a route we ever
// want to link directly to. so we use this to build the routes but pb.project()
@@ -72,6 +74,17 @@ export const pb = {
vpcs: (params: Project) => `${projectBase(params)}/vpcs`,
vpc: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}`,
vpcEdit: (params: Vpc) => `${pb.vpc(params)}/edit`,
+
+ vpcFirewallRules: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/firewall-rules`,
+ vpcFirewallRulesNew: (params: Vpc) =>
+ `${pb.vpcs(params)}/${params.vpc}/firewall-rules-new`,
+ vpcFirewallRuleEdit: (params: VpcFirewallRule) =>
+ `${pb.vpcs(params)}/${params.vpc}/firewall-rules/${params.firewallRule}/edit`,
+ vpcSubnets: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/subnets`,
+ vpcSubnetsNew: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/subnets-new`,
+ vpcSubnetsEdit: (params: VpcSubnet) =>
+ `${pb.vpcs(params)}/${params.vpc}/subnets/${params.subnet}/edit`,
+
floatingIps: (params: Project) => `${projectBase(params)}/floating-ips`,
floatingIpsNew: (params: Project) => `${projectBase(params)}/floating-ips-new`,
floatingIp: (params: FloatingIp) => `${pb.floatingIps(params)}/${params.floatingIp}`,
From d5ef769b8f0c3b1eeb330fbd84cac04caf3cac26 Mon Sep 17 00:00:00 2001
From: Benjamin Leonard
Date: Fri, 17 May 2024 16:05:57 +0100
Subject: [PATCH 02/22] Test fixes
---
test/e2e/firewall-rules.e2e.ts | 8 ++++----
test/e2e/networking.e2e.ts | 2 +-
test/e2e/vpcs.e2e.ts | 6 ++++--
3 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/test/e2e/firewall-rules.e2e.ts b/test/e2e/firewall-rules.e2e.ts
index 7287a598e9..1ee0d1c116 100644
--- a/test/e2e/firewall-rules.e2e.ts
+++ b/test/e2e/firewall-rules.e2e.ts
@@ -25,7 +25,7 @@ test('can create firewall rule', async ({ page }) => {
await expect(modal).toBeHidden()
// open modal
- await page.getByRole('button', { name: 'New rule' }).click()
+ await page.getByRole('link', { name: 'New rule' }).click()
// modal is now open
await expect(modal).toBeVisible()
@@ -141,7 +141,7 @@ test('firewall rule form targets table', async ({ page }) => {
await page.getByRole('tab', { name: 'Firewall Rules' }).click()
// open modal
- await page.getByRole('button', { name: 'New rule' }).click()
+ await page.getByRole('link', { name: 'New rule' }).click()
const targets = page.getByRole('table', { name: 'Targets' })
const addButton = page.getByRole('button', { name: 'Add target' })
@@ -191,7 +191,7 @@ test('firewall rule form hosts table', async ({ page }) => {
await page.getByRole('tab', { name: 'Firewall Rules' }).click()
// open modal
- await page.getByRole('button', { name: 'New rule' }).click()
+ await page.getByRole('link', { name: 'New rule' }).click()
const hosts = page.getByRole('table', { name: 'Host filters' })
const addButton = page.getByRole('button', { name: 'Add host filter' })
@@ -254,7 +254,7 @@ test('can update firewall rule', async ({ page }) => {
await expect(modal).toBeHidden()
// can click name cell to edit
- await page.getByRole('button', { name: 'allow-icmp' }).click()
+ await page.getByRole('link', { name: 'allow-icmp' }).click()
// modal is now open
await expect(modal).toBeVisible()
diff --git a/test/e2e/networking.e2e.ts b/test/e2e/networking.e2e.ts
index 9d3760ff4c..7b975dea61 100644
--- a/test/e2e/networking.e2e.ts
+++ b/test/e2e/networking.e2e.ts
@@ -64,7 +64,7 @@ test('Create and edit subnet', async ({ page }) => {
await page.getByRole('tab', { name: 'Subnets' }).click()
// Create subnet
- await page.click('role=button[name="New subnet"]')
+ await page.click('role=link[name="New subnet"]')
await expectVisible(page, [
'role=heading[name="Create subnet"]',
'role=button[name="Create subnet"]',
diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts
index 2fa7d57e39..b8e87fe35f 100644
--- a/test/e2e/vpcs.e2e.ts
+++ b/test/e2e/vpcs.e2e.ts
@@ -27,8 +27,10 @@ test('can nav to VpcPage from /', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'mock-vpc' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Firewall rules' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'allow-icmp' })).toBeVisible()
- await expect(page).toHaveURL('/projects/mock-project/vpcs/mock-vpc')
- await expect(page).toHaveTitle('mock-vpc / VPCs / mock-project / Oxide Console')
+ await expect(page).toHaveURL('/projects/mock-project/vpcs/mock-vpc/firewall-rules')
+ await expect(page).toHaveTitle(
+ 'Firewall Rules / mock-vpc / VPCs / mock-project / Oxide Console'
+ )
// we can also click the firewall rules cell to get to the VPC detail
await page.goBack()
From a5ddc631d6d18f46351573530cf7e18fd2865f81 Mon Sep 17 00:00:00 2001
From: Benjamin Leonard
Date: Fri, 17 May 2024 16:11:30 +0100
Subject: [PATCH 03/22] Add missing paths to test
---
app/util/path-builder.spec.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 46bb65295c..4a514a5187 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -89,6 +89,12 @@ test('path builder', () => {
"systemUtilization": "/system/utilization",
"vpc": "/projects/p/vpcs/v",
"vpcEdit": "/projects/p/vpcs/v/edit",
+ "vpcFirewallRuleEdit": "/projects/p/vpcs/v/firewall-rules/fr/edit",
+ "vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules",
+ "vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new",
+ "vpcSubnets": "/projects/p/vpcs/v/subnets",
+ "vpcSubnetsEdit": "/projects/p/vpcs/v/subnets/su/edit",
+ "vpcSubnetsNew": "/projects/p/vpcs/v/subnets-new",
"vpcs": "/projects/p/vpcs",
"vpcsNew": "/projects/p/vpcs-new",
}
From 45c7f4104cf783a23b6dd95605c3fd71dbd7bd3a Mon Sep 17 00:00:00 2001
From: Benjamin Leonard
Date: Fri, 17 May 2024 16:33:18 +0100
Subject: [PATCH 04/22] Create firewall from template
---
app/forms/firewall-rules-create.tsx | 43 +++++++++++--------
.../vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx | 11 +++++
app/routes.tsx | 2 +-
app/util/path-builder.ts | 2 +
app/util/template.ts | 14 ++++++
5 files changed, 52 insertions(+), 20 deletions(-)
create mode 100644 app/util/template.ts
diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx
index 53a9e48db4..57c74f2357 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 {
apiQueryClient,
@@ -44,6 +44,7 @@ import { KEYS } from '~/ui/util/keys'
import { sortBy } from '~/util/array'
import { links } from '~/util/links'
import { pb } from '~/util/path-builder'
+import { incrementName } from '~/util/template'
export type FirewallRuleValues = {
enabled: boolean
@@ -75,24 +76,6 @@ export const valuesToRuleUpdate = (values: FirewallRuleValues): VpcFirewallRuleU
targets: values.targets,
})
-const defaultValues: FirewallRuleValues = {
- enabled: true,
- name: '',
- description: '',
-
- priority: 0,
- action: 'allow',
- direction: 'inbound',
-
- // in the request body, these go in a `filters` object. we probably don't
- // need such nesting here though. not even sure how to do it
- protocols: [],
-
- ports: [],
- hosts: [],
- targets: [],
-}
-
type PortRangeFormValues = {
portRange: string
}
@@ -562,6 +545,9 @@ export function CreateFirewallRuleForm() {
const vpcSelector = useVpcSelector()
const queryClient = useApiQueryClient()
+ // To use as a template to base a new rule off
+ const { firewallRule } = useParams()
+
const navigate = useNavigate()
const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector))
@@ -577,6 +563,25 @@ export function CreateFirewallRuleForm() {
query: vpcSelector,
})
const existingRules = useMemo(() => sortBy(data.rules, (r) => r.priority), [data])
+ const originalRule = existingRules.find((rule) => rule.name === firewallRule)
+
+ const defaultValues: FirewallRuleValues = {
+ enabled: originalRule ? originalRule.status === 'enabled' : true,
+ name: originalRule ? incrementName(originalRule.name) : '',
+ description: originalRule ? originalRule.description : '',
+
+ priority: originalRule ? originalRule.priority : 0,
+ action: originalRule ? originalRule.action : 'allow',
+ direction: originalRule ? originalRule.direction : 'inbound',
+
+ // in the request body, these go in a `filters` object. we probably don't
+ // need such nesting here though. not even sure how to do it
+ protocols: originalRule ? originalRule.filters.protocols || [] : [],
+
+ ports: originalRule ? originalRule.filters.ports || [] : [],
+ hosts: originalRule ? originalRule.filters.hosts || [] : [],
+ targets: originalRule ? originalRule.targets : [],
+ }
const form = useForm({ defaultValues })
diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
index 312c186fd8..ba0117e8ba 100644
--- a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
+++ b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
@@ -156,6 +156,17 @@ export function VpcFirewallRulesTab() {
)
},
},
+ {
+ label: 'New similar rule',
+ onActivate() {
+ navigate(
+ pb.vpcFirewallRulesNewFromTemplate({
+ ...vpcSelector,
+ firewallRule: rule.name,
+ })
+ )
+ },
+ },
{
label: 'Delete',
onActivate: confirmDelete({
diff --git a/app/routes.tsx b/app/routes.tsx
index 9c558733a7..2c513dade4 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -355,7 +355,7 @@ export const routes = createRoutesFromElements(
} loader={VpcFirewallRulesTab.loader}>
}
loader={CreateFirewallRuleForm.loader}
handle={{ crumb: 'New Firewall Rule' }}
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index dc2bfb345a..1da9117c08 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -78,6 +78,8 @@ export const pb = {
vpcFirewallRules: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/firewall-rules`,
vpcFirewallRulesNew: (params: Vpc) =>
`${pb.vpcs(params)}/${params.vpc}/firewall-rules-new`,
+ vpcFirewallRulesNewFromTemplate: (params: VpcFirewallRule) =>
+ `${pb.vpcs(params)}/${params.vpc}/firewall-rules-new/${params.firewallRule}`,
vpcFirewallRuleEdit: (params: VpcFirewallRule) =>
`${pb.vpcs(params)}/${params.vpc}/firewall-rules/${params.firewallRule}/edit`,
vpcSubnets: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/subnets`,
diff --git a/app/util/template.ts b/app/util/template.ts
new file mode 100644
index 0000000000..f7eeeb8525
--- /dev/null
+++ b/app/util/template.ts
@@ -0,0 +1,14 @@
+export const incrementName = (str: string) => {
+ let name = str
+ const match = name.match(/(.*)-(\d+)$/)
+
+ if (match) {
+ const base = match[1]
+ const num = parseInt(match[2], 10)
+ name = `${base}-${num + 1}`
+ } else {
+ name = `${name}-1`
+ }
+
+ return name
+}
From 19c664499facaad48996fe16932b6efc6ae305ea Mon Sep 17 00:00:00 2001
From: Benjamin Leonard
Date: Fri, 17 May 2024 16:35:40 +0100
Subject: [PATCH 05/22] Update path-builder.spec.ts
---
app/util/path-builder.spec.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 4a514a5187..c243d10cd4 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -92,6 +92,7 @@ test('path builder', () => {
"vpcFirewallRuleEdit": "/projects/p/vpcs/v/firewall-rules/fr/edit",
"vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules",
"vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new",
+ "vpcFirewallRulesNewFromTemplate": "/projects/p/vpcs/v/firewall-rules-new/fr",
"vpcSubnets": "/projects/p/vpcs/v/subnets",
"vpcSubnetsEdit": "/projects/p/vpcs/v/subnets/su/edit",
"vpcSubnetsNew": "/projects/p/vpcs/v/subnets-new",
From 428aedbb7f8b4031f004539f9202bbc9e95ef515 Mon Sep 17 00:00:00 2001
From: Benjamin Leonard
Date: Fri, 17 May 2024 16:42:00 +0100
Subject: [PATCH 06/22] Add missing license
---
app/util/template.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/util/template.ts b/app/util/template.ts
index f7eeeb8525..4c2f2fff9e 100644
--- a/app/util/template.ts
+++ b/app/util/template.ts
@@ -1,3 +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
+ */
export const incrementName = (str: string) => {
let name = str
const match = name.match(/(.*)-(\d+)$/)
From ecd5967c559c891cb3644f1384394d40160b9e88 Mon Sep 17 00:00:00 2001
From: Benjamin Leonard
Date: Fri, 17 May 2024 16:44:08 +0100
Subject: [PATCH 07/22] Move `incrementName` into `str` and add test
---
app/util/str.spec.ts | 18 +++++++++++++++++-
app/util/str.ts | 15 +++++++++++++++
app/util/template.ts | 21 ---------------------
3 files changed, 32 insertions(+), 22 deletions(-)
delete mode 100644 app/util/template.ts
diff --git a/app/util/str.spec.ts b/app/util/str.spec.ts
index 37115bd02e..b7670d6a6c 100644
--- a/app/util/str.spec.ts
+++ b/app/util/str.spec.ts
@@ -7,7 +7,15 @@
*/
import { describe, expect, it, test } from 'vitest'
-import { camelCase, capitalize, commaSeries, kebabCase, titleCase, validateIp } from './str'
+import {
+ camelCase,
+ capitalize,
+ commaSeries,
+ incrementName,
+ kebabCase,
+ titleCase,
+ validateIp,
+} from './str'
describe('capitalize', () => {
it('capitalizes the first letter', () => {
@@ -140,3 +148,11 @@ test.each([
])('validateIp catches invalid IP: %s', (s) => {
expect(validateIp(s)).toStrictEqual({ isv4: false, isv6: false, valid: false })
})
+
+describe('incrementName', () => {
+ it('increments the name', () => {
+ expect(incrementName('increment-name')).toBe('increment-name-1')
+ expect(incrementName('increment-name-1')).toBe('increment-name-2')
+ expect(incrementName('increment-name-99')).toBe('increment-name-100')
+ })
+})
diff --git a/app/util/str.ts b/app/util/str.ts
index 55cde1f7bb..3f8bd2f91f 100644
--- a/app/util/str.ts
+++ b/app/util/str.ts
@@ -65,3 +65,18 @@ export const validateIp = (ip: string) => {
const isv6 = !isv4 && IPV6_REGEX.test(ip)
return { isv4, isv6, valid: isv4 || isv6 }
}
+
+export const incrementName = (str: string) => {
+ let name = str
+ const match = name.match(/(.*)-(\d+)$/)
+
+ if (match) {
+ const base = match[1]
+ const num = parseInt(match[2], 10)
+ name = `${base}-${num + 1}`
+ } else {
+ name = `${name}-1`
+ }
+
+ return name
+}
diff --git a/app/util/template.ts b/app/util/template.ts
deleted file mode 100644
index 4c2f2fff9e..0000000000
--- a/app/util/template.ts
+++ /dev/null
@@ -1,21 +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
- */
-export const incrementName = (str: string) => {
- let name = str
- const match = name.match(/(.*)-(\d+)$/)
-
- if (match) {
- const base = match[1]
- const num = parseInt(match[2], 10)
- name = `${base}-${num + 1}`
- } else {
- name = `${name}-1`
- }
-
- return name
-}
From b291892e87937cd752812c9a8e4a5815ec318b61 Mon Sep 17 00:00:00 2001
From: Benjamin Leonard
Date: Fri, 17 May 2024 16:46:35 +0100
Subject: [PATCH 08/22] Update import
---
app/forms/firewall-rules-create.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx
index 57c74f2357..761916bd67 100644
--- a/app/forms/firewall-rules-create.tsx
+++ b/app/forms/firewall-rules-create.tsx
@@ -44,7 +44,7 @@ import { KEYS } from '~/ui/util/keys'
import { sortBy } from '~/util/array'
import { links } from '~/util/links'
import { pb } from '~/util/path-builder'
-import { incrementName } from '~/util/template'
+import { incrementName } from '~/util/str'
export type FirewallRuleValues = {
enabled: boolean
From 1c2fd0cf29c2d2e65a79b384ae704bc20ad760f3 Mon Sep 17 00:00:00 2001
From: David Crespo
Date: Thu, 23 May 2024 14:03:43 -0500
Subject: [PATCH 09/22] refactor rule to form values logic
---
app/forms/firewall-rules-create.tsx | 48 +++++++++++++++++++----------
1 file changed, 31 insertions(+), 17 deletions(-)
diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx
index 761916bd67..4421791faa 100644
--- a/app/forms/firewall-rules-create.tsx
+++ b/app/forms/firewall-rules-create.tsx
@@ -76,6 +76,34 @@ export const valuesToRuleUpdate = (values: FirewallRuleValues): VpcFirewallRuleU
targets: values.targets,
})
+/** 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: '',
+
+ priority: 0,
+ action: 'allow',
+ direction: 'inbound',
+
+ // in the request body, these go in a `filters` object. we probably don't
+ // need such nesting here though. not even sure how to do it
+ protocols: [],
+
+ ports: [],
+ hosts: [],
+ targets: [],
+}
+
type PortRangeFormValues = {
portRange: string
}
@@ -565,23 +593,9 @@ export function CreateFirewallRuleForm() {
const existingRules = useMemo(() => sortBy(data.rules, (r) => r.priority), [data])
const originalRule = existingRules.find((rule) => rule.name === firewallRule)
- const defaultValues: FirewallRuleValues = {
- enabled: originalRule ? originalRule.status === 'enabled' : true,
- name: originalRule ? incrementName(originalRule.name) : '',
- description: originalRule ? originalRule.description : '',
-
- priority: originalRule ? originalRule.priority : 0,
- action: originalRule ? originalRule.action : 'allow',
- direction: originalRule ? originalRule.direction : 'inbound',
-
- // in the request body, these go in a `filters` object. we probably don't
- // need such nesting here though. not even sure how to do it
- protocols: originalRule ? originalRule.filters.protocols || [] : [],
-
- ports: originalRule ? originalRule.filters.ports || [] : [],
- hosts: originalRule ? originalRule.filters.hosts || [] : [],
- targets: originalRule ? originalRule.targets : [],
- }
+ const defaultValues: FirewallRuleValues = originalRule
+ ? ruleToValues({ ...originalRule, name: incrementName(originalRule.name) })
+ : defaultValuesEmpty
const form = useForm({ defaultValues })
From aa31af4468383d7828c11ded0aa28f1f00c66493 Mon Sep 17 00:00:00 2001
From: David Crespo
Date: Thu, 23 May 2024 14:46:20 -0500
Subject: [PATCH 10/22] basic test
---
test/e2e/firewall-rules.e2e.ts | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/test/e2e/firewall-rules.e2e.ts b/test/e2e/firewall-rules.e2e.ts
index 1ee0d1c116..bd28553ea2 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']
@@ -309,3 +309,21 @@ test('can update firewall rule', async ({ page }) => {
await expect(page.locator(`text="${name}"`)).toBeVisible()
}
})
+
+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', 'New similar rule')
+
+ await expect(page).toHaveURL(url + '-new/allow-rdp')
+ await expect(modal).toBeVisible()
+ await expect(page.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue(
+ 'allow-rdp-1'
+ )
+
+ // TODO: should assert all the values, really
+})
From 5ed3c68714bca7a5927abc95d25f78fbab2b488a Mon Sep 17 00:00:00 2001
From: David Crespo
Date: Thu, 23 May 2024 15:06:03 -0500
Subject: [PATCH 11/22] clean up vpc and instance detail in path builder
---
app/forms/instance-create.tsx | 2 +-
app/pages/project/instances/InstancesPage.tsx | 4 +--
app/routes.tsx | 6 +++-
app/table/cells/InstanceLinkCell.tsx | 2 +-
app/util/path-builder.spec.ts | 5 ++--
app/util/path-builder.ts | 29 +++++++++++--------
test/e2e/instance-networking.e2e.ts | 4 +--
7 files changed, 30 insertions(+), 22 deletions(-)
diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx
index 5bbffa4cb9..bfb25d21fa 100644
--- a/app/forms/instance-create.tsx
+++ b/app/forms/instance-create.tsx
@@ -174,7 +174,7 @@ export function CreateInstanceForm() {
instance
)
addToast({ content: 'Your instance has been created' })
- navigate(pb.instancePage({ project, instance: instance.name }))
+ navigate(pb.instance({ project, instance: instance.name }))
},
})
diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx
index aae82e939d..e0e6c55887 100644
--- a/app/pages/project/instances/InstancesPage.tsx
+++ b/app/pages/project/instances/InstancesPage.tsx
@@ -80,7 +80,7 @@ export function InstancesPage() {
},
...(instances?.items || []).map((i) => ({
value: i.name,
- onSelect: () => navigate(pb.instancePage({ project, instance: i.name })),
+ onSelect: () => navigate(pb.instance({ project, instance: i.name })),
navGroup: 'Go to instance',
})),
],
@@ -97,7 +97,7 @@ export function InstancesPage() {
const columns = useMemo(
() => [
colHelper.accessor('name', {
- cell: makeLinkCell((instance) => pb.instancePage({ project, instance })),
+ cell: makeLinkCell((instance) => pb.instance({ project, instance })),
}),
colHelper.accessor((i) => ({ ncpus: i.ncpus, memory: i.memory }), {
header: 'CPU, RAM',
diff --git a/app/routes.tsx b/app/routes.tsx
index 9c558733a7..f3dcdf2ebb 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -350,8 +350,12 @@ export const routes = createRoutesFromElements(
- } />
} loader={VpcPage.loader}>
+ }
+ loader={VpcFirewallRulesTab.loader}
+ />
} loader={VpcFirewallRulesTab.loader}>
{
if (!instance) return
return (
-
+
{instance.name}
)
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 4a514a5187..7244193d8a 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -38,10 +38,9 @@ test('path builder', () => {
"floatingIpEdit": "/projects/p/floating-ips/f/edit",
"floatingIps": "/projects/p/floating-ips",
"floatingIpsNew": "/projects/p/floating-ips-new",
- "instance": "/projects/p/instances/i",
+ "instance": "/projects/p/instances/i/storage",
"instanceConnect": "/projects/p/instances/i/connect",
"instanceMetrics": "/projects/p/instances/i/metrics",
- "instancePage": "/projects/p/instances/i/storage",
"instanceStorage": "/projects/p/instances/i/storage",
"instances": "/projects/p/instances",
"instancesNew": "/projects/p/instances-new",
@@ -87,7 +86,7 @@ test('path builder', () => {
"systemHealth": "/system/health",
"systemIssues": "/system/issues",
"systemUtilization": "/system/utilization",
- "vpc": "/projects/p/vpcs/v",
+ "vpc": "/projects/p/vpcs/v/firewall-rules",
"vpcEdit": "/projects/p/vpcs/v/edit",
"vpcFirewallRuleEdit": "/projects/p/vpcs/v/firewall-rules/fr/edit",
"vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules",
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index dc2bfb345a..853e9d7698 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -24,10 +24,13 @@ type FloatingIp = Required
type VpcFirewallRule = Required
type VpcSubnet = Required
-// this is used as the basis for many routes, but is itself not a route we ever
-// want to link directly to. so we use this to build the routes but pb.project()
-// is different (includes /instances)
+// these are used as the basis for many routes but are not themselves routes we
+// ever want to link to. so we use this to build the routes but pb.project() is
+// different (includes /instances)
const projectBase = ({ project }: Project) => `${pb.projects()}/${project}`
+const instanceBase = ({ project, instance }: Instance) =>
+ `${pb.instances({ project })}/${instance}`
+const vpcBase = ({ project, vpc }: Vpc) => `${pb.vpcs({ project })}/${vpc}`
export const pb = {
projects: () => `/projects`,
@@ -43,7 +46,6 @@ export const pb = {
instances: (params: Project) => `${projectBase(params)}/instances`,
instancesNew: (params: Project) => `${projectBase(params)}/instances-new`,
- instance: (params: Instance) => `${pb.instances(params)}/${params.instance}`,
/**
* This route exists as a direct link to the default tab of the instance page. Unfortunately
@@ -52,15 +54,15 @@ export const pb = {
*
* @see https://github.com/oxidecomputer/console/pull/1267#discussion_r1016766205
*/
- instancePage: (params: Instance) => pb.instanceStorage(params),
+ instance: (params: Instance) => pb.instanceStorage(params),
- instanceMetrics: (params: Instance) => `${pb.instance(params)}/metrics`,
- instanceStorage: (params: Instance) => `${pb.instance(params)}/storage`,
- instanceConnect: (params: Instance) => `${pb.instance(params)}/connect`,
+ instanceMetrics: (params: Instance) => `${instanceBase(params)}/metrics`,
+ instanceStorage: (params: Instance) => `${instanceBase(params)}/storage`,
+ instanceConnect: (params: Instance) => `${instanceBase(params)}/connect`,
- nics: (params: Instance) => `${pb.instance(params)}/network-interfaces`,
+ nics: (params: Instance) => `${instanceBase(params)}/network-interfaces`,
- serialConsole: (params: Instance) => `${pb.instance(params)}/serial-console`,
+ serialConsole: (params: Instance) => `${instanceBase(params)}/serial-console`,
disksNew: (params: Project) => `${projectBase(params)}/disks-new`,
disks: (params: Project) => `${projectBase(params)}/disks`,
@@ -72,8 +74,11 @@ export const pb = {
vpcsNew: (params: Project) => `${projectBase(params)}/vpcs-new`,
vpcs: (params: Project) => `${projectBase(params)}/vpcs`,
- vpc: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}`,
- vpcEdit: (params: Vpc) => `${pb.vpc(params)}/edit`,
+
+ // 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) => `${pb.vpcs(params)}/${params.vpc}/firewall-rules`,
vpcFirewallRulesNew: (params: Vpc) =>
diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts
index 11e9695ef0..a18c692a57 100644
--- a/test/e2e/instance-networking.e2e.ts
+++ b/test/e2e/instance-networking.e2e.ts
@@ -30,9 +30,9 @@ test('Instance networking tab — NIC table', async ({ page }) => {
await expectRowVisible(nicTable, { name: 'my-nicprimary' })
// check VPC link in table points to the right page
- await expect(page.locator('role=cell >> role=link[name="mock-vpc"]')).toHaveAttribute(
+ await expect(nicTable.getByRole('link', { name: 'mock-vpc' })).toHaveAttribute(
'href',
- '/projects/mock-project/vpcs/mock-vpc'
+ '/projects/mock-project/vpcs/mock-vpc/firewall-rules'
)
const addNicButton = page.getByRole('button', { name: 'Add network interface' })
From 373fedaf6f551f1485b527f1371cd164e27b2655 Mon Sep 17 00:00:00 2001
From: David Crespo
Date: Thu, 23 May 2024 15:30:17 -0500
Subject: [PATCH 12/22] fix warning on firewall rules leaf route without
element
---
app/routes.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/routes.tsx b/app/routes.tsx
index f3dcdf2ebb..33179cf425 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -357,7 +357,11 @@ export const routes = createRoutesFromElements(
loader={VpcFirewallRulesTab.loader}
/>
} loader={VpcFirewallRulesTab.loader}>
-
+ }
From 2ddf55dd48d53ce2b4f7ca9c1f3cb7d29d8bc0f2 Mon Sep 17 00:00:00 2001
From: David Crespo
Date: Fri, 24 May 2024 15:41:05 -0500
Subject: [PATCH 13/22] clean up path params and path builder stuff a bit
---
app/api/path-params.ts | 2 +-
app/forms/firewall-rules-edit.tsx | 18 +++++-------------
app/hooks/use-params.ts | 5 ++---
.../vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx | 4 ++--
app/routes.tsx | 2 +-
app/util/path-builder.spec.ts | 2 +-
app/util/path-builder.ts | 18 ++++++++----------
7 files changed, 20 insertions(+), 31 deletions(-)
diff --git a/app/api/path-params.ts b/app/api/path-params.ts
index e0be7474ab..a14c1c38c2 100644
--- a/app/api/path-params.ts
+++ b/app/api/path-params.ts
@@ -16,7 +16,7 @@ export type NetworkInterface = Merge
export type Snapshot = Merge
export type Vpc = Merge
export type VpcSubnet = Merge
-export type VpcFirewallRule = Merge
+export type FirewallRule = Merge
export type Silo = { silo?: string }
export type IdentityProvider = Merge
export type SystemUpdate = { version: string }
diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx
index 0657268da9..6bd64ccd95 100644
--- a/app/forms/firewall-rules-edit.tsx
+++ b/app/forms/firewall-rules-edit.tsx
@@ -16,12 +16,7 @@ import {
} from '@oxide/api'
import { SideModalForm } from '~/components/form/SideModalForm'
-import {
- getVpcSelector,
- useForm,
- useVpcFirewallRuleSelector,
- useVpcSelector,
-} from '~/hooks'
+import { getVpcSelector, useFirewallRuleSelector, useForm, useVpcSelector } from '~/hooks'
import { invariant } from '~/util/invariant'
import { pb } from '~/util/path-builder'
@@ -42,18 +37,15 @@ EditFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => {
}
export function EditFirewallRuleForm() {
- const vpcFirewallRuleSelector = useVpcFirewallRuleSelector()
+ const { vpc, project, rule } = useFirewallRuleSelector()
const vpcSelector = useVpcSelector()
const queryClient = useApiQueryClient()
const { data } = usePrefetchedApiQuery('vpcFirewallRulesView', {
- query: { project: vpcFirewallRuleSelector.project, vpc: vpcFirewallRuleSelector.vpc },
+ query: { project, vpc },
})
- const existingRules = data.rules
- const originalRule = existingRules.find(
- (rule) => rule.name === vpcFirewallRuleSelector.firewallRule
- )
+ const originalRule = data.rules.find((r) => r.name === rule)
invariant(originalRule, 'Firewall rule must exist')
@@ -97,7 +89,7 @@ export function EditFirewallRuleForm() {
onSubmit={(values) => {
// note different filter logic from create: filter out the rule with the
// *original* name because we need to overwrite that rule
- const otherRules = existingRules
+ const otherRules = data.rules
.filter((r) => r.name !== originalRule.name)
.map(firewallRuleGetToPut)
diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts
index f4f44fa117..35932cce8d 100644
--- a/app/hooks/use-params.ts
+++ b/app/hooks/use-params.ts
@@ -36,7 +36,7 @@ export const getProjectSelector = requireParams('project')
export const getFloatingIpSelector = requireParams('project', 'floatingIp')
export const getInstanceSelector = requireParams('project', 'instance')
export const getVpcSelector = requireParams('project', 'vpc')
-export const getVpcFirewallRuleSelector = requireParams('project', 'vpc', 'firewallRule')
+export const getFirewallRuleSelector = requireParams('project', 'vpc', 'rule')
export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet')
export const getSiloSelector = requireParams('silo')
export const getSiloImageSelector = requireParams('image')
@@ -80,8 +80,7 @@ export const useProjectSnapshotSelector = () =>
export const useInstanceSelector = () => useSelectedParams(getInstanceSelector)
export const useVpcSelector = () => useSelectedParams(getVpcSelector)
export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector)
-export const useVpcFirewallRuleSelector = () =>
- useSelectedParams(getVpcFirewallRuleSelector)
+export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector)
export const useSiloSelector = () => useSelectedParams(getSiloSelector)
export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector)
export const useIdpSelector = () => useSelectedParams(getIdpSelector)
diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
index 312c186fd8..6c155a3b78 100644
--- a/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
+++ b/app/pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx
@@ -136,7 +136,7 @@ export function VpcFirewallRulesTab() {
{info.getValue()}
@@ -151,7 +151,7 @@ export function VpcFirewallRulesTab() {
navigate(
pb.vpcFirewallRuleEdit({
...vpcSelector,
- firewallRule: rule.name,
+ rule: rule.name,
})
)
},
diff --git a/app/routes.tsx b/app/routes.tsx
index 33179cf425..b958fee844 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -369,7 +369,7 @@ export const routes = createRoutesFromElements(
handle={{ crumb: 'New Firewall Rule' }}
/>
}
loader={EditFirewallRuleForm.loader}
handle={{ crumb: 'Edit Firewall Rule' }}
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 7244193d8a..627aa0187e 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -22,7 +22,7 @@ const params = {
image: 'im',
snapshot: 'sn',
pool: 'pl',
- firewallRule: 'fr',
+ rule: 'fr',
subnet: 'su',
}
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 853e9d7698..875b6ae4ef 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -21,7 +21,7 @@ type Snapshot = Required
type SiloImage = Required
type IpPool = Required
type FloatingIp = Required
-type VpcFirewallRule = Required
+type FirewallRule = Required
type VpcSubnet = Required
// these are used as the basis for many routes but are not themselves routes we
@@ -80,15 +80,13 @@ export const pb = {
vpcEdit: (params: Vpc) => `${vpcBase(params)}/edit`,
- vpcFirewallRules: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/firewall-rules`,
- vpcFirewallRulesNew: (params: Vpc) =>
- `${pb.vpcs(params)}/${params.vpc}/firewall-rules-new`,
- vpcFirewallRuleEdit: (params: VpcFirewallRule) =>
- `${pb.vpcs(params)}/${params.vpc}/firewall-rules/${params.firewallRule}/edit`,
- vpcSubnets: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/subnets`,
- vpcSubnetsNew: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}/subnets-new`,
- vpcSubnetsEdit: (params: VpcSubnet) =>
- `${pb.vpcs(params)}/${params.vpc}/subnets/${params.subnet}/edit`,
+ vpcFirewallRules: (params: Vpc) => `${vpcBase(params)}/firewall-rules`,
+ vpcFirewallRulesNew: (params: Vpc) => `${vpcBase(params)}/firewall-rules-new`,
+ vpcFirewallRuleEdit: (params: FirewallRule) =>
+ `${pb.vpcFirewallRules(params)}/${params.rule}/edit`,
+ vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`,
+ vpcSubnetsNew: (params: Vpc) => `${vpcBase(params)}/subnets-new`,
+ vpcSubnetsEdit: (params: VpcSubnet) => `${pb.vpcSubnets(params)}/${params.subnet}/edit`,
floatingIps: (params: Project) => `${projectBase(params)}/floating-ips`,
floatingIpsNew: (params: Project) => `${projectBase(params)}/floating-ips-new`,
From 24a9b561d22ee66f4f4b70a3f9fc4bcf9e121f99 Mon Sep 17 00:00:00 2001
From: David Crespo
Date: Fri, 7 Jun 2024 16:58:28 -0500
Subject: [PATCH 14/22] merge vpc-routes into firewall-template
---
.eslintrc.cjs | 17 ++
app/api/path-params.ts | 2 +-
app/api/roles.spec.ts | 5 +
app/api/roles.ts | 12 +-
app/api/util.ts | 96 ++++-----
app/components/MswBanner.tsx | 1 +
app/components/RefetchIntervalPicker.tsx | 1 +
app/components/Terminal.tsx | 12 +-
app/components/form/SideModalForm.tsx | 4 +-
.../form/fields/DateTimeRangePicker.spec.tsx | 4 +-
.../form/fields/DisksTableField.tsx | 13 +-
.../form/fields/ImageSelectField.tsx | 5 +-
app/components/form/fields/ListboxField.tsx | 2 +-
.../form/fields/NetworkInterfaceField.tsx | 22 +--
app/components/form/fields/TlsCertsField.tsx | 12 +-
app/forms/disk-attach.tsx | 4 +-
app/forms/disk-create.tsx | 6 +-
app/forms/firewall-rules-create.tsx | 58 +++---
app/forms/firewall-rules-edit.tsx | 18 +-
app/forms/image-upload.tsx | 2 +-
app/forms/instance-create.tsx | 182 ++++++++++++++++++
app/forms/network-interface-edit.tsx | 9 +-
app/forms/subnet-edit.tsx | 5 +-
app/forms/vpc-edit.tsx | 2 +-
app/hooks/use-params.ts | 5 +-
app/pages/SiloAccessPage.tsx | 6 +-
.../project/access/ProjectAccessPage.tsx | 9 +-
.../floating-ips/AttachFloatingIpModal.tsx | 3 +-
app/pages/project/instances/actions.tsx | 12 +-
.../instances/instance/SerialConsolePage.tsx | 113 ++++++++---
app/pages/project/vpcs/VpcPage/VpcPage.tsx | 3 +-
.../vpcs/VpcPage/tabs/VpcFirewallRulesTab.tsx | 12 +-
.../inventory/sled/SledInstancesTab.tsx | 6 +-
app/routes.tsx | 9 +-
app/table/cells/LinkCell.tsx | 2 +-
app/ui/lib/ActionMenu.tsx | 2 +
app/ui/lib/Button.tsx | 3 +-
app/ui/lib/FileInput.tsx | 1 +
app/ui/lib/Listbox.tsx | 4 +-
app/ui/lib/MiniTable.tsx | 12 ++
app/ui/lib/NumberInput.tsx | 1 +
app/ui/lib/Pagination.tsx | 2 +
app/ui/lib/RangeCalendar.tsx | 1 +
app/ui/lib/Slash.tsx | 10 +
app/ui/lib/Toast.tsx | 1 +
app/ui/lib/Tooltip.stories.tsx | 2 +-
app/ui/styles/index.css | 2 +-
app/util/array.spec.tsx | 45 +----
app/util/array.ts | 76 +-------
app/util/file.spec.ts | 2 +-
app/util/math.ts | 6 +-
app/util/object.spec.ts | 18 --
app/util/object.ts | 32 ---
app/util/path-builder.spec.ts | 2 +-
app/util/path-builder.ts | 22 +--
mock-api/msw/db.ts | 18 +-
mock-api/msw/handlers.ts | 65 +++++--
mock-api/msw/util.ts | 13 +-
mock-api/silo.ts | 5 +-
package-lock.json | 72 +++++--
package.json | 14 +-
test/e2e/images.e2e.ts | 4 +-
test/e2e/instance-create.e2e.ts | 103 +++++++++-
test/e2e/instance-disks.e2e.ts | 2 +
test/e2e/silos.e2e.ts | 2 +-
test/e2e/utils.ts | 5 +-
66 files changed, 758 insertions(+), 463 deletions(-)
create mode 100644 app/ui/lib/Slash.tsx
delete mode 100644 app/util/object.spec.ts
delete mode 100644 app/util/object.ts
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index dc12fdcd89..2aef4ef7f4 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -6,9 +6,13 @@ module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
warnOnUnsupportedTypeScriptVersion: false,
+ // this config is needed for type aware lint rules
+ project: true,
+ tsconfigRootDir: __dirname,
},
extends: [
'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/strict',
'plugin:@typescript-eslint/stylistic',
'plugin:jsx-a11y/recommended',
@@ -45,6 +49,18 @@ module.exports = {
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
+
+ // disabling the type-aware rules we don't like
+ // https://typescript-eslint.io/getting-started/typed-linting/
+ '@typescript-eslint/no-floating-promises': 'off',
+ '@typescript-eslint/no-misused-promises': 'off',
+ '@typescript-eslint/unbound-method': 'off',
+ '@typescript-eslint/no-unsafe-argument': 'off',
+ '@typescript-eslint/no-unsafe-assignment': 'off',
+ '@typescript-eslint/no-unsafe-call': 'off',
+ '@typescript-eslint/no-unsafe-member-access': 'off',
+ '@typescript-eslint/no-unsafe-return': 'off',
+
eqeqeq: ['error', 'always', { null: 'ignore' }],
'import/no-default-export': 'error',
'import/no-unresolved': 'off', // plugin doesn't know anything
@@ -70,6 +86,7 @@ module.exports = {
radix: 'error',
'react-hooks/exhaustive-deps': 'error',
'react-hooks/rules-of-hooks': 'error',
+ 'react/button-has-type': 'error',
'react/jsx-boolean-value': 'error',
'react/display-name': 'off',
'react/react-in-jsx-scope': 'off',
diff --git a/app/api/path-params.ts b/app/api/path-params.ts
index e0be7474ab..a14c1c38c2 100644
--- a/app/api/path-params.ts
+++ b/app/api/path-params.ts
@@ -16,7 +16,7 @@ export type NetworkInterface = Merge
export type Snapshot = Merge
export type Vpc = Merge
export type VpcSubnet = Merge
-export type VpcFirewallRule = Merge
+export type FirewallRule = Merge
export type Silo = { silo?: string }
export type IdentityProvider = Merge
export type SystemUpdate = { version: string }
diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts
index 984d76d240..81b6418f4f 100644
--- a/app/api/roles.spec.ts
+++ b/app/api/roles.spec.ts
@@ -8,6 +8,7 @@
import { describe, expect, it, test } from 'vitest'
import {
+ allRoles,
byGroupThenName,
deleteRole,
getEffectiveRole,
@@ -154,3 +155,7 @@ test('byGroupThenName sorts as expected', () => {
expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e])
})
+
+test('allRoles', () => {
+ expect(allRoles).toEqual(['admin', 'collaborator', 'viewer'])
+})
diff --git a/app/api/roles.ts b/app/api/roles.ts
index bdd1cf97da..e5c9125382 100644
--- a/app/api/roles.ts
+++ b/app/api/roles.ts
@@ -12,8 +12,7 @@
* it belongs in the API proper.
*/
import { useMemo } from 'react'
-
-import { lowestBy, sortBy } from '~/util/array'
+import * as R from 'remeda'
import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api'
import { usePrefetchedApiQuery } from './client'
@@ -27,12 +26,13 @@ export type RoleKey = FleetRole | SiloRole | ProjectRole
/** Turn a role order record into a sorted array of strings. */
// used for displaying lists of roles, like in a
),
- errorTitle: `Could not stop ${instance.name}`,
+ errorTitle: `Error stopping ${instance.name}`,
})
},
disabled: !instanceCan.stop(instance) && (
diff --git a/app/pages/project/instances/instance/SerialConsolePage.tsx b/app/pages/project/instances/instance/SerialConsolePage.tsx
index 793d9eb654..f8f083e55f 100644
--- a/app/pages/project/instances/instance/SerialConsolePage.tsx
+++ b/app/pages/project/instances/instance/SerialConsolePage.tsx
@@ -5,14 +5,22 @@
*
* Copyright Oxide Computer Company
*/
+import cn from 'classnames'
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
-import { Link } from 'react-router-dom'
-
-import { api } from '@oxide/api'
+import { Link, type LoaderFunctionArgs } from 'react-router-dom'
+
+import {
+ api,
+ apiQueryClient,
+ instanceCan,
+ usePrefetchedApiQuery,
+ type InstanceState,
+} from '@oxide/api'
import { PrevArrow12Icon } from '@oxide/design-system/icons/react'
import { EquivalentCliCommand } from '~/components/EquivalentCliCommand'
-import { useInstanceSelector } from '~/hooks'
+import { InstanceStatusBadge } from '~/components/StatusBadge'
+import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
import { Badge, type BadgeColor } from '~/ui/lib/Badge'
import { Spinner } from '~/ui/lib/Spinner'
import { cliCmd } from '~/util/cli-cmd'
@@ -36,13 +44,29 @@ const statusMessage: Record = {
error: 'error',
}
+SerialConsolePage.loader = async ({ params }: LoaderFunctionArgs) => {
+ const { project, instance } = getInstanceSelector(params)
+ await apiQueryClient.prefetchQuery('instanceView', {
+ path: { instance },
+ query: { project },
+ })
+ return null
+}
+
export function SerialConsolePage() {
const instanceSelector = useInstanceSelector()
const { project, instance } = instanceSelector
+ const { data: instanceData } = usePrefetchedApiQuery('instanceView', {
+ query: { project },
+ path: { instance },
+ })
+
const ws = useRef(null)
- const [connectionStatus, setConnectionStatus] = useState('connecting')
+ const canConnect = instanceCan.serialConsole(instanceData)
+ const initialState = canConnect ? 'connecting' : 'closed'
+ const [connectionStatus, setConnectionStatus] = useState(initialState)
// In dev, React 18 strict mode fires all effects twice for lulz, even ones
// with no dependencies. In order to prevent the websocket from being killed
@@ -54,6 +78,8 @@ export function SerialConsolePage() {
// 1a. cleanup runs, nothing happens because socket was not open yet
// 2. effect runs, but `ws.current` is truthy, so nothing happens
useEffect(() => {
+ if (!canConnect) return
+
// TODO: error handling if this connection fails
if (!ws.current) {
const { project, instance } = instanceSelector
@@ -70,7 +96,7 @@ export function SerialConsolePage() {
ws.current.close()
}
}
- }, [instanceSelector])
+ }, [instanceSelector, canConnect])
// Because this one does not look at ready state, just whether the thing is
// defined, it will remove the event listeners before the spurious second
@@ -81,20 +107,22 @@ export function SerialConsolePage() {
// 1a. cleanup runs, event listeners removed
// 2. effect runs again, event listeners attached again
useEffect(() => {
+ if (!canConnect) return // don't bother if instance is not running
+
const setOpen = () => setConnectionStatus('open')
const setClosed = () => setConnectionStatus('closed')
const setError = () => setConnectionStatus('error')
ws.current?.addEventListener('open', setOpen)
- ws.current?.addEventListener('closed', setClosed)
+ ws.current?.addEventListener('close', setClosed)
ws.current?.addEventListener('error', setError)
return () => {
ws.current?.removeEventListener('open', setOpen)
- ws.current?.removeEventListener('closed', setClosed)
+ ws.current?.removeEventListener('close', setClosed)
ws.current?.removeEventListener('error', setError)
}
- }, [])
+ }, [canConnect])
return (
@@ -109,7 +137,13 @@ export function SerialConsolePage() {
- {connectionStatus !== 'open' && }
+ {connectionStatus === 'connecting' && }
+ {connectionStatus === 'error' && }
+ {connectionStatus === 'closed' && !canConnect && (
+
+ )}
+ {/* closed && canConnect shouldn't be possible because there's no way to
+ * close an open connection other than leaving the page */}
{ws.current && }
+ You can only connect to the serial console on a running instance.
+
+
+)
+
+// TODO: sure would be nice to say something useful about the error, but
+// we don't know what kind of thing we might pull off the error event
+const ErrorSkeleton = () => (
+
+