{connectionStatus === 'connecting' &&
}
{connectionStatus === 'error' &&
}
diff --git a/app/routes.tsx b/app/routes.tsx
index 86b159f5fa..10e8d1b7da 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -39,7 +39,8 @@ import { CreateRouterSideModalForm } from './forms/vpc-router-create'
import { EditRouterSideModalForm } from './forms/vpc-router-edit'
import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create'
import { EditRouterRouteSideModalForm } from './forms/vpc-router-route-edit'
-import type { CrumbFunc } from './hooks/use-title'
+import { makeCrumb } from './hooks/use-crumbs'
+import { getInstanceSelector, getProjectSelector, getVpcSelector } from './hooks/use-params'
import { AuthenticatedLayout } from './layouts/AuthenticatedLayout'
import { AuthLayout } from './layouts/AuthLayout'
import { SerialConsoleContentPane } from './layouts/helpers'
@@ -90,12 +91,6 @@ import { SilosPage } from './pages/system/silos/SilosPage'
import { SystemUtilizationPage } from './pages/system/UtilizationPage'
import { pb } from './util/path-builder'
-const projectCrumb: CrumbFunc = (m) => m.params.project!
-const instanceCrumb: CrumbFunc = (m) => m.params.instance!
-const vpcCrumb: CrumbFunc = (m) => m.params.vpc!
-const siloCrumb: CrumbFunc = (m) => m.params.silo!
-const poolCrumb: CrumbFunc = (m) => m.params.pool!
-
export const routes = createRoutesFromElements(
}>
} />
@@ -117,14 +112,23 @@ export const routes = createRoutesFromElements(
// very important. see `currentUserLoader` and `useCurrentUser`
shouldRevalidate={() => true}
>
-
}>
+
}
+ >
+
} />
} handle={{ crumb: 'Profile' }} />
-
} loader={SSHKeysPage.loader}>
-
+
}
+ loader={SSHKeysPage.loader}
+ handle={makeCrumb('SSH Keys', pb.sshKeys)}
+ >
+
}
- handle={{ crumb: 'New SSH key' }}
+ handle={{ crumb: 'New SSH key', titleOnly: true }}
/>
@@ -133,7 +137,7 @@ export const routes = createRoutesFromElements(
}
loader={SilosPage.loader}
- handle={{ crumb: 'Silos' }}
+ handle={makeCrumb('Silos', pb.silos())}
>
} />
@@ -143,13 +147,14 @@ export const routes = createRoutesFromElements(
path=":silo"
element={
}
loader={SiloPage.loader}
- handle={{ crumb: siloCrumb }}
+ handle={makeCrumb((p) => p.silo!)}
>
} />
}
loader={EditIdpSideModalForm.loader}
+ handle={{ crumb: 'Edit Identity Provider', titleOnly: true }}
/>
@@ -164,48 +169,61 @@ export const routes = createRoutesFromElements(
path="inventory"
element={
}
loader={InventoryPage.loader}
- handle={{ crumb: 'Inventory' }}
+ handle={makeCrumb('Inventory', pb.sledInventory())}
>
} loader={SledsTab.loader} />
-
} loader={SledsTab.loader} />
-
} loader={DisksTab.loader} />
-
-
}
- loader={SledPage.loader}
- handle={{ crumb: 'Sleds' }}
- >
}
- loader={SledInstancesTab.loader}
+ path="sleds"
+ element={
}
+ handle={{ crumb: 'Sleds' }}
+ loader={SledsTab.loader}
/>
}
- loader={SledInstancesTab.loader}
+ path="disks"
+ element={
}
+ handle={{ crumb: 'Disks' }}
+ loader={DisksTab.loader}
/>
-
-
+
+
+ }
+ loader={SledPage.loader}
+ // a crumb for the sled ID looks ridiculous, unfortunately
+ >
+ }
+ loader={SledInstancesTab.loader}
+ />
+ }
+ loader={SledInstancesTab.loader}
+ />
+
+
+
} />
}
loader={IpPoolsPage.loader}
- handle={{ crumb: 'IP pools' }}
+ handle={{ crumb: 'IP Pools' }}
>
} />
-
+
}
loader={IpPoolPage.loader}
- handle={{ crumb: poolCrumb }}
+ handle={makeCrumb((p) => p.pool!)}
>
- } />
+ }
+ handle={{ crumb: 'Add Range', titleOnly: true }}
+ />
} />
- {/* These are done here instead of nested so we don't flash a layout on 404s */}
-
} />
-
}>
}
loader={EditSiloImageSideModalForm.loader}
- handle={{ crumb: 'Edit Image' }}
+ handle={{ crumb: 'Edit Image', titleOnly: true }}
/>
-
}>
-
+ {/* these are here instead of under projects because they need to use SiloLayout */}
+
}
+ >
+
}
- handle={{ crumb: 'New project' }}
+ handle={{ crumb: 'New project', titleOnly: true }}
/>
}
loader={EditProjectSideModalForm.loader}
- handle={{ crumb: 'Edit project' }}
+ handle={{ crumb: 'Edit project', titleOnly: true }}
/>
+
}
@@ -276,228 +301,290 @@ export const routes = createRoutesFromElements(
{/* PROJECT */}
- {/* Serial console page gets its own little section here because it
- cannot use the normal
.*/}
- } />}
- loader={ProjectLayout.loader}
- handle={{ crumb: projectCrumb }}
- >
-
-
- }
- handle={{ crumb: 'Serial Console' }}
- />
-
-
-
-
- }
- loader={ProjectLayout.loader}
- handle={{ crumb: projectCrumb }}
- >
+
+ {/* Serial console page gets its own little section here because it
+ cannot use the normal .*/}
}
- loader={CreateInstanceForm.loader}
- handle={{ crumb: 'New instance' }}
- />
-
- } loader={InstancesPage.loader} />
-
- } />
- } loader={InstancePage.loader}>
- }
- loader={StorageTab.loader}
- handle={{ crumb: 'Storage' }}
- />
- }
- loader={NetworkingTab.loader}
- handle={{ crumb: 'Networking' }}
- />
- }
- loader={MetricsTab.loader}
- handle={{ crumb: 'metrics' }}
- />
+ path=":project"
+ element={} />}
+ loader={ProjectLayout.loader}
+ handle={makeCrumb(
+ (p) => p.project!,
+ (p) => pb.project(getProjectSelector(p))
+ )}
+ >
+
+ p.instance!)}>
}
- loader={ConnectTab.loader}
- handle={{ crumb: 'Connect' }}
+ path="serial-console"
+ loader={SerialConsolePage.loader}
+ element={}
+ handle={{ crumb: 'Serial Console' }}
/>
- }>
-
+ }
+ loader={ProjectLayout.loader}
+ handle={makeCrumb(
+ (p) => p.project!,
+ (p) => pb.project(getProjectSelector(p))
+ )}
+ >
+ } />
}
- handle={{ crumb: 'New VPC' }}
+ path="instances-new"
+ element={}
+ loader={CreateInstanceForm.loader}
+ handle={{ crumb: 'New instance' }}
/>
-
-
-
-
- } loader={VpcPage.loader}>
- }
- loader={VpcFirewallRulesTab.loader}
- />
- } loader={VpcFirewallRulesTab.loader}>
+
+ } loader={InstancesPage.loader} />
+ p.instance!,
+ (p) => pb.instance(getInstanceSelector(p))
+ )}
+ >
+ } />
+ } loader={InstancePage.loader}>
}
- loader={EditVpcSideModalForm.loader}
- handle={{ crumb: 'Edit VPC' }}
+ path="storage"
+ element={}
+ loader={StorageTab.loader}
+ handle={{ crumb: 'Storage' }}
/>
}
+ loader={NetworkingTab.loader}
+ handle={{ crumb: 'Networking' }}
/>
}
- loader={CreateFirewallRuleForm.loader}
- handle={{ crumb: 'New Firewall Rule' }}
+ path="metrics"
+ element={}
+ loader={MetricsTab.loader}
+ handle={{ crumb: 'Metrics' }}
/>
}
- loader={EditFirewallRuleForm.loader}
- handle={{ crumb: 'Edit Firewall Rule' }}
+ path="connect"
+ element={}
+ loader={ConnectTab.loader}
+ handle={{ crumb: 'Connect' }}
/>
- } loader={VpcSubnetsTab.loader}>
-
+
+
+
+ pb.vpcs(getProjectSelector(p)))}
+ element={}
+ >
+
+ }
+ handle={{ crumb: 'New VPC', titleOnly: true }}
+ />
+
+
+
+ p.vpc!,
+ (p) => pb.vpc(getVpcSelector(p))
+ )}
+ >
+ } loader={VpcPage.loader}>
}
- handle={{ crumb: 'New Subnet' }}
+ index
+ element={}
+ loader={VpcFirewallRulesTab.loader}
/>
}
- loader={EditSubnetForm.loader}
- handle={{ crumb: 'Edit Subnet' }}
- />
-
- } loader={VpcRoutersTab.loader}>
-
+ element={}
+ loader={VpcFirewallRulesTab.loader}
+ >
+ }
+ loader={EditVpcSideModalForm.loader}
+ handle={{ crumb: 'Edit VPC' }}
+ />
}
- loader={EditRouterSideModalForm.loader}
- handle={{ crumb: 'Edit Router' }}
+ path="firewall-rules"
+ handle={{ crumb: 'Firewall Rules' }}
+ element={null}
/>
+
+ }
+ loader={CreateFirewallRuleForm.loader}
+ handle={{ crumb: 'New Rule', titleOnly: true }}
+ />
+ }
+ loader={EditFirewallRuleForm.loader}
+ handle={{ crumb: 'Edit Rule', titleOnly: true }}
+ />
+
}
- handle={{ crumb: 'New Router' }}
- />
+ element={}
+ loader={VpcSubnetsTab.loader}
+ handle={{ crumb: 'Subnets' }}
+ >
+
+ }
+ handle={{ crumb: 'New Subnet', titleOnly: true }}
+ />
+ }
+ loader={EditSubnetForm.loader}
+ handle={{ crumb: 'Edit Subnet', titleOnly: true }}
+ />
+
+ }
+ handle={{ crumb: 'Routers' }}
+ loader={VpcRoutersTab.loader}
+ >
+
+ }
+ loader={EditRouterSideModalForm.loader}
+ handle={{ crumb: 'Edit Router', titleOnly: true }}
+ />
+
+ }
+ handle={{ crumb: 'New Router', titleOnly: true }}
+ />
+
+
+
+
+
+ p.vpc!)}>
+
+ }
+ loader={RouterPage.loader}
+ handle={makeCrumb((p) => p.router!)}
+ >
+
+
+ }
+ loader={CreateRouterRouteSideModalForm.loader}
+ handle={{ crumb: 'New Route', titleOnly: true }}
+ />
+ }
+ loader={EditRouterRouteSideModalForm.loader}
+ handle={{ crumb: 'Edit Route', titleOnly: true }}
+ />
+
+
-
- }
- loader={RouterPage.loader}
- handle={{ crumb: 'Routes' }}
- path="vpcs/:vpc/routers/:router"
- >
- }
- loader={CreateRouterRouteSideModalForm.loader}
- handle={{ crumb: 'New Route' }}
- />
- }
- loader={EditRouterRouteSideModalForm.loader}
- handle={{ crumb: 'Edit Route' }}
- />
-
- } loader={FloatingIpsPage.loader}>
-
- }
- handle={{ crumb: 'New Floating IP' }}
- />
}
- loader={EditFloatingIpSideModalForm.loader}
- handle={{ crumb: 'Edit Floating IP' }}
- />
-
+ element={}
+ loader={FloatingIpsPage.loader}
+ handle={makeCrumb('Floating IPs', (p) => pb.floatingIps(getProjectSelector(p)))}
+ >
+
+ }
+ handle={{ crumb: 'New Floating IP', titleOnly: true }}
+ />
+ }
+ loader={EditFloatingIpSideModalForm.loader}
+ handle={{ crumb: 'Edit Floating IP', titleOnly: true }}
+ />
+
- } loader={DisksPage.loader}>
navigate('../disks')} />
- }
- handle={{ crumb: 'New disk' }}
- />
-
-
-
+ element={}
+ handle={makeCrumb('Disks', (p) => pb.disks(getProjectSelector(p)))}
+ loader={DisksPage.loader}
+ >
+
+ navigate('../disks')} />
+ }
+ handle={{ crumb: 'New disk', titleOnly: true }}
+ />
+
- } loader={SnapshotsPage.loader}>
-
- }
- handle={{ crumb: 'New snapshot' }}
- />
}
- loader={CreateImageFromSnapshotSideModalForm.loader}
- handle={{ crumb: 'Create image from snapshot' }}
- />
-
+ element={}
+ handle={makeCrumb('Snapshots', (p) => pb.snapshots(getProjectSelector(p)))}
+ loader={SnapshotsPage.loader}
+ >
+
+ }
+ handle={{ crumb: 'New snapshot', titleOnly: true }}
+ />
+ }
+ loader={CreateImageFromSnapshotSideModalForm.loader}
+ handle={{ crumb: 'Create image from snapshot', titleOnly: true }}
+ />
+
- } loader={ImagesPage.loader}>
-
}
- />
+ element={}
+ handle={makeCrumb('Images', (p) => pb.projectImages(getProjectSelector(p)))}
+ loader={ImagesPage.loader}
+ >
+
+ }
+ />
+ }
+ loader={EditProjectImageSideModalForm.loader}
+ handle={{ crumb: 'Edit Image', titleOnly: true }}
+ />
+
}
- loader={EditProjectImageSideModalForm.loader}
- handle={{ crumb: 'Edit Image' }}
+ path="access"
+ element={}
+ loader={ProjectAccessPage.loader}
+ handle={{ crumb: 'Access' }}
/>
- }
- loader={ProjectAccessPage.loader}
- handle={{ crumb: 'Access' }}
- />
diff --git a/app/ui/lib/DialogOverlay.tsx b/app/ui/lib/DialogOverlay.tsx
index 1d12dabcba..005ebac86f 100644
--- a/app/ui/lib/DialogOverlay.tsx
+++ b/app/ui/lib/DialogOverlay.tsx
@@ -9,5 +9,9 @@
import { forwardRef } from 'react'
export const DialogOverlay = forwardRef((_, ref) => (
-
+
))
diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css
index 5746fa8af6..c2c6fa55ed 100644
--- a/app/ui/styles/index.css
+++ b/app/ui/styles/index.css
@@ -2,7 +2,7 @@
* 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
*/
@@ -44,6 +44,7 @@
:root {
--content-gutter: 2.5rem;
+ --top-bar-height: 54px;
}
@layer base {
diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap
new file mode 100644
index 0000000000..fbb1d08283
--- /dev/null
+++ b/app/util/__snapshots__/path-builder.spec.ts.snap
@@ -0,0 +1,951 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`breadcrumbs 2`] = `
+{
+ "deviceSuccess (/device/success)": [],
+ "diskInventory (/system/inventory/disks)": [
+ {
+ "label": "Inventory",
+ "path": "/system/inventory/sleds",
+ },
+ {
+ "label": "Disks",
+ "path": "/system/inventory/disks",
+ },
+ ],
+ "disks (/projects/p/disks)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Disks",
+ "path": "/projects/p/disks",
+ },
+ ],
+ "disksNew (/projects/p/disks-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Disks",
+ "path": "/projects/p/disks",
+ },
+ ],
+ "floatingIpEdit (/projects/p/floating-ips/f/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Floating IPs",
+ "path": "/projects/p/floating-ips",
+ },
+ ],
+ "floatingIps (/projects/p/floating-ips)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Floating IPs",
+ "path": "/projects/p/floating-ips",
+ },
+ ],
+ "floatingIpsNew (/projects/p/floating-ips-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Floating IPs",
+ "path": "/projects/p/floating-ips",
+ },
+ ],
+ "instance (/projects/p/instances/i/storage)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "i",
+ "path": "/projects/p/instances/i/storage",
+ },
+ {
+ "label": "Storage",
+ "path": "/projects/p/instances/i/storage",
+ },
+ ],
+ "instanceConnect (/projects/p/instances/i/connect)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "i",
+ "path": "/projects/p/instances/i/storage",
+ },
+ {
+ "label": "Connect",
+ "path": "/projects/p/instances/i/connect",
+ },
+ ],
+ "instanceMetrics (/projects/p/instances/i/metrics)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "i",
+ "path": "/projects/p/instances/i/storage",
+ },
+ {
+ "label": "Metrics",
+ "path": "/projects/p/instances/i/metrics",
+ },
+ ],
+ "instanceNetworking (/projects/p/instances/i/networking)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "i",
+ "path": "/projects/p/instances/i/storage",
+ },
+ {
+ "label": "Networking",
+ "path": "/projects/p/instances/i/networking",
+ },
+ ],
+ "instanceStorage (/projects/p/instances/i/storage)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "i",
+ "path": "/projects/p/instances/i/storage",
+ },
+ {
+ "label": "Storage",
+ "path": "/projects/p/instances/i/storage",
+ },
+ ],
+ "instances (/projects/p/instances)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ ],
+ "instancesNew (/projects/p/instances-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "New instance",
+ "path": "/projects/p/instances-new",
+ },
+ ],
+ "ipPool (/system/networking/ip-pools/pl)": [
+ {
+ "label": "IP Pools",
+ "path": "/system/networking/ip-pools",
+ },
+ {
+ "label": "pl",
+ "path": "/system/networking/ip-pools/pl",
+ },
+ ],
+ "ipPoolEdit (/system/networking/ip-pools/pl/edit)": [
+ {
+ "label": "IP Pools",
+ "path": "/system/networking/ip-pools",
+ },
+ {
+ "label": "pl",
+ "path": "/system/networking/ip-pools/pl",
+ },
+ {
+ "label": "Edit IP pool",
+ "path": "/system/networking/ip-pools/pl/edit",
+ },
+ ],
+ "ipPoolRangeAdd (/system/networking/ip-pools/pl/ranges-add)": [
+ {
+ "label": "IP Pools",
+ "path": "/system/networking/ip-pools",
+ },
+ {
+ "label": "pl",
+ "path": "/system/networking/ip-pools/pl",
+ },
+ ],
+ "ipPools (/system/networking/ip-pools)": [
+ {
+ "label": "IP Pools",
+ "path": "/system/networking/",
+ },
+ ],
+ "ipPoolsNew (/system/networking/ip-pools-new)": [
+ {
+ "label": "IP Pools",
+ "path": "/system/networking/",
+ },
+ ],
+ "profile (/settings/profile)": [
+ {
+ "label": "Settings",
+ "path": "/settings/profile",
+ },
+ {
+ "label": "Profile",
+ "path": "/settings/profile",
+ },
+ ],
+ "project (/projects/p/instances)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ ],
+ "projectAccess (/projects/p/access)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Access",
+ "path": "/projects/p/access",
+ },
+ ],
+ "projectEdit (/projects/p/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ ],
+ "projectImageEdit (/projects/p/images/im/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Images",
+ "path": "/projects/p/images",
+ },
+ ],
+ "projectImages (/projects/p/images)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Images",
+ "path": "/projects/p/images",
+ },
+ ],
+ "projectImagesNew (/projects/p/images-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Images",
+ "path": "/projects/p/images",
+ },
+ ],
+ "projects (/projects)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ ],
+ "projectsNew (/projects-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ ],
+ "samlIdp (/system/silos/s/idps/saml/pr)": [
+ {
+ "label": "Silos",
+ "path": "/system/silos",
+ },
+ {
+ "label": "s",
+ "path": "/system/silos/s",
+ },
+ ],
+ "serialConsole (/projects/p/instances/i/serial-console)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Instances",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "i",
+ "path": "/projects/p/instances/i",
+ },
+ {
+ "label": "Serial Console",
+ "path": "/projects/p/instances/i/serial-console",
+ },
+ ],
+ "silo (/system/silos/s)": [
+ {
+ "label": "Silos",
+ "path": "/system/silos",
+ },
+ {
+ "label": "s",
+ "path": "/system/silos/s",
+ },
+ ],
+ "siloAccess (/access)": [
+ {
+ "label": "Access",
+ "path": "/access",
+ },
+ ],
+ "siloIdpsNew (/system/silos/s/idps-new)": [
+ {
+ "label": "Silos",
+ "path": "/system/silos",
+ },
+ {
+ "label": "s",
+ "path": "/system/silos/s",
+ },
+ ],
+ "siloImageEdit (/images/im/edit)": [
+ {
+ "label": "Images",
+ "path": "/images",
+ },
+ ],
+ "siloImages (/images)": [
+ {
+ "label": "Images",
+ "path": "/images",
+ },
+ ],
+ "siloIpPools (/system/silos/s?tab=ip-pools)": [
+ {
+ "label": "Silos",
+ "path": "/system/silos",
+ },
+ {
+ "label": "s",
+ "path": "/system/silos/s",
+ },
+ ],
+ "siloUtilization (/utilization)": [
+ {
+ "label": "Utilization",
+ "path": "/utilization",
+ },
+ ],
+ "silos (/system/silos)": [
+ {
+ "label": "Silos",
+ "path": "/system/silos",
+ },
+ ],
+ "silosNew (/system/silos-new)": [
+ {
+ "label": "Silos",
+ "path": "/system/silos",
+ },
+ ],
+ "sled (/system/inventory/sleds/sl)": [
+ {
+ "label": "Inventory",
+ "path": "/system/inventory",
+ },
+ {
+ "label": "Sleds",
+ "path": "/system/inventory/sleds",
+ },
+ ],
+ "sledInstances (/system/inventory/sleds/sl/instances)": [
+ {
+ "label": "Inventory",
+ "path": "/system/inventory",
+ },
+ {
+ "label": "Sleds",
+ "path": "/system/inventory/sleds",
+ },
+ {
+ "label": "Instances",
+ "path": "/system/inventory/sleds/sl/instances",
+ },
+ ],
+ "sledInventory (/system/inventory/sleds)": [
+ {
+ "label": "Inventory",
+ "path": "/system/inventory/sleds",
+ },
+ {
+ "label": "Sleds",
+ "path": "/system/inventory/sleds",
+ },
+ ],
+ "snapshotImagesNew (/projects/p/snapshots/sn/images-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Snapshots",
+ "path": "/projects/p/snapshots",
+ },
+ ],
+ "snapshots (/projects/p/snapshots)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Snapshots",
+ "path": "/projects/p/snapshots",
+ },
+ ],
+ "snapshotsNew (/projects/p/snapshots-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "Snapshots",
+ "path": "/projects/p/snapshots",
+ },
+ ],
+ "sshKeys (/settings/ssh-keys)": [
+ {
+ "label": "Settings",
+ "path": "/settings/profile",
+ },
+ {
+ "label": "SSH Keys",
+ "path": "/settings/ssh-keys",
+ },
+ ],
+ "sshKeysNew (/settings/ssh-keys-new)": [
+ {
+ "label": "Settings",
+ "path": "/settings/profile",
+ },
+ {
+ "label": "SSH Keys",
+ "path": "/settings/ssh-keys",
+ },
+ ],
+ "systemUtilization (/system/utilization)": [
+ {
+ "label": "Utilization",
+ "path": "/system/utilization",
+ },
+ ],
+ "vpc (/projects/p/vpcs/v/firewall-rules)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Firewall Rules",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ ],
+ "vpcEdit (/projects/p/vpcs/v/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Edit VPC",
+ "path": "/projects/p/vpcs/v/edit",
+ },
+ ],
+ "vpcFirewallRuleClone (/projects/p/vpcs/v/firewall-rules-new/fr)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Firewall Rules",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcFirewallRuleEdit (/projects/p/vpcs/v/firewall-rules/fr/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Firewall Rules",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcFirewallRules (/projects/p/vpcs/v/firewall-rules)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Firewall Rules",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ ],
+ "vpcFirewallRulesNew (/projects/p/vpcs/v/firewall-rules-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Firewall Rules",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcRouter (/projects/p/vpcs/v/routers/r)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v",
+ },
+ {
+ "label": "Routers",
+ "path": "/projects/p/vpcs/v/routers",
+ },
+ {
+ "label": "r",
+ "path": "/projects/p/vpcs/v/routers/r",
+ },
+ {
+ "label": "Routes",
+ "path": "/projects/p/vpcs/v/routers/r/",
+ },
+ ],
+ "vpcRouterEdit (/projects/p/vpcs/v/routers/r/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Routers",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcRouterRouteEdit (/projects/p/vpcs/v/routers/r/routes/rr/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v",
+ },
+ {
+ "label": "Routers",
+ "path": "/projects/p/vpcs/v/routers",
+ },
+ {
+ "label": "r",
+ "path": "/projects/p/vpcs/v/routers/r",
+ },
+ {
+ "label": "Routes",
+ "path": "/projects/p/vpcs/v/routers/r/",
+ },
+ ],
+ "vpcRouterRoutesNew (/projects/p/vpcs/v/routers/r/routes-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v",
+ },
+ {
+ "label": "Routers",
+ "path": "/projects/p/vpcs/v/routers",
+ },
+ {
+ "label": "r",
+ "path": "/projects/p/vpcs/v/routers/r",
+ },
+ {
+ "label": "Routes",
+ "path": "/projects/p/vpcs/v/routers/r/",
+ },
+ ],
+ "vpcRouters (/projects/p/vpcs/v/routers)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Routers",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcRoutersNew (/projects/p/vpcs/v/routers-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Routers",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcSubnets (/projects/p/vpcs/v/subnets)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Subnets",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcSubnetsEdit (/projects/p/vpcs/v/subnets/su/edit)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Subnets",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcSubnetsNew (/projects/p/vpcs/v/subnets-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v/firewall-rules",
+ },
+ {
+ "label": "Subnets",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcs (/projects/p/vpcs)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ ],
+ "vpcsNew (/projects/p/vpcs-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ ],
+}
+`;
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 76e39846ef..a6ca824e76 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -5,8 +5,13 @@
*
* Copyright Oxide Computer Company
*/
+import { matchRoutes } from 'react-router-dom'
+import * as R from 'remeda'
import { expect, test } from 'vitest'
+import { matchesToCrumbs } from '~/hooks/use-crumbs'
+import { routes } from '~/routes'
+
import { pb } from './path-builder'
// params can be the same for all of them because they only use what they need
@@ -36,7 +41,6 @@ test('path builder', () => {
"diskInventory": "/system/inventory/disks",
"disks": "/projects/p/disks",
"disksNew": "/projects/p/disks-new",
- "floatingIp": "/projects/p/floating-ips/f",
"floatingIpEdit": "/projects/p/floating-ips/f/edit",
"floatingIps": "/projects/p/floating-ips",
"floatingIpsNew": "/projects/p/floating-ips-new",
@@ -47,7 +51,6 @@ test('path builder', () => {
"instanceStorage": "/projects/p/instances/i/storage",
"instances": "/projects/p/instances",
"instancesNew": "/projects/p/instances-new",
- "inventory": "/system/inventory",
"ipPool": "/system/networking/ip-pools/pl",
"ipPoolEdit": "/system/networking/ip-pools/pl/edit",
"ipPoolRangeAdd": "/system/networking/ip-pools/pl/ranges-add",
@@ -57,19 +60,16 @@ test('path builder', () => {
"project": "/projects/p/instances",
"projectAccess": "/projects/p/access",
"projectEdit": "/projects/p/edit",
- "projectImage": "/projects/p/images/im",
"projectImageEdit": "/projects/p/images/im/edit",
"projectImages": "/projects/p/images",
"projectImagesNew": "/projects/p/images-new",
"projects": "/projects",
"projectsNew": "/projects-new",
- "rackInventory": "/system/inventory/racks",
"samlIdp": "/system/silos/s/idps/saml/pr",
"serialConsole": "/projects/p/instances/i/serial-console",
"silo": "/system/silos/s",
"siloAccess": "/access",
"siloIdpsNew": "/system/silos/s/idps-new",
- "siloImage": "/images/im",
"siloImageEdit": "/images/im/edit",
"siloImages": "/images",
"siloIpPools": "/system/silos/s?tab=ip-pools",
@@ -84,9 +84,6 @@ test('path builder', () => {
"snapshotsNew": "/projects/p/snapshots-new",
"sshKeys": "/settings/ssh-keys",
"sshKeysNew": "/settings/ssh-keys-new",
- "system": "/system",
- "systemHealth": "/system/health",
- "systemIssues": "/system/issues",
"systemUtilization": "/system/utilization",
"vpc": "/projects/p/vpcs/v/firewall-rules",
"vpcEdit": "/projects/p/vpcs/v/edit",
@@ -108,3 +105,39 @@ test('path builder', () => {
}
`)
})
+
+// matchRoutes returns something slightly different from UIMatch
+const getMatches = (pathname: string) =>
+ matchRoutes(routes, pathname)!.map((m) => ({
+ pathname: m.pathname,
+ params: m.params,
+ handle: m.route.handle,
+ // not used
+ id: '',
+ data: undefined,
+ }))
+
+// run every route in the path builder through the crumbs logic
+test('breadcrumbs', () => {
+ const pairs = Object.entries(pb).map(([key, fn]) => {
+ const pathname = fn(params)
+ return [
+ `${key} (${pathname})`,
+ matchesToCrumbs(getMatches(pathname))
+ .filter((c) => !c.titleOnly)
+ // omit titleOnly because of noise in the snapshot
+ .map(R.omit(['titleOnly'])),
+ ] as const
+ })
+
+ const zeroCrumbKeys = pairs
+ .filter(([_, crumbs]) => crumbs.length === 0)
+ .map(([key]) => key)
+ expect(zeroCrumbKeys).toMatchInlineSnapshot(`
+ [
+ "deviceSuccess (/device/success)",
+ ]
+ `)
+
+ expect(Object.fromEntries(pairs)).toMatchSnapshot()
+})
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 1709b19c59..de5b58ea1f 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -43,8 +43,7 @@ export const pb = {
projectAccess: (params: Project) => `${projectBase(params)}/access`,
projectImages: (params: Project) => `${projectBase(params)}/images`,
projectImagesNew: (params: Project) => `${projectBase(params)}/images-new`,
- projectImage: (params: Image) => `${pb.projectImages(params)}/${params.image}`,
- projectImageEdit: (params: Image) => `${pb.projectImage(params)}/edit`,
+ projectImageEdit: (params: Image) => `${pb.projectImages(params)}/${params.image}/edit`,
instances: (params: Project) => `${projectBase(params)}/instances`,
instancesNew: (params: Project) => `${projectBase(params)}/instances-new`,
@@ -99,19 +98,15 @@ export const pb = {
floatingIps: (params: Project) => `${projectBase(params)}/floating-ips`,
floatingIpsNew: (params: Project) => `${projectBase(params)}/floating-ips-new`,
- floatingIp: (params: FloatingIp) => `${pb.floatingIps(params)}/${params.floatingIp}`,
- floatingIpEdit: (params: FloatingIp) => `${pb.floatingIp(params)}/edit`,
+ floatingIpEdit: (params: FloatingIp) =>
+ `${pb.floatingIps(params)}/${params.floatingIp}/edit`,
siloUtilization: () => '/utilization',
siloAccess: () => '/access',
siloImages: () => '/images',
- siloImage: (params: SiloImage) => `${pb.siloImages()}/${params.image}`,
- siloImageEdit: (params: SiloImage) => `${pb.siloImage(params)}/edit`,
+ siloImageEdit: (params: SiloImage) => `${pb.siloImages()}/${params.image}/edit`,
- system: () => '/system',
- systemIssues: () => '/system/issues',
systemUtilization: () => '/system/utilization',
- systemHealth: () => '/system/health',
ipPools: () => '/system/networking/ip-pools',
ipPoolsNew: () => '/system/networking/ip-pools-new',
@@ -119,8 +114,6 @@ export const pb = {
ipPoolEdit: (params: IpPool) => `${pb.ipPool(params)}/edit`,
ipPoolRangeAdd: (params: IpPool) => `${pb.ipPool(params)}/ranges-add`,
- inventory: () => '/system/inventory',
- rackInventory: () => '/system/inventory/racks',
sledInventory: () => '/system/inventory/sleds',
diskInventory: () => '/system/inventory/disks',
sled: ({ sledId }: Sled) => `/system/inventory/sleds/${sledId}`,
diff --git a/tailwind.config.js b/tailwind.config.js
index 9868c46e9e..16af5bc06d 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -48,8 +48,9 @@ module.exports = {
modal: '40',
sideModalDropdown: '40',
sideModal: '30',
- topBarDropdown: '25',
- topBar: '20',
+ modalOverlay: '25',
+ topBarDropdown: '20',
+ topBar: '15',
popover: '10',
contentDropdown: '10',
content: '0',
diff --git a/test/e2e/breadcrumbs.e2e.ts b/test/e2e/breadcrumbs.e2e.ts
new file mode 100644
index 0000000000..2c260a2d1c
--- /dev/null
+++ b/test/e2e/breadcrumbs.e2e.ts
@@ -0,0 +1,82 @@
+/*
+ * 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
+ */
+
+import { expect, test, type Page } from '@playwright/test'
+
+async function getCrumbs(page: Page) {
+ const links = await page
+ .getByRole('navigation', { name: 'Breadcrumbs' })
+ .getByRole('link')
+ .all()
+ return Promise.all(
+ links.map(async (link) => [await link.textContent(), await link.getAttribute('href')])
+ )
+}
+
+type Pair = [string, string]
+
+async function expectCrumbs(page: Page, crumbs: Pair[]) {
+ await expect.poll(() => getCrumbs(page)).toEqual(crumbs)
+}
+
+const projectCrumbs: Pair[] = [
+ ['Projects', '/projects'],
+ ['mock-project', '/projects/mock-project/instances'],
+]
+
+// Not trying to get too comprehensive about testing the crumbs, that's what the
+// big snapshot unit test is for. Just testing a couple of cases here to make
+// sure the functions tested by the unit test are hooked up right in the UI.
+test('breadcrumbs', async ({ page }) => {
+ await page.goto('/projects/mock-project/vpcs')
+ const vpcsCrumbs: Pair[] = [...projectCrumbs, ['VPCs', '/projects/mock-project/vpcs']]
+ await expectCrumbs(page, vpcsCrumbs)
+
+ // the breadcrumbs should be the same with the form open because
+ // the form route doesn't have its own crumb
+ await page.getByRole('link', { name: 'New VPC' }).click()
+ await expect(page).toHaveURL('/projects/mock-project/vpcs-new')
+ await expectCrumbs(page, vpcsCrumbs)
+
+ // try a nested one with a tab
+ await page.goto('/projects/mock-project/instances/db1/networking')
+ await expectCrumbs(page, [
+ ...projectCrumbs,
+ ['Instances', '/projects/mock-project/instances'],
+ ['db1', '/projects/mock-project/instances/db1/storage'],
+ ['Networking', '/projects/mock-project/instances/db1/networking'],
+ ])
+
+ // test a settings page
+ await page.goto('/settings/ssh-keys')
+ await expectCrumbs(page, [
+ ['Settings', '/settings/profile'],
+ ['SSH Keys', '/settings/ssh-keys'],
+ ])
+
+ // test a couple of system pages
+ await page.goto('/system/silos/maze-war')
+ const siloCrumbs: Pair[] = [
+ ['Silos', '/system/silos'],
+ ['maze-war', '/system/silos/maze-war'],
+ ]
+ await expectCrumbs(page, siloCrumbs)
+ // same crumbs on IdP detail side modal
+ await page.getByRole('link', { name: 'mock-idp' }).click()
+ await expect(page).toHaveURL('/system/silos/maze-war/idps/saml/mock-idp')
+ await expectCrumbs(page, siloCrumbs)
+
+ await page.goto('/system/networking/ip-pools/ip-pool-1')
+ const poolCrumbs: Pair[] = [
+ ['IP Pools', '/system/networking/ip-pools'],
+ ['ip-pool-1', '/system/networking/ip-pools/ip-pool-1'],
+ ]
+ await expectCrumbs(page, poolCrumbs)
+ await page.goto('/system/networking/ip-pools/ip-pool-1/ranges-add')
+ await expectCrumbs(page, poolCrumbs)
+})
diff --git a/test/e2e/instance-serial.e2e.ts b/test/e2e/instance-serial.e2e.ts
index 417ad1044c..399f4bf577 100644
--- a/test/e2e/instance-serial.e2e.ts
+++ b/test/e2e/instance-serial.e2e.ts
@@ -19,7 +19,7 @@ test('serial console can connect while starting', async ({ page }) => {
// now go starting to its serial console page while it's starting up
await expect(page).toHaveURL('/projects/mock-project/instances/abc/storage')
await page.getByRole('tab', { name: 'Connect' }).click()
- await page.getByRole('link', { name: 'Connect' }).click()
+ await page.getByRole('main').getByRole('link', { name: 'Connect' }).click()
// The message goes from creating to starting and then disappears once
// the instance is running. skip the check for "creating" because it can
diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts
index 75629efbb7..8da3ba836f 100644
--- a/test/e2e/instance.e2e.ts
+++ b/test/e2e/instance.e2e.ts
@@ -17,7 +17,7 @@ const expectInstanceState = async (page: Page, instance: string, state: string)
test('can delete a failed instance', async ({ page }) => {
await page.goto('/projects/mock-project/instances')
- await expect(page).toHaveTitle('Instances / mock-project / Oxide Console')
+ await expect(page).toHaveTitle('Instances / mock-project / Projects / Oxide Console')
const cell = page.getByRole('cell', { name: 'you-fail' })
await expect(cell).toBeVisible() // just to match hidden check at the end
@@ -52,7 +52,7 @@ test('can start a failed instance', async ({ page }) => {
test('can stop a failed instance', async ({ page }) => {
await page.goto('/projects/mock-project/instances')
- await expect(page).toHaveTitle('Instances / mock-project / Oxide Console')
+ await expect(page).toHaveTitle('Instances / mock-project / Projects / Oxide Console')
await expectInstanceState(page, 'you-fail', 'failed')
await clickRowAction(page, 'you-fail', 'Stop')
await page.getByRole('button', { name: 'Confirm' }).click()
@@ -85,7 +85,7 @@ test('can stop and delete a running instance', async ({ page }) => {
test('can stop a starting instance, then start it again', async ({ page }) => {
await page.goto('/projects/mock-project/instances')
- await expect(page).toHaveTitle('Instances / mock-project / Oxide Console')
+ await expect(page).toHaveTitle('Instances / mock-project / Projects / Oxide Console')
await expectInstanceState(page, 'not-there-yet', 'starting')
await clickRowAction(page, 'not-there-yet', 'Stop')
diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts
index ea17e815dc..70db5b15fb 100644
--- a/test/e2e/ip-pools.e2e.ts
+++ b/test/e2e/ip-pools.e2e.ts
@@ -13,7 +13,7 @@ import { clickRowAction, expectRowVisible, expectToast } from './utils'
test('IP pool list', async ({ page }) => {
await page.goto('/system/networking/ip-pools')
- await expect(page).toHaveTitle('IP pools / Oxide Console')
+ await expect(page).toHaveTitle('IP Pools / Oxide Console')
await expect(page.getByRole('heading', { name: 'IP Pools' })).toBeVisible()
@@ -55,7 +55,7 @@ test('IP pool silo list', async ({ page }) => {
await page.goto('/system/networking/ip-pools')
await page.getByRole('link', { name: 'ip-pool-1' }).click()
- await expect(page).toHaveTitle('ip-pool-1 / IP pools / Oxide Console')
+ await expect(page).toHaveTitle('ip-pool-1 / IP Pools / Oxide Console')
await page.getByRole('tab', { name: 'Linked silos' }).click()
// this is here because waiting for the `tab` query param to show up avoids
@@ -251,7 +251,9 @@ test('IP range validation and add', async ({ page }) => {
await expect(page.getByText('Capacity32')).toBeVisible()
// go back to the pool and verify the utilization column changed
- await page.getByRole('link', { name: 'IP Pools' }).click()
+ // use the sidebar nav to get there
+ const sidebar = page.getByRole('navigation', { name: 'Sidebar navigation' })
+ await sidebar.getByRole('link', { name: 'IP Pools' }).click()
await expectRowVisible(table, {
name: 'ip-pool-2',
Utilization: 'v4' + '0 / 1' + 'v6' + '0 / 32',
@@ -285,7 +287,9 @@ test('remove range', async ({ page }) => {
await expect(page.getByText('Capacity21')).toBeVisible()
// go back to the pool and verify the utilization column changed
- await page.getByRole('link', { name: 'IP Pools' }).click()
+ // use the topbar breadcrumb to get there
+ const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' })
+ await breadcrumbs.getByRole('link', { name: 'IP Pools' }).click()
await expectRowVisible(table, {
name: 'ip-pool-1',
Utilization: '6 / 21',
diff --git a/test/e2e/networking.e2e.ts b/test/e2e/networking.e2e.ts
index 221dac5d67..b13bf99b52 100644
--- a/test/e2e/networking.e2e.ts
+++ b/test/e2e/networking.e2e.ts
@@ -47,7 +47,8 @@ test('Create and edit VPC', async ({ page }) => {
}
// now go back up a level to vpcs table
- await page.getByRole('link', { name: 'VPCs' }).click()
+ const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' })
+ await breadcrumbs.getByRole('link', { name: 'VPCs' }).click()
await expect(table.getByRole('row')).toHaveCount(3) // header plus two rows
await expectRowVisible(table, {
name: 'another-vpc',
diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts
index 2e6ea3b466..dadcc16be5 100644
--- a/test/e2e/vpcs.e2e.ts
+++ b/test/e2e/vpcs.e2e.ts
@@ -29,7 +29,7 @@ test('can nav to VpcPage from /', async ({ page }) => {
await expect(page.getByRole('cell', { name: 'allow-icmp' })).toBeVisible()
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'
+ 'Firewall Rules / mock-vpc / VPCs / mock-project / Projects / Oxide Console'
)
// we can also click the firewall rules cell to get to the VPC detail
@@ -62,7 +62,8 @@ test('can edit VPC', async ({ page }) => {
await expect(page.getByText('descriptionupdated description')).toBeVisible()
// go to the VPCs list page and verify the name and description change
- await page.getByRole('link', { name: 'VPCs' }).click()
+ const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' })
+ await breadcrumbs.getByRole('link', { name: 'VPCs' }).click()
await expect(page.getByRole('table').locator('tbody >> tr')).toHaveCount(1)
await expectRowVisible(page.getByRole('table'), {
name: 'mock-vpc-2',