-
Keep the console a thin client over the Oxide API: minimize client-only state, surface API concepts, and bias toward simple, predictable UI that works everywhere.
-
Favor well-supported libraries, avoid premature abstractions, and use routes to capture state.
-
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/pagesand forms inapp/formsto use as templates. -
@oxide/apiis atapp/apiand@oxide/api-mocksis atmock-api/index.ts. -
Use Node.js 22+, then install deps and start the mock-backed dev server (skip if
npm run devis already running in another terminal):npm install npm run dev
- Comment the why, not the what. If a line's purpose isn't obvious from context, give a short reason (e.g.,
// clear API error state)
- Treat
app/api/util.ts(and friends) as a thin translation layer: mirror backend rules only when the UI needs them, keep the client copy minimal, and always link to the authoritative Omicron source so reviewers can verify the behavior. Only keep 7 chars of the commit hash in the URL. - API constants live in
app/api/util.tswith links to Omicron source.
- Run local checks before sending PRs:
npm run lint,npm run tsc,npm test run, andnpm run e2ec. - You don't usually need to run all the e2e tests, so try to filter by file and tes t name like
npm run e2ec -- instance -g 'boot disk'. CI will run the full set. - Keep Playwright specs focused on user-visible behavior—use accessible locators (
getByRole,getByLabel), the helpers intest/e2e/utils.ts(expectToast,expectRowVisible,selectOption,clickRowAction), and close toasts so follow-on assertions aren’t blocked. - Cover role-gated flows by logging in with
getPageAsUser; exercise negative paths (e.g., forbidden actions) alongside happy paths as shown intest/e2e/system-update.e2e.ts. - Consider
expectVisibleandexpectNotVisibledeprecated: preferexpect().toBeVisible()andtoBeHidden()in new code. - When UI needs new mock behavior, extend the MSW handlers/db minimally so E2E tests stay deterministic; prefer storing full API responses so subsequent calls see the updated state (
mock-api/msw/db.ts,mock-api/msw/handlers.ts). - Co-locate Vitest specs next to the code they cover; use Testing Library utilities (
render,renderHook,fireEvent, fake timers) to assert observable output rather than implementation details (app/ui/lib/FileInput.spec.tsx,app/hooks/use-pagination.spec.ts). - For sweeping styling changes, coordinate with the visual regression harness and follow
test/visual/README.mdfor the workflow. - Fix root causes of flaky timing rather than adding
sleep()workarounds in tests.
- Data from
usePrefetchedQueryis guaranteed to be defined (the loader ensures it and the hook throws if it's not present). Do not addif (!data) returnguards on these values. - Define queries with
q(api.endpoint, params)for single items orgetListQFn(api.listEndpoint, params)for lists. Prefetch inclientLoaderand read withusePrefetchedQuery; for on-demand fetches (modals, secondary data), useuseQuerydirectly. - Use
ALL_ISHfromapp/util/consts.tswhen UI needs "all" items. UsequeryClient.invalidateEndpointto invalidate queries. - For paginated tables, compose
getListQFnwithuseQueryTable; the helper wrapslimit/pageTokenhandling and keeps placeholder data stable (app/api/hooks.ts:123-188,app/pages/ProjectsPage.tsx:40-132). - When a loader needs dependent data, fetch the primary list with
queryClient.fetchQuery, prefetch its per-item queries, and only await a bounded batch so render isn't blocked (seeapp/pages/project/affinity/AffinityPage.tsx). - When modals need async data, fetch with
queryClient.ensureQueryDatabefore opening the modal so cached data is reused and there's no content pop-in. - Use
qErrorsAllowedin loaders for endpoints where some users may lack permission, so the page degrades gracefully instead of the loader throwing (seeSiloScimTab.tsx).
- Wrap writes in
useApiMutation, useconfirmActionto guard destructive intent, and surface results withaddToast. - Keep page scaffolding consistent:
PageHeader,PageTitle,DocsPopover,RefreshButton,PropertiesTable, andCardBlockprovide the expected layout for new system pages. - When a page should be discoverable from the command palette, extend
useQuickActionswith the new entry so it appears in the quick actions menu (seeapp/pages/ProjectsPage.tsx:100-115). - Gate per-resource actions with capability helpers:
instanceCan.start(instance),diskCan.delete(disk), etc. (app/api/util.ts:91-207)—these return booleans and have.statesproperties listing valid states. Always use these instead of inline state checks; they centralize business logic and link to Omicron source explaining restrictions. - Prefer disabling buttons with
disabledReasonover hiding them so users can discover the action exists. ComputedisabledReasonas astring | undefinedternary chain and derivedisabledfrom!!disabledReason. - When closing a modal that uses
useApiMutation, callmutation.reset()in the dismiss handler to clear stale error state so it doesn't persist on next open.
- Update commit hash in
OMICRON_VERSION. - Run
npm run gen-api. - Run
npm run tsc. - Fix type errors. New endpoints in
mock-api/msw/handlers.tsshould be added asNotImplemented.
- Only implement what is necessary to exercise the UI; keep the db seeded via
mock-api/msw/db.ts. - Store API response objects in the mock tables when possible so state persists across calls.
- Enforce role checks with
requireFleetViewer/requireFleetCollab/requireFleetAdmin, and return realistic errors (e.g. downgrade guard insystemUpdateStatus).
- Add routes in
app/routes.tsx, usinglazy(() => import(...).then(convert))so loaders becomeclientLoaderand components stay tree-shakeable. - Export navigation helpers via
pbinapp/util/path-builder.ts; every new route should get a path-builder entry and appear inapp/util/path-builder.spec.ts's snapshot. - Breadcrumbs come from route
handle.crumb; usemakeCrumb/titleCrumband provide apathwhen the parent route redirects (app/hooks/use-crumbs.ts:21-64). UsetitleCrumbfor side modal forms that should appear in page title but not nav breadcrumbs (checkCrumb.titleOnlyflag). - When adding tabs or redirects, wire the canonical link in the path builder (e.g., point to the default tab) and update the sidebar/quick actions as needed.
- For tabs synced with query params, use
QueryParamTabscomponent which manages?tab=param and removes it when default tab is selected (app/components/QueryParamTabs.tsx).
- Forms live under
app/forms; start by copying a nearby example such asapp/forms/project-create.tsx:21-61. - Use
react-hook-formwith the shared shells (SideModalForm,ModalForm,FullPageForm) so UX and submit handling stay consistent (app/components/form/SideModalForm.tsx:32-140). - Wire submissions through
useApiMutation, invalidate or seed queries withuseApiQueryClient, and surface success with toasts/navigation (app/forms/project-create.tsx:34-55). - Prefer the existing field components (
app/components/form/fields) and only introduce new ones when the design system requires it. - Let form state mirror the form's UI structure, not the API request shape. Transform to the API shape in the
onSubmithandler. This keeps fields, validation, and conditional logic straightforward. - Use react-hook-form's
watchand conditional rendering to keep fields in sync. AvoiduseEffectto propagate form values between fields—it causes extra renders and subtle ordering bugs. Reset related fields in change handlers instead. Compute default values up front inuseForm({ defaultValues })rather than usinguseEffect+setValue. - Never access react-hook-form internals like
control._formValues; useuseWatchor restructure so you don't need the value. - In nested form contexts (sub-forms inside a page form),
preventDefault()on Enter in text inputs to avoid accidental outer-form submission. - In submit handlers, prefer early return over
invariantfor states that form validation should have prevented—crashing the app is worse than a silent noop for an edge case no user can reach. - In general, use
useEffectas a last resort! Try to figure out a non-useEffect version first. See https://react.dev/learn/you-might-not-need-an-effect.md when thinking about difficult cases.
- Use shared column helpers from
app/table/columns/common.tsx:Columns.id(with copy button),Columns.description(truncated with tooltip),Columns.size(formatted with units),Columns.timeCreated,Columns.timeModified. - Compose row actions with
useColsWithActionsand the confirm-action stores; prime modals by seeding list data into the cache (e.g.,queryClient.setQueryData) so edits open immediately (app/pages/ProjectsPage.tsx). getActionsColautomatically includes "Copy ID" if row hasidfield, and actions labeled "delete" get destructive styling. Passdisabledprop with ReactNode for tooltip explaining why action is unavailable (app/table/columns/action-col.tsx).- Let
useQueryTabledrive pagination, scroll reset, and placeholder loading states instead of reimplementing TanStack Table plumbing (app/table/QueryTable.tsx). - Use
PropertiesTablecompound component for detail views:PropertiesTable.Row,PropertiesTable.IdRow(truncated ID with copy),PropertiesTable.DescriptionRow,PropertiesTable.DateRow(app/ui/lib/PropertiesTable.tsx). - Hoist static column definitions to module scope;
useMemowith an empty dependency array is a code smell indicating the value doesn't belong inside the component. More generally, don't reach foruseMemofor simple ternary/conditional logic; reserve it for genuinely expensive computation or when referential identity matters for downstream deps.
- Build pages inside the shared
PageContainer/ContentPaneso you inherit the skip link, sticky footer, pagination target, and scroll restoration tied to#scroll-container(app/layouts/helpers.tsx,app/hooks/use-scroll-restoration.ts). - Surface page-level buttons and pagination via the
PageActionsandPaginationtunnels fromtunnel-rat; anything rendered through.Inlands in.Targetautomatically. - For global loading states, reuse
PageSkeleton—it keeps the MSW banner and grid layout stable, andskipPathslets you opt-out for routes with custom layouts (app/components/PageSkeleton.tsx). - Enforce accessibility at the type level: use
AriaLabeltype fromapp/ui/util/aria.tswhich requires exactly one ofaria-labeloraria-labelledbyon custom interactive components.
- Wrap
useParamswith the provided selectors (useProjectSelector,useInstanceSelector, etc.) so required params throw during dev and produce memoized results safe for dependency arrays (app/hooks/use-params.ts). - Prefer
queryClient.fetchQueryinsideclientLoaderblocks when the page needs data up front, and throwtrigger404on real misses so the error boundary renders Not Found.
- Use the zustand-powered confirm helpers (
confirmDelete,confirmAction) for destructive flows—passmutateAsynclambdas so failures can emit toasts automatically (app/stores/confirm-delete.tsx,app/stores/confirm-action.ts). - Toasts live in the global store: call
addToastwith a string, node, or config and letToastStackhandle animation and dismissal (app/stores/toast.ts,app/components/ToastStack.tsx).
- Reach for primitives in
app/uibefore inventing page-specific widgets; that directory holds router-agnostic building blocks. - When you just need Tailwind classes on a DOM element, use the
classedhelper instead of creating one-off wrappers (app/util/classed.ts). - Define helper components at the module level, not inside other components' render functions—the
react/no-unstable-nested-componentseslint rule enforces this to prevent performance issues and broken component identity. Extract nested components to the top level and pass any needed values as props. - Reuse utility components for consistent formatting—
TimeAgo,EmptyMessage,CardBlock,DocsPopover,PropertiesTable, etc. - Import icons from
@oxide/design-system/icons/reactwith size suffixes:16for inline/table,24for headers/buttons,12for tiny indicators. - Keep help URLs in
links/docLinks(app/util/links.ts). - Prefer flexbox
gapfor spacing between inline elements over margin utilities likeml-*. - Use proper casing in badge and label source text even when CSS
text-transformchanges display, since screen readers and clipboard copy use the source. - Keep UI microcopy concise and imperative ("Manage resources" not "Can manage resources"); avoid semicolons.
- Don't use default prop values that force callers to pass empty strings to opt out; make props truly optional.
- All API errors flow through
processServerErrorinapp/api/errors.ts, which transforms raw errors into user-friendly messages. - On 401 errors, requests auto-redirect to
/login. On 403, the error boundary checks for IDP misconfiguration. - Throw
trigger404in loaders when resources don't exist; the error boundary will render Not Found.
- Check
app/util/*for string formatting, date handling, IP parsing, etc. Checktypes/util.d.tsfor type helpers. - Use
validateNamefor resource names,validateDescriptionfor descriptions,validateIp/validateIpNetfor IPs. - Role helpers live in
app/api/roles.ts. - Use ts-pattern exhaustive match when doing conditional logic on union types to make sure all arms are handled
- Avoid type casts (
as) where possible; prefer type-safe alternatives likesatisfies,.returnType<T>()for ts-pattern, oras const - Use
remeda(imported asR) for sorting and data transformations—e.g.,R.sortBy(items, (x) => x.key1, (x) => x.key2)instead of manual.sort()comparators. - Prefer small composable predicates (e.g.,
poolHasIpVersion(versions)) that chain with.filter()over monolithic filter functions with multiple optional parameters. - When using
!(non-null assertion), add a comment justifying why the value is guaranteed to exist. - When multiple boolean states control mutually exclusive UI, consolidate into a single discriminated union type (pairs with ts-pattern exhaustive matching).
- Use generated API types from
@oxide/apirather than redeclaring their shape as inline object types. - Add explicit type annotations on
.then/.catchcallbacks in generic API wrappers to preventanyfrom leaking. - Use
satisfiesto catch type errors masked byany-typed callbacks (e.g., react-hook-form'sonChange). The assertion costs nothing at runtime but catches mismatches at build time.