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.
| Page | Backing endpoint(s) |
|---|---|
| Sign in / Register | POST /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 |
| Overview | GET /orgs/{org_id} |
| Account | GET /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) |
| Members | GET/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 |
| Invitations | GET/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 Accounts | GET/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” section | GET/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 |
| Analyzers | GET/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 Categories | GET/POST /orgs/{org_id}/rule-categories, GET/PATCH/DELETE /orgs/{org_id}/rule-categories/{category_id}, POST .../seed-defaults |
| Report Templates | GET/POST /orgs/{org_id}/report-templates, GET/PATCH/DELETE /orgs/{org_id}/report-templates/{template_id}, POST .../seed-builtin, POST .../preview |
| LLM Credentials | GET/POST/DELETE /orgs/{org_id}/llm-credentials |
| Quota | PATCH /orgs/{org_id}/quota, GET /orgs/{org_id}/usage |
| Usage | GET /orgs/{org_id}/usage, GET /metrics/summary, GET /metrics/usage |
| Audit Log | GET /metrics/audit, GET /metrics/audit/export — filterable by action/actor/target/date range, CSV/JSON export |
| GitHub Integration | GET /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 |
| Notifications | GET/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 Webhook | GET/PUT/DELETE /orgs/{org_id}/integrations/review-webhook — URL, optional HMAC signing secret (write-only, never redisplayed), enabled toggle. Superseded by Notifications; still supported |
| Findings | GET /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 / Report | GET /namespaces (namespace picker), GET /{namespace}/dump, embeds the public HTML report endpoint |
| Analyze | GET /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 → Organizations | GET /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 → Settings | GET/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 → Usage | GET /admin/usage |
| Platform Admin → Audit Log | GET /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:
| Route | Purpose |
|---|---|
GET /auth/me | Resolve the current session cookie into {user, org_id, role, is_superadmin} |
GET /auth/me/organizations | List every org the signed-in user belongs to (org switcher) |
POST /auth/session/switch | Re-scope the session cookie to a different org the user is a member of |
POST /orgs | Create 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.
Switching between many organizations
Section titled “Switching between many organizations”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.
Where a switch lands you
Section titled “Where a switch lands you”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.
Organization is per session, not per tab
Section titled “Organization is per session, not per tab”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.
Cookies and consent
Section titled “Cookies and consent”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:
| Name | Purpose | Lifetime |
|---|---|---|
mcp_session | Sign-in session and active organization | Until sign-out, or session.access_ttl_minutes (8 h default) |
oauth_state / sso_state | Request-forgery protection during a social or SSO login | 10 minutes |
cookie_consent | The visitor’s consent choice | 6 months |
sidebar_state | Sidebar open or collapsed | 7 days |
theme (localStorage) | Light or dark theme | Until 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.
Running locally
Section titled “Running locally”cd dashboardcp .env.example .env.local # point API_BASE / NEXT_PUBLIC_API_BASE at your running APInpm installnpm run devNo 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.
Report viewing
Section titled “Report viewing”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.
Analysis workflow visualization
Section titled “Analysis workflow visualization”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.
Linking the workflow together
Section titled “Linking the workflow together”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.