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. Sign in also offers “Continue with SSO” (POST /auth/sso/discoverGET /auth/sso/{org_id}/start)
Accept invite (/invite/[token])GET /invite/{token} (public preview), POST /invite/{token}/accept
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
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
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}
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
Needs-Review WebhookGET/PUT/DELETE /orgs/{org_id}/integrations/review-webhook — URL, optional HMAC signing secret (write-only, never redisplayed), enabled toggle
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

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 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.


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.

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