Files
pragent/pilot/README-dashboard.md
T
2026-08-22 18:05:02 +00:00

13 KiB

pragent pilot — central dashboard service

A read-only overview + per-repo / per-PR drilldown over the same SQLite feedback DB the webhook writes, plus a small form to mutate .pr-review.json on a covered repo via the Gitea contents API. Companion to the webhook service; reuses the webhook image (pragent-webhook:dashboard) — the pilot modules are baked into /app/pilot/, and the dashboard is just python3 -m pilot.dashboard.

Architecture

Browser
   │
   ▼
Caddy (TLS, wildcard cert via Cloudflare DNS-01)
   │   https://pragent-dashboard.marcospaulo.dev.br → 100.74.17.70:31541
   ▼
Service oauth2-proxy-dashboard.pragent.svc.cluster.local  (NodePort 31541, ns pragent)
   │
   │   oauth2-proxy fronts the dashboard, enforces Logto SSO + email allowlist
   │   sets X-Forwarded-User / X-Forwarded-Email on accepted requests
   ▼
Service pragent-dashboard.pragent.svc.cluster.local  (ClusterIP, ns pragent)
   │
   ▼
pragent-dashboard pod  (uid 10001, /data RO, no subprocess fan-out)
   │
   ├── read /data/feedback.db           (PVC pragent-feedback-data, RO)
   ├── GET  .../repos/{o}/{r}/...        (Gitea contents API, bot token)
   └── PUT  .../repos/{o}/{r}/contents/.pr-review.json
            (edit form submit; Gitea commits a new sha)

Fail-soft. Nothing is ever written to local disk by the dashboard — the SQLite file is read-only and .pr-review.json mutations go through Gitea's contents API so the commit history records who changed what.

The dashboard Service is ClusterIP — only oauth2-proxy can reach it. Public access is gated by Caddy (TLS termination) → oauth2-proxy (Logto SSO

  • allowlist) → dashboard.

What it does

  • Overview (GET /): summary stats across all onboarded repos — total reviews, distinct PRs, finding counts by severity, false-positive / accepted-pattern scores (see "Feedback loop" in README-webhook.md), and a sparkline of review activity.
  • Repo drilldown (GET /r/<owner>/<name>): per-repo PRs with their last-review status, finding counts, and links to PR-level drilldowns.
  • PR drilldown (GET /r/<owner>/<name>/<index>): the bot's review(s) on that PR, inline findings, and reaction / resolved status harvested by feedback_harvest.py.
  • Raw review (GET /r/<owner>/<name>/<index>/raw): the markdown body of the most recent review, for copy-paste / diff-with-prose workflows.
  • Edit form (POST /r/<owner>/<name>/edit): a small HTML page that loads the current .pr-review.json from the repo's default branch and lets the operator edit the JSON (validated, then PUT to Gitea contents API). This is how repo-local focus / instructions / reviewers / severity_threshold get tuned per-repo after seeing the feedback roll-up.

All routes return HTML (or plain text for /raw) with the same stylesheet (/static/style.css).

Routes

method path auth description
GET / yes Overview
GET /static/style.css no Stylesheet
GET /r/<owner>/<name> yes Repo drilldown
GET /r/<owner>/<name>/<index> yes PR drilldown
GET /r/<owner>/<name>/<index>/raw yes Most recent review body as markdown
POST /r/<owner>/<name>/edit yes Edit .pr-review.json on the default branch

Auth is enforced by oauth2-proxy upstream; the dashboard itself only checks the X-Forwarded-User header that oauth2-proxy sets after a successful Logto login + email allowlist match.

There is no /health route — don't add one to the k8s probes without updating pilot/dashboard.py (the handler returns 404 on unknown paths, so a probe would loop forever).

Mutations flow through Gitea, not local fs

The edit endpoint reads the current .pr-review.json from GET /repos/{o}/{r}/contents/.pr-review.json?ref=<default-branch>, lets the operator edit it in a form (validated as JSON, length-capped per field, no schema migration), and PUTs the new content back via the contents API with a commit message like pragent dashboard: update .pr-review.json. Every edit is a real Gitea commit on the default branch, attributable to pragent-bot, and the next webhook fire picks up the new config — no Pod restart, no image rebuild, no pod-level state.

The /data mount is read-only (see the readOnly: true on the volumeMount in ~/k8s/pragent-dashboard.yaml): the dashboard never writes the SQLite file, only the webhook + the daily cronjob do, and keeping it RO means a buggy deploy can't corrupt the harvested feedback.

Auth (Logto SSO via oauth2-proxy)

Authentication is delegated to oauth2-proxy, which fronts the dashboard in-cluster. The dashboard never sees a cookie or a token — it only inspects X-Forwarded-User (set by oauth2-proxy after a successful Logto login + email allowlist match). Missing header → 401 with WWW-Authenticate: Basic realm="pragent-dashboard", which lets oauth2-proxy intercept and bounce the browser to Logto.

Email allowlist lives in the ConfigMap oauth2-proxy-dashboard-emails in namespace pragent:

data:
  authenticated-emails: |
    marcos.paulodasilva.mp@gmail.com
    thiago@marcospaulo.dev.br

Edit the ConfigMap to add/remove users; oauth2-proxy hot-reloads the file (it logs watching ... for updates), no restart needed. This is the same isolation pattern as the minecraft-sso / code-server allowlists — see ~/.claude/memory/minecraft-sso.md.

The Logto app is pragent-dashboard (tenant default, type Traditional), created by direct INSERT into Logto Postgres mirroring the proven minecraft-sso pattern. Credentials live in ~/k8s/oauth2-proxy-dashboard-secret.yaml (mode 600, NOT in git).

Public URL: https://pragent-dashboard.marcospaulo.dev.br (Caddy TLS termination via wildcard cert → Tailscale → NodePort 31541 → oauth2-proxy → dashboard ClusterIP).

If Logto goes down and you need to access the dashboard before the oauth2-proxy restart dance (see ~/.claude/memory/logto-fix.md), pilot/dashboard.py can be patched to accept a fallback cookie by re-adding the PRAGENT_DASHBOARD_TOKEN env path — the route gate is isolated in _is_authed and the logic is straightforward. The current commit intentionally has no bypass because Logto SSO is the single source of truth for "who can touch .pr-review.json".

Deploy

The dashboard shares the webhook image, so there's nothing to rebuild beyond what the webhook already does. After editing pilot/dashboard.py or pilot/dashboard_data.py, redo the webhook image rebuild + containerd import (see README-webhook.md § "K8s deployment") and roll both deployments.

K="microk8s kubectl"

# 1. (one-time) create the Logto app + cookie secret + oauth2-proxy
#    See ~/.claude/memory/minecraft-sso.md for the SQL INSERT recipe
#    and ~/k8s/oauth2-proxy-dashboard*.yaml for the manifests.

# 2. apply all pragent-dashboard manifests (dashboard + oauth2-proxy)
$K apply -f ~/k8s/oauth2-proxy-dashboard.yaml
$K apply -f ~/k8s/pragent-dashboard.yaml

# 3. roll on image / code changes
$K -n pragent rollout restart deploy/pragent-dashboard
$K -n pragent rollout status  deploy/pragent-dashboard --timeout=120s
$K -n pragent logs -f deploy/pragent-dashboard

K8s manifests:

  • ~/k8s/pragent-dashboard.yaml — Deployment + ClusterIP Service.

    • image: pragent-webhook:dashboard + imagePullPolicy: Never — local containerd only, same image as the webhook.
    • nodeSelector: kubernetes.io/hostname: kubernets — pinned to the node holding the /data PVC.
    • securityContext: runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001 — matches the image's USER directive; fsGroup makes the RO hostpath volume readable.
    • volumeMounts.feedback-data.readOnly: true — dashboard is read-only over /data; mutations go through Gitea, not local fs.
    • No readinessProbe / livenessProbe — the dashboard has no /health route. If you add one to pilot/dashboard.py, add a probe here too.
    • resources.requests: {cpu: 100m, memory: 256Mi} / limits: {cpu: 500m, memory: 512Mi} — read-heavy + tiny writes, no opencode subprocess fan-out, much smaller than the webhook.
    • Service.type: ClusterIP — only oauth2-proxy can reach it.
  • ~/k8s/oauth2-proxy-dashboard.yaml — Deployment + ConfigMap + NodePort Service (oauth2-proxy-dashboard, NodePort 31541, namespace pragent). Same shape as the code-server / minecraft-sso oauth2-proxy. NodePort 31541 was chosen because 31540 was the old dashboard NodePort and the 30096..30969 media range + 30350-30351 (other oauth2-proxy NodePorts) were taken.

  • ~/k8s/oauth2-proxy-dashboard-secret.yaml — client-id / client-secret / cookie-secret (mode 600, NOT in git).

Smoke test

# 1. anonymous request → 302 redirect to Logto
curl -I https://pragent-dashboard.marcospaulo.dev.br/

# 2. pod logs
microk8s kubectl logs -n pragent -l app=oauth2-proxy-dashboard --tail=50
microk8s kubectl logs -n pragent -l app=pragent-dashboard --tail=50

# 3. in-cluster direct probe (should 401 without X-Forwarded-User)
microk8s kubectl port-forward -n pragent svc/pragent-dashboard 8181:80 &
sleep 2
curl -I http://localhost:8181/                       # expect 401 + WWW-Authenticate: Basic
curl -I -H "X-Forwarded-User: marcos@example.com" http://localhost:8181/  # expect 200
kill %1

The HTML returned with a valid X-Forwarded-User should contain a <title> (whatever the dashboard renders) and never Traceback or any Python exception output. A 401 on the unauthenticated GET is the expected behaviour — oauth2-proxy catches it and redirects to Logto.

Threat model / security notes

  • Behind Logto SSO. Anonymous traffic gets 302 → Logto. Allowed emails (marcos, thiago) reach the dashboard after Logto login; all others see oauth2-proxy's "not authorized" page. Adding a user is a one-line ConfigMap edit; oauth2-proxy hot-reloads the allowlist.
  • PRAGENT_BOT_TOKEN is Gitea Write scoped to onboarded repos, so a successful auth bypass on the dashboard is Gitea repo write access, not just read. oauth2-proxy's email allowlist is the only authentication factor — there is no second factor. If this becomes a concern, swap oauth2-proxy for an IdP that supports TOTP/WebAuthn and the dashboard needs no further changes (it just reads the forwarded headers).
  • CSRF on the edit form. Per-process random secret embedded as a hidden input + double-submit via the X-Forwarded-User context. An attacker would need to (a) steal the user's Logto session cookie from oauth2-proxy and (b) read the rendered HTML to harvest the CSRF token. Both have to happen in the same browser.
  • Read-only /data mount. The dashboard can't corrupt the harvested SQLite file even if it's compromised. The webhook and the daily cronjob are the only writers.
  • ClusterIP dashboard Service. Even if a malicious actor discovered the dashboard's container port, they cannot reach it from outside the cluster — only oauth2-proxy can. NetworkPolicy is the cluster default deny.
  • uid 10001 + runAsNonRoot: true. No host-level escalation if the dashboard is popped — it has no caps, no /proc mounts.
  • No author-controlled input is eval-ed. The edit form parses the JSON, validates types / lengths, and re-serialises before the Gitea PUT. The review-side hostile-input concerns from README-webhook.md § "Threat model" do not apply to the dashboard — the dashboard is a read-mostly viewer over already-harvested, already-posted data.

Known limitations (pilot)

  • Logto SSO is the only auth factor — no per-user sessions, no CSRF token tied to a per-user identity (the per-process CSRF secret is global). Adequate for a single-operator dashboard; not adequate for multi-tenant.
  • No /health route — if the dashboard process wedges on a Gitea hang, k8s won't restart it. Add a /health route to pilot/dashboard.py
    • a probe here before relying on this in production.
  • The overview is a single-process render over a SQLite file that the daily cronjob also writes. A long Gitea hang during a page render can stall the dashboard until the client request times out (30 s). The underlying SQLite reader is read-only and concurrent-safe, so no data corruption — just a slow page.