diff --git a/AGENTS.md b/AGENTS.md
index 32a74b8c9..1748b7dc8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,6 +5,7 @@
- Before starting a feature, skim an existing page or form with similar behavior and mirror the conventions—this codebase is intentionally conventional. Look for similar pages in `app/pages` and forms in `app/forms` to use as templates.
- `@oxide/api` is at `app/api` and `@oxide/api-mocks` is at `mock-api/index.ts`.
- The language server often has out of date errors. TypeScript 7 is extremely fast, so confirm errors that come from the language server by running `npm run tsc`
+- This repo uses oxfmt and oxlint, not prettier or eslint
- Use Node.js 22+, then install deps and start the mock-backed dev server (skip if `npm run dev` is already running in another terminal):
```sh
@@ -48,6 +49,7 @@
# Mutations & UI flow
- Wrap writes in `useApiMutation`, use `confirmAction` to guard destructive intent, and surface results with `addToast`.
+- When a form's `onSuccess` always navigates away, pass `loading={mutation.isPending || mutation.isSuccess}` to the form shell. `isPending` alone flips false before the navigation unmounts the modal, so the button's spinner animates back out right before close. Skip `isSuccess` if the form can stay open and be reused after success, or if the mutation lives in a component that survives the modal (e.g., a tab page with `{open && }`) — there success closes the modal synchronously so `isPending` alone is glitch-free, and a sticky `isSuccess` would strand a spinner on next open.
- Mutation error display depends on context. In forms, errors display inline via `submitError={mutation.error}` — do not add `onError` with a toast to the `useApiMutation` call. In `confirmAction`/`confirmDelete` flows, the confirm modal catches the error and shows a toast using `errorTitle` — do not also add `onError` on the mutation, or the user will see two toasts. For standalone actions (fire-and-forget `mutate` calls not wrapped in a confirm modal or form), use `onError` on the mutation to show an error toast.
- Keep page scaffolding consistent: `PageHeader`, `PageTitle`, `DocsPopover`, `RefreshButton`, `PropertiesTable`, and `CardBlock` provide the expected layout for new system pages.
- When a page should be discoverable from the command palette, extend `useQuickActions` with the new entry so it appears in the quick actions menu (see `app/pages/ProjectsPage.tsx:100-115`).
diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx
index 0afc39048..d98ec68cc 100644
--- a/app/components/AttachEphemeralIpModal.tsx
+++ b/app/components/AttachEphemeralIpModal.tsx
@@ -85,7 +85,7 @@ export const AttachEphemeralIpModal = ({
submitLabel="Attach"
submitDisabled={submitDisabled}
submitError={instanceEphemeralIpAttach.error}
- loading={instanceEphemeralIpAttach.isPending}
+ loading={instanceEphemeralIpAttach.isPending || instanceEphemeralIpAttach.isSuccess}
onSubmit={({ pool }) => {
instanceEphemeralIpAttach.mutate({
path: { instance },
diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx
index a6563145a..b24adf40d 100644
--- a/app/components/AttachFloatingIpModal.tsx
+++ b/app/components/AttachFloatingIpModal.tsx
@@ -91,7 +91,7 @@ export const AttachFloatingIpModal = ({
onDismiss={onDismiss}
submitLabel="Attach floating IP"
submitError={floatingIpAttach.error}
- loading={floatingIpAttach.isPending}
+ loading={floatingIpAttach.isPending || floatingIpAttach.isSuccess}
title="Attach floating IP"
onSubmit={() =>
floatingIpAttach.mutate({
diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx
index dfaaaa037..5b84f002b 100644
--- a/app/forms/anti-affinity-group-create.tsx
+++ b/app/forms/anti-affinity-group-create.tsx
@@ -62,7 +62,7 @@ export default function CreateAntiAffinityGroupForm() {
body: { ...values, failureDomain: 'sled' },
})
}
- loading={createAntiAffinityGroup.isPending}
+ loading={createAntiAffinityGroup.isPending || createAntiAffinityGroup.isSuccess}
submitError={createAntiAffinityGroup.error}
submitLabel="Add group"
>
diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx
index d238827f8..82736bf88 100644
--- a/app/forms/anti-affinity-group-edit.tsx
+++ b/app/forms/anti-affinity-group-edit.tsx
@@ -77,7 +77,7 @@ export default function EditAntiAffintyGroupForm() {
body: values,
})
}}
- loading={editAntiAffinityGroup.isPending}
+ loading={editAntiAffinityGroup.isPending || editAntiAffinityGroup.isSuccess}
submitError={editAntiAffinityGroup.error}
submitLabel="Edit group"
>
diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx
index 32c097d1e..8de92bd88 100644
--- a/app/forms/disk-create.tsx
+++ b/app/forms/disk-create.tsx
@@ -191,7 +191,7 @@ export function CreateDiskSideModalForm({
createDisk.mutate({ query: { project }, body })
}
}}
- loading={createDisk.isPending}
+ loading={createDisk.isPending || createDisk.isSuccess}
submitError={createDisk.error}
>
diff --git a/app/forms/external-subnet-edit.tsx b/app/forms/external-subnet-edit.tsx
index 68260f7ca..b1f00d29d 100644
--- a/app/forms/external-subnet-edit.tsx
+++ b/app/forms/external-subnet-edit.tsx
@@ -98,7 +98,7 @@ export default function EditExternalSubnetSideModalForm() {
body: { name, description },
})
}}
- loading={editExternalSubnet.isPending}
+ loading={editExternalSubnet.isPending || editExternalSubnet.isSuccess}
submitError={editExternalSubnet.error}
>
diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx
index 7eaa231f7..613e18c1d 100644
--- a/app/forms/firewall-rules-create.tsx
+++ b/app/forms/firewall-rules-create.tsx
@@ -122,7 +122,7 @@ export default function CreateFirewallRuleForm() {
},
})
}}
- loading={updateRules.isPending}
+ loading={updateRules.isPending || updateRules.isSuccess}
submitError={updateRules.error}
submitLabel="Add rule"
>
diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx
index 48cff6e12..e3b0438cd 100644
--- a/app/forms/firewall-rules-edit.tsx
+++ b/app/forms/firewall-rules-edit.tsx
@@ -132,7 +132,7 @@ export default function EditFirewallRuleForm() {
}
// validationSchema={validationSchema}
// validateOnBlur
- loading={updateRules.isPending}
+ loading={updateRules.isPending || updateRules.isSuccess}
submitError={updateRules.error}
>
diff --git a/app/forms/fleet-access.tsx b/app/forms/fleet-access.tsx
index 018097f55..9b5ed0447 100644
--- a/app/forms/fleet-access.tsx
+++ b/app/forms/fleet-access.tsx
@@ -66,7 +66,7 @@ export function FleetAccessAddUserSideModal({
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
- loading={updatePolicy.isPending}
+ loading={updatePolicy.isPending || updatePolicy.isSuccess}
submitError={updatePolicy.error}
>
{
updatePolicy.reset() // clear API error state so it doesn't persist on next open
diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx
index b36b218fe..981eafa2d 100644
--- a/app/forms/floating-ip-create.tsx
+++ b/app/forms/floating-ip-create.tsx
@@ -94,7 +94,7 @@ export default function CreateFloatingIpSideModalForm() {
}
createFloatingIp.mutate({ query: projectSelector, body })
}}
- loading={createFloatingIp.isPending}
+ loading={createFloatingIp.isPending || createFloatingIp.isSuccess}
submitError={createFloatingIp.error}
>
diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx
index c5b44542d..261377d2f 100644
--- a/app/forms/floating-ip-edit.tsx
+++ b/app/forms/floating-ip-edit.tsx
@@ -97,7 +97,7 @@ export default function EditFloatingIpSideModalForm() {
body: { name, description },
})
}}
- loading={editFloatingIp.isPending}
+ loading={editFloatingIp.isPending || editFloatingIp.isSuccess}
submitError={editFloatingIp.error}
>
diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx
index 9ff9c039b..ffc9458b8 100644
--- a/app/forms/idp/create.tsx
+++ b/app/forms/idp/create.tsx
@@ -129,7 +129,7 @@ export default function CreateIdpSideModalForm() {
},
})
}}
- loading={createIdp.isPending}
+ loading={createIdp.isPending || createIdp.isSuccess}
submitError={createIdp.error}
submitLabel="Create provider"
>
diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx
index 012548590..07fc254f5 100644
--- a/app/forms/image-from-snapshot.tsx
+++ b/app/forms/image-from-snapshot.tsx
@@ -89,7 +89,7 @@ export default function CreateImageFromSnapshotSideModalForm() {
})
}
submitError={createImage.error}
- loading={createImage.isPending}
+ loading={createImage.isPending || createImage.isSuccess}
>
{data.name}
diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx
index 6065a08fb..6674c0e17 100644
--- a/app/forms/instance-create.tsx
+++ b/app/forms/instance-create.tsx
@@ -606,7 +606,7 @@ export default function CreateInstanceForm() {
},
})
}}
- loading={createInstance.isPending}
+ loading={createInstance.isPending || createInstance.isSuccess}
submitError={createInstance.error}
>
@@ -854,7 +854,9 @@ export default function CreateInstanceForm() {
disabled={isSubmitting}
/>
- Create instance
+
+ Create instance
+
navigate(pb.instances({ project }))} />
diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx
index 67bd8e9d3..98b5dc326 100644
--- a/app/forms/ip-pool-create.tsx
+++ b/app/forms/ip-pool-create.tsx
@@ -59,7 +59,7 @@ export default function CreateIpPoolSideModalForm() {
onSubmit={({ name, description, ipVersion, poolType }) => {
createPool.mutate({ body: { name, description, ipVersion, poolType } })
}}
- loading={createPool.isPending}
+ loading={createPool.isPending || createPool.isSuccess}
submitError={createPool.error}
>
diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx
index fee459f14..ff8e13f8d 100644
--- a/app/forms/ip-pool-edit.tsx
+++ b/app/forms/ip-pool-edit.tsx
@@ -71,7 +71,7 @@ export default function EditIpPoolSideModalForm() {
onSubmit={({ name, description }) => {
editPool.mutate({ path: poolSelector, body: { name, description } })
}}
- loading={editPool.isPending}
+ loading={editPool.isPending || editPool.isSuccess}
submitError={editPool.error}
>
diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx
index ba3c740e2..490e4fcc1 100644
--- a/app/forms/ip-pool-range-add.tsx
+++ b/app/forms/ip-pool-range-add.tsx
@@ -71,7 +71,7 @@ export default function IpPoolAddRange() {
title="Add IP range"
onDismiss={onDismiss}
onSubmit={(body) => addRange.mutate({ path: { pool }, body })}
- loading={addRange.isPending}
+ loading={addRange.isPending || addRange.isSuccess}
submitError={addRange.error}
>
diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx
index 15566bc56..45be3fd2c 100644
--- a/app/forms/project-access.tsx
+++ b/app/forms/project-access.tsx
@@ -64,7 +64,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
- loading={updatePolicy.isPending}
+ loading={updatePolicy.isPending || updatePolicy.isSuccess}
submitError={updatePolicy.error}
onDismiss={onDismiss}
>
@@ -118,7 +118,7 @@ export function ProjectAccessEditUserSideModal({
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
- loading={updatePolicy.isPending}
+ loading={updatePolicy.isPending || updatePolicy.isSuccess}
submitError={updatePolicy.error}
onDismiss={onDismiss}
>
diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx
index 46c0fec48..0f865b6a7 100644
--- a/app/forms/project-create.tsx
+++ b/app/forms/project-create.tsx
@@ -56,7 +56,7 @@ export default function ProjectCreateSideModalForm() {
onSubmit={({ name, description }) => {
createProject.mutate({ body: { name, description } })
}}
- loading={createProject.isPending}
+ loading={createProject.isPending || createProject.isSuccess}
submitError={createProject.error}
>
diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx
index 4e3521ca4..dbce4fa4f 100644
--- a/app/forms/project-edit.tsx
+++ b/app/forms/project-edit.tsx
@@ -66,7 +66,7 @@ export default function EditProjectSideModalForm() {
onSubmit={({ name, description }) => {
editProject.mutate({ path: projectSelector, body: { name, description } })
}}
- loading={editProject.isPending}
+ loading={editProject.isPending || editProject.isSuccess}
submitError={editProject.error}
>
diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx
index dd1388c99..bb0d6c09e 100644
--- a/app/forms/silo-access.tsx
+++ b/app/forms/silo-access.tsx
@@ -65,7 +65,7 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
- loading={updatePolicy.isPending}
+ loading={updatePolicy.isPending || updatePolicy.isSuccess}
submitError={updatePolicy.error}
>
{
updatePolicy.reset() // clear API error state so it doesn't persist on next open
diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx
index 2a72e6f2c..bfc475e71 100644
--- a/app/forms/silo-create.tsx
+++ b/app/forms/silo-create.tsx
@@ -113,7 +113,7 @@ export default function CreateSiloSideModalForm() {
},
})
}}
- loading={createSilo.isPending}
+ loading={createSilo.isPending || createSilo.isSuccess}
submitError={createSilo.error}
>
} />
diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx
index 8079c59f3..68a235db8 100644
--- a/app/forms/snapshot-create.tsx
+++ b/app/forms/snapshot-create.tsx
@@ -77,7 +77,7 @@ export default function SnapshotCreate() {
createSnapshot.mutate({ query: projectSelector, body: values })
}}
submitError={createSnapshot.error}
- loading={createSnapshot.isPending}
+ loading={createSnapshot.isPending || createSnapshot.isSuccess}
>
diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx
index 01e05201c..8162b3f4f 100644
--- a/app/forms/ssh-key-create.tsx
+++ b/app/forms/ssh-key-create.tsx
@@ -62,7 +62,7 @@ export function SSHKeyCreate({ onDismiss, onSuccess, message }: Props) {
title="Add SSH key"
onDismiss={handleDismiss}
onSubmit={(body) => createSshKey.mutate({ body })}
- loading={createSshKey.isPending}
+ loading={createSshKey.isPending || createSshKey.isSuccess}
submitError={createSshKey.error}
>
diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx
index ddfe14501..ba4ad5803 100644
--- a/app/forms/subnet-create.tsx
+++ b/app/forms/subnet-create.tsx
@@ -78,7 +78,7 @@ export default function CreateSubnetForm() {
},
})
}
- loading={createSubnet.isPending}
+ loading={createSubnet.isPending || createSubnet.isSuccess}
submitError={createSubnet.error}
>
diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx
index d7a9986f9..65c399578 100644
--- a/app/forms/subnet-edit.tsx
+++ b/app/forms/subnet-edit.tsx
@@ -94,7 +94,7 @@ export default function EditSubnetForm() {
},
})
}}
- loading={updateSubnet.isPending}
+ loading={updateSubnet.isPending || updateSubnet.isSuccess}
submitError={updateSubnet.error}
>
diff --git a/app/forms/subnet-pool-create.tsx b/app/forms/subnet-pool-create.tsx
index 9767a2fb0..857d5c5c6 100644
--- a/app/forms/subnet-pool-create.tsx
+++ b/app/forms/subnet-pool-create.tsx
@@ -55,7 +55,7 @@ export default function CreateSubnetPoolSideModalForm() {
onSubmit={({ name, description, ipVersion }) => {
createPool.mutate({ body: { name, description, ipVersion } })
}}
- loading={createPool.isPending}
+ loading={createPool.isPending || createPool.isSuccess}
submitError={createPool.error}
>
diff --git a/app/forms/subnet-pool-edit.tsx b/app/forms/subnet-pool-edit.tsx
index 2c5943c4d..cb9d024d5 100644
--- a/app/forms/subnet-pool-edit.tsx
+++ b/app/forms/subnet-pool-edit.tsx
@@ -70,7 +70,7 @@ export default function EditSubnetPoolSideModalForm() {
body: { name, description },
})
}}
- loading={editPool.isPending}
+ loading={editPool.isPending || editPool.isSuccess}
submitError={editPool.error}
>
diff --git a/app/forms/subnet-pool-member-add.tsx b/app/forms/subnet-pool-member-add.tsx
index b1709fe41..79cbf9a83 100644
--- a/app/forms/subnet-pool-member-add.tsx
+++ b/app/forms/subnet-pool-member-add.tsx
@@ -138,7 +138,7 @@ export default function SubnetPoolMemberAdd() {
},
})
}}
- loading={addMember.isPending}
+ loading={addMember.isPending || addMember.isSuccess}
submitError={addMember.error}
>
navigate(pb.vpcs(projectSelector))}
- loading={createVpc.isPending}
+ loading={createVpc.isPending || createVpc.isSuccess}
submitError={createVpc.error}
>
diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx
index 574a1957f..5a9f3510e 100644
--- a/app/forms/vpc-edit.tsx
+++ b/app/forms/vpc-edit.tsx
@@ -73,7 +73,7 @@ export default function EditVpcSideModalForm() {
body: { name, description, dnsName },
})
}}
- loading={editVpc.isPending}
+ loading={editVpc.isPending || editVpc.isSuccess}
submitError={editVpc.error}
>
diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx
index 2578df6b0..d11b329ef 100644
--- a/app/forms/vpc-router-create.tsx
+++ b/app/forms/vpc-router-create.tsx
@@ -52,7 +52,7 @@ export default function RouterCreate() {
resourceName="router"
onDismiss={onDismiss}
onSubmit={(body) => createRouter.mutate({ query: vpcSelector, body })}
- loading={createRouter.isPending}
+ loading={createRouter.isPending || createRouter.isSuccess}
submitError={createRouter.error}
>
diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx
index 883fda9d6..ca6ff35f8 100644
--- a/app/forms/vpc-router-edit.tsx
+++ b/app/forms/vpc-router-edit.tsx
@@ -78,7 +78,7 @@ export default function EditRouterSideModalForm() {
body,
})
}
- loading={editRouter.isPending}
+ loading={editRouter.isPending || editRouter.isSuccess}
submitError={editRouter.error}
>
diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx
index 764497144..4eefb64e2 100644
--- a/app/forms/vpc-router-route-create.tsx
+++ b/app/forms/vpc-router-route-create.tsx
@@ -79,7 +79,7 @@ export default function CreateRouterRouteSideModalForm() {
},
})
}
- loading={createRouterRoute.isPending}
+ loading={createRouterRoute.isPending || createRouterRoute.isSuccess}
submitError={createRouterRoute.error}
>
diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx
index 2e7583969..81880349f 100644
--- a/app/forms/vpc-router-route-edit.tsx
+++ b/app/forms/vpc-router-route-edit.tsx
@@ -98,7 +98,7 @@ export default function EditRouterRouteSideModalForm() {
},
})
}
- loading={updateRouterRoute.isPending}
+ loading={updateRouterRoute.isPending || updateRouterRoute.isSuccess}
submitError={updateRouterRoute.error}
submitDisabled={disabled ? routeFormMessage.vpcSubnetNotModifiable : undefined}
>
diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx
index ff522771b..9944039c4 100644
--- a/app/pages/SiloImagesPage.tsx
+++ b/app/pages/SiloImagesPage.tsx
@@ -191,7 +191,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
{
if (!image || !project) return // shouldn't happen because of validation
@@ -275,7 +275,7 @@ const DemoteImageModal = ({
{
if (!project) return // shouldn't happen because of validation
diff --git a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx
index 3004700ce..e8958cf9e 100644
--- a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx
+++ b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx
@@ -291,7 +291,7 @@ const AttachExternalSubnetModal = ({
}}
submitLabel="Attach"
submitError={externalSubnetAttach.error}
- loading={externalSubnetAttach.isPending}
+ loading={externalSubnetAttach.isPending || externalSubnetAttach.isSuccess}
onDismiss={onDismiss}
>
setCreateModalOpen(false)}
+ onDismiss={() => {
+ setCreateModalOpen(false)
+ createNic.reset() // clear stale error state
+ }}
onSubmit={(body) => createNic.mutate({ query: instanceSelector, body })}
+ // not || isSuccess: the modal unmounts synchronously in onSuccess,
+ // and a sticky isSuccess would strand a spinner on next open
+ loading={createNic.isPending}
submitError={createNic.error}
/>
)}
@@ -897,7 +903,7 @@ const AttachExternalSubnetModal = ({
}}
submitLabel="Attach"
submitError={externalSubnetAttach.error}
- loading={externalSubnetAttach.isPending}
+ loading={externalSubnetAttach.isPending || externalSubnetAttach.isSuccess}
onDismiss={onDismiss}
>
void }) {
path: { silo },
})
}
- loading={updateQuotas.isPending}
+ loading={updateQuotas.isPending || updateQuotas.isSuccess}
submitError={updateQuotas.error}
>
} variant="info" />