Frontend
The UI is a single-page React application in web/ that talks only to /api/v1/ with the session cookie. It is built by Vite into web/dist, copied to internal/web/dist and embedded into northplaned with go:embed, so the binary always ships the UI it was built with. The server renders only four pages itself — login, first-run setup, self-registration and the public status page — because those must work without JavaScript or before a session exists (internal/web/web.go). What each page does for users is documented in the User interface section; this page is about the code.
| Area | Packages |
|---|---|
| Build | vite 8, @vitejs/plugin-react 6, typescript 6 (~6.0), @tailwindcss/vite |
| Runtime | react 19, react-dom 19 |
| Routing and data | @tanstack/react-router, @tanstack/react-query 5, @tanstack/react-virtual (Objects list only) |
| Styling | Tailwind CSS v4 (index.css @theme), tw-animate-css, class-variance-authority, clsx + tailwind-merge (cn() in lib/utils.ts) |
| Components | radix-ui (monolithic package), shadcn/ui files generated into src/components/ui/ (style new-york, base colour neutral, CSS variables; see components.json), cmdk (command palette), lucide-react (icons) |
| Charts | uplot behind components/Chart.tsx, split into its own charts chunk |
| Validation | zod 4 (schemas.ts) |
| Markdown | react-markdown + remark-gfm (agent chat only) |
| Codegen | openapi-typescript 7 (dev) |
| Tests | vitest 4, jsdom, @testing-library/react, @testing-library/user-event, @testing-library/jest-dom, msw 2, @vitest/coverage-v8, @playwright/test |
| Lint | eslint 10, typescript-eslint, eslint-plugin-react-hooks 7 (React Compiler rules), eslint-plugin-react-refresh |
Declared but unused in src: next-themes and the sonner toaster wrapper (the app uses inline page-local banners, not toasts).
Project layout
Section titled “Project layout”web/├── index.html entry; lang="de" (Stept loads at runtime via main.tsx)├── public/ favicon.svg, sw.js (service-worker kill switch)├── e2e/ Playwright specs, global-setup/teardown, lib/roles.ts├── src/│ ├── main.tsx QueryClientProvider + TanStack router, lazy routes, 404/error components│ ├── api.ts fetch wrapper, APIError, resourceApi, queryClient, fmtTime/fmtAgo│ ├── types.ts curated domain types + state helpers (hand-written)│ ├── types.gen.ts generated from the OpenAPI spec (`make types`) — DO NOT EDIT│ ├── types.gen.conformance.ts compile-time guard: types.ts refines types.gen.ts│ ├── schemas.ts zod schemas for the riskiest payloads│ ├── i18n.ts `de` (reference) and `en` catalogs, t()│ ├── theme.ts / theme-data.ts / mode.ts / branding.ts / favicon.ts look & feel│ ├── settings.ts refresh interval (localStorage + /users/me/preferences)│ ├── tenant.ts active tenant (X-Northplane-Tenant)│ ├── permissions.ts permImplies / hasPermission│ ├── index.css Tailwind, tokens, 30 theme blocks × light/dark, a11y rules│ ├── pages/ one file per route (Overview, Problems, Objects, Alerts, …)│ ├── components/│ │ ├── ui/ shadcn primitives (do not hand-edit style)│ │ ├── kit.tsx the app component kit (Spinner, Empty, ErrorState, Field, …)│ │ ├── Layout.tsx, CommandPalette.tsx, TenantSwitcher.tsx, RefreshControl.tsx, AISidebar.tsx│ │ ├── admin/ agent/ alerting/ dash/ objects/ feature components│ ├── hooks/useSave.ts mutation wrapper (invalidate + onDone + error)│ ├── lib/ duration, humanize, perfdata, redact, uistream, utils│ └── test/ setup.ts, msw.ts, render.tsx├── vite.config.ts vitest.config.ts playwright.config.ts eslint.config.js├── tsconfig.json tsconfig.app.json tsconfig.node.json tsconfig.test.json└── components.json shadcn configuration (aliases @/components, @/lib, @/hooks)Conventions: pages in src/pages/*.tsx; feature components under src/components/{admin,agent,alerting,dash,objects}; generic primitives only from @/components/ui/*; app-level building blocks from @/components/kit. German copy in code comments is common; identifiers are English.
Routing and pages
Section titled “Routing and pages”main.tsx builds a TanStack router: a root route renders Layout with a Suspense spinner around Outlet; every page is lazy()-loaded so each route is its own chunk. Routes: / (Overview), /agent, /problems, /objects (+ /objects/$id), /alerts, /incidents, /events, /oncall, /alerting, /dashboards (+ /dashboards/$name), /reports, /business, /discovery, /maintenance, /templates, /admin. defaultNotFoundComponent renders a real 404; defaultErrorComponent renders the shared ErrorState with Retry.
Filters that should be linkable live in URL search params and are validated in the route (validateSearch): /objects takes selector, q, state, kind; /alerts takes status, severity. If wallboard is present in the search, Layout renders the page without the sidebar.
To add a page: create src/pages/Things.tsx exporting ThingsPage, add a lazy() import and a createRoute({ getParentRoute: () => rootRoute, path: '/things', component: ThingsPage }) entry in main.tsx, add a sidebar item to the nav list in components/Layout.tsx and the de/en labels in i18n.ts. The command palette’s static page entries in CommandPalette.tsx are a separate, English-only list.
API client (src/api.ts)
Section titled “API client (src/api.ts)”- Base URL is relative:
fetch('/api/v1' + path, { credentials: 'same-origin' })— the browser session cookienp_sessionauthenticates; the SPA never holds a bearer token. - Headers:
Content-Type: application/jsonwhen a body is present;If-Match: "<etag>"whenetagis given;X-Northplane-TenantfromactiveTenantId()(tenant.ts) when an admin has switched tenant. - 401 →
window.location.href = '/login'(hard redirect) and anAPIError(401, 'auth'). - Errors: RFC 9457 bodies become
APIError { status, code, message (title), detail }; non-JSON errors get codeunknown. 204/202 bodies resolve toundefined. - Helpers:
get,post,put(path, body, etag),del,getWithEtag(path, schema?)→{ data, etag }(parseEtagstripsW/and quotes),ListResponse<T> { items, nextCursor? }. resourceApi<T>(base, schema?)is the CRUD facade for named-resource endpoints:queryKey: ['resources', base],list()(?limit=500),get(name)(with ETag),create(doc),update(name, doc, etag)(If-Match),remove(name).queryClientdefaults:staleTime15 s,retry1,refetchOnWindowFocusfalse. Live lists poll: each passesuseRefreshInterval()(fromsettings.ts; presets 5/10/30/60 s or off, default 30 s, persisted inlocalStoragenp.refreshIntervaland server-side/users/me/preferences) as React Query’srefetchInterval, withplaceholderData: keepPreviousData. There is no SSE in the UI —lib/uistream.tsis the AI-chat stream client, not a live-data channel.- Mutations go through
useSave(fn, { invalidate, onDone })(hooks/useSave.ts);FormErrorrendersAPIErrortitle + detail; 409/412 are shown asKonflikt — bitte neu laden./ “Conflict — please reload”.DeleteButtonis a two-click inline confirm (no nativeconfirm()).
Typed codegen
Section titled “Typed codegen”The Go API is the single source of truth for wire shapes. make types runs northplaned openapi (no server needed), writes the spec to /tmp/np-openapi.json, copies it to docs/src/assets/openapi.json, and runs openapi-typescript into src/types.gen.ts (header: “Code generated by make types … DO NOT EDIT”), which exports paths, components['schemas'] and operations. CI’s types job runs make types-check and fails on any diff — see Testing.
src/types.ts stays hand-written on purpose: it is narrower than the wire DTOs (semantic unions such as Severity, AlertStatus, Kind, StateType, numeric state enums) and omits transport-only fields, plus helpers stateLabel/stateIcon/stateColor/sevColor/eventBadge, svcStates, hostStates. src/types.gen.conformance.ts asserts at compile time (ConformsDeep) that Alert, Incident, CheckState, NPObject (vs ObjectView) and Overview refine their generated counterparts: every field the frontend declares must exist on the DTO with a compatible type; omitting wire-only fields is fine, null and undefined are interchangeable. Because that file is part of tsc -b (not a test), backend drift fails npm run build and CI. types.gen.test.ts gives it a runtime touchpoint for coverage.
Workflow after a backend change: make types → fix types.ts/consumers until npm run build is green → commit types.gen.ts and the docs’ openapi.json together with the Go change.
Runtime validation (zod)
Section titled “Runtime validation (zod)”src/schemas.ts holds zod schemas for the payloads most likely to come back malformed: dashboardWidgetSchema/dashboardDocSchema (the dashboard spec is frontend-owned JSON), plus alert, npObject, overview and incident schemas, each cross-checked against types.ts with Expect<Equal<…>> type tests. api() and getWithEtag() accept an optional schema; a failed parse becomes a 502-shaped APIError('invalid_response') that the normal ErrorState path renders instead of a crash deep in a component. resourceApi('dashboards', dashboardDocSchema) validates the single-document read; lists and writes stay on the bare cast.
- One typed catalog in
src/i18n.ts, no i18next:const de = { … } as constis the reference language,const en: Record<keyof typeof de, string>the fallback. TheRecordtype makes a missing English key a compile error. - Language is chosen once at module load:
navigator.language.startsWith('de') ? de : en— no user preference, no switcher, no server setting.index.htmldeclareslang="de"; the server-rendered pages are German only. - Use
t('key')(TKey = keyof typeof de). Adding a string = add the key todeanden. A few labels are intentionally literal (command palette page names, “Logout”, “Raw JSON”, “ICS”). - Dates:
Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' })in the browser locale (fmtTime); ages viafmtAgo(12s,5m,2h 3m,1d 4h). - The e2e suite is pinned to
de-DE; Vitest sees jsdom’sen-US— see Testing.
Theming, mode, branding, favicon
Section titled “Theming, mode, branding, favicon”Two independent axes:
| Axis | Module | DOM | Values |
|---|---|---|---|
| colour theme | theme.ts (+ theme-data.ts registry) |
<html data-theme="…"> |
31 ids: northplane (the built-in :root palette — selecting it clears the attribute) plus 30 generated themes (obsidianFire, currentRed, warmAmber, deepTeal, …); default for a fresh user is obsidianFire |
| light/dark mode | mode.ts |
<html class="light"> |
system / light / dark; default dark; system follows prefers-color-scheme live |
Tokens are the shadcn set (--background … --ring) plus --success, --warning, --danger, --chart-1..5, --sidebar*, exposed through @theme inline in index.css. Each generated theme has two CSS blocks — :root[data-theme="id"] (dark) and :root.light[data-theme="id"] (light) — and Tailwind’s dark: variant is bound to :root:not(.light). Both side-effect modules are imported first in main.tsx so the attribute and class are set before first paint.
Persistence: localStorage keys np.theme and np.mode (instant boot, cross-tab storage sync) and the instance-wide document GET/PUT /api/v1/branding {theme, mode} owned by branding.ts (the only writer). The branding is adopted once on shell mount; writing needs config:write (a 403 is swallowed); branding is per instance, not per user or tenant. favicon.ts repaints the radar glyph as a data-URI SVG in the live --sidebar-primary colour on every theme/mode change; public/favicon.svg (tinted #FF5C3A) serves the server-rendered pages. Operator view: Branding and themes.
Permissions (src/permissions.ts)
Section titled “Permissions (src/permissions.ts)”A port of the backend’s model.Permission.Implies: permImplies(have, want) is true for an exact match, *, *:*, or resource/action wildcards (admin:*, *:read); hasPermission(perms, want) folds over whoami.permissions. The UI uses it to hide controls — the tenant switcher (admin:tenants), the Appearance controls (config:write) — never to authorise anything; the API enforces. Permission names and roles: Users, roles and permissions.
Component kit
Section titled “Component kit”src/components/kit.tsx is the shared vocabulary: Spinner, Empty, ErrorState (icon + “Laden fehlgeschlagen.” + detail + Retry), Tile, LabelChips, Field (label/hint/required), DurationInput (validates Go durations via lib/duration.ts), KVEditor, ListEditor (with suggestions), FormError, SubmitRow, DeleteButton; it re-exports useSave and isDuration. Other shared pieces: DualListPicker (two-pane transfer list), MultiSelect (chips + typeahead), dash/pickers.tsx (ObjectPicker = GET /objects?q=&limit=50, MetricPicker = GET /objects/{id}/metrics), alerting/common.tsx (DateTimeInput, ChannelPicker, SeverityField, ToggleRow), admin/common.tsx (StatusBadge, TypeBadge, TableActions, RowActions). Libraries under lib/: humanize (SNMP sysUpTime), perfdata (Nagios perfdata parser), redact (masks token-like values ••• in effective config), duration, utils (cn).
Two quirks worth knowing: Radix Select cannot hold an empty-string value, so sentinels (__all__, __none__, __both__, __home__, __root__, __default__) are mapped back to empty/undefined at the edges; and accessibility rules are enforced in code — a single :focus-visible outline, status badges never colour-only, aria-label on icon buttons, prefers-reduced-motion disables overlay animations.
ESLint and TypeScript
Section titled “ESLint and TypeScript”eslint.config.js (flat config): js.recommended, tseslint.recommended, react-hooks flat recommended (incl. React Compiler rules), react-refresh Vite preset, browser globals; react-refresh/only-export-components is an error with allowConstantExport, switched off for src/components/ui/** (shadcn exports cva variants), src/components/kit.tsx (re-exports helpers), src/main.tsx and tests; react-hooks/incompatible-library is off for src/pages/Objects.tsx (TanStack Virtual); test files get Vitest + Node globals; dist and coverage are ignored. Run with npm run lint.
tsconfig.app.json: target/lib ES2023 + DOM, strict, noUncheckedIndexedAccess, noUnusedLocals, noUnusedParameters, erasableSyntaxOnly, noFallthroughCasesInSwitch, moduleResolution: bundler, verbatimModuleSyntax, jsx: react-jsx, paths { "@/*": ["./src/*"] } (TypeScript 6: relative to the tsconfig, no baseUrl); it excludes *.test.ts(x) and src/test, which tsconfig.test.json covers with vitest/jest-dom/node types. tsconfig.node.json types vite.config.ts.
Building and embedding
Section titled “Building and embedding”cd web && npm run build # tsc -b && vite build → web/distmake web # the above + copy web/dist → internal/web/distmake build # go build embeds internal/web/distvite.config.ts: plugins react() and tailwindcss(); alias @ → ./src; optimizeDeps.include pre-bundles react, TanStack, lucide, uplot, zod (avoids mid-session re-optimisation in dev); build.target es2022; manualChunks puts anything containing uplot into charts and all other node_modules into vendor, so the output is per-page chunks + vendor + charts + index-*.js/css. The dev proxy is described under make dev.
On the Go side internal/web/web.go embeds dist/ with //go:embed all:dist and serves it as the catch-all: /assets/* with Cache-Control: public, max-age=31536000, immutable (hashed names), everything else no-cache, unknown paths fall back to index.html for client routing; GateSPA redirects unauthenticated document navigations (GET/HEAD with Accept: text/html, not under /assets/) to /login so the shell never flashes before the client’s own 401 redirect. If the embed is only the stub, the server answers 501 UI not embedded in this build — run make web before building.
The Stept widget and the CSP hash
Section titled “The Stept widget and the CSP hash”The SPA and the server-rendered login and register pages embed the Stept assistant (chat widget + product tours). The SPA injects it at runtime: main.tsx asks the anonymous /api/v1/system/info whether the assistant is enabled, then sets window.SteptSettings = { workspaceKey: "wk_…" } from bundled JS and appends the async loader <script> for https://app.stepped.ai/widget-assets/loader.js. The server-rendered pages keep an inline bootstrap snippet (steptSnippet in internal/web/web.go). Runtime switch: NORTHPLANE_DISABLE_ASSISTANT=true (config disableAssistant: true) turns the widget off everywhere without a rebuild.
The server’s Content-Security-Policy for non-/api/ paths allows exactly that: app.stepped.ai in img-src, script-src, connect-src (https + wss) and frame-src, plus the inline bootstrap script by SHA-256 hash, which keeps script-src free of 'unsafe-inline':
script-src 'self' https://app.stepped.ai 'sha256-HlAiISfjqhgIiTh24Wt2L3bd5wG1TYbHlnpS0PMuIA8='If you change a single byte of the inline script (for example a new workspace key), recompute the hash and update it in internal/server/server.go:
printf '%s' '<script-body>' | openssl dgst -sha256 -binary | openssl base64Disabling the widget is a config switch (see above). Removing it entirely means deleting the injection block in web/src/main.tsx, the steptSnippet constant and its uses in web.go, and the Stept origins + hash from the CSP, then make web. The documentation at /docs/ uses its own CSP without Stept (see Documentation); the full header set is in TLS and proxying.