Skip to content

Dashboard

The dashboard is a Next.js app (dashboard/ in the repository) that gives a human user everything the REST API exposes, without hand-crafting HTTP requests: organization switching, member/API-key/team management, SSO, SCIM provisioning, analyzer load/unload, custom rules, custom report templates, LLM credentials, quota, GitHub App integration, findings triage, and usage/cost charts.

It authenticates the same way as any other session client — email/password against POST /auth/session, or a completed SSO login (GET /auth/sso/callback) — both set the same httpOnly mcp_session cookie, no separate login system.


PageBacking endpoint(s)
Sign in / RegisterPOST /auth/session, POST /auth/register — both accept ?invite=<token> to join an org directly instead of creating/picking one. Register also reads GET /auth/registration-options and drops the “create an organization” choice (and its org-name field) when registration.allow_public_org_creation is off. When the operator requires terms (registration.terms_*), Register also shows a required checkbox linking to them, which gates both the form and the “Continue with …” buttons. Sign in also offers “Continue with SSO” (POST /auth/sso/discover → GET /auth/sso/{org_id}/start)
Accept invite (/invite/[token])GET /invite/{token} (public preview), POST /invite/{token}/accept, then POST /auth/session/switch onto the org just joined — accepting alone only creates the membership, it leaves the session cookie pointing wherever it already pointed
Onboarding (no org yet)POST /orgs, POST /auth/session/switch — shown instead of the normal dashboard shell when GET /auth/me reports org_id: null. Also reads GET /auth/registration-options: with public org creation off, the create-an-org form is replaced by a “use an invite link, or ask an administrator” note, leaving the invite route as the only way out. Also lists any invitations addressed to this account (GET /auth/me/invitations) — the one route out that needs nothing from the user
OverviewGET /orgs/{org_id}
AccountGET /auth/me, PATCH /auth/me (own name), POST /auth/me/password (own password, requires current password) — reachable regardless of org membership. Also lists every organization this account belongs to (GET /auth/me/organizations) with a self-service Leave action per row (POST /orgs/{org_id}/leave), and any invitations waiting for this email (GET /auth/me/invitations, POST /auth/me/invitations/{invite_id}/accept)
MembersGET/POST /orgs/{org_id}/members — each row also shows the member’s team names and, when set, a “scim”/“sso”/“invite” provenance badge (provisioned_via — informational only); admin/owner additionally get inline role change, org-scoped active/inactive toggle, one-time password reset, and remove (PATCH/DELETE /orgs/{org_id}/members/{user_id}, POST .../reset-password)
Teams (/teams, /teams/[team_id])GET/POST /orgs/{org_id}/teams, GET/PATCH/DELETE /orgs/{org_id}/teams/{team_id}, GET/POST /orgs/{org_id}/teams/{team_id}/members, DELETE .../members/{user_id} — pure grouping, no access-control effect
InvitationsGET/POST /orgs/{org_id}/invites, DELETE /orgs/{org_id}/invites/{invite_id} — an invitation can be left open (a link anyone holding it can redeem) or addressed to one email, in which case only that account can accept it and it appears on their Account page without the link having to be delivered
Service AccountsGET/POST /auth/keys, DELETE /auth/keys/{key_id} — name + optional IP source filter (ip_allowlist) per key
SSO (page has two labeled sections: “Single sign-on” and “SCIM provisioning”)GET/POST/PATCH/DELETE /orgs/{org_id}/sso
SSO page’s “SCIM provisioning” sectionGET/POST/PATCH/DELETE /orgs/{org_id}/scim, POST .../rotate-token — tenant URL, bearer-token reveal-once, default role, ordered group-name-regex-to-role mappings
AnalyzersGET/POST/DELETE /orgs/{org_id}/analyzers/{name}
Analyzer ruleset preview (/analyzers/[analyzer]/rules)GET /{analyzer}/rules (read-only; links out to Rules for editing)
Rules (paginated, 15/page)GET/POST /orgs/{org_id}/rules, GET/PATCH/DELETE /orgs/{org_id}/rules/{rule_id}
Rule CategoriesGET/POST /orgs/{org_id}/rule-categories, GET/PATCH/DELETE /orgs/{org_id}/rule-categories/{category_id}, POST .../seed-defaults
Report TemplatesGET/POST /orgs/{org_id}/report-templates, GET/PATCH/DELETE /orgs/{org_id}/report-templates/{template_id}, POST .../seed-builtin, POST .../preview
LLM CredentialsGET/POST/DELETE /orgs/{org_id}/llm-credentials
QuotaPATCH /orgs/{org_id}/quota, GET /orgs/{org_id}/usage
UsageGET /orgs/{org_id}/usage, GET /metrics/summary, GET /metrics/usage
Audit LogGET /metrics/audit, GET /metrics/audit/export — filterable by action/actor/target/date range, CSV/JSON export
GitHub IntegrationGET /models (model picker), GET/POST/DELETE /orgs/{org_id}/integrations/github, POST /orgs/{org_id}/integrations/github/manifest, DELETE /orgs/{org_id}/integrations/github/app
NotificationsGET/POST/PATCH/DELETE /orgs/{org_id}/notifications/channels + POST .../test — N channels per org (Slack or raw webhook), per-channel event subscriptions built from GET /notifications/events, per-channel test send and last-delivery status. Destination URLs are write-only and shown only as a redacted hint
Needs-Review WebhookGET/PUT/DELETE /orgs/{org_id}/integrations/review-webhook — URL, optional HMAC signing secret (write-only, never redisplayed), enabled toggle. Superseded by Notifications; still supported
FindingsGET /findings, GET /findings/summary, PATCH /findings/{hash}/{rule_id} — status now includes needs_review, both auto-set (low LLM confidence, or a rule marked requires_review) and settable manually from this page’s status dropdown
Resources / ReportGET /namespaces (namespace picker), GET /{namespace}/dump, embeds the public HTML report endpoint
AnalyzeGET /models (model picker, falls back to free text if the catalog is empty), POST /{analyzer}/{namespace}/analyze (a “Variant” picker, sourced from the selected analyzer’s rule_types/default_rule_type, is shown when it has more than one and folds the choice into the request’s rule_type), GET /jobs/{job_id}. The Analyzer picker defaults to Auto-detect, which posts to POST /{namespace}/analyze/auto instead (no Variant picker — auto mode always uses each matched analyzer’s default) and renders the response’s detections as a small file → analyzer/skipped-reason table above the report
Jobs (paginated, 15/page)GET /jobs — every job for the org, from the Analyze page and the GitHub webhook. A job whose analyzer is auto (any /analyze/auto run, or a GitHub webhook run) shows an “Auto” badge with the resolved analyzers_used. GET /jobs/{job_id} additionally fetches a ?job_id= link target as a pinned card; any row (pinned or not) expands in place into full status, live progress, and links to that job’s Findings/Report — see Linking the workflow together
Platform Admin → OrganizationsGET /admin/organizations (incl. an SSO enabled/not-configured badge per org), GET /admin/organizations/{org_id} (adds the full SSO connection when one exists), POST /admin/users/{id}/superadmin. Its detail page’s Members card is the superadmin drill-down — same role/active/reset-password/remove actions as the org-scoped Members page (GET/PATCH/DELETE /admin/organizations/{org_id}/members[/{user_id}], POST .../reset-password), reachable without being a member of that org, plus a per-row “Edit account”/“Reset password” pair for the whole-account fields (PATCH /admin/users/{user_id}, POST /admin/users/{user_id}/reset-password) — kept in a visually separate column since it affects every org that user belongs to, not just this one
Platform Admin → Models (9 defaults seeded at startup)GET/POST /admin/models, PATCH/DELETE /admin/models/{llm_backend}/{model}
Platform Admin → SettingsGET/PATCH /admin/settings (hardcoded-default fields the config file has no say in), GET /admin/storage, POST /admin/storage/prepare-mongodb, POST /admin/storage/switch (live backend cutover)
Platform Admin → UsageGET /admin/usage
Platform Admin → Audit LogGET /admin/metrics/summary (cross-org rollup), GET /admin/audit/GET /admin/organizations/{org_id}/audit (org picker, default “All organizations”), GET /admin/audit/export. The org detail page also shows an org’s 5 most recent audit events inline, linking through to this page pre-filtered

The Platform Admin section only appears in the sidebar when GET /auth/me reports is_superadmin: true — a permission axis independent of any org’s role. See Platform Admin for what it grants and how to bootstrap the first one.

Almost all of the above already existed in the REST API before the dashboard did — building it added no new business logic beyond three small session-introspection routes needed to bootstrap identity on page load and support switching between organizations:

RoutePurpose
GET /auth/meResolve the current session cookie into {user, org_id, role, is_superadmin}
GET /auth/me/organizationsList every org the signed-in user belongs to (org switcher)
POST /auth/session/switchRe-scope the session cookie to a different org the user is a member of
POST /orgsCreate an additional organization (switcher → Create organization), then switch into it. Hidden when registration.allow_public_org_creation is off, unless the user is a superadmin — GET /auth/registration-options

The one later exception is the GitHub Integration page’s “Your own GitHub App” tab, which drove new backend endpoints (the manifest flow) rather than just wrapping existing ones — an org admin can register a custom, org-owned GitHub App instead of the platform’s shared one, entirely from the dashboard: a “Create GitHub App” button submits a real top-level browser form to github.com (not a fetch call — GitHub needs to perform its own redirect back), and the App’s private key/webhook secret never pass through the browser at all.

The sidebar switcher lists memberships inline up to eight, which covers almost everyone. Past that it truncates — current organization always first, so truncation can never hide where you are — and offers Search all N organizations, a filterable picker with ↑↓/Enter navigation. The same picker opens from anywhere with ⌘⇧O / Ctrl+Shift+O.

The picker is a dialog rather than a search field inside the dropdown on purpose: Base UI menus own keyboard focus and run their own typeahead over item labels, so a text input inside one fights the menu for every keystroke.

Switching stays on the page you were already on, when that page means the same thing in the new organization — Members, Findings, Rules and every other entry in the sidebar. Anything else falls back to /overview, because a route carrying an id (/resources/{namespace}/report, /teams/{team_id}, /analyzers/{analyzer}/rules) names something that belongs to the organization you just left. Query strings are dropped for the same reason: ?namespace=, ?job_id= and friends are org-scoped ids even on a page whose path isn’t.

The set of preservable routes is derived from the composed sidebar nav rather than hand-listed, so a page added to the sidebar becomes switch-preserving with no second list to update.

The org a request resolves to comes from the mcp_session cookie, and a cookie belongs to the browser rather than to one tab. Switching organizations therefore re-scopes every open tab, not just the one the switcher was clicked in — a second tab left on another org keeps displaying what it already rendered while every action taken in it now applies somewhere else.

Rather than let that stay silent, (dashboard)/layout.tsx wraps the whole shell in an OrgContextGuard. The switching tab announces the new org on a BroadcastChannel, and every other tab re-checks GET /auth/me whenever it regains focus (which also catches a switch made from another browser or device, and browsers without BroadcastChannel). On a mismatch the stale tab raises a dialog offering either “switch back” (POST /auth/session/switch onto the org that tab was showing) or “continue” (land on /overview under the session’s current org).

The announcement carries the id of the tab that sent it, and a tab ignores its own — a BroadcastChannel message reaches every other channel object with that name, including the ones in the sending tab, so without that tag the switching tab warns itself about its own switch. The dialog is also driven by comparing the session’s org against the org this tab last rendered on every render, not by a flag latched when the mismatch was first noticed: the tab’s own org changes under it as router.refresh() lands, and the dialog has to close itself when it does.

Two tabs genuinely live on two different organizations is not supported — that needs the org carried per request instead of per browser, which is a change to the auth path and to every route, not to this component.

The other later exception is org-less accounts + invitation links (see Invitations): POST /auth/register no longer requires org_name, so a signed-in user can have org_id: null. (dashboard)/layout.tsx redirects that state to /onboarding (outside the authenticated shell, alongside /login//register) before rendering the sidebar at all — every other page under (dashboard)/ fetches identity via getMeWithOrg() rather than getMe() directly, which performs the same redirect, so no individual page has to guard against a null org_id itself.


On a visitor’s first visit, every page shows a cookie banner in the bottom corner, whether the visitor is signed in or not. It has Refuse all and Accept all, which carry equal weight, and Customize, which opens a per-category dialog. The choice is stored in a cookie_consent cookie for six months, a refusal included, and then the banner asks again. The same dialog can be reopened at any time from Cookie settings in the sidebar’s user menu, from the site footer, or from /cookies.

/cookies is a public page that lists everything OpenTremor stores in the browser, with each item’s purpose and lifetime:

NamePurposeLifetime
mcp_sessionSign-in session and active organizationUntil sign-out, or session.access_ttl_minutes (8 h default)
oauth_state / sso_stateRequest-forgery protection during a social or SSO login10 minutes
cookie_consentThe visitor’s consent choice6 months
sidebar_stateSidebar open or collapsed7 days
theme (localStorage)Light or dark themeUntil cleared

All of these are strictly necessary, and exempt from consent under the EU ePrivacy rules. The only optional category, Analytics, gates nothing out of the box. It exists so that an optional tracker can be added without a separate consent mechanism. If you add one to a fork, render it inside <ConsentGate category="analytics"> from @opentremor/platform-ui/components/cookie-consent, and list any new cookie in COOKIE_INVENTORY (@opentremor/platform-ui/lib/cookie-inventory) so it appears on /cookies. Anything you add in front of the dashboard, such as a load balancer’s sticky-session cookie or an analytics tag injected at the ingress, bypasses the banner, and you are responsible for consent to it.


Terminal window
cd dashboard
cp .env.example .env.local # point API_BASE / NEXT_PUBLIC_API_BASE at your running API
npm install
npm run dev

No reverse proxy is needed in local dev even though the dashboard and API run on different ports — the API’s default CORS configuration reflects the request Origin whenever allow_credentials=True (Starlette behavior, not a "*" literal), so the httpOnly session cookie round-trips correctly across ports.

The Resources/Report page does not re-implement report rendering — it fetches the rendered HTML server-side via the authenticated GET /{namespace}/report?format=html endpoint and embeds it with <iframe srcDoc>, so report styling and content stay in one place (views/formatter.py / report_service.py), and no public link needs to be minted just to view your own org’s report while already signed in. A template picker next to the page title lets you switch between the built-in HTML template and any of your org’s custom HTML templates (GET /orgs/{org_id}/report-templates?format=html) — selecting one appends &template_id= to the same server-side fetch, so this stays a thin, query-param-driven addition rather than a client-side re-fetch of report data.

The Usage page’s “Requests” stat (and the “Requests by API key” chart below it) only reflect API-key-authenticated traffic — see Endpoints — Metrics for why: a session-cookie request (i.e. this very page being loaded) doesn’t count, so reloading it never inflates its own numbers. “Resources analyzed” reflects ingest spans from either ingestion path (POST .../ingest or the Analyze page’s POST .../analyze), which share one implementation for exactly this reason.

Each row on the Findings page has a chevron toggle (components/finding-detail-panel.tsx) that expands, on demand, into the resource block, the LLM’s evidence/risk/next-step for that specific finding, and the rule that matched — none of which is on FindingRecord itself (a deliberately flattened triage summary), so the panel fetches GET /resource/{hash} and GET /orgs/{org_id}/rules/{rule_id} the first time a row is expanded and keeps the result for the rest of the page’s lifetime. The expanded panel also shows a “View report” link per namespace in FindingRecord.namespaces (a finding can appear in more than one), each linking straight to that namespace’s Resources/Report page.

The Rules page’s table (custom-rules-manager.tsx) paginates client-side, 15 rows at a time, applied after search/source/category/severity/analyzer/enabled filtering — GET /orgs/{org_id}/rules itself returns the full unpaginated list, so this is purely a dashboard-side slice with Prev/Next controls, no new query params. Changing any filter resets back to page 1. The superadmin cross-org table on /admin/rules (platform-rules-manager.tsx) is not paginated.

Both tables show a “Needs review” badge next to a rule’s title when its requires_review flag is set; only the org-scoped Rules page’s create/edit form exposes the “Always needs human review” toggle that sets it — the platform-admin view is toggle/delete-only for enabled, same as before this field existed.

The Report Templates page itself (report-templates-manager.tsx) mirrors the Rules page’s structure — a table of an org’s templates (name, built-in/custom origin, a Switch for is_default) plus a create/edit form with a plain Textarea for the Jinja2 source (no code-editor dependency in this project, same choice already made for custom rules’ Markdown description) and a “Preview” button. Preview (report-template-preview.tsx) opens a dialog, takes a namespace, and calls POST /orgs/{org_id}/report-templates/preview to render the current in-progress form state (not yet saved) against that namespace’s real analysed data — an HTML result renders in an <iframe srcDoc>, markdown results in a <pre> block.

Rows are grouped under a header row per format rather than listed flat with a format badge, because is_default is scoped to (org, format) — up to three switches are on at once (one per format) and that only reads as correct when same-format rows sit together. Toggling one on optimistically clears its same-format sibling, matching what clear_other_defaults does server-side; toggling the last one off is allowed and falls back to the built-in template for that format.

A “Get shareable link” button on that page mints a real public link on demand — a high-entropy GET /reports/{token} URL, time-limited by the org’s report_link_ttl_hours (default 24h, see Endpoints — Report links). Use this when you actually want to hand the report to someone outside the dashboard; don’t reuse the iframe’s content for that, since it isn’t a shareable URL.

Both the Analyze page’s job poller and the Jobs page render a 4-stage stepper (Queued → Ingest & parse → Analyze resources → Report ready/Failed), derived purely from a job’s status plus two counters — processed_count/total_count — that run_analysis() updates via an optional progress_cb after each unit is analysed (only wired for the async job path; the sync path answers within one request and has no use for it). A failed job maps onto whichever stage the counters say it reached, since the job model doesn’t record which stage actually threw. lib/analysis-steps.ts derives the step list once; analysis-stepper.tsx renders it full-size (Analyze page) or as a compact icon row with a text summary (Jobs table).

The Jobs page (/jobs, jobs-table.tsx) lists every job for the org — from the Analyze page as well as the GitHub webhook — via a new GET /jobs endpoint (status filter, paginated 15/page), auto-polling every 3s while any visible row is queued/running. It’s the only place to see webhook-triggered jobs; the Analyze page’s poller only knows about the one job it just launched.

The Analyze page’s job poller (job-status-poller.tsx) links out to /jobs?job_id=<id> as soon as a job is created, not just once it’s done. The Jobs page reads that job_id query param server-side, fetches the job directly via GET /jobs/{job_id} independent of the current status filter/page, and renders it as a pinned, pre-expanded “Linked job” card above the filtered table — so the link always resolves to something visible.

Every row in the Jobs table (pinned or not) can be expanded in place, mirroring the Findings table’s chevron-toggle pattern, via a shared job-detail-panel.tsx component. It needs no extra fetch — everything it renders already lives on the polled JobStatus object — and shows the full-size stepper, a processed/total progress bar, current_resource (the resource most recently picked up by the analysis loop; since resources are processed strictly one at a time, this doubles as “what’s running right now” while the job is running), the auto-detection breakdown, and two links: “View findings” (/findings?namespace=<job.namespace>, which the Findings page’s namespace filter already reads as its default) always, and “View report” once the job is done.

It also carries the two lifecycle actions. Cancel shows on a queued or running job that has a background run to stop — not on a mode: "sync" one, which lives entirely inside the request that started it. Retry/Resume shows on any job that stopped with work left: failed (labelled “Retry”), cancelled, or done with quota_exceeded — the last two labelled “Resume”, since nothing went wrong with either and they are being picked back up rather than re-attempted. A budget-capped job also gets a line of explanation, so a job reading done that still offers a Resume button says why. Both buttons mirror their endpoint’s own preconditions rather than inventing their own, so a visible button is one the API will accept.

Every run appears here, inline ones included: a run small enough to answer within its own request (server.sync_analysis_max_units, default 5) is recorded as a mode: "sync" job, and the Analyze page links to it — from the report on success, and from a callout on failure, which is the case that needed it. Before that, a small analysis that failed left only a toast.

Below the auto-detection breakdown it also renders any agent-injection signals the job found (injection-detections-panel.tsx), severity-coloured, each with the file and line — or the channel, for a signal in a pull request’s title, description or a commit message, which belong to no file. Evidence is shown pre-formatted and never collapsed: for these signals it is often the exact whitespace, or invisible characters rendered as <U+200B> escapes, and normalising it away would hide the thing being reported. The same signals appear on the Findings page like any other finding — this panel exists so that a reviewer looking at why a job held a pull request sees the reason without leaving the job.

See Deployment for how the dashboard is built and served alongside the API in Docker Compose.