Skip to content

Latest commit

 

History

History
145 lines (111 loc) · 17.5 KB

File metadata and controls

145 lines (111 loc) · 17.5 KB

Principles & setup

  • The console is a thin client over the Oxide API. Minimize client-only state, surface API concepts directly, 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/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):

    npm install
    npm run dev

React & TypeScript conventions

  • Use useEffect as a last resort. Try to find a non-effect version first; see https://react.dev/learn/you-might-not-need-an-effect.md for the hard cases.
  • Don't reach for useMemo for simple ternary/conditional logic; reserve it for genuinely expensive computation or when referential identity matters for downstream deps. useMemo with an empty dependency array is a sign the value belongs at module scope.
  • Define helper components at the module level, not inside other components' render functions. Nested definitions get a new identity every render, which breaks state and hurts performance. This is a convention, not a lint rule. Extract nested components to the top level and pass any needed values as props.
  • Use ts-pattern exhaustive match when doing conditional logic on union types to make sure all arms are handled.
  • When multiple boolean states control mutually exclusive UI, consolidate into a single discriminated union type (pairs with ts-pattern exhaustive matching).
  • Avoid type casts (as) where possible; prefer type-safe alternatives like satisfies, .returnType<T>() for ts-pattern, or as const.
  • Use satisfies to catch type errors masked by any-typed callbacks (e.g., react-hook-form's onChange). The assertion costs nothing at runtime but catches mismatches at build time.
  • Add explicit type annotations on .then/.catch callbacks in generic API wrappers to prevent any from leaking.
  • When using ! (non-null assertion), add a comment justifying why the value is guaranteed to exist.
  • Use generated API types from @oxide/api rather than redeclaring their shape as inline object types.
  • Use remeda (imported as R) 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.

Comment style

  • 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)

API utilities & constants

  • API constants and business rules live in app/api/util.ts (and friends). Treat it 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.

Testing code

  • Before sending a PR, run npm run lint, npm run tsc, and npm test run. For e2e, run only the specs your change touches, filtered by file and test name like npm run e2ec -- instance -g 'boot disk'. CI runs the full e2e suite.
  • Keep Playwright specs focused on user-visible behavior—use accessible locators (getByRole, getByLabel), the helpers in test/e2e/utils.ts (expectToast, expectRowVisible, selectOption, clickRowAction), and close toasts so follow-on assertions aren't blocked. Avoid Playwright's legacy string selector syntax like page.click('role=button[name="..."]'); prefer page.getByRole('button', { name: '...' }).click() and friends. Avoid getByTestId in e2e tests—prefer scoping with accessible locators like page.getByRole('dialog') when possible. Treat expectVisible/expectNotVisible as deprecated: use expect().toBeVisible()/toBeHidden() in new code.
  • Cover role-gated flows by logging in with getPageAsUser; exercise negative paths (e.g., forbidden actions) alongside happy paths as shown in test/e2e/system-update.e2e.ts.
  • 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. Plain .spec.ts files run in node with no DOM, so keep them to pure logic. Anything that renders a component or touches a browser API goes in a .browser.spec.tsx file, which runs in real browsers via Vitest Browser Mode — use vitest-browser-react's async render/renderHook (app/ui/lib/FileInput.browser.spec.tsx, app/hooks/use-pagination.browser.spec.ts).
  • Treat Vitest browser specs as small e2e tests: query by accessible role, label, or visible text and use retrying browser matchers. Avoid selectors coupled to CSS classes or internal DOM structure; inspect layout or computed styles only when the behavior has no semantic representation.
  • For sweeping styling changes, coordinate with the visual regression harness and follow test/visual/README.md for the workflow.
  • Fix root causes of flaky timing rather than adding sleep() workarounds in tests.
  • When an intermittent browser or E2E failure does not reproduce under normal repetition, retry under controlled CPU contention before concluding it is CI-only. Keep the load bounded so the browser and test runner can still make progress, enable failure-only traces when available, and use a shell trap to clean up every churn process.
  • Local Playwright runs write a compact plain-text report to .e2e-logs/ (gitignored, one timestamped .log per run, last 10 kept) via the custom reporter at test/e2e/compact-reporter.ts. Top line is status: ... total=N passed=N ...; each failure is a ── UNEXPECTED|FLAKY file:line title block followed by the error (ANSI stripped). Latest run: ls .e2e-logs | tail -1 — Read it directly, no parsing needed.

Data fetching pattern

  • usePrefetchedQuery requires that the loader fetched and awaited the same query — the hook throws if the data isn't in the cache. Because of that guarantee, do not add if (!data) return guards on its results. If the loader's fetch is conditional or not awaited, the guarantee doesn't hold: use useQuery with a loading fallback instead.
  • Define queries with q(api.endpoint, params) for single items or getListQFn(api.listEndpoint, params) for lists. Prefetch in clientLoader and read with usePrefetchedQuery; for on-demand fetches (modals, secondary data), use useQuery directly.
  • Use ALL_ISH from app/util/consts.ts when UI needs "all" items. After a mutation, invalidate with queryClient.invalidateEndpoint or seed the cache with setQueryData (from useApiQueryClient).
  • When a loader needs per-item data for a list, await the list with queryClient.fetchQuery, then kick off prefetchQuery for each item without awaiting, so render isn't blocked. Read the per-item queries with useQuery and a skeleton fallback — they may not have resolved by first render (see app/pages/project/affinity/AffinityPage.tsx).
  • When modals need async data, fetch with queryClient.ensureQueryData before opening the modal so cached data is reused and there's no content pop-in.
  • Use qErrorsAllowed in loaders for endpoints where some users may lack permission, so the page degrades gracefully instead of the loader throwing (see SiloScimTab.tsx).

Mutations & UI flow

  • Wrap writes in useApiMutation and surface results with addToast (app/stores/toast.ts). Guard destructive flows with the zustand confirm helpers confirmDelete/confirmAction (app/stores/confirm-delete.tsx, app/stores/confirm-action.ts), passing a mutateAsync lambda so the modal can catch failures and toast them.
  • 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 && <Modal/>}) — 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).
  • Gate per-resource actions with capability helpers: instanceCan.start(instance), diskCan.delete(disk), etc. (app/api/util.ts)—these return booleans and have .states properties 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 disabledReason over hiding them so users can discover the action exists. Compute disabledReason as a string | undefined ternary chain and derive disabled from !!disabledReason.
  • When closing a modal that uses useApiMutation, call mutation.reset() in the dismiss handler to clear stale error state so it doesn't persist on next open.

Upgrading pinned omicron version

  • Follow docs/update-pinned-api.md.

Mock API work

  • 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 in systemUpdateStatus).
  • All UUIDs in mock-api/ must be valid RFC 4122 (a safety test enforces this). Use uuidgen to generate them—do not hand-write UUIDs.
  • To test error paths in e2e tests, do not use page.route to intercept API calls. Instead, add a sentinel (a well-known name, id, or fixture value) to the mock handler that makes it return the desired error, and drive the test through the real UI. Branch on an input the user controls (e.g., if (body.name === '<sentinel>') throw 500) or, if no such input is available, add a sentinel fixture that is otherwise inert. Keeping the mock backend authoritative means the failure path is reproducible in the dev server too, not only from tests.
  • MSW starts fresh with a new db on every page load, so in E2E tests, use client-side navigation (click links/breadcrumbs) after mutations instead of page.goto to preserve db state within a test.

Routing

  • Add routes in app/routes.tsx, using lazy(() => import(...).then(convert)) so loaders become clientLoader and components stay tree-shakeable.
  • Export navigation helpers via pb in app/util/path-builder.ts; every new route should get a path-builder entry and appear in app/util/path-builder.spec.ts's snapshot.
  • Breadcrumbs come from route handle.crumb; use makeCrumb/titleCrumb and provide a path when the parent route redirects (app/hooks/use-crumbs.ts). Use titleCrumb for side modal forms that should appear in page title but not nav breadcrumbs (check Crumb.titleOnly flag).
  • 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 the URL, use QueryParamTabs (app/components/QueryParamTabs.tsx).

Forms

  • Forms live under app/forms; start by copying a nearby example such as app/forms/project-create.tsx.
  • Use react-hook-form with the shared shells (SideModalForm, ModalForm, FullPageForm) so UX and submit handling stay consistent (app/components/form/SideModalForm.tsx).
  • Wire submissions through useApiMutation and surface success with toasts/navigation (app/forms/project-create.tsx).
  • 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 onSubmit handler. This keeps fields, validation, and conditional logic straightforward.
  • Use react-hook-form's watch and conditional rendering to keep fields in sync. Avoid useEffect to 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 in useForm({ defaultValues }) rather than using useEffect + setValue.
  • Never access react-hook-form internals like control._formValues; use useWatch or 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 invariant for states that form validation should have prevented—crashing the app is worse than a silent noop for an edge case no user can reach.

Tables & detail views

  • Use the shared Columns helpers in app/table/columns/common.tsx for common columns like ID, description, size, and timestamps.
  • Compose row actions with useColsWithActions; prime modals by seeding list data into the cache (e.g., queryClient.setQueryData) so edits open immediately (app/pages/ProjectsPage.tsx).
  • getActionsCol automatically includes "Copy ID" if row has id field, and actions labeled "delete" get destructive styling. Pass disabled prop with ReactNode for tooltip explaining why action is unavailable (app/table/columns/action-col.tsx).
  • For paginated tables, pass a getListQFn query to useQueryTable (app/table/QueryTable.tsx) instead of wiring up TanStack Table and pagination yourself (see app/pages/ProjectsPage.tsx).
  • Use the PropertiesTable compound component for detail views (app/ui/lib/PropertiesTable.tsx).
  • Hoist static column definitions to module scope.

Layout & accessibility

  • Build pages inside the shared PageContainer/ContentPane (app/layouts/helpers.tsx).
  • Surface page-level buttons and pagination via the PageActions and Pagination tunnels from tunnel-rat; anything rendered through .In lands in .Target automatically.
  • For global loading states, reuse PageSkeleton—it keeps the MSW banner and grid layout stable, and skipPaths lets you opt-out for routes with custom layouts (app/components/PageSkeleton.tsx).
  • Enforce accessibility at the type level: use AriaLabel type from app/ui/util/aria.ts which requires exactly one of aria-label or aria-labelledby on custom interactive components.

Route params & loaders

  • Wrap useParams with 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.fetchQuery inside clientLoader blocks when the page needs data up front, and throw trigger404 on real misses so the error boundary renders Not Found.

UI components & styling

  • Reach for primitives in app/ui before inventing page-specific widgets; that directory holds router-agnostic building blocks.
  • When you just need Tailwind classes on a DOM element, use the classed helper instead of creating one-off wrappers (app/util/classed.ts).
  • Reuse utility components for consistent formatting—TimeAgo, EmptyMessage, CardBlock, DocsPopover, PropertiesTable, etc.
  • Import icons from @oxide/design-system/icons/react with size suffixes: 16 for inline/table, 24 for headers/buttons, 12 for tiny indicators.
  • Keep help URLs in links/docLinks (app/util/links.ts).
  • Prefer flexbox gap for spacing between inline elements over margin utilities like ml-*.
  • Use proper casing in badge and label source text even when CSS text-transform changes 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.

Error handling

  • Don't format API errors by hand. They already pass through processServerError (app/api/errors.ts), and 401/403 handling lives in the client and error boundary.

Utilities & helpers

  • Check app/util/* for string formatting, date handling, IP parsing, etc. Check types/util.d.ts for type helpers.
  • Use validateName for resource names, validateDescription for descriptions, validateIp/validateIpNet for IPs.
  • Role helpers live in app/api/roles.ts.