---
name: quota-dashboard-builder
description: "Build and deploy a single-file Python quota viewer / usage dashboard for LLM coding-plan accounts (MiniMax Coding Plan, OpenCode Go, Ollama Cloud, etc.). Includes FastAPI backend with credential-masked config, single-file HTML/JS dashboard with auto-refresh, and nginx + acme.sh HTTPS fronting on a chosen subdomain. Load when the user wants a 'quota viewer', 'coding plan usage', 'account usage dashboard', or 'compare multiple LLM accounts at a glance'."
version: 1.0.0
license: MIT
platforms: [linux]
metadata:
  hermes:
    tags: [quota, dashboard, fastapi, minimax, opencode, ollama, coding-plan, llm, single-file, venv]
---

# Quota Dashboard Builder (single-file Python + HTML)

End-to-end recipe for a "see all my LLM coding-plan quotas at a glance" tool, deployed behind a subdomain on a host that already runs nginx + acme.sh. Pattern is **one ~400-line `app.py` + one ~9 KB `static/index.html` + one `config.json` (chmod 600)**. No Docker, no uv, no frontend build. Runs as a `python app.py` background process; restart by re-running the same command.

## Why this shape (instead of an existing tool)

User explicitly directed "not on top of an existing one — separate, pure Python I just `python`" when given the choice between Docker-building upstream QuotaHub and writing something from scratch. Why the upstream was rejected for this user (2026-07-05): heavy multi-stage Docker build with `corepack enable && pnpm install` + `uv sync` in buildkit stages failed end-to-end against the host's network egress (buildkit stage could not reach `127.0.0.1:7890` because of network namespace isolation, and the pypi mirror was unreachable from inside buildkit). The single-file Python pattern skips the entire build pipeline and uses pip with a CN mirror (or whatever pip finds locally).

If you catch yourself reaching for Docker / docker-compose / a multi-stage Dockerfile for a "small quota viewer", **stop and write `app.py` instead.** Same FastAPI features, no Docker, ~2 minutes to debug.

## When to load this

User asks for:
- "Show me the quota/usage of my MiniMax / OpenCode / Ollama accounts"
- "Make a dashboard for my coding-plan balances"
- "Compare N LLM accounts side by side"
- "Build me a /api/quota endpoint that aggregates across providers"

User already has a working nginx + acme.sh setup (so HTTPS, daemon config, and DNS conventions are known). If they don't, finish that first — see `nginx-reverse-proxy-ssl`.

## Step 0 — triage credentials BEFORE writing scrapers (verified 2026-07-05)

When the user hands you a blob of secrets and says "these are for provider X", verify what each secret actually is. Common shape confusion that wastes a full debug cycle:

| User says they gave you | Actual shape was | What it does |
|---|---|---|
| "opencode cookie" / "opencode auth" | `sk-PX3C6kmMs1sHH45O...` (40+ chars, `sk-` prefix) | Likely an **OpenCode API key** (used as bearer token for `opencode.ai/v1` API calls). Will NOT work as a cookie; server returns `302 → /auth/authorize`. |
| "opencode API key" / "opencode token" | `auth=Fe26.2**7648dd526002bce30e353c53fc65f775c8bb585f...` | This is a **dashboard cookie** (`auth=Fe26.2*JWT_BODY*SIGNATURE`). Will NOT work as an OpenAI-compatible API key. Works for the dashboard scrape path. |
| "MiniMax api_key" | Anything starting with `sk-` or `eyJ...` (JWT) | MiniMax Coding Plan uses `Bearer sk-cp-...` (Coding Plan key) OR `Bearer eyJ...` (OpenAI-compatible JWT). Both work for `GET /v1/api/openplatform/coding_plan/remains` but only the JWT works for the OpenAI-compatible `POST /v1/chat/completions` endpoint. |

Two ways to triage fast:

1. **First non-negotiable question when user hands you keys:** "Which credential type — `api_key` for the OpenAI-compatible API, or `cookie` to scrape the dashboard?" and ask for either DevTools-Network Cookie value or a generated API key from the dashboard.
2. **Always check** with a one-line probe before building a scraper:
   - For OpenCode Go: `curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" -H 'Cookie: <cookie>' -L https://opencode.ai/`. `200` on `/` = cookie valid. `302 → /auth/authorize` on `/workspace/...` = wrong workspace id (cookie OK), `302` on `/` = cookie bad.
   - For MiniMax: `curl -sS https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains -H "Authorization: Bearer <key>"`. `{"base_resp":{"status_code":0}}` = key valid (check the wrapper, not just HTTP status). `{"status_code":1004,...}` = key invalid format.

Don't proceed to building a `fetch_<provider>` until you've confirmed which credential type the user gave you. Building the wrong abstraction is the #1 cause of "fetches return 302 / 401 / wrong data" in this skill's history.

## Architecture

```
Browser ──HTTPS──▶ nginx (existing, /etc/nginx/conf.d/<host>.conf)
                       │
                       ▼ (proxy_pass http://127.0.0.1:8788)
                  Single Python process
                  (./venv/bin/python app.py, ~5 MiB RSS)
                       │
                       ├─ /api/health, /api/config → config.json (chmod 600)
                       ├─ /api/<provider>/quota ──▶ httpx calls to provider APIs
                       └─ /                       → static/index.html (auto-refresh every 30s)
```

Why `127.0.0.1:8788` (no exposed ports): the host's nginx is already on 80/443. Adding another public port bleeds attack surface.

## Step 1 — skeleton (10 minutes)

```bash
mkdir -p /root/<projectname>/static && cd /root/<projectname>
python3 -m venv venv
./venv/bin/pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
./venv/bin/pip install --quiet fastapi 'httpx' 'uvicorn[standard]' pydantic
```

Then write `app.py` (see template below for the shape). Run as foreground first to verify:

```bash
./venv/bin/python app.py --host 127.0.0.1 --port 8788
```

In another terminal: `curl http://127.0.0.1:8788/api/health` → `{"status":"ok"}`. If that's green, move on.

## Step 2 — config.json (chmod 600, NEVER commit keys)

```json
{
  "<provider>_accounts": [
    { "name": "MyAccount", "<credential field>": "sk-..." }
  ]
}
```

Use whichever key name the data source needs (e.g. `api_key` for MiniMax, `auth_cookie` for OpenCode). `permission 0o600`. The skill's mask helper will return `sk-1…2yu4` (first 4 + ellipsis + last 4) on `/api/config` so the masked secret is testable.

## Step 3 — pick providers / write fetchers

Each provider has its own `fetch_<provider>(account, index) -> AccountResult` and `<provider>_quota` endpoint. Add to:

- `app.py` — dataclasses for `AccountConfig`, `Window`, `AccountResult`
- `app.py` — `fetch_<provider>` async function returning `AccountResult`
- `app.py` — `main.py` endpoints calling `fetch_<provider>(account, i).to_dict()`
- `config.json` + `config.py` — add field for new account list

**Per-provider quirks live in `references/<provider>-integration.md`, not in this skill.** Common pitfalls:

- **MiniMax Coding Plan** — field `current_interval_usage_count` is misleadingly named; trust `*_remaining_percent` (0-100, integer), compute `used = 100 - remaining_percent`. Status codes: 0 = ok, 1004 = invalid api_key.
- **OpenCode Go** — official quota endpoint isn't an API; you scrape the dashboard RSC HTML at `https://opencode.ai/workspace/<wrk_id>/go` with `auth=Fe26.2**...` cookie. Pattern: `rollingUsage:$R[N]={status:"ok",resetInSec:...,usagePercent:...}`. Fields appear in either order → use TWO regex (primary + alt) and remember which one matched to decode group order. **Always** scrape for `wrk_` IDs first via `GET /go` if the user gives cookie but no workspace_id.
- **Ollama Cloud** — extract from upstream QuotaHub (`backend/app/ollama_quota.py`) which already has it.

## Step 4 — UI (`static/index.html`)

Single-file HTML, no framework. Uses `fetch()` to GET the JSON endpoints and rebuilds cards on each refresh. Verified working 2026-07-05 in `/root/miniquota/static/index.html` (9 KB). Rendered UI:

- Dark theme (CSS variables, `--bg: #0d1117` etc.)
- Section per provider; grid of cards auto-fill `minmax(320px, 1fr)`
- Each card shows `<name>`, optional `<workspace_id>`, ok/error badge, one Window row per quota: progress bar (green < 60%, yellow 60-90%, red ≥ 90%) + reset countdown + numeric `used / total`
- Header: health badge, "refresh now" button, auto-refresh every 30s (`setInterval(reload, 30000)`)
- Mobile-friendly: cards wrap automatically

Serve via `FileResponse(STATIC_DIR / "index.html")` from a `@app.get("/", include_in_schema=False)` route. Real path: `/root/miniquota/static/index.html`.

## Step 5 — nginx + acme.sh (use `nginx-reverse-proxy-ssl`)

Skip if user already has the same subdomain fronting a different service; reuse an existing subdomain and path prefix instead.

```nginx
# /etc/nginx/conf.d/<host>.conf — see nginx-reverse-proxy-ssl/SKILL.md for the
# full template; key bits: 80 server with /.well-known/acme-challenge location,
# 443 server proxy_pass http://127.0.0.1:8788, ssl_certificate points at
# <host>.fullchain.crt (NOT .crt) — see code 21 pitfall in that skill.
```

## Step 6 — run it (do NOT use nohup &; Hermes terminal prefers `background=true`)

```python
# Foreground (debug):
./venv/bin/python app.py --host 127.0.0.1 --port 8788

# Background (long-lived):
terminal(background=true, notify_on_complete=false)   # server is meant to run forever
# Or, via a manual launchpad, NOT nohup ... &:
setsid ./venv/bin/python app.py --host 127.0.0.1 --port 8788 > /var/log/<name>.log 2>&1 < /dev/null
```

Avoid `nohup ... &` / `disown` — Hermes terminal refuses shell-level background wrappers. Use `terminal(background=true)` for tracking and lifecycle.

If you need auto-restart on crash + boot persistence, wrap step 6 in a `systemd` unit (ExitType=simple, Restart=always, RestartSec=5). This step is optional for a personal tool.

## Pitfalls

### `pkill` / `kill` from `terminal(background=true)` processes gets blocked by sandbox

Hit 2026-07-05: trying to kill the server with `pkill -f "app.py"` from a normal `terminal()` call returned exit -15 / blocked by the sandbox even after approval. The reliable kill sequence is the `process(action="kill")` against the session_id returned by `terminal(background=true)`. If you don't have the session_id, use `ps -ef | grep app.py` to find the PID, then use a tracked `process(action="kill")` session or accept the existing process and start a new one on a different port.

### `terminal(background=true)` restart doesn't drop processes that hold port 8788

If you start a server, kill it via `process`, sleep 1, then start another — the kernel may not yet have released the socket. Symptom: `[ERROR] connection refused` immediately. **Fix:** before restart, `ss -tln | grep :8788` and verify port is free. If not, sleep 2 more and re-check.

### `write_file` refuses `/etc/nginx/conf.d/<host>.conf` and similar system paths

Confirmed across multiple deploys. Use `terminal` + heredoc:

```bash
cat > /etc/nginx/conf.d/<host>.conf <<'NGINX_CONF'
... full vhost content ...
NGINX_CONF
```

`openssl` commands also work in foreground `terminal`. `systemctl reload nginx` likewise.

### acme.sh `--install-cert` half-fail leaving 0-byte leaf cert + nginx "still up" but TLS broken

See **`nginx-reverse-proxy-ssl/SKILL.md` → pitfalls → "acme.sh: --install-cert can half-fail"**. Verified during quota.spannerjun.top rollout. Always check `ls -la /etc/nginx/ssl/<host>*.crt` after install-cert and verify with `openssl s_client`.

### FastAPI: `list[AccountResult]` raises `ResponseValidationError: Input should be a valid dictionary`

`@dataclass` doesn't auto-serialize to JSON in FastAPI's response model. Each endpoint must call `.to_dict()` on each result before returning the list. Verified 2026-07-05. Symptom in logs: `{'type': 'dict_type', 'loc': ('response', 0), 'msg': 'Input should be a valid dictionary', 'input': AccountResult(index=0, ...)}`. Fix: build the list explicitly:

```python
results = []
for i, account in enumerate(accounts):
    r = await fetch_<provider>(account, i)
    results.append(r.to_dict())
return results
```

Or set `response_model=List[Dict]` (less explicit, hides the dataclass — prefer the explicit version).

### Public-IP scanners probe `/api/env`, `/.env` immediately after LE cert issuance

Verified 2026-07-05: Within ~30 minutes of quota.spannerjun.top's first cert, multiple scanner IPs hit `/api/env`, `/.env`, `/.env.production`, `/js/config.js`, `/settings.js`. All 404 except `/api/config` (200 with masked creds). This is automated scanning triggered by LE cert issuance being a public event; treat the IPs and user-agents as ephemeral — do not hardcode any IP in this skill. **Threat model assumption:** any new subdomain with a fresh cert gets scanned quickly. Don't expose:

- `/api/env` style endpoints
- Verbose `/api/health` (the upstream `{"status":"ok"}` is fine; don't include version banners)
- Internal config that even masked would reveal which providers are in use

### OpenCode Go scraper: silent "always 100%" decoder-swap on alt-regex matches

The dual-regex pattern documented in `references/minimax-opencode-integration.md` §OpenCode Go has a second footgun that **fails silently** (no traceback, every account returns success=True, all windows come back as 100%). Verified during 2026-07-05 quota.spannerjun.top rollout.

**Cause:** You dispatch on `primary_re.search(html) or alt_re.search(html)` with Python's short-circuit OR. The regex match *succeeds* in both cases. You then unconditionally do `pct, reset = float(g1), int(float(g2))` — which is the **primary order** decoder. When the HTML actually emits `resetInSec` first, g1 is the seconds-until-reset (a large integer like `85268`) and g2 is the actual percent (`26`). Decoding groups as `(pct, reset)` makes pct = 85268, which `clamp(0, 100)` rounds to **100**. Every window of every account reports 100%. No exception, no warning. The dashboard looks identical before vs. after running for several hours.

**Why you don't catch it:** the per-account happy path is intact (cookie still works, HTML still arrives, regex still matches), and the windows render with the right LABEL and the right `reset_at` (which is computed from the *actual* reset seconds — `now + 85268s` does end up at a sensible future time). Only the bar fill color is wrong.

**Two ways to catch it fast during testing:**

1. Compare the response times: if `reset_in_sec` is consistently huge (e.g. > 60 000 for the 5h Rolling window), you decoded wrong.
2. Pick a window where the user knows the real value (e.g. "I just used 30% of my weekly budget last night"). If the UI shows 100%, the decoder swapped.

**The fix is structural, not heuristic.** Track which regex matched with a boolean — never rely on `try/except`:

```python
m = primary_re.search(html)
used_alt = False
if not m:
    m = alt_re.search(html)
    used_alt = True
if not m:
    continue
g1, g2 = m.group(1), m.group(2)
if used_alt:
    reset = int(float(g1)); pct = float(g2)
else:
    pct = float(g1)); reset = int(float(g2))
```

Re-order on contact: when you write the alt patterns, put `usagePercent` first in the *group index* `(reset, pct)` so that even with the wrong decoder the wrong value would be reset_at, not the visible bar fill — but the explicit-flag approach above is still required because the regex group order itself is determined by the `(...)` capture positions, not the field order in the HTML.

### Dashboard scrape pre-reset flicker: `resetInSec: 7` makes UI flash 100%

Verified 2026-07-05: OpenCode Go's rolling dashboard sometimes returns `usagePercent:100` with `resetInSec:7` for windows that are about to reset. Symptom in the UI: every refresh shows 100% for a frame, then drops back to the real value once the window rolls over. The cause is dashboard HTML being captured mid-transition (RSC streaming). Three optional mitigations, pick one:

1. **Drop the windows for the last N seconds before reset** (server-side, in `fetch_opencode`):
   ```python
   if 0 < reset_in_sec < 15:
       continue   # skip the window — it's about to reset; avoid flicker
   ```
2. **Cache the previous tick's value** in `app.py` (a module-level `dict[(account_id, label)] = pct`) and serve the cache if `0 < reset_in_sec < 15`.
3. **Hide the bar in the UI** if `reset_in_sec < 15` (CSS `opacity: 0.3`), letting the user see the value but de-emphasize it.

Option 1 is simplest and avoids the cache state. Option 2 is best when you have multiple accounts and want correct display during reset transitions. Option 3 is a band-aid — do not rely on it.

Same flicker shows up on MiniMax — `remains_time` can be a few hundred ms before reset. Same fixes apply.

## Files

- `templates/app.py` — minimal FastAPI skeleton (one provider, one endpoint)
- `templates/index.html` — copy this as `static/index.html` and start the UI there
- `templates/config.json.example` — schema placeholder (chmod 600 the real `config.json`)
- `references/minimax-opencode-integration.md` — provider-specific quirks (field semantics, regex patterns, status codes)
- `references/deployment-notes-2026-07-05.md` — verified build transcript from the original miniquota rollout; useful as a worked example

## Related skills

- `nginx-reverse-proxy-ssl` — for the HTTPS fronting; follow its pitfalls section to avoid the half-fail trap
- `python-debugpy` — if a fetch function returns wrong numbers, attach a debugger to it
- `host-inventory` — record the project at `/root/<projectname>/` and the running service in memory
- `plan` — when adding a 2nd/3rd provider, write the data-class, fetch fn, and endpoint as a plan first; the codebase is small but the failure modes are well-isolated
- `delegate-coding-agent` (autonomous-ai-agents) — for adding the next provider's scraper; it's a moderate-size coding task that often hits multi-file changes
