Skip to content

Add manual switch, threshold control, and web dashboard - #1

Merged
jphein merged 2 commits into
masterfrom
feat/control-surface
May 22, 2026
Merged

jphein merged 2 commits into
masterfrom
feat/control-surface

Conversation

@jphein

@jphein jphein commented May 22, 2026

Copy link
Copy Markdown
Owner

Summary

  • When teamclaude runs headless (systemd, container), the interactive TUI never renders — its s/a/r/R keys all require a TTY. This adds three out-of-band control surfaces.
  • HTTP endpoints: POST /teamclaude/switch (pin account by name), POST /teamclaude/threshold (change rotation cutoff 0..1, persisted via existing atomicConfigUpdate), GET /ui (dashboard SPA served on same port).
  • CLI wrappers: teamclaude switch <name> and teamclaude threshold <value> — value accepts "85", "85%", or "0.85".
  • Dashboard: single-file src/web/index.html, polls /teamclaude/status every 2s, per-account 5h/7d quota bars, click any non-active card to pin, threshold slider live-updates rotation cutoff. Uses createElement + textContent throughout (no innerHTML) so API-returned account names can't inject markup. Dark/light themes via prefers-color-scheme.

Why

I'm running teamclaude as a systemd --user service that has no TTY, so the TUI's keyboard controls aren't reachable. Rather than spawning a separate TUI client against the daemon, the proxy itself now exposes manual-control endpoints on its existing port. Same loopback auth-skip rule as /teamclaude/status — no additional listener, no new auth model.

Test plan

  • curl -s http://localhost:3456/teamclaude/status still returns full status JSON
  • curl http://localhost:3456/ui returns 200 + HTML
  • teamclaude switch claude2 flips currentAccount
  • teamclaude threshold 85 sets switchThreshold: 0.85 and persists to ~/.config/teamclaude.json
  • Dashboard renders quota bars, active card is highlighted, click pins a different account
  • Threshold slider POSTs on change and the new value survives a server restart
  • Verify under upstream KarpelesLab review (this PR is on the fork for review only — not intended to land upstream as-is unless wanted)

🤖 Generated with Claude Code

When teamclaude runs as a long-lived service (systemd, container), the
interactive TUI never renders — its keyboard controls require a TTY.
This adds three out-of-band control surfaces that don't depend on the
TUI being active:

- POST /teamclaude/switch — pin the active account by name
- POST /teamclaude/threshold — change the rotation cutoff (0..1) and
  persist it via the existing atomicConfigUpdate path
- GET  /ui — single-page dashboard served on the same proxy port,
  polling /teamclaude/status every 2s with per-account quota bars,
  click-to-pin account selection, and a live threshold slider

Two thin CLI wrappers map onto the new endpoints:
- teamclaude switch <name>
- teamclaude threshold <value>  (accepts "85", "85%", or "0.85")

Dashboard uses createElement + textContent throughout (no innerHTML)
so account names from the API can't smuggle markup into the DOM.
Dark/light themes via prefers-color-scheme.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 22, 2026 17:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a web dashboard and new CLI commands (switch and threshold) to manage account rotation and pinning. The dashboard is served via a new /ui endpoint. Feedback suggests improving the routing logic to handle query strings correctly and caching the static dashboard HTML in memory to optimize performance.

Comment thread src/server.js Outdated
}

// GET /ui — serve the dashboard (single-page HTML)
if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/' || req.url === '/ui/index.html')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current routing logic for the dashboard uses exact string matching on req.url. This will fail if the user includes a query string (e.g., http://localhost:3456/ui?refresh=true). It is safer to check the pathname specifically.

      const url = new URL(req.url, 'http://localhost');
      if (req.method === 'GET' && (url.pathname === '/ui' || url.pathname === '/ui/' || url.pathname === '/ui/index.html')) {

Comment thread src/server.js Outdated
// GET /ui — serve the dashboard (single-page HTML)
if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/' || req.url === '/ui/index.html')) {
try {
const html = await readFile(join(__dirname, 'web', 'index.html'), 'utf-8');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The dashboard HTML file is read from disk on every request to /ui. Since this file is static and small, it should be cached in memory after the first read to improve performance and reduce unnecessary I/O.

- Parse req.url with new URL() once per request and match on .pathname
  so /ui?foo=bar (or /teamclaude/status?refresh=1) still hits the right
  handler. Previous exact-string equality silently fell through to the
  upstream proxy on any query string.
- Cache dashboardHtml in a closure variable after first read. The file
  is static for the lifetime of the process, so subsequent /ui requests
  skip the readFile() round-trip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jphein
jphein merged commit ebef300 into master May 22, 2026
@jphein
jphein deleted the feat/control-surface branch May 22, 2026 17:29
jphein pushed a commit that referenced this pull request Jun 23, 2026
Passive remains the default; this adds an explicitly opt-in active quota refresh
for users who need idle accounts' quota kept fresh.

- src/prober.js: reschedulable background probe, OFF by default. When on, reads
  each OAuth account's utilization from the zero-spend /api/oauth/usage endpoint
  (no message quota consumed), with per-probe timeout, 401 refresh-retry, and
  overlap guard. API-key accounts are skipped.
- oauth.js: fetchUsage + normalizeUsageBucket (tolerant of pct/fraction and
  sec/ms reset formats).
- account-manager.js: applyUsageData updates 5h/7d/Sonnet-7d quota without
  counting a request; new unified7dSonnet(+Reset) fields (cleared on expiry,
  persisted across restarts).
- index.js: start/stop the prober from config.quotaProbeSeconds; `teamclaude
  probe <off|seconds>` (min 30s) writes the config and notifies a running server;
  reload reschedules live. Help updated.
- TUI/status: show a Sonnet weekly bar/line when the probe has populated it —
  a no-op when absent. Closes #1.
- config.example + README documented; tests for normalize/applyUsageData/prober.

Default stays passive (quotaProbeSeconds 0). The probe is the one sanctioned
active-upstream feature, gated behind explicit opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants