Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8acd5fc29 | |||
| be75572977 | |||
| 03ecc9e29a | |||
| 4aae61297f | |||
| 837da6e5de |
@@ -131,6 +131,20 @@
|
||||
# liveness/stale sweeps (which skip them). (default: 600000 = 10min).
|
||||
# DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000
|
||||
|
||||
# ── Privacy and push ingest (Settings → Data) ────────────────────────────────
|
||||
# All three are normally saved from Settings into the settings file; set here,
|
||||
# they are pinned and the Settings page shows them read-only.
|
||||
# Redact secrets (API keys, tokens, bearer headers, private keys, password=…)
|
||||
# from stored event payloads and summaries: 1 = on (default: off).
|
||||
# AGENTHUB_REDACT_SECRETS=1
|
||||
# Folders whose hooks are dropped whole (and skipped by the history import and
|
||||
# the Codex reader) — absolute paths, ':'-separated (';' on Windows); a folder
|
||||
# and everything under it.
|
||||
# AGENTHUB_IGNORE_CWD=/home/you/clients/secret:/home/you/private
|
||||
# SHA-256 (hex) of the token POST /api/hooks/ingest-batch accepts. Unset = push
|
||||
# ingest off. Settings → Data → Push ingest makes the token and stores only this.
|
||||
# AGENTHUB_PUSH_TOKEN_SHA256=
|
||||
|
||||
# ── Lanes (durable parallel-work units and managed git worktrees) ────────────
|
||||
# Directory dashboard-managed worktrees are provisioned under. It is also the
|
||||
# boundary safety check 2 resolves every destructive lane path against: nothing
|
||||
|
||||
@@ -78,3 +78,6 @@ __pycache__/
|
||||
|
||||
# JetBrains IDE config — machine-specific, never shared
|
||||
.idea/
|
||||
|
||||
# Prometheus bearer token for docker-compose.monitoring.yml (docs/MONITORING.md)
|
||||
monitoring/prometheus/dashboard-token
|
||||
|
||||
+71
-8
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# Pre-commit: auto-format staged files, then run the full test suite.
|
||||
# The commit is ABORTED unless every test passes.
|
||||
# Pre-commit: auto-format staged files, then run the tests the commit needs —
|
||||
# both full suites for a big or risky commit, only the related tests for a
|
||||
# small one, none for docs alone (see step 2). The commit is ABORTED unless
|
||||
# every test it runs passes.
|
||||
#
|
||||
# Activated via `core.hooksPath=.husky` (set by the package.json "prepare"
|
||||
# script on `npm install`). No husky/lint-staged runtime required.
|
||||
@@ -44,13 +46,13 @@ fi
|
||||
# is deterministic: it fails both runs and still blocks the commit. This keeps
|
||||
# the gate strict without making commits a dice roll on machine load.
|
||||
run_suite() {
|
||||
# $1 = human label, $2 = npm script
|
||||
# $1 = human label, $2 = shell command
|
||||
echo "🧪 Running $1 tests..."
|
||||
if npm run "$2"; then
|
||||
if sh -c "$2"; then
|
||||
return 0
|
||||
fi
|
||||
echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..."
|
||||
if npm run "$2"; then
|
||||
if sh -c "$2"; then
|
||||
echo "✅ $1 suite passed on retry — treating the first failure as a load flake."
|
||||
echo " If this keeps happening, the flaky test above deserves a real fix."
|
||||
return 0
|
||||
@@ -59,7 +61,68 @@ run_suite() {
|
||||
return 1
|
||||
}
|
||||
|
||||
run_suite backend test:server
|
||||
run_suite frontend test:client
|
||||
# The frontend suite needs NODE_ENV=test: a shell exporting
|
||||
# NODE_ENV=production gets React's production build, where act() throws.
|
||||
CLIENT_FULL="NODE_ENV=test npm run test:client"
|
||||
|
||||
echo "✅ Formatting applied and all tests passed — proceeding with commit."
|
||||
# How much to run. A small commit runs only the tests that touch what it
|
||||
# stages; a big or risky one runs both full suites, as does
|
||||
# AGENTHUB_FULL_TESTS=1. Docs alone (*.md, docs/) run nothing.
|
||||
# client/ files → `vitest related`: vitest's own import graph.
|
||||
# anything else → every server/__tests__ file that names the file's module
|
||||
# (a string match: wide on purpose, `db` reaches most).
|
||||
# ponytail: name matching misses a test that reaches a module only through
|
||||
# another one; the full run before a release still catches that.
|
||||
SMALL_MAX=10
|
||||
CODE="$(git diff --cached --name-only --diff-filter=ACMRD | grep -vE '\.md$|^docs/' || true)"
|
||||
COUNT=$(printf '%s\n' "$CODE" | grep -c . || true)
|
||||
FULL=0
|
||||
[ "${AGENTHUB_FULL_TESTS:-0}" = "1" ] && FULL=1
|
||||
[ "$COUNT" -gt "$SMALL_MAX" ] && FULL=1
|
||||
# A dependency, build/test config, this hook or a deleted file can break
|
||||
# anything, whatever imports it.
|
||||
if printf '%s\n' "$CODE" | grep -qE '(^|/)package(-lock)?\.json$|(^|/)(vite|vitest|tailwind|postcss|tsconfig)[^/]*$|^\.husky/|^scripts/test-server\.js$'; then
|
||||
FULL=1
|
||||
fi
|
||||
if git diff --cached --name-only --diff-filter=D | grep -vE '\.md$|^docs/' | grep -q .; then
|
||||
FULL=1
|
||||
fi
|
||||
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "📝 Docs only — no tests to run."
|
||||
elif [ "$FULL" = "1" ]; then
|
||||
echo "🧪 Full run ($COUNT code files staged)."
|
||||
run_suite backend "npm run test:server"
|
||||
run_suite frontend "$CLIENT_FULL"
|
||||
else
|
||||
echo "🧪 Small commit ($COUNT code files) — running the related tests only."
|
||||
OTHER="$(printf '%s\n' "$CODE" | grep -v '^client/' || true)"
|
||||
CLIENT="$(printf '%s\n' "$CODE" | grep '^client/' | sed 's|^client/||' || true)"
|
||||
|
||||
if [ -n "$OTHER" ]; then
|
||||
RELATED="$(
|
||||
printf '%s\n' "$OTHER" | while IFS= read -r f; do
|
||||
case "$f" in
|
||||
server/__tests__/*.test.js) echo "$f" ;;
|
||||
*)
|
||||
name="$(basename "$f")"
|
||||
name="${name%.*}"
|
||||
grep -lF -- "$name" server/__tests__/*.test.js 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
done | sort -u | tr '\n' ' '
|
||||
)"
|
||||
if [ -n "$RELATED" ]; then
|
||||
run_suite "backend ($(echo $RELATED | wc -w) files)" "node scripts/test-server.js $RELATED"
|
||||
else
|
||||
echo "⏭️ No server test names a staged file — backend skipped."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$CLIENT" ]; then
|
||||
LIST="$(printf '%s\n' "$CLIENT" | tr '\n' ' ')"
|
||||
run_suite "frontend (related)" "cd client && NODE_ENV=test NODE_OPTIONS=--no-experimental-webstorage npx vitest related --run --passWithNoTests $LIST"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Formatting applied and the tests passed — proceeding with commit."
|
||||
|
||||
+30
-5
@@ -325,8 +325,9 @@ graph TD
|
||||
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
|
||||
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
|
||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
||||
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Four rule types: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
||||
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
||||
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
|
||||
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
||||
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
|
||||
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
|
||||
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
|
||||
@@ -351,8 +352,8 @@ graph TD
|
||||
| `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers |
|
||||
| `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle |
|
||||
| `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition |
|
||||
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
|
||||
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
|
||||
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) and `?tool=` (an AI tool id; `parseScope` folds both into one predicate over `sessions`) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
|
||||
| `server/lib/scoped-stats.js` | Scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` or `?tool=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
|
||||
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
|
||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
|
||||
@@ -367,6 +368,7 @@ graph TD
|
||||
| `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. |
|
||||
| `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. |
|
||||
| `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/<id>.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. |
|
||||
| `lib/cursor-usage.js` | Cursor sessions (no tokens: Cursor's transcripts carry none) from `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`, every `DASHBOARD_CURSOR_POLL_MS`. See **Cursor Sessions**. |
|
||||
| `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. |
|
||||
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
|
||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||
@@ -850,7 +852,7 @@ erDiagram
|
||||
alert_rules {
|
||||
TEXT id PK "UUID"
|
||||
TEXT name "User-facing rule name"
|
||||
TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold"
|
||||
TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold|cost_threshold|budget (no CHECK; validated in lib/alerts.js)"
|
||||
TEXT config "Type-specific JSON config"
|
||||
INTEGER enabled "1|0"
|
||||
INTEGER cooldown_seconds "Per-scope dedup window, default 300"
|
||||
@@ -1731,6 +1733,29 @@ is ever inferred.
|
||||
|
||||
---
|
||||
|
||||
## Cursor Sessions
|
||||
|
||||
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
|
||||
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
|
||||
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
|
||||
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
|
||||
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
|
||||
`tool = 'cursor'`: id = file name, name = the first `<user_query>` line (60
|
||||
chars), start = the file's birth time (mtime where there is none), `active`
|
||||
while it was written in the last two minutes, else `completed` at its last
|
||||
write. The cwd is the project slug walked back onto the filesystem (every
|
||||
non-alphanumeric character is a `-` in the slug, so only a path that still
|
||||
exists resolves); otherwise it stays NULL. `cursor_transcripts` holds the mtime
|
||||
read, so a session the user deleted stays deleted until Cursor writes to it again.
|
||||
|
||||
**No tokens, no model, no cost.** A transcript line is `{role, message:{content}}`
|
||||
or `{type: "turn_ended"}` — no usage, no model id, no timestamps — so a Cursor
|
||||
session has no `token_usage` rows. The editor's `state.vscdb` keeps a
|
||||
`tokenCount` per chat bubble and a display model name (`GPT`, `default`, a BYOK
|
||||
name); AgentHub does not read it. No Cursor hooks are installed.
|
||||
|
||||
---
|
||||
|
||||
## Agent Extension Layer
|
||||
|
||||
The repository includes a triple extension strategy:
|
||||
|
||||
+4
-3
@@ -117,9 +117,10 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
|
||||
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
|
||||
service install`), so the dashboard comes back after every login with no
|
||||
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
||||
Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
|
||||
installs `~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout:
|
||||
the same switch, or `agenthub service install`), so the dashboard comes back after every login with no
|
||||
Claude Code session, and launchd restarts it if it dies. Lane panes survive
|
||||
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
|
||||
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
|
||||
|
||||
@@ -86,6 +86,12 @@ Internal build — all rights reserved.
|
||||
notification type can pick its own.
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
- **Outside the dashboard, all opt-in:** a read-only MCP server
|
||||
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
|
||||
Claude Code statusline with session and today's cost
|
||||
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
|
||||
Prometheus rules + Grafana boards for `/api/metrics`
|
||||
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -197,7 +203,10 @@ agenthub service uninstall # disable and remove it
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. Plugin installs get the same persistence automatically — see
|
||||
once yourself. The same is a switch in the dashboard — Settings → About →
|
||||
**Start AgentHub automatically** (off by default; it enables or disables the
|
||||
unit and never stops the running server). Plugin installs get it only once
|
||||
that switch is on — see
|
||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
||||
|
||||
## The CLI
|
||||
@@ -268,6 +277,13 @@ for a session the hooks report on (a long tool call writes nothing, and after a
|
||||
Stop agy may still be waiting for you), after two minutes for one without
|
||||
hooks. Antigravity workspaces carry no pipeline either.
|
||||
|
||||
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
|
||||
read from Cursor's agent transcripts
|
||||
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
|
||||
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
||||
no token counts or model ids there, so a Cursor session shows no tokens and no
|
||||
cost.
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
||||
@@ -314,7 +330,13 @@ npm run test:server # node:test
|
||||
npm run test:client # Vitest
|
||||
```
|
||||
|
||||
Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
||||
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
|
||||
runs Prettier, then the tests the commit needs: both full suites for a big or
|
||||
risky commit (over 10 code files, a dependency, build/test config or the hook
|
||||
itself, a deleted file), only the related tests for a small one (`vitest
|
||||
related` for `client/`, the server test files that name each other staged
|
||||
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
|
||||
both full suites.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -323,6 +345,8 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
||||
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
||||
| `docs/` | Architecture, API, workspaces, database |
|
||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||
|
||||
|
||||
+11
-3
@@ -176,7 +176,10 @@ agenthub service uninstall # disable và gỡ bỏ
|
||||
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
|
||||
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
|
||||
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
|
||||
Bản cài qua plugin cũng được tự khởi động tương tự — xem
|
||||
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
|
||||
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
|
||||
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
|
||||
bật — xem
|
||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
|
||||
`docs/PLUGINS.md`.
|
||||
|
||||
@@ -269,8 +272,13 @@ npm run test:server # node:test
|
||||
npm run test:client # Vitest
|
||||
```
|
||||
|
||||
Cả hai phải xanh (pass) trước khi commit; pre-commit hook chạy chúng cộng
|
||||
thêm Prettier.
|
||||
Cả hai phải xanh (pass) trước khi commit. Pre-commit hook (`.husky/pre-commit`)
|
||||
chạy Prettier, rồi chạy những test mà commit cần: cả hai bộ đầy đủ cho commit
|
||||
lớn hoặc rủi ro (hơn 10 file code, đổi dependency, cấu hình build/test hay
|
||||
chính hook, có xoá file), chỉ test liên quan cho commit nhỏ (`vitest related`
|
||||
cho `client/`, các file test server có nhắc tới module được stage), và không
|
||||
chạy test nào nếu chỉ sửa tài liệu. `AGENTHUB_FULL_TESTS=1 git commit …` buộc
|
||||
chạy cả hai bộ đầy đủ.
|
||||
|
||||
## Cấu trúc thư mục
|
||||
|
||||
|
||||
+66
-14
@@ -141,21 +141,10 @@ function termWidth() {
|
||||
/**
|
||||
* Resolve the dashboard base URL. Env override first (matches the hook
|
||||
* handler's contract), then the live-server discovery file, then the default
|
||||
* port. The discovery module is best-effort and never throws.
|
||||
* port — shared with the MCP server and the statusline (`dashboard-url.js`).
|
||||
*/
|
||||
function baseUrl() {
|
||||
const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT;
|
||||
if (envPort) return `http://127.0.0.1:${envPort}`;
|
||||
try {
|
||||
const { resolveDashboardPort } = require(
|
||||
path.join(REPO_ROOT, "server", "lib", "server-info.js")
|
||||
);
|
||||
const port = resolveDashboardPort();
|
||||
if (port) return `http://127.0.0.1:${port}`;
|
||||
} catch {
|
||||
/* discovery unavailable — fall through to the default */
|
||||
}
|
||||
return "http://127.0.0.1:4820";
|
||||
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
|
||||
}
|
||||
|
||||
/** Thrown when the dashboard server does not answer — the dispatcher decides
|
||||
@@ -1037,6 +1026,8 @@ async function cmdRemoteSources(flags, positional) {
|
||||
ssh_port: flags.port != null ? Number(flags.port) : null,
|
||||
identity_file: flags.identity != null ? String(flags.identity) : null,
|
||||
remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null,
|
||||
sync_codex: !!flags.codex,
|
||||
remote_codex_home: flags["codex-home"] != null ? String(flags["codex-home"]) : null,
|
||||
enabled: flags.disabled ? false : true,
|
||||
};
|
||||
const r = await post("/api/remote-sources", body);
|
||||
@@ -2797,7 +2788,7 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
"remote-sources add",
|
||||
"",
|
||||
"--label X --host user@host [--port N --identity path --remote-home path]",
|
||||
"--label X --host user@host [--port N --identity path --remote-home path --codex --codex-home path]",
|
||||
],
|
||||
["remote-sources test <id>", "", "Probe SSH connectivity to a source"],
|
||||
["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"],
|
||||
@@ -2917,6 +2908,22 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Integrations",
|
||||
[
|
||||
[
|
||||
"mcp",
|
||||
"",
|
||||
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
|
||||
],
|
||||
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
|
||||
[
|
||||
"statusline install|uninstall",
|
||||
"[--force]",
|
||||
"Add/remove AgentHub's statusLine in ~/.claude/settings.json (--force: replace or remove another one)",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Administration",
|
||||
[
|
||||
@@ -3664,8 +3671,43 @@ const SUBCOMMANDS = {
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
import: ["rescan", "path"],
|
||||
statusline: ["install", "uninstall"],
|
||||
};
|
||||
|
||||
/** `agenthub statusline install|uninstall [--force]` — the user's settings.json. */
|
||||
function cmdStatusline(sub, flags) {
|
||||
const { install, uninstall } = require(path.join(REPO_ROOT, "statusline", "install.js"));
|
||||
const { getSettingsPath } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||
const file = getSettingsPath();
|
||||
const force = Boolean(flags.force);
|
||||
const r = sub === "install" ? install(file, { force }) : uninstall(file, { force });
|
||||
const prev = r.previous ? JSON.stringify(r.previous) : "";
|
||||
switch (r.status) {
|
||||
case "installed":
|
||||
console.log(`${c.green("✔")} statusLine set in ${file}: ${r.command}`);
|
||||
if (prev) console.log(c.dim(` replaced ${prev} (backup beside the file)`));
|
||||
console.log(c.dim(" Claude Code picks it up on the next prompt."));
|
||||
return;
|
||||
case "unchanged":
|
||||
console.log(`statusLine already set in ${file}: ${r.command}`);
|
||||
return;
|
||||
case "removed":
|
||||
console.log(`${c.green("✔")} statusLine removed from ${file} (backup beside the file)`);
|
||||
return;
|
||||
case "absent":
|
||||
console.log(`No statusLine in ${file}.`);
|
||||
return;
|
||||
case "foreign":
|
||||
console.error(c.red(`✖ ${file} has a statusLine that is not AgentHub's: ${prev}`));
|
||||
console.error(
|
||||
c.dim(
|
||||
` Re-run with --force to ${sub === "install" ? "replace" : "remove"} it (a backup is kept).`
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one parsed command. Returns the handler's promise; may throw
|
||||
* ServerDownError (for the caller's offline routing) or call process.exit
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
@@ -3800,6 +3842,16 @@ async function runCommand(argv) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
case "mcp":
|
||||
return require(path.join(REPO_ROOT, "mcp", "server.js")).main();
|
||||
case "statusline":
|
||||
if (rest[0] === "install" || rest[0] === "uninstall") return cmdStatusline(rest[0], flags);
|
||||
if (rest[0]) {
|
||||
console.error("usage: agenthub statusline [install|uninstall [--force]]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
|
||||
case "open":
|
||||
return cmdOpen();
|
||||
case "version":
|
||||
|
||||
+6
-2
@@ -199,13 +199,16 @@ client/
|
||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
|
||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
│ ├── pages/ # Route pages
|
||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
|
||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
|
||||
│ │ ├── QueryExplorer.tsx # /query: filter sessions / events / agents, sort, page, export CSV/JSON (GET /api/query)
|
||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
|
||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
|
||||
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
|
||||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
|
||||
│ │ ├── Settings.tsx
|
||||
@@ -501,6 +504,7 @@ function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
<Route path="query" element={<QueryExplorer />} />
|
||||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||||
|
||||
@@ -82,6 +82,12 @@ const SettingsHub = lazy(() =>
|
||||
const SessionDetail = lazy(() =>
|
||||
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
|
||||
);
|
||||
const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const QueryExplorer = lazy(() =>
|
||||
import("./pages/QueryExplorer").then((m) => ({ default: m.QueryExplorer }))
|
||||
);
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -158,6 +164,22 @@ export default function App() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="snapshot/:token"
|
||||
element={
|
||||
<Page>
|
||||
<SnapshotView />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="query"
|
||||
element={
|
||||
<Page>
|
||||
<QueryExplorer />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route
|
||||
path="workflows"
|
||||
|
||||
@@ -70,6 +70,8 @@ import {
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -87,9 +89,10 @@ import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { FieldHelp } from "./FieldHelp";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { fmtCost, timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -124,6 +127,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
||||
"inactivity",
|
||||
"status_duration",
|
||||
"token_threshold",
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
@@ -139,6 +144,11 @@ interface RuleFormState {
|
||||
minutes: string;
|
||||
status: "working" | "waiting";
|
||||
total_tokens: string;
|
||||
usd: string;
|
||||
period: "day" | "week" | "month";
|
||||
limit_usd: string;
|
||||
thresholds: string;
|
||||
budget_tool: string;
|
||||
cooldown_seconds: string;
|
||||
}
|
||||
|
||||
@@ -153,6 +163,11 @@ const EMPTY_FORM: RuleFormState = {
|
||||
minutes: "10",
|
||||
status: "working",
|
||||
total_tokens: "1000000",
|
||||
usd: "5",
|
||||
period: "month",
|
||||
limit_usd: "100",
|
||||
thresholds: "80, 100",
|
||||
budget_tool: "",
|
||||
cooldown_seconds: "300",
|
||||
};
|
||||
|
||||
@@ -171,12 +186,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
|
||||
minutes: str(c.minutes, EMPTY_FORM.minutes),
|
||||
status: c.status === "waiting" ? "waiting" : "working",
|
||||
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
|
||||
usd: str(c.usd, EMPTY_FORM.usd),
|
||||
period: c.period === "day" || c.period === "week" ? c.period : "month",
|
||||
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
|
||||
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
|
||||
budget_tool: str(c.tool, ""),
|
||||
cooldown_seconds: String(rule.cooldown_seconds),
|
||||
};
|
||||
}
|
||||
|
||||
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
|
||||
function parseThresholds(text: string): number[] | null {
|
||||
const parts = text
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const nums = parts.map((p) => Number(p));
|
||||
if (nums.length === 0 || nums.length > 10) return null;
|
||||
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
|
||||
}
|
||||
|
||||
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
switch (form.rule_type) {
|
||||
case "event_pattern": {
|
||||
@@ -198,6 +229,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
return { status: form.status, minutes: parseFloat(form.minutes) };
|
||||
case "token_threshold":
|
||||
return { total_tokens: parseInt(form.total_tokens, 10) };
|
||||
case "cost_threshold":
|
||||
return { usd: parseFloat(form.usd) };
|
||||
case "budget": {
|
||||
const config: AlertRule["config"] = {
|
||||
period: form.period,
|
||||
limit_usd: parseFloat(form.limit_usd),
|
||||
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
};
|
||||
if (form.budget_tool) config.tool = form.budget_tool;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +268,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
|
||||
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
|
||||
case "token_threshold":
|
||||
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
|
||||
case "cost_threshold":
|
||||
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
|
||||
case "budget":
|
||||
return t("ruleDesc.budget", {
|
||||
limit: fmtCost(c.limit_usd ?? 0),
|
||||
period: t(`periods.${c.period ?? "month"}`),
|
||||
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +295,9 @@ export function AlertsNotifications() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
||||
const [preview, setPreview] = useState<RulePreview | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const aiTools = useAiTools();
|
||||
const ruleCtx = useContextMenu<AlertRule>();
|
||||
// A rule-menu item: close the menu, then act on the rule it was opened on.
|
||||
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
|
||||
@@ -322,10 +376,35 @@ export function AlertsNotifications() {
|
||||
});
|
||||
}, [loadAlerts]);
|
||||
|
||||
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
|
||||
// Any edit makes a shown preview stale.
|
||||
const set = (patch: Partial<RuleFormState>) => {
|
||||
setPreview(null);
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const onPreview = async () => {
|
||||
if (previewing) return;
|
||||
setPreviewing(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const cooldown = parseInt(form.cooldown_seconds, 10);
|
||||
setPreview(
|
||||
await api.alerts.rules.preview({
|
||||
rule_type: form.rule_type,
|
||||
config: buildConfig(form),
|
||||
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setPreview(null);
|
||||
setEditingId(null);
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
@@ -333,6 +412,7 @@ export function AlertsNotifications() {
|
||||
|
||||
const openEditRule = (rule: AlertRule) => {
|
||||
setForm(formFromRule(rule));
|
||||
setPreview(null);
|
||||
setEditingId(rule.id);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
@@ -417,7 +497,10 @@ export function AlertsNotifications() {
|
||||
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
|
||||
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
|
||||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
|
||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
|
||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
|
||||
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
|
||||
(form.rule_type !== "budget" ||
|
||||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
{
|
||||
@@ -680,6 +763,100 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.rule_type === "cost_threshold" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.usd")}
|
||||
<FieldHelp description={t("rules.help.usd")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.usd}
|
||||
onChange={(e) => set({ usd: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.rule_type === "budget" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.period")}
|
||||
<FieldHelp description={t("rules.help.period")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.period}
|
||||
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
{(["day", "week", "month"] as const).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`periods.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.limitUsd")}
|
||||
<FieldHelp description={t("rules.help.limitUsd")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.limit_usd}
|
||||
onChange={(e) => set({ limit_usd: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.thresholds")}
|
||||
<FieldHelp description={t("rules.help.thresholds")} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.thresholds}
|
||||
onChange={(e) => set({ thresholds: e.target.value })}
|
||||
placeholder="80, 100"
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.budgetTool")}
|
||||
<FieldHelp description={t("rules.help.budgetTool")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.budget_tool}
|
||||
onChange={(e) => set({ budget_tool: e.target.value })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
<option value="">{t("rules.form.allTools")}</option>
|
||||
{aiTools.tools.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && <RulePreviewResult preview={preview} />}
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="mb-1.5 flex items-center gap-1">
|
||||
@@ -701,14 +878,29 @@ export function AlertsNotifications() {
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
onClick={onSaveRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPreview}
|
||||
disabled={!canSubmit || previewing}
|
||||
title={t("rules.previewHint")}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{previewing ? t("rules.previewing") : t("rules.preview")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSaveRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{editingId ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
||||
</div>
|
||||
@@ -927,3 +1119,51 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
|
||||
function RulePreviewResult({ preview }: { preview: RulePreview }) {
|
||||
const { t } = useTranslation("alerts");
|
||||
if (!preview.supported) {
|
||||
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
|
||||
}
|
||||
if (preview.periods) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
|
||||
{preview.periods.map((p) => (
|
||||
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
|
||||
<span className="text-fg-muted">{p.period_start}</span>
|
||||
<span className="text-fg-secondary">
|
||||
{fmtCost(p.spend)} · {Math.round(p.pct)}%
|
||||
</span>
|
||||
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
|
||||
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||
<p className="text-fg-secondary">
|
||||
{t("rules.previewResult", {
|
||||
fires: preview.fires,
|
||||
sessions: preview.sessions,
|
||||
days: preview.window_days,
|
||||
})}
|
||||
</p>
|
||||
{preview.samples.map((s, i) => (
|
||||
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(s.session_id)}`}
|
||||
className="truncate hover:text-accent"
|
||||
>
|
||||
{s.session_id.slice(0, 8)}
|
||||
</Link>
|
||||
<span>{timeAgo(s.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export function DocumentTitle() {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
title = t("settings");
|
||||
} else if (path.startsWith("/query")) {
|
||||
title = t("query");
|
||||
} else if (path !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @file EventReplay.tsx
|
||||
* @description The session Replay tab: a scrubber over a session's recorded
|
||||
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
|
||||
* whose markers jump the cursor, the state derived up to the cursor
|
||||
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
|
||||
* events, so the live analysis page and a frozen snapshot share it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
chronological,
|
||||
lifecycleMarkers,
|
||||
replayState,
|
||||
LIFECYCLE_TYPES,
|
||||
REPLAY_SPEEDS,
|
||||
REPLAY_STEP_MS,
|
||||
} from "../lib/replay";
|
||||
import { buildEventTitle } from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { formatDateTimeFull, formatMs } from "../lib/format";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { EventDetail } from "./EventDetail";
|
||||
|
||||
/** The newest this many events are replayed; older ones are left out. */
|
||||
export const REPLAY_MAX_EVENTS = 5000;
|
||||
const PAGE = 500;
|
||||
|
||||
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
|
||||
export async function loadReplayEvents(
|
||||
sessionId: string
|
||||
): Promise<{ events: DashboardEvent[]; total: number }> {
|
||||
const all: DashboardEvent[] = [];
|
||||
let total = 0;
|
||||
do {
|
||||
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
|
||||
total = r.total;
|
||||
if (r.events.length === 0) break;
|
||||
all.push(...r.events);
|
||||
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
|
||||
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
|
||||
}
|
||||
|
||||
const MARKER_COLOR: Record<string, string> = {
|
||||
SessionStart: "bg-status-success",
|
||||
UserPromptSubmit: "bg-accent",
|
||||
Compaction: "bg-status-warning",
|
||||
SubagentStop: "bg-violet-500",
|
||||
APIError: "bg-status-danger",
|
||||
Stop: "bg-fg-muted",
|
||||
};
|
||||
|
||||
const btn =
|
||||
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
|
||||
export function EventReplay({
|
||||
events: raw,
|
||||
total,
|
||||
agentInfoById,
|
||||
sessionNameById,
|
||||
}: {
|
||||
events: DashboardEvent[];
|
||||
/** The session's full event count, when more than `events` were recorded. */
|
||||
total?: number;
|
||||
agentInfoById: Map<string, AgentInfo>;
|
||||
sessionNameById: Map<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const events = useMemo(() => chronological(raw), [raw]);
|
||||
const markers = useMemo(() => lifecycleMarkers(events), [events]);
|
||||
const last = events.length - 1;
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<number>(1);
|
||||
const at = Math.min(cursor, Math.max(0, last));
|
||||
const state = useMemo(() => replayState(events, at), [events, at]);
|
||||
const event = events[at];
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
if (at >= last) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
|
||||
return () => clearTimeout(timer);
|
||||
}, [playing, at, last, speed]);
|
||||
|
||||
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
|
||||
|
||||
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
|
||||
const toggle = () => {
|
||||
if (!playing && at >= last) setCursor(0);
|
||||
setPlaying((p) => !p);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// The slider steps itself on arrow keys; only the rest is ours there.
|
||||
const onSlider = e.target instanceof HTMLInputElement;
|
||||
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
} else if (onSlider) return;
|
||||
else if (e.key === "ArrowLeft") go(at - 1);
|
||||
else if (e.key === "ArrowRight") go(at + 1);
|
||||
else if (e.key === "Home") go(0);
|
||||
else if (e.key === "End") go(last);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
const markerLabel = (type: string) => t(`replay.marker.${type}`);
|
||||
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-4 outline-none"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKey}
|
||||
role="group"
|
||||
aria-label={t("replay.label")}
|
||||
data-testid="event-replay"
|
||||
>
|
||||
{total != null && total > events.length && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("replay.truncated", { shown: events.length, total })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => go(at - 1)}
|
||||
disabled={at <= 0}
|
||||
title={t("replay.back")}
|
||||
>
|
||||
<StepBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${btn} bg-accent/15 text-accent`}
|
||||
onClick={toggle}
|
||||
title={playing ? t("replay.pause") : t("replay.play")}
|
||||
aria-pressed={playing}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => go(at + 1)}
|
||||
disabled={at >= last}
|
||||
title={t("replay.forward")}
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
className="ml-2 flex items-center gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("replay.speed")}
|
||||
>
|
||||
{REPLAY_SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
aria-pressed={speed === s}
|
||||
className={`rounded px-2 py-1 text-xs font-mono ${
|
||||
speed === s
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto font-mono text-xs text-fg-muted">
|
||||
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
|
||||
{formatDateTimeFull(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={last}
|
||||
value={at}
|
||||
onChange={(e) => go(Number(e.target.value))}
|
||||
aria-label={t("replay.scrubber")}
|
||||
className="w-full accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative h-4 rounded bg-surface-2"
|
||||
role="group"
|
||||
aria-label={t("replay.minimap")}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
|
||||
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
|
||||
/>
|
||||
{markers.map((m) => (
|
||||
<button
|
||||
key={m.index}
|
||||
type="button"
|
||||
onClick={() => go(m.index)}
|
||||
title={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
|
||||
style={{ left: `${m.pct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{present.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
|
||||
{present.map((k) => (
|
||||
<span key={k} className="inline-flex items-center gap-1">
|
||||
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
|
||||
{markerLabel(k)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||
<Tile label={t("replay.state.status")}>
|
||||
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
|
||||
</Tile>
|
||||
<Tile label={t("replay.state.tool")}>
|
||||
<span className="font-mono">{state.currentTool ?? "-"}</span>
|
||||
</Tile>
|
||||
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
|
||||
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
|
||||
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
|
||||
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
|
||||
</div>
|
||||
{state.toolCounts.length > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("replay.state.toolCounts")}
|
||||
>
|
||||
{state.toolCounts.map((x) => (
|
||||
<span
|
||||
key={x.tool}
|
||||
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{x.tool} ×{x.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
|
||||
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
|
||||
{buildEventTitle(event)}
|
||||
</div>
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file EventTimeline.tsx
|
||||
* @description A session's events as a flat, expandable list — one row per
|
||||
* event (age, status badge, origin · title, tool), opening into EventDetail.
|
||||
* Used by the session analysis page's Timeline tab and the read-only snapshot
|
||||
* page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { EventDetail } from "./EventDetail";
|
||||
import {
|
||||
agentOriginLabel,
|
||||
buildEventTitle,
|
||||
buildOriginLabel,
|
||||
projectFromCwd,
|
||||
projectFromEvent,
|
||||
statusFromEventType,
|
||||
} from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
|
||||
export function EventTimeline({
|
||||
events,
|
||||
agentInfoById,
|
||||
sessionNameById,
|
||||
fallbackCwd,
|
||||
}: {
|
||||
events: DashboardEvent[];
|
||||
agentInfoById: Map<string, AgentInfo>;
|
||||
sessionNameById: Map<string, string>;
|
||||
/** The session's cwd, for events that carry none of their own. */
|
||||
fallbackCwd?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||
|
||||
function toggleEvent(id: number) {
|
||||
setExpandedEvents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
|
||||
const projectByEventId = useMemo(() => {
|
||||
const map = new Map<number, string | null>();
|
||||
for (const e of events) map.set(e.id, projectFromEvent(e));
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
|
||||
{events.map((event, i) => {
|
||||
const key = event.id ?? i;
|
||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||
return (
|
||||
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
|
||||
<div
|
||||
key={key}
|
||||
style={
|
||||
isOpen
|
||||
? undefined
|
||||
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
|
||||
}
|
||||
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</div>
|
||||
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
|
||||
{(() => {
|
||||
// Session is implicit on this page - project is
|
||||
// still shown so the row identifies the working
|
||||
// directory when you share / search. Events without
|
||||
// their own cwd (e.g. TurnDuration) fall back to the
|
||||
// session's cwd so every row carries the dir prefix.
|
||||
const project =
|
||||
projectByEventId.get(event.id) ?? projectFromCwd(fallbackCwd) ?? null;
|
||||
const origin = buildOriginLabel(
|
||||
project,
|
||||
null,
|
||||
agentOriginLabel(event.agent_id, agentInfoById)
|
||||
);
|
||||
return (
|
||||
<span className="text-sm text-fg-secondary flex-1 truncate">
|
||||
{origin && (
|
||||
<span className="text-fg-muted mr-1" title={event.agent_id ?? undefined}>
|
||||
{origin} ·
|
||||
</span>
|
||||
)}
|
||||
{buildEventTitle(event)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{event.tool_name && (
|
||||
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
|
||||
{event.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,11 @@
|
||||
* written back on every toggle. Failures to access storage are swallowed so a
|
||||
* private-browsing quota error never breaks the UI.
|
||||
*
|
||||
* ## Sidebar width
|
||||
* The expanded sidebar's right edge drags (or takes ←/→) between
|
||||
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
|
||||
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
|
||||
*
|
||||
* ## Narrow viewports
|
||||
* With no stored preference the sidebar starts collapsed below `lg` (see
|
||||
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
|
||||
@@ -79,6 +84,26 @@ import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Sagi } from "./Sagi/Sagi";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
export const SIDEBAR_MIN = 240;
|
||||
/** Wide enough for long workspace names on a large screen, no wider. */
|
||||
export const SIDEBAR_MAX = 480;
|
||||
|
||||
/** A width inside the bounds, and at most 40% of the window. */
|
||||
export function clampSidebar(px: number): number {
|
||||
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
|
||||
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
|
||||
}
|
||||
|
||||
function loadWidth(): number {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(WIDTH_KEY));
|
||||
if (n) return clampSidebar(n);
|
||||
} catch {}
|
||||
return SIDEBAR_MIN;
|
||||
}
|
||||
|
||||
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
|
||||
export const DRAWER_QUERY = "(max-width: 767.98px)";
|
||||
|
||||
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
const { pathname, search } = useLocation();
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
|
||||
const [width, setWidth] = useState(loadWidth);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
|
||||
|
||||
const saveWidth = useCallback((px: number) => {
|
||||
const next = clampSidebar(px);
|
||||
setWidth(next);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(next));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// A pick in the drawer navigates; the drawer has done its job.
|
||||
useEffect(closeDrawer, [pathname, search, closeDrawer]);
|
||||
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
connectionOpen={connectionOpen}
|
||||
onConnectionOpenChange={setConnectionOpen}
|
||||
onUpdateAvailableChange={setUpdateAvailable}
|
||||
width={width}
|
||||
resizing={resizing}
|
||||
/>
|
||||
{!drawer && !collapsed && (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={SIDEBAR_MIN}
|
||||
aria-valuemax={SIDEBAR_MAX}
|
||||
aria-label={t("resizeSidebar")}
|
||||
title={t("resizeSidebar")}
|
||||
data-testid="sidebar-resize"
|
||||
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
|
||||
resizing ? "bg-accent/60" : ""
|
||||
}`}
|
||||
style={{ left: width }}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
setResizing(true);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (resizing) setWidth(clampSidebar(e.clientX));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!resizing) return;
|
||||
setResizing(false);
|
||||
saveWidth(e.clientX);
|
||||
}}
|
||||
onPointerCancel={() => setResizing(false)}
|
||||
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
|
||||
className={`min-h-screen min-w-0 pb-6 duration-200 outline-none ${
|
||||
resizing ? "" : "transition-[margin-left,width]"
|
||||
}`}
|
||||
style={{
|
||||
marginLeft: sidebarWidth,
|
||||
width: `calc(100% - ${sidebarWidth})`,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file RemoteSources.tsx
|
||||
* @description Settings UI for the Remote Data Sources feature: manage the SSH
|
||||
* machines this dashboard pulls Claude Code history from, and choose the global
|
||||
* machines this dashboard pulls Claude Code (and, opted in per source, Codex)
|
||||
* history from, and choose the global
|
||||
* "data scope" (which machines' data the whole app shows).
|
||||
*
|
||||
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
|
||||
@@ -97,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
|
||||
ssh_port: null,
|
||||
identity_file: "",
|
||||
remote_home: "",
|
||||
sync_codex: false,
|
||||
remote_codex_home: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
@@ -219,6 +222,8 @@ export function RemoteSources() {
|
||||
ssh_port: s.ssh_port,
|
||||
identity_file: s.identity_file || "",
|
||||
remote_home: s.remote_home || "",
|
||||
sync_codex: !!s.sync_codex,
|
||||
remote_codex_home: s.remote_codex_home || "",
|
||||
enabled: s.enabled,
|
||||
});
|
||||
setEditingId(s.id);
|
||||
@@ -242,6 +247,8 @@ export function RemoteSources() {
|
||||
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
|
||||
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
|
||||
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
|
||||
sync_codex: !!form.sync_codex,
|
||||
remote_codex_home: form.remote_codex_home?.trim() ? form.remote_codex_home.trim() : null,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
try {
|
||||
@@ -591,6 +598,31 @@ export function RemoteSources() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={!!form.sync_codex}
|
||||
onChange={(v) => setForm((f) => ({ ...f, sync_codex: v }))}
|
||||
label={t("remoteSources.fieldSyncCodex", "Also pull Codex sessions")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
{form.sync_codex && (
|
||||
<div>
|
||||
<label className="block text-xs text-fg-muted mb-1">
|
||||
{t("remoteSources.fieldRemoteCodexHome", "Remote Codex home (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.codex"
|
||||
value={form.remote_codex_home ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remote_codex_home: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
|
||||
{t(
|
||||
"remoteSources.fieldRemoteCodexHomeHint",
|
||||
"Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Checkbox
|
||||
checked={!!form.enabled}
|
||||
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
@@ -674,6 +706,9 @@ export function RemoteSources() {
|
||||
{s.host}
|
||||
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
||||
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
||||
{s.sync_codex
|
||||
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted mt-1">
|
||||
{s.last_sync_at
|
||||
@@ -689,9 +724,19 @@ export function RemoteSources() {
|
||||
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
|
||||
n: s.last_sync_counts.sessions_tagged,
|
||||
})}`}
|
||||
{s.last_sync_counts?.codex_sessions != null &&
|
||||
` · ${t("remoteSources.syncCodex", "{{n}} Codex", {
|
||||
n: s.last_sync_counts.codex_sessions,
|
||||
})}`}
|
||||
</div>
|
||||
{s.status === "error" && s.last_error && (
|
||||
<div className="text-[11px] text-status-danger mt-1 break-words">
|
||||
{/* A sync that pulled one CLI's history but not the other's is
|
||||
`ok` with the other's failure in last_error. */}
|
||||
{s.last_error && (s.status === "error" || s.status === "ok") && (
|
||||
<div
|
||||
className={`text-[11px] mt-1 break-words ${
|
||||
s.status === "error" ? "text-status-danger" : "text-status-warning"
|
||||
}`}
|
||||
>
|
||||
{s.last_error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||||
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
||||
* own pace and holds its last frame longer, so a loop breathes instead of
|
||||
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -30,11 +33,28 @@ const ROW: Record<Mood, number> = {
|
||||
worried: 5, // failed
|
||||
disconnected: 5,
|
||||
};
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
||||
const PACE: [number, number][] = [
|
||||
[220, 1400], // idle: a breath, then a pause
|
||||
[120, 120], // run right — keeps up with the drag
|
||||
[120, 120], // run left
|
||||
[180, 400], // waving
|
||||
[160, 700], // jumping
|
||||
[200, 1200], // failed
|
||||
[220, 900], // waiting
|
||||
[170, 400], // running (working)
|
||||
[220, 900], // review
|
||||
];
|
||||
const SLEEP_PACE: [number, number] = [450, 2000];
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
@@ -42,23 +62,36 @@ export function PetSprite({
|
||||
id: string;
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = ROW[mood] ?? 0;
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const timer = window.setInterval(
|
||||
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
|
||||
mood === "sleeping" ? 400 : 125
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [kind, row, mood, reducedMotion]);
|
||||
const count = FRAMES[row] ?? 1;
|
||||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||||
let f = 0;
|
||||
let timer = 0;
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
f = (f + 1) % count;
|
||||
setFrame(f);
|
||||
tick();
|
||||
},
|
||||
f === count - 1 ? hold : step
|
||||
);
|
||||
};
|
||||
tick();
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [kind, row, sleeping, reducedMotion]);
|
||||
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -203,6 +203,7 @@ export function Sagi() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const brain = useSagiBrain();
|
||||
@@ -331,6 +332,8 @@ export function Sagi() {
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerUp={place.onPointerUp}
|
||||
@@ -358,6 +361,8 @@ export function Sagi() {
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
|
||||
@@ -94,6 +94,8 @@ describe("PetSprite", () => {
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
||||
act(() => vi.advanceTimersByTime(50));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
@@ -108,6 +110,45 @@ describe("PetSprite", () => {
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
|
||||
});
|
||||
|
||||
it("runs the way it is dragged and waves on hover, over its mood", () => {
|
||||
const props = {
|
||||
id: "cp-goku",
|
||||
kind: "codex" as const,
|
||||
mood: "thinking" as const,
|
||||
size: 208,
|
||||
reducedMotion: false,
|
||||
onBroken: () => {},
|
||||
};
|
||||
const { rerender } = render(<PetSprite {...props} action="run-left" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
|
||||
rerender(<PetSprite {...props} action="run-right" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
|
||||
rerender(<PetSprite {...props} action="wave" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
|
||||
rerender(<PetSprite {...props} action={null} />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("holds a loop's last frame before starting over", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
cleanup,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
@@ -192,3 +201,22 @@ describe("Sagi widget", () => {
|
||||
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSagiPosition moving", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("names the drag's direction and settles once the pointer rests", () => {
|
||||
const { result } = renderHook(() => useSagiPosition(68));
|
||||
const target = { setPointerCapture() {}, releasePointerCapture() {} };
|
||||
const ev = (x: number) =>
|
||||
({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never;
|
||||
act(() => result.current.onPointerDown(ev(500)));
|
||||
act(() => result.current.onPointerMove(ev(480)));
|
||||
expect(result.current.moving).toBe("left");
|
||||
act(() => result.current.onPointerMove(ev(520)));
|
||||
expect(result.current.moving).toBe("right");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(result.current.moving).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* viewport fraction so it survives resizes). A small movement threshold tells
|
||||
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
||||
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
||||
* `moving` names the way it is heading — while dragged (cleared once the
|
||||
* pointer rests) and during the glide to its edge — so a pet can run that way.
|
||||
* It never rests over the fixed bottom status bar; by default it docks
|
||||
* bottom-right just above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -83,6 +85,8 @@ const DRAG_THRESHOLD = 5;
|
||||
const STATUS_BAR = 24;
|
||||
// One arrow-key step, in px.
|
||||
const KEY_STEP = 40;
|
||||
// A held but unmoving drag stops running after this; also the snap glide's length.
|
||||
const MOVE_SETTLE_MS = 300;
|
||||
|
||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||
@@ -109,6 +113,8 @@ export interface SagiPlacement {
|
||||
/** True when the avatar sits in the lower half - flyouts open upward. */
|
||||
openUp: boolean;
|
||||
dragging: boolean;
|
||||
/** The way the avatar is moving horizontally right now, if it is. */
|
||||
moving: "left" | "right" | null;
|
||||
onPointerDown: (e: ReactPointerEvent) => void;
|
||||
onPointerMove: (e: ReactPointerEvent) => void;
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
@@ -123,6 +129,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||
const [, force] = useState(0); // re-derive resting coords on resize
|
||||
const [moving, setMoving] = useState<"left" | "right" | null>(null);
|
||||
const moveTimer = useRef(0);
|
||||
const lastX = useRef(0);
|
||||
const heading = useCallback((dir: "left" | "right" | null) => {
|
||||
window.clearTimeout(moveTimer.current);
|
||||
setMoving(dir);
|
||||
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
|
||||
|
||||
const draggedRef = useRef(false);
|
||||
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
||||
@@ -152,6 +167,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
/* capture unsupported - window-free fallback still works via props */
|
||||
}
|
||||
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
||||
lastX.current = e.clientX;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[screen.left, screen.top]
|
||||
@@ -172,8 +188,14 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
);
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
|
||||
const stepX = e.clientX - lastX.current;
|
||||
if (Math.abs(stepX) >= 2) {
|
||||
lastX.current = e.clientX;
|
||||
heading(stepX > 0 ? "right" : "left");
|
||||
}
|
||||
},
|
||||
[size]
|
||||
[size, heading]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
@@ -193,12 +215,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
// Run along the glide to the edge, when there is a real distance to cover.
|
||||
const glide = restingScreen(next, size).left - live.left;
|
||||
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[size]
|
||||
[size, heading]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
@@ -214,10 +239,11 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
}
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
if (next.side !== pos.side) heading(next.side);
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
},
|
||||
[pos, size]
|
||||
[pos, size, heading]
|
||||
);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
@@ -233,6 +259,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
side: pos.side,
|
||||
openUp: screen.top + size / 2 > vh() / 2,
|
||||
dragging: drag !== null,
|
||||
moving,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
|
||||
@@ -76,6 +76,8 @@ import type { Agent, Session, SessionStats } from "../lib/types";
|
||||
interface SessionOverviewProps {
|
||||
session: Session;
|
||||
agents: Agent[];
|
||||
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
|
||||
stats?: SessionStats;
|
||||
}
|
||||
|
||||
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||
@@ -160,18 +162,19 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||
const [fetched, setStats] = useState<SessionStats | null>(null);
|
||||
const stats = frozen ?? fetched;
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (session.status !== "active") return;
|
||||
if (frozen || session.status !== "active") return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [session.status]);
|
||||
}, [frozen, session.status]);
|
||||
|
||||
// A refresh asked for while one is in flight is not dropped: it runs once
|
||||
// when the current one settles (that is the throttle's trailing call).
|
||||
@@ -198,6 +201,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
|
||||
// Initial load + reload when the session id changes.
|
||||
useEffect(() => {
|
||||
if (frozen) return;
|
||||
fetchStats();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
@@ -206,6 +210,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// stream still refreshes) and again after a reconnect.
|
||||
useLiveRefresh(() => void fetchStats(), {
|
||||
throttleMs: REFRESH_THROTTLE_MS,
|
||||
enabled: !frozen,
|
||||
match: (msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) return true;
|
||||
if (
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
TableProperties,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -83,6 +84,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
{ to: "/query", icon: TableProperties, key: "nav:query" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
@@ -170,6 +172,10 @@ interface SidebarProps {
|
||||
onUpdateAvailableChange?: (available: boolean) => void;
|
||||
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
|
||||
drawer?: { open: boolean; onClose: () => void };
|
||||
/** Expanded width in px, chosen by dragging its edge (see Layout). */
|
||||
width?: number;
|
||||
/** True while that edge is dragged: no width transition lagging the pointer. */
|
||||
resizing?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -180,6 +186,8 @@ export function Sidebar({
|
||||
onConnectionOpenChange,
|
||||
onUpdateAvailableChange,
|
||||
drawer,
|
||||
width = 240,
|
||||
resizing = false,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
@@ -423,8 +431,9 @@ export function Sidebar({
|
||||
? "visible translate-x-0 shadow-2xl transition-transform"
|
||||
: "invisible -translate-x-full transition-[transform,visibility]"
|
||||
}`
|
||||
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
|
||||
: `z-30 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
|
||||
}`}
|
||||
style={drawer || collapsed ? undefined : { width }}
|
||||
>
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file SnapshotButton.tsx
|
||||
* @description The session analysis page's **Snapshot** action: confirms with a
|
||||
* "redact file paths" choice, freezes the session through `POST
|
||||
* /api/snapshots`, copies the `/snapshot/<token>` link and offers it inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Camera } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
|
||||
export function snapshotUrl(token: string): string {
|
||||
return `${window.location.origin}/snapshot/${token}`;
|
||||
}
|
||||
|
||||
export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [redact, setRedact] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||
setToken(snapshot.token);
|
||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{token && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-fg-secondary">
|
||||
<Link to={`/snapshot/${token}`} className="text-accent hover:underline">
|
||||
{t("snapshot.open")}
|
||||
</Link>
|
||||
<CopyButton text={snapshotUrl(token)} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("snapshot.hint")}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t("snapshot.create")}
|
||||
</button>
|
||||
{error && (
|
||||
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("snapshot.title")}
|
||||
message={t("snapshot.body")}
|
||||
confirmLabel={t("snapshot.confirm")}
|
||||
cancelLabel={t("snapshot.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
onConfirm={() => void create()}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
<Checkbox checked={redact} onChange={setRedact} label={t("snapshot.redactPaths")} />
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,10 +23,10 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { CostResult, ToolQuota } from "../lib/types";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
@@ -342,6 +342,8 @@ export function StatusBar({
|
||||
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
||||
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -381,6 +383,13 @@ export function StatusBar({
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
.status()
|
||||
.then(({ budgets }) => {
|
||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||
if (live) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -470,6 +479,20 @@ export function StatusBar({
|
||||
r={sessionCost}
|
||||
/>
|
||||
)}
|
||||
{budget && (
|
||||
<Link
|
||||
to="/?tab=analytics&sub=cost"
|
||||
data-testid="status-bar-budget"
|
||||
title={t("statusBar.budgetTitle", {
|
||||
name: budget.name,
|
||||
spend: usd(budget.spend),
|
||||
limit: usd(budget.config.limit_usd ?? 0),
|
||||
})}
|
||||
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
|
||||
>
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file Tests for the alert rule form's spend rules: a budget rule is built
|
||||
* from period / limit / thresholds / AI tool (plus the browser's timezone
|
||||
* offset), Preview shows what it would have fired without saving it, and
|
||||
* Create saves the same config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
alerts: {
|
||||
list: vi.fn().mockResolvedValue({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
|
||||
rules: {
|
||||
list: vi.fn().mockResolvedValue({ rules: [] }),
|
||||
create: vi.fn().mockResolvedValue({ rule: {} }),
|
||||
preview: vi.fn().mockResolvedValue({
|
||||
supported: true,
|
||||
window_days: 7,
|
||||
fires: 1,
|
||||
sessions: 0,
|
||||
samples: [],
|
||||
periods: [
|
||||
{
|
||||
period_start: "2026-09-01",
|
||||
period_end: "2026-09-30",
|
||||
spend: 120,
|
||||
pct: 120,
|
||||
crossed: [80, 100],
|
||||
},
|
||||
{
|
||||
period_start: "2026-10-01",
|
||||
period_end: "2026-10-31",
|
||||
spend: 10,
|
||||
pct: 10,
|
||||
crossed: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
tools: [{ id: "codex", label: "Codex" }],
|
||||
default: "claude",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { AlertsNotifications } from "../AlertsNotifications";
|
||||
|
||||
describe("AlertsNotifications — spend rules", () => {
|
||||
it("previews and creates a budget rule", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertsNotifications />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await user.click(await screen.findByText("Add rule"));
|
||||
await user.type(screen.getByPlaceholderText("e.g. Error burst"), "Monthly");
|
||||
const selects = screen.getAllByRole("combobox");
|
||||
await user.selectOptions(selects[0]!, "budget");
|
||||
await user.selectOptions(screen.getByDisplayValue("month"), "week");
|
||||
const limit = screen.getByDisplayValue("100");
|
||||
await user.clear(limit);
|
||||
await user.type(limit, "50");
|
||||
await screen.findByRole("option", { name: "Codex" });
|
||||
await user.selectOptions(screen.getByDisplayValue("All AI tools"), "codex");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Preview/ }));
|
||||
const config = {
|
||||
period: "week",
|
||||
limit_usd: 50,
|
||||
thresholds: [80, 100],
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
tool: "codex",
|
||||
};
|
||||
expect(api.alerts.rules.preview).toHaveBeenCalledWith({
|
||||
rule_type: "budget",
|
||||
config,
|
||||
cooldown_seconds: 300,
|
||||
});
|
||||
expect(await screen.findByText("2026-09-01")).toBeInTheDocument();
|
||||
expect(screen.getByText("80% 100%")).toBeInTheDocument();
|
||||
expect(api.alerts.rules.create).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Create rule/ }));
|
||||
expect(api.alerts.rules.create).toHaveBeenCalledWith({
|
||||
name: "Monthly",
|
||||
rule_type: "budget",
|
||||
config,
|
||||
cooldown_seconds: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file EventReplay.test.tsx
|
||||
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
|
||||
* marker jumping the cursor, playback advancing on a timer, and the derived
|
||||
* state following the cursor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { EventReplay } from "../EventReplay";
|
||||
import type { DashboardEvent } from "../../lib/types";
|
||||
|
||||
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
|
||||
return {
|
||||
id,
|
||||
session_id: "s1",
|
||||
agent_id: "a1",
|
||||
event_type,
|
||||
tool_name,
|
||||
summary: `${event_type} ${tool_name ?? ""}`.trim(),
|
||||
data: null,
|
||||
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
|
||||
} as DashboardEvent;
|
||||
}
|
||||
|
||||
// Given newest first, as the events API answers: the replay orders them itself.
|
||||
const events = [
|
||||
ev(4, "Stop"),
|
||||
ev(3, "PostToolUse", "Bash"),
|
||||
ev(2, "PreToolUse", "Bash"),
|
||||
ev(1, "SessionStart"),
|
||||
];
|
||||
|
||||
function renderReplay() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const position = () => screen.getByText(/^\d+ \/ 4/);
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("EventReplay", () => {
|
||||
it("starts at the first event and steps with the buttons", () => {
|
||||
renderReplay();
|
||||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||||
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||||
expect(position().textContent).toMatch(/^3 \/ 4/);
|
||||
expect(screen.getByText("Bash ×1")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTitle("Jump to end (End)"));
|
||||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
|
||||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||
});
|
||||
|
||||
it("moves on arrow keys and jumps from a lifecycle marker", () => {
|
||||
renderReplay();
|
||||
const root = screen.getByTestId("event-replay");
|
||||
fireEvent.keyDown(root, { key: "ArrowRight" });
|
||||
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||||
// The in-flight tool at the PreToolUse.
|
||||
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
|
||||
fireEvent.keyDown(root, { key: "End" });
|
||||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
|
||||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||
});
|
||||
|
||||
it("plays to the end at the chosen speed", () => {
|
||||
vi.useFakeTimers();
|
||||
renderReplay();
|
||||
fireEvent.click(screen.getByRole("button", { name: "5×" }));
|
||||
fireEvent.click(screen.getByTitle("Play (Space)"));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||||
// One step per tick: each step schedules the next.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
}
|
||||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Responsive shell: with no stored preference the sidebar starts
|
||||
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
|
||||
* from a menu button — a modal dialog that Escape and the backdrop close,
|
||||
* handing focus back to the button.
|
||||
* handing focus back to the button. The expanded sidebar's edge resizes it
|
||||
* within its bounds and remembers the width.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
|
||||
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
|
||||
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
|
||||
|
||||
import { Layout, DRAWER_QUERY } from "../Layout";
|
||||
import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout";
|
||||
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
|
||||
|
||||
function viewport(width: number) {
|
||||
@@ -34,6 +35,7 @@ function viewport(width: number) {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
|
||||
localStorage.removeItem("agenthub.sidebar.width");
|
||||
});
|
||||
|
||||
describe("loadCollapsed", () => {
|
||||
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout sidebar width", () => {
|
||||
it("the edge drags within bounds, is remembered, and a double-click resets it", () => {
|
||||
viewport(1440);
|
||||
vi.stubGlobal("innerWidth", 2560);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const edge = screen.getByTestId("sidebar-resize");
|
||||
const main = screen.getByRole("main");
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
|
||||
|
||||
fireEvent.pointerDown(edge, { pointerId: 1 });
|
||||
fireEvent.pointerMove(edge, { clientX: 360 });
|
||||
fireEvent.pointerUp(edge, { clientX: 360 });
|
||||
expect(main.style.marginLeft).toBe("360px");
|
||||
expect(screen.getByTestId("sidebar").style.width).toBe("360px");
|
||||
expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360");
|
||||
|
||||
fireEvent.pointerDown(edge, { pointerId: 1 });
|
||||
fireEvent.pointerUp(edge, { clientX: 2000 });
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`);
|
||||
|
||||
fireEvent.keyDown(edge, { key: "ArrowLeft" });
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`);
|
||||
|
||||
fireEvent.doubleClick(edge);
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
|
||||
});
|
||||
|
||||
it("never wider than 40% of the window", () => {
|
||||
viewport(1100);
|
||||
vi.stubGlobal("innerWidth", 1100);
|
||||
localStorage.setItem("agenthub.sidebar.width", "480");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("440px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,21 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
|
||||
{
|
||||
id: "b2",
|
||||
name: "Monthly",
|
||||
config: { limit_usd: 100 },
|
||||
spend: 85,
|
||||
pct: 85,
|
||||
crossed: [80],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -142,6 +157,14 @@ describe("StatusBar", () => {
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
|
||||
});
|
||||
|
||||
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-budget");
|
||||
expect(chip.textContent).toBe("Budget 85%");
|
||||
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
|
||||
@@ -248,6 +248,7 @@ function TreeNodes({
|
||||
type="button"
|
||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||
data-tree-kept={isKept || undefined}
|
||||
data-tree-ignored={e.ignored || undefined}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
@@ -255,11 +256,13 @@ function TreeNodes({
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||
isKept
|
||||
? "bg-kept/10 text-kept"
|
||||
: mark
|
||||
? mark.tone
|
||||
: e.changed
|
||||
? "text-status-warning"
|
||||
: "text-fg-primary"
|
||||
: e.ignored
|
||||
? "text-kept"
|
||||
: mark
|
||||
? mark.tone
|
||||
: e.changed
|
||||
? "text-status-warning"
|
||||
: "text-fg-primary"
|
||||
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
||||
>
|
||||
{e.dir ? (
|
||||
@@ -495,7 +498,9 @@ function ChangeRow({
|
||||
return (
|
||||
<li
|
||||
className="group flex items-baseline gap-1"
|
||||
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
|
||||
// A file has no chevron: reserve its 1rem so the letter lines up
|
||||
// with a sibling folder's icon, one step inside the parent folder.
|
||||
style={{ paddingLeft: `${depth + 1}rem` }}
|
||||
onContextMenu={(e) => onMenu(e, f)}
|
||||
>
|
||||
<button
|
||||
@@ -571,10 +576,7 @@ function ChangeTreeNodes({
|
||||
const open = !collapsed.has(key);
|
||||
return (
|
||||
<li key={d.path}>
|
||||
<div
|
||||
className="group flex items-center gap-1"
|
||||
style={{ paddingLeft: `${depth * 0.9}rem` }}
|
||||
>
|
||||
<div className="group flex items-center gap-1" style={{ paddingLeft: `${depth}rem` }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -125,7 +125,23 @@ function HomeBody({
|
||||
const entry = aiTools.byId(tool);
|
||||
const claude = tool === "claude";
|
||||
const codex = tool === "codex";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
// The half-typed prompt survives leaving the page or the workspace.
|
||||
const draftKey = `agenthub.workspace.draft.${lane.id}`;
|
||||
const [prompt, setPrompt] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(draftKey) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (prompt) localStorage.setItem(draftKey, prompt);
|
||||
else localStorage.removeItem(draftKey);
|
||||
} catch {
|
||||
/* storage unavailable: the draft just does not survive */
|
||||
}
|
||||
}, [draftKey, prompt]);
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [router, setRouter] = useSessionRouter();
|
||||
const routers = useAiRouters();
|
||||
|
||||
@@ -193,7 +193,9 @@ describe("freeSlot / paneRunId", () => {
|
||||
it("picks the first slot no live run of this lane holds", () => {
|
||||
expect(freeSlot(1, [])).toBe(0);
|
||||
expect(freeSlot(1, runs)).toBe(1);
|
||||
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
|
||||
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
|
||||
(slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle
|
||||
);
|
||||
expect(freeSlot(1, all)).toBeNull();
|
||||
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
|
||||
expect(freeSlot(2, all)).toBe(0);
|
||||
@@ -222,6 +224,23 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
|
||||
first.unmount();
|
||||
const other = renderHome(lane({ id: 2 }));
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
|
||||
other.unmount();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file AutostartSettings — Settings → About's "Start automatically" switch:
|
||||
* whether the dashboard starts at boot/login through the per-user service
|
||||
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
||||
* stops the running server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AutostartStatus } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function AutostartSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<AutostartStatus | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.autostart
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const change = async (enabled: boolean) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
setStatus(await api.settings.autostart.set(enabled));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const note = !status
|
||||
? undefined
|
||||
: !status.available
|
||||
? t("autostart.unavailable")
|
||||
: status.foreign
|
||||
? t("autostart.foreign")
|
||||
: t(`autostart.desc_${status.kind}`);
|
||||
|
||||
return (
|
||||
<SettingsSection id="autostart" title={t("autostart.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("autostart.label")}
|
||||
description={note}
|
||||
checked={!!status?.enabled}
|
||||
disabled={!status?.available || status.foreign || saving}
|
||||
onChange={(v) => void change(v)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file PrivacySettings.tsx
|
||||
* @description Settings → Data → Privacy: the two opt-in ingest controls of
|
||||
* `server/lib/privacy.js` — redact secrets (API keys, tokens, bearer headers,
|
||||
* private keys, `password=…`) from the payloads and summaries AgentHub stores,
|
||||
* and a list of folders whose hooks are dropped whole (one absolute path per
|
||||
* line; a folder and everything under it). Both persist through
|
||||
* `PUT /api/settings/privacy`; one the environment set is shown read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ShieldCheck } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { PrivacySettings as PrivacyState } from "../../lib/api";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
const lines = (text: string) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function PrivacySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [state, setState] = useState<PrivacyState | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const apply = (s: PrivacyState) => {
|
||||
setState(s);
|
||||
setDraft(s.ignore_cwd.join("\n"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.privacy
|
||||
.get()
|
||||
.then(apply)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
async function save(patch: { redact_secrets?: boolean; ignore_cwd?: string[] }) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
apply(await api.settings.privacy.save(patch));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const listChanged = !!state && lines(draft).join("\n") !== state.ignore_cwd.join("\n");
|
||||
|
||||
return (
|
||||
<div id="privacy" className="scroll-mt-24">
|
||||
<SettingsSection
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-fg-muted" />
|
||||
{t("privacy.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("privacy.description")}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={t("privacy.redact")}
|
||||
description={t("privacy.redactHint")}
|
||||
checked={!!state?.redact_secrets}
|
||||
disabled={!state || saving || state.env_owned.redact_secrets}
|
||||
onChange={(v) => void save({ redact_secrets: v })}
|
||||
/>
|
||||
<div className="py-3 space-y-2">
|
||||
<label htmlFor="privacy-ignore" className="block text-sm text-fg-secondary">
|
||||
{t("privacy.ignore")}
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t("privacy.ignoreHint")}</p>
|
||||
<textarea
|
||||
id="privacy-ignore"
|
||||
className="input w-full font-mono text-xs min-h-[72px]"
|
||||
placeholder={"/home/you/clients/secret-project\n~/private"}
|
||||
value={draft}
|
||||
disabled={!state || state.env_owned.ignore_cwd}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving || !listChanged}
|
||||
onClick={() => void save({ ignore_cwd: lines(draft) })}
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
{(state?.env_owned.redact_secrets || state?.env_owned.ignore_cwd) && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("privacy.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file PushIngestSettings.tsx
|
||||
* @description Settings → Data → Push ingest: the bearer token that opens
|
||||
* `POST /api/hooks/ingest-batch`, for a machine this dashboard cannot reach
|
||||
* over SSH, so the machine pushes its hooks instead. Off until a token is
|
||||
* created; the token is shown once, right after it is made (only its hash is
|
||||
* stored), and can be rotated or revoked. Also shows a forwarder script for
|
||||
* the other machine — its token in a header file, never on a command line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, Copy, KeyRound, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { PushTokenStatus } from "../../lib/api";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
|
||||
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
|
||||
function forwarder(origin: string) {
|
||||
return `#!/bin/sh
|
||||
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
|
||||
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
|
||||
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
|
||||
# Authorization: Bearer <token>
|
||||
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
|
||||
'{source: $s, events: [{hook_type: $t, data: .}]}' |
|
||||
curl -s -m 5 -o /dev/null -X POST "${origin}/api/hooks/ingest-batch" \\
|
||||
-H "@$HOME/.claude/agenthub-push.header" \\
|
||||
-H "Content-Type: application/json" --data-binary @-
|
||||
exit 0`;
|
||||
}
|
||||
|
||||
export function PushIngestSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<PushTokenStatus | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Rotating a live token or revoking one asks first.
|
||||
const [confirm, setConfirm] = useState<"rotate" | "revoke" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.pushToken
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
async function run(fn: () => Promise<void>) {
|
||||
setConfirm(null);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const rotate = () =>
|
||||
run(async () => {
|
||||
const r = await api.settings.pushToken.rotate();
|
||||
setToken(r.token);
|
||||
setStatus({ enabled: r.enabled, env_owned: r.env_owned });
|
||||
});
|
||||
const revoke = () =>
|
||||
run(async () => {
|
||||
setStatus(await api.settings.pushToken.revoke());
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
const script = forwarder(window.location.origin);
|
||||
|
||||
return (
|
||||
<div id="push-ingest" className="scroll-mt-24">
|
||||
<SettingsSection
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
{t("pushIngest.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("pushIngest.description")}
|
||||
>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`text-xs ${status?.enabled ? "text-status-success" : "text-fg-muted"}`}
|
||||
>
|
||||
{status?.enabled ? t("pushIngest.on") : t("pushIngest.off")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs disabled:opacity-40"
|
||||
disabled={busy || !status || status.env_owned}
|
||||
onClick={() => (status?.enabled ? setConfirm("rotate") : void rotate())}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{status?.enabled ? t("pushIngest.rotate") : t("pushIngest.create")}
|
||||
</button>
|
||||
{status?.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
disabled={busy || status.env_owned}
|
||||
onClick={() => setConfirm("revoke")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t("pushIngest.revoke")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{token && (
|
||||
<div className="rounded-lg border border-status-warning/25 bg-status-warning/10 p-3 space-y-2">
|
||||
<p className="text-xs text-status-warning">{t("pushIngest.shownOnce")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 break-all text-xs text-fg-primary">{token}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
title={t("pushIngest.copy")}
|
||||
onClick={() => void copyText(token)}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status?.env_owned && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("pushIngest.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-fg-muted">{t("pushIngest.reachHint")}</p>
|
||||
<div className="relative">
|
||||
<pre className="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary">
|
||||
{script}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs absolute top-1.5 right-1.5"
|
||||
title={t("pushIngest.copy")}
|
||||
onClick={() => void copyText(script)}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("pushIngest.limits")}</p>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={confirm !== null}
|
||||
title={t(confirm === "revoke" ? "pushIngest.revokeConfirm" : "pushIngest.rotateConfirm")}
|
||||
confirmLabel={t(confirm === "revoke" ? "pushIngest.revoke" : "pushIngest.rotate")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={confirm === "revoke"}
|
||||
busy={busy}
|
||||
onConfirm={() => void (confirm === "revoke" ? revoke() : rotate())}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* @file ReportSettings.tsx
|
||||
* @description Settings → Reports: scheduled report definitions (`/api/reports`)
|
||||
* — create/edit (name, window, daily/weekly + weekday, local hour, HTML/JSON),
|
||||
* enable switch, Run now, a confirmed Delete, and each report's run history
|
||||
* with download links. Every row action is also on a right-click menu. The
|
||||
* browser's timezone offset is saved with the definition, so its hour is the
|
||||
* user's local hour.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, History, Pencil, Play, Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import type { ReportDefinition, ReportInput, ReportRun } from "../../lib/types";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { Select } from "../Select";
|
||||
import { Switch } from "../Switch";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
const EMPTY: ReportInput = {
|
||||
name: "",
|
||||
window_days: 7,
|
||||
frequency: "weekly",
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: 0,
|
||||
formats: ["html"],
|
||||
enabled: true,
|
||||
};
|
||||
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export function ReportSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const [items, setItems] = useState<ReportDefinition[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** The form: a new report (id null) or the one being edited. */
|
||||
const [form, setForm] = useState<{ id: number | null; value: ReportInput } | null>(null);
|
||||
const [deleting, setDeleting] = useState<ReportDefinition | null>(null);
|
||||
const [busy, setBusy] = useState<number | "form" | null>(null);
|
||||
const [history, setHistory] = useState<{ id: number; runs: ReportRun[] } | null>(null);
|
||||
const ctx = useContextMenu<ReportDefinition>();
|
||||
|
||||
const weekday = (d: number) =>
|
||||
new Intl.DateTimeFormat(i18n.resolvedLanguage ?? i18n.language, { weekday: "long" }).format(
|
||||
new Date(Date.UTC(2026, 0, 4 + d))
|
||||
);
|
||||
const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err));
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.reports
|
||||
.list()
|
||||
.then((r) => setItems(r.reports))
|
||||
.catch(fail);
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const showHistory = async (id: number) => {
|
||||
try {
|
||||
const r = await api.reports.runs(id);
|
||||
setHistory({ id, runs: r.runs });
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return;
|
||||
setBusy("form");
|
||||
const value = { ...form.value, tz_offset: new Date().getTimezoneOffset() };
|
||||
try {
|
||||
if (form.id === null) await api.reports.create(value);
|
||||
else await api.reports.update(form.id, value);
|
||||
setForm(null);
|
||||
setError(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (r: ReportDefinition, enabled: boolean) => {
|
||||
try {
|
||||
await api.reports.update(r.id, { enabled });
|
||||
load();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async (r: ReportDefinition) => {
|
||||
setBusy(r.id);
|
||||
try {
|
||||
const { run } = await api.reports.run(r.id);
|
||||
if (run.status === "error") setError(run.error);
|
||||
load();
|
||||
if (history?.id === r.id) await showHistory(r.id);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await api.reports.remove(deleting.id);
|
||||
if (history?.id === deleting.id) setHistory(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const edit = (r: ReportDefinition) =>
|
||||
setForm({
|
||||
id: r.id,
|
||||
value: {
|
||||
name: r.name,
|
||||
window_days: r.window_days,
|
||||
frequency: r.frequency,
|
||||
hour: r.hour,
|
||||
weekday: r.weekday,
|
||||
tz_offset: r.tz_offset,
|
||||
formats: r.formats,
|
||||
enabled: r.enabled,
|
||||
},
|
||||
});
|
||||
const pick = (fn: (r: ReportDefinition) => void) => () => {
|
||||
const r = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (r) fn(r);
|
||||
};
|
||||
const schedule = (r: ReportInput) =>
|
||||
[
|
||||
r.frequency === "daily"
|
||||
? t("reports.everyDay", { time: `${pad(r.hour)}:00` })
|
||||
: t("reports.everyWeek", { day: weekday(r.weekday), time: `${pad(r.hour)}:00` }),
|
||||
t("reports.window", { count: r.window_days }),
|
||||
r.formats.map((f) => f.toUpperCase()).join(", "),
|
||||
].join(" · ");
|
||||
|
||||
const set = (patch: Partial<ReportInput>) =>
|
||||
setForm((f) => (f ? { ...f, value: { ...f.value, ...patch } } : f));
|
||||
const toggleFormat = (f: "html" | "json", on: boolean) =>
|
||||
set({
|
||||
formats: on
|
||||
? [...(form?.value.formats ?? []), f]
|
||||
: (form?.value.formats ?? []).filter((x) => x !== f),
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("reports.title")} description={t("reports.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{form ? (
|
||||
<div
|
||||
className="my-2 space-y-3 rounded-md border border-border p-3"
|
||||
data-testid="report-form"
|
||||
>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
{t("reports.name")}
|
||||
<input
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={form.value.name}
|
||||
maxLength={200}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-fg-muted">
|
||||
{t("reports.windowDays")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={366}
|
||||
className="input mt-1 block w-24 text-sm"
|
||||
value={form.value.window_days}
|
||||
onChange={(e) => set({ window_days: Math.trunc(Number(e.target.value)) })}
|
||||
/>
|
||||
</label>
|
||||
<div className="w-36">
|
||||
<Select
|
||||
value={form.value.frequency}
|
||||
onChange={(v) => set({ frequency: v })}
|
||||
label={t("reports.frequency")}
|
||||
options={[
|
||||
{ value: "daily", label: t("reports.daily") },
|
||||
{ value: "weekly", label: t("reports.weekly") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{form.value.frequency === "weekly" && (
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={String(form.value.weekday)}
|
||||
onChange={(v) => set({ weekday: Number(v) })}
|
||||
label={t("reports.weekday")}
|
||||
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({
|
||||
value: String(d),
|
||||
label: weekday(d),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-28">
|
||||
<Select
|
||||
value={String(form.value.hour)}
|
||||
onChange={(v) => set({ hour: Number(v) })}
|
||||
label={t("reports.hour")}
|
||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Checkbox
|
||||
checked={form.value.formats.includes("html")}
|
||||
onChange={(v) => toggleFormat("html", v)}
|
||||
label="HTML"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={form.value.formats.includes("json")}
|
||||
onChange={(v) => toggleFormat("json", v)}
|
||||
label="JSON"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("reports.windowHint")}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs"
|
||||
disabled={
|
||||
busy === "form" || !form.value.name.trim() || form.value.formats.length === 0
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("reports.save")}
|
||||
</button>
|
||||
<button type="button" className="btn-ghost text-xs" onClick={() => setForm(null)}>
|
||||
{t("reports.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost my-2 text-xs"
|
||||
onClick={() => setForm({ id: null, value: { ...EMPTY, name: t("reports.defaultName") } })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("reports.add")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("reports.empty")}</p>}
|
||||
{items && items.length > 0 && (
|
||||
<ul className="divide-y divide-border/60" data-testid="report-list">
|
||||
{items.map((r) => (
|
||||
<li key={r.id} onContextMenu={(e) => ctx.open(e, r)} className="py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-fg-secondary">{r.name}</p>
|
||||
<p className="text-[11px] text-fg-muted">{schedule(r)}</p>
|
||||
{r.last_run && (
|
||||
<p
|
||||
className={`text-[11px] ${
|
||||
r.last_run.status === "error" ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("reports.lastRun", { at: formatDateTime(r.last_run.created_at) })}
|
||||
{r.last_run.status === "error" && ` · ${r.last_run.error ?? ""}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={r.enabled}
|
||||
onChange={(v) => void toggle(r, v)}
|
||||
label={t("reports.enabled")}
|
||||
title={t("reports.enabled")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy === r.id}
|
||||
onClick={() => void runNow(r)}
|
||||
title={t("reports.runNow")}
|
||||
aria-label={t("reports.runNow")}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => (history?.id === r.id ? setHistory(null) : void showHistory(r.id))}
|
||||
title={t("reports.history")}
|
||||
aria-label={t("reports.history")}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => edit(r)}
|
||||
title={t("reports.edit")}
|
||||
aria-label={t("reports.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs text-status-danger"
|
||||
onClick={() => setDeleting(r)}
|
||||
title={t("reports.delete")}
|
||||
aria-label={t("reports.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{history?.id === r.id && (
|
||||
<ul
|
||||
className="mt-2 space-y-1 border-l border-border pl-3"
|
||||
data-testid="report-runs"
|
||||
>
|
||||
{history.runs.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("reports.noRuns")}</li>
|
||||
)}
|
||||
{history.runs.map((run) => (
|
||||
<li key={run.id} className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="text-fg-secondary">{formatDateTime(run.created_at)}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(`reports.trigger.${run.trigger}`)} · {run.window_from} → {run.window_to}
|
||||
</span>
|
||||
{run.status === "error" ? (
|
||||
<span className="text-status-danger">{run.error}</span>
|
||||
) : (
|
||||
run.files.map((f) => (
|
||||
<a
|
||||
key={f}
|
||||
href={api.reports.fileUrl(r.id, run.id, f)}
|
||||
download
|
||||
className="inline-flex items-center gap-0.5 text-accent hover:underline"
|
||||
title={t("reports.download", { format: f.toUpperCase() })}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{f.toUpperCase()}
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="report-menu">
|
||||
<MenuItem onSelect={pick((r) => void runNow(r))}>{t("reports.runNow")}</MenuItem>
|
||||
<MenuItem onSelect={pick((r) => void showHistory(r.id))}>{t("reports.history")}</MenuItem>
|
||||
<MenuItem onSelect={pick(edit)}>{t("reports.edit")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick(setDeleting)}>
|
||||
{t("reports.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={t("reports.deleteTitle")}
|
||||
message={t("reports.deleteBody")}
|
||||
confirmLabel={t("reports.delete")}
|
||||
cancelLabel={t("reports.cancel")}
|
||||
onConfirm={() => void remove()}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file SnapshotSettings.tsx
|
||||
* @description Settings → Snapshots: every frozen session (`GET
|
||||
* /api/snapshots`), each with Open, Copy link and a confirmed Delete — the
|
||||
* buttons and the same actions on a right-click menu. Snapshots survive Clear
|
||||
* Data, so this is the one place they go away.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import type { SessionSnapshotInfo } from "../../lib/types";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CopyButton } from "../event-views/primitives";
|
||||
import { snapshotUrl } from "../SnapshotButton";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function SnapshotSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<SessionSnapshotInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<SessionSnapshotInfo | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<SessionSnapshotInfo>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.snapshots
|
||||
.list()
|
||||
.then((r) => setItems(r.snapshots))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.snapshots.remove(deleting.id);
|
||||
setItems((xs) => xs?.filter((x) => x.id !== deleting.id) ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
const pick = (fn: (s: SessionSnapshotInfo) => void) => () => {
|
||||
const s = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (s) fn(s);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("snapshots.title")} description={t("snapshots.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("snapshots.empty")}</p>}
|
||||
{items && items.length > 0 && (
|
||||
<ul className="divide-y divide-border/60" data-testid="snapshot-list">
|
||||
{items.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
onContextMenu={(e) => ctx.open(e, s)}
|
||||
className="flex items-center gap-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-fg-secondary">
|
||||
{s.session_name || s.session_id.slice(0, 8)}
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-[11px] text-fg-muted">
|
||||
<span>{formatDateTime(s.created_at)}</span>
|
||||
<span>· {t("snapshots.events", { n: s.event_count ?? 0 })}</span>
|
||||
{s.redacted && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5"
|
||||
title={t("snapshots.redacted")}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{t("snapshots.redacted")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/snapshot/${s.token}`}
|
||||
className="btn-ghost text-xs"
|
||||
title={t("snapshots.open")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<CopyButton text={snapshotUrl(s.token)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleting(s)}
|
||||
className="btn-ghost text-xs text-status-danger"
|
||||
title={t("snapshots.delete")}
|
||||
aria-label={t("snapshots.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="snapshot-menu">
|
||||
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
|
||||
{t("snapshots.open")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
|
||||
>
|
||||
{t("snapshots.copyLink")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick(setDeleting)}>
|
||||
{t("snapshots.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={t("snapshots.deleteTitle")}
|
||||
message={t("snapshots.deleteBody")}
|
||||
confirmLabel={t("snapshots.delete")}
|
||||
cancelLabel={t("snapshots.cancel")}
|
||||
busy={busy}
|
||||
onConfirm={() => void remove()}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file PushIngestSettings.test.tsx
|
||||
* @description Settings → Data → Push ingest and Privacy: a created token is
|
||||
* shown once (a reload shows only that push is on), and the ignored-folder
|
||||
* list is saved one path per line with blank lines dropped.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
|
||||
const { pushToken, privacy } = vi.hoisted(() => ({
|
||||
pushToken: { get: vi.fn(), rotate: vi.fn(), revoke: vi.fn() },
|
||||
privacy: { get: vi.fn(), save: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({ api: { settings: { pushToken, privacy } } }));
|
||||
|
||||
import { PushIngestSettings } from "../PushIngestSettings";
|
||||
import { PrivacySettings } from "../PrivacySettings";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("PushIngestSettings", () => {
|
||||
it("shows a new token once, and never on a fresh load", async () => {
|
||||
const user = userEvent.setup();
|
||||
pushToken.get.mockResolvedValue({ enabled: false, env_owned: false });
|
||||
pushToken.rotate.mockResolvedValue({ token: "ahp_secret", enabled: true, env_owned: false });
|
||||
const view = render(<PushIngestSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: /Create token/ }));
|
||||
expect(await screen.findByText("ahp_secret")).toBeInTheDocument();
|
||||
view.unmount();
|
||||
|
||||
pushToken.get.mockResolvedValue({ enabled: true, env_owned: false });
|
||||
render(<PushIngestSettings />);
|
||||
expect(await screen.findByRole("button", { name: /Rotate token/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("ahp_secret")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrivacySettings", () => {
|
||||
it("saves the ignored folders one per line", async () => {
|
||||
const user = userEvent.setup();
|
||||
const state = {
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
};
|
||||
privacy.get.mockResolvedValue(state);
|
||||
privacy.save.mockImplementation((p) => Promise.resolve({ ok: true, ...state, ...p }));
|
||||
render(<PrivacySettings />);
|
||||
const box = await screen.findByLabelText("Ignored folders");
|
||||
await user.type(box, "/a/b{enter}{enter}/c");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(privacy.save).toHaveBeenCalledWith({ ignore_cwd: ["/a/b", "/c"] }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @file ReportSettings.test.tsx
|
||||
* @description Settings → Reports: a new report is saved with the browser's
|
||||
* timezone offset, Run now runs it, and History lists the runs with download
|
||||
* links built from the report and run ids.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
|
||||
const { reports } = vi.hoisted(() => ({
|
||||
reports: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
run: vi.fn(),
|
||||
runs: vi.fn(),
|
||||
fileUrl: (id: number, runId: number, f: string) => `/api/reports/${id}/runs/${runId}/${f}`,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({ api: { reports } }));
|
||||
|
||||
import { ReportSettings } from "../ReportSettings";
|
||||
|
||||
const DEF = {
|
||||
id: 3,
|
||||
name: "Weekly summary",
|
||||
window_days: 7,
|
||||
frequency: "weekly" as const,
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: 0,
|
||||
formats: ["html" as const, "json" as const],
|
||||
enabled: true,
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
last_slot: null,
|
||||
last_run: null,
|
||||
};
|
||||
const RUN = {
|
||||
id: 9,
|
||||
report_id: 3,
|
||||
created_at: "2026-10-05T01:00:00Z",
|
||||
trigger: "manual" as const,
|
||||
status: "ok" as const,
|
||||
window_from: "2026-09-28",
|
||||
window_to: "2026-10-04",
|
||||
files: ["html", "json"],
|
||||
error: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ReportSettings", () => {
|
||||
it("creates a report with the browser's timezone offset", async () => {
|
||||
const user = userEvent.setup();
|
||||
reports.list.mockResolvedValueOnce({ reports: [] }).mockResolvedValue({ reports: [DEF] });
|
||||
reports.create.mockResolvedValue({ report: DEF });
|
||||
render(<ReportSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: /New report/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "JSON" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(reports.create).toHaveBeenCalledWith({
|
||||
name: "Weekly summary",
|
||||
window_days: 7,
|
||||
frequency: "weekly",
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
formats: ["html", "json"],
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
expect(await screen.findByText(/last 7 days · HTML, JSON/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("runs a report now and lists its runs with download links", async () => {
|
||||
const user = userEvent.setup();
|
||||
reports.list.mockResolvedValue({ reports: [DEF] });
|
||||
reports.run.mockResolvedValue({ run: RUN });
|
||||
reports.runs.mockResolvedValue({ runs: [RUN] });
|
||||
render(<ReportSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Run now" }));
|
||||
expect(reports.run).toHaveBeenCalledWith(3);
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
const link = await screen.findByRole("link", { name: /HTML/ });
|
||||
expect(link).toHaveAttribute("href", "/api/reports/3/runs/9/html");
|
||||
expect(screen.getByText(/2026-09-28 → 2026-10-04/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,20 +7,26 @@
|
||||
"event_pattern": "Event pattern",
|
||||
"inactivity": "Inactivity",
|
||||
"status_duration": "Stuck agent",
|
||||
"token_threshold": "Token threshold"
|
||||
"token_threshold": "Token threshold",
|
||||
"cost_threshold": "Cost threshold",
|
||||
"budget": "Spend budget"
|
||||
},
|
||||
"ruleTypeHints": {
|
||||
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
|
||||
"inactivity": "Fires when an active session has received no events for the configured number of minutes.",
|
||||
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
|
||||
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold."
|
||||
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
|
||||
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
|
||||
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
|
||||
},
|
||||
"ruleDesc": {
|
||||
"eventPattern": "Matches {{pattern}}",
|
||||
"eventPatternCount": "≥{{count}} × {{pattern}} within {{window}} min",
|
||||
"inactivity": "No session activity for {{minutes}} min",
|
||||
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
||||
"tokenThreshold": "Session tokens exceed {{tokens}}"
|
||||
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
||||
"costThreshold": "Session cost reaches {{usd}}",
|
||||
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Alert rules",
|
||||
@@ -48,7 +54,13 @@
|
||||
"agentStatus": "Agent status",
|
||||
"minutes": "Minutes",
|
||||
"totalTokens": "Total tokens",
|
||||
"cooldown": "Cooldown (seconds)"
|
||||
"cooldown": "Cooldown (seconds)",
|
||||
"usd": "Cost (USD)",
|
||||
"period": "Period",
|
||||
"limitUsd": "Limit (USD)",
|
||||
"thresholds": "Alert at (%)",
|
||||
"budgetTool": "AI tool",
|
||||
"allTools": "All AI tools"
|
||||
},
|
||||
"deleteTitle": "Delete alert rule?",
|
||||
"help": {
|
||||
@@ -63,8 +75,19 @@
|
||||
"minutesInactivity": "Fire when an active session has had no events for this many minutes (a stalled or forgotten session).",
|
||||
"minutesStatus": "Fire when an agent stays in the selected status with no activity for this many minutes (a stuck agent).",
|
||||
"totalTokens": "Fire when a session's cumulative tokens (input + output + cache read + cache write) cross this number. e.g. 1000000 = 1M tokens.",
|
||||
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown."
|
||||
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown.",
|
||||
"usd": "A session reaching this many US dollars fires the alert.",
|
||||
"period": "Day starts at your local midnight, week on Monday, month on the 1st.",
|
||||
"limitUsd": "The spend limit for one period, in US dollars.",
|
||||
"thresholds": "Percents of the limit, comma-separated, e.g. 50, 80, 100. Each fires once per period.",
|
||||
"budgetTool": "Count only this AI tool's sessions."
|
||||
},
|
||||
"preview": "Preview",
|
||||
"previewing": "Checking…",
|
||||
"previewHint": "What this rule would have fired over the last 7 days — nothing is saved or sent",
|
||||
"previewResult": "Would have fired {{fires}} times in {{sessions}} sessions over the last {{days}} days.",
|
||||
"previewBudget": "Spend per period (this one and the five before it):",
|
||||
"previewUnsupported": "This rule type cannot be replayed from history.",
|
||||
"edit": "Edit rule",
|
||||
"save": "Save rule",
|
||||
"typeFixed": "A rule's type cannot be changed — create a new rule instead.",
|
||||
@@ -81,6 +104,11 @@
|
||||
"emptyDescription": "Alerts fired by your rules will appear here in real time.",
|
||||
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire."
|
||||
},
|
||||
"periods": {
|
||||
"day": "day",
|
||||
"week": "week",
|
||||
"month": "month"
|
||||
},
|
||||
"errors": {
|
||||
"loadRules": "Could not load alert rules: {{error}}",
|
||||
"loadAlerts": "Could not load alerts: {{error}}",
|
||||
|
||||
@@ -51,6 +51,61 @@
|
||||
"last7Days": "Last 7 days",
|
||||
"less": "Less",
|
||||
"more": "More",
|
||||
"toolFilter": "AI tool",
|
||||
"allTools": "All AI tools",
|
||||
"costByTool": "Cost by AI tool",
|
||||
"spendPace": "Spend pace",
|
||||
"burnRate": "Burn rate (7-day avg / day)",
|
||||
"burnRateHint": "Average cost per day over the last 7 days, today included",
|
||||
"monthToDate": "Month to date",
|
||||
"forecastMonth": "Forecast for this month",
|
||||
"forecastHint": "Month to date plus the 7-day pace for each day left",
|
||||
"budgets": "Budgets",
|
||||
"noBudgets": "No spend budget yet.",
|
||||
"addBudget": "Add one in Settings → Notifications",
|
||||
"projected": "projected",
|
||||
"projectedHint": "This period's spend at the current pace, carried to its end",
|
||||
"range": "Date range",
|
||||
"rangePreset": {
|
||||
"today": "Today",
|
||||
"week": "This week",
|
||||
"month": "This month",
|
||||
"7d": "Last 7 days",
|
||||
"30d": "Last 30 days",
|
||||
"90d": "Last 90 days",
|
||||
"all": "All time",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"rangeFrom": "From",
|
||||
"rangeTo": "To",
|
||||
"query": {
|
||||
"title": "Query Explorer",
|
||||
"subtitle": "Filter sessions, events and agents, then export what you see.",
|
||||
"entity": "Data",
|
||||
"entities": {
|
||||
"sessions": "Sessions",
|
||||
"events": "Events",
|
||||
"agents": "Agents"
|
||||
},
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search text…",
|
||||
"status": "Status",
|
||||
"allStatuses": "All statuses",
|
||||
"cwd": "Folder",
|
||||
"cwdPlaceholder": "/path/to/project",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"sortBy": "Sort by {{column}}",
|
||||
"exportCsv": "Export CSV",
|
||||
"exportJson": "Export JSON",
|
||||
"exportNote": "An export holds the whole filter, up to {{cap}} rows.",
|
||||
"exportTruncated": "{{total}} rows match; the export holds the first {{cap}}.",
|
||||
"rows": "{{from}}–{{to}} of {{total}}",
|
||||
"prev": "Previous page",
|
||||
"next": "Next page",
|
||||
"empty": "Nothing matches these filters.",
|
||||
"reset": "Reset filters"
|
||||
},
|
||||
"tokensExCacheReads": "Tokens (excl. cache reads)",
|
||||
"cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
|
||||
"avgPerWeekday30d": "Average per weekday, last 30 days"
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
"today": "Today {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}",
|
||||
"budget": "Budget {{pct}}%",
|
||||
"budgetTitle": "{{name}}: {{spend}} of {{limit}} this period",
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "{{pct}}% used",
|
||||
"quotaTitle": "Usage",
|
||||
@@ -80,10 +82,15 @@
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
"quotaWindow": { "wk": "Week", "mo": "Month" }
|
||||
"quotaWindow": {
|
||||
"wk": "Week",
|
||||
"mo": "Month"
|
||||
}
|
||||
},
|
||||
"version": "Version",
|
||||
"query": "Query Explorer",
|
||||
"openMenu": "Open menu",
|
||||
"resizeSidebar": "Resize sidebar (double-click to reset)",
|
||||
"closeMenu": "Close menu",
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now"
|
||||
|
||||
@@ -54,6 +54,55 @@
|
||||
"copyId": "Copy session ID",
|
||||
"copyFolder": "Copy folder path"
|
||||
},
|
||||
"replay": {
|
||||
"tab": "Replay",
|
||||
"loading": "Loading events…",
|
||||
"label": "Session replay",
|
||||
"truncated": "Replaying the newest {{shown}} of {{total}} events.",
|
||||
"start": "Jump to start (Home)",
|
||||
"back": "Step back (←)",
|
||||
"play": "Play (Space)",
|
||||
"pause": "Pause (Space)",
|
||||
"forward": "Step forward (→)",
|
||||
"end": "Jump to end (End)",
|
||||
"speed": "Playback speed",
|
||||
"position": "{{at}} / {{n}}",
|
||||
"scrubber": "Event position",
|
||||
"minimap": "Lifecycle markers",
|
||||
"marker": {
|
||||
"SessionStart": "Session start",
|
||||
"UserPromptSubmit": "Prompt",
|
||||
"Compaction": "Compaction",
|
||||
"SubagentStop": "Subagent done",
|
||||
"APIError": "API error",
|
||||
"Stop": "Turn end"
|
||||
},
|
||||
"state": {
|
||||
"status": "Status",
|
||||
"tool": "Running tool",
|
||||
"prompts": "Prompts",
|
||||
"errors": "Errors",
|
||||
"compactions": "Compactions",
|
||||
"elapsed": "Elapsed",
|
||||
"toolCounts": "Tool calls so far"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"create": "Snapshot",
|
||||
"hint": "Freeze this session into a read-only copy with a share link",
|
||||
"title": "Create a snapshot",
|
||||
"body": "Freezes this session as it is now — its agents, events, stats and cost — into a read-only copy under a private link. Later activity does not change it, and it survives Clear Data.",
|
||||
"redactPaths": "Redact file paths (working directory, transcript and file paths)",
|
||||
"confirm": "Create and copy link",
|
||||
"cancel": "Cancel",
|
||||
"open": "Open snapshot",
|
||||
"notFound": "Snapshot not found",
|
||||
"banner": "Snapshot taken {{date}}",
|
||||
"bannerBody": "A read-only copy of the session as it was then. Nothing here updates.",
|
||||
"redacted": "Paths redacted",
|
||||
"openLive": "Open live session",
|
||||
"timeline": "Timeline ({{n}})"
|
||||
},
|
||||
"exportCsv": "CSV",
|
||||
"exportJson": "JSON",
|
||||
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
|
||||
|
||||
@@ -303,6 +303,14 @@
|
||||
"typeToConfirm": "Type “{{phrase}}” to confirm:",
|
||||
"phrase": "clear all data"
|
||||
},
|
||||
"autostart": {
|
||||
"title": "Startup",
|
||||
"label": "Start AgentHub automatically",
|
||||
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Server runtime information.",
|
||||
@@ -561,6 +569,11 @@
|
||||
"deleteAndPurge": "Remove + delete data",
|
||||
"removeKeep": "Remove (keep data)",
|
||||
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||
"fieldSyncCodex": "Also pull Codex sessions",
|
||||
"fieldRemoteCodexHome": "Remote Codex home (optional)",
|
||||
"fieldRemoteCodexHomeHint": "Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost.",
|
||||
"codexOn": "+ Codex",
|
||||
"syncCodex": "{{n}} Codex",
|
||||
"fieldLabelPlaceholder": "Dev box"
|
||||
},
|
||||
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
|
||||
@@ -802,6 +815,14 @@
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Server version, uptime and runtime."
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Snapshots",
|
||||
"description": "Frozen, read-only copies of sessions, each behind its own link."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"description": "Analytics summaries written on a schedule, as HTML or JSON."
|
||||
}
|
||||
},
|
||||
"backPrevious": "Back",
|
||||
@@ -839,6 +860,79 @@
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Session snapshots",
|
||||
"description": "Created from a session's page with Snapshot. They survive Clear Data; delete them here.",
|
||||
"empty": "No snapshots yet.",
|
||||
"events": "{{n}} events",
|
||||
"redacted": "Paths redacted",
|
||||
"open": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"delete": "Delete",
|
||||
"deleteTitle": "Delete this snapshot?",
|
||||
"deleteBody": "Its link stops working. The session itself is not touched.",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"pushIngest": {
|
||||
"title": "Push ingest",
|
||||
"description": "For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:<name>) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.",
|
||||
"on": "On — a token exists",
|
||||
"off": "Off — no token yet",
|
||||
"create": "Create token",
|
||||
"rotate": "Rotate token",
|
||||
"revoke": "Revoke",
|
||||
"rotateConfirm": "Make a new token? The current one stops working at once.",
|
||||
"revokeConfirm": "Revoke the token and turn push ingest off?",
|
||||
"shownOnce": "Copy this token now: only its hash is stored, so it is never shown again.",
|
||||
"copy": "Copy",
|
||||
"envOwned": "AGENTHUB_PUSH_TOKEN_SHA256 is set by the environment the dashboard started in; change it there.",
|
||||
"reachHint": "The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:",
|
||||
"limits": "At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy",
|
||||
"description": "What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.",
|
||||
"redact": "Redact secrets before storing",
|
||||
"redactHint": "API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.",
|
||||
"ignore": "Ignored folders",
|
||||
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
|
||||
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Scheduled reports",
|
||||
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
|
||||
"add": "New report",
|
||||
"defaultName": "Weekly summary",
|
||||
"empty": "No scheduled report yet.",
|
||||
"name": "Name",
|
||||
"windowDays": "Days covered",
|
||||
"frequency": "Frequency",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"weekday": "Day",
|
||||
"hour": "Time",
|
||||
"windowHint": "A report covers the full days before the day it runs, at this browser's time zone.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"enabled": "Enabled",
|
||||
"runNow": "Run now",
|
||||
"history": "History",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"everyDay": "Every day at {{time}}",
|
||||
"everyWeek": "Every {{day}} at {{time}}",
|
||||
"window_one": "last {{count}} day",
|
||||
"window_other": "last {{count}} days",
|
||||
"lastRun": "Last run {{at}}",
|
||||
"noRuns": "No runs yet.",
|
||||
"trigger": {
|
||||
"schedule": "Scheduled",
|
||||
"manual": "Manual"
|
||||
},
|
||||
"download": "Download {{format}}",
|
||||
"deleteTitle": "Delete this report?",
|
||||
"deleteBody": "Its schedule, its run history and the files it wrote are deleted."
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "This browser's preferences",
|
||||
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||
|
||||
@@ -7,20 +7,26 @@
|
||||
"event_pattern": "Mẫu sự kiện",
|
||||
"inactivity": "Không hoạt động",
|
||||
"status_duration": "Agent bị kẹt",
|
||||
"token_threshold": "Ngưỡng token"
|
||||
"token_threshold": "Ngưỡng token",
|
||||
"cost_threshold": "Ngưỡng chi phí",
|
||||
"budget": "Ngân sách chi tiêu"
|
||||
},
|
||||
"ruleTypeHints": {
|
||||
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
|
||||
"inactivity": "Kích hoạt khi một phiên đang hoạt động không nhận được sự kiện nào trong số phút đã cấu hình.",
|
||||
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
|
||||
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng."
|
||||
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
|
||||
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
|
||||
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
|
||||
},
|
||||
"ruleDesc": {
|
||||
"eventPattern": "Khớp {{pattern}}",
|
||||
"eventPatternCount": "≥{{count}} × {{pattern}} trong {{window}} phút",
|
||||
"inactivity": "Phiên không hoạt động trong {{minutes}} phút",
|
||||
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
|
||||
"tokenThreshold": "Token của phiên vượt {{tokens}}"
|
||||
"tokenThreshold": "Token của phiên vượt {{tokens}}",
|
||||
"costThreshold": "Chi phí phiên chạm {{usd}}",
|
||||
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Quy tắc cảnh báo",
|
||||
@@ -48,7 +54,13 @@
|
||||
"agentStatus": "Trạng thái agent",
|
||||
"minutes": "Phút",
|
||||
"totalTokens": "Tổng token",
|
||||
"cooldown": "Thời gian chờ (giây)"
|
||||
"cooldown": "Thời gian chờ (giây)",
|
||||
"usd": "Chi phí (USD)",
|
||||
"period": "Kỳ",
|
||||
"limitUsd": "Hạn mức (USD)",
|
||||
"thresholds": "Cảnh báo ở (%)",
|
||||
"budgetTool": "Công cụ AI",
|
||||
"allTools": "Mọi công cụ AI"
|
||||
},
|
||||
"deleteTitle": "Xóa quy tắc cảnh báo?",
|
||||
"help": {
|
||||
@@ -63,8 +75,19 @@
|
||||
"minutesInactivity": "Kích hoạt khi một phiên đang hoạt động không có sự kiện nào trong bấy nhiêu phút (phiên bị treo hoặc bị quên).",
|
||||
"minutesStatus": "Kích hoạt khi agent ở trạng thái đã chọn mà không có hoạt động trong bấy nhiêu phút (agent bị kẹt).",
|
||||
"totalTokens": "Kích hoạt khi tổng token của phiên (input + output + cache read + cache write) vượt số này. vd 1000000 = 1 triệu token.",
|
||||
"cooldown": "Số giây tối thiểu giữa các cảnh báo lặp cho cùng quy tắc + phiên + agent. Tránh trùng lặp gây nhiễu. Mặc định 300 (5 phút); 0 = không có thời gian chờ."
|
||||
"cooldown": "Số giây tối thiểu giữa các cảnh báo lặp cho cùng quy tắc + phiên + agent. Tránh trùng lặp gây nhiễu. Mặc định 300 (5 phút); 0 = không có thời gian chờ.",
|
||||
"usd": "Một phiên chạm số đô la này sẽ kích hoạt cảnh báo.",
|
||||
"period": "Ngày bắt đầu lúc nửa đêm giờ địa phương, tuần từ thứ Hai, tháng từ ngày 1.",
|
||||
"limitUsd": "Hạn mức chi tiêu cho một kỳ, tính bằng đô la Mỹ.",
|
||||
"thresholds": "Phần trăm hạn mức, cách nhau bằng dấu phẩy, vd 50, 80, 100. Mỗi ngưỡng kích hoạt một lần mỗi kỳ.",
|
||||
"budgetTool": "Chỉ tính các phiên của công cụ AI này."
|
||||
},
|
||||
"preview": "Xem trước",
|
||||
"previewing": "Đang kiểm tra…",
|
||||
"previewHint": "Quy tắc này lẽ ra đã kích hoạt những gì trong 7 ngày qua — không lưu, không gửi gì",
|
||||
"previewResult": "Lẽ ra đã kích hoạt {{fires}} lần ở {{sessions}} phiên trong {{days}} ngày qua.",
|
||||
"previewBudget": "Chi tiêu mỗi kỳ (kỳ này và năm kỳ trước):",
|
||||
"previewUnsupported": "Loại quy tắc này không phát lại được từ lịch sử.",
|
||||
"edit": "Sửa quy tắc",
|
||||
"save": "Lưu quy tắc",
|
||||
"typeFixed": "Không đổi được loại của quy tắc — hãy tạo quy tắc mới.",
|
||||
@@ -81,6 +104,11 @@
|
||||
"emptyDescription": "Cảnh báo do các quy tắc của bạn kích hoạt sẽ hiển thị tại đây theo thời gian thực.",
|
||||
"emptyUnacked": "Tất cả đã được xác nhận. Cảnh báo mới sẽ hiển thị tại đây khi được kích hoạt."
|
||||
},
|
||||
"periods": {
|
||||
"day": "ngày",
|
||||
"week": "tuần",
|
||||
"month": "tháng"
|
||||
},
|
||||
"errors": {
|
||||
"loadRules": "Không tải được quy tắc cảnh báo: {{error}}",
|
||||
"loadAlerts": "Không tải được cảnh báo: {{error}}",
|
||||
|
||||
@@ -51,6 +51,61 @@
|
||||
"last7Days": "7 ngày gần nhất",
|
||||
"less": "Ít",
|
||||
"more": "Nhiều",
|
||||
"toolFilter": "Công cụ AI",
|
||||
"allTools": "Mọi công cụ AI",
|
||||
"costByTool": "Chi phí theo công cụ AI",
|
||||
"spendPace": "Nhịp chi tiêu",
|
||||
"burnRate": "Tốc độ đốt (TB 7 ngày / ngày)",
|
||||
"burnRateHint": "Chi phí trung bình mỗi ngày trong 7 ngày qua, tính cả hôm nay",
|
||||
"monthToDate": "Từ đầu tháng",
|
||||
"forecastMonth": "Dự báo cả tháng",
|
||||
"forecastHint": "Từ đầu tháng cộng nhịp 7 ngày cho mỗi ngày còn lại",
|
||||
"budgets": "Ngân sách",
|
||||
"noBudgets": "Chưa có ngân sách chi tiêu.",
|
||||
"addBudget": "Thêm trong Cài đặt → Thông báo",
|
||||
"projected": "dự kiến",
|
||||
"projectedHint": "Chi tiêu kỳ này theo nhịp hiện tại, kéo đến cuối kỳ",
|
||||
"range": "Khoảng thời gian",
|
||||
"rangePreset": {
|
||||
"today": "Hôm nay",
|
||||
"week": "Tuần này",
|
||||
"month": "Tháng này",
|
||||
"7d": "7 ngày qua",
|
||||
"30d": "30 ngày qua",
|
||||
"90d": "90 ngày qua",
|
||||
"all": "Toàn bộ",
|
||||
"custom": "Tùy chọn"
|
||||
},
|
||||
"rangeFrom": "Từ ngày",
|
||||
"rangeTo": "Đến ngày",
|
||||
"query": {
|
||||
"title": "Truy vấn dữ liệu",
|
||||
"subtitle": "Lọc phiên, sự kiện và agent, rồi xuất đúng những gì đang thấy.",
|
||||
"entity": "Dữ liệu",
|
||||
"entities": {
|
||||
"sessions": "Phiên",
|
||||
"events": "Sự kiện",
|
||||
"agents": "Agent"
|
||||
},
|
||||
"search": "Tìm kiếm",
|
||||
"searchPlaceholder": "Tìm văn bản…",
|
||||
"status": "Trạng thái",
|
||||
"allStatuses": "Mọi trạng thái",
|
||||
"cwd": "Thư mục",
|
||||
"cwdPlaceholder": "/duong/dan/du-an",
|
||||
"from": "Từ ngày",
|
||||
"to": "Đến ngày",
|
||||
"sortBy": "Sắp xếp theo {{column}}",
|
||||
"exportCsv": "Xuất CSV",
|
||||
"exportJson": "Xuất JSON",
|
||||
"exportNote": "Tệp xuất chứa toàn bộ kết quả lọc, tối đa {{cap}} dòng.",
|
||||
"exportTruncated": "{{total}} dòng khớp; tệp xuất chỉ chứa {{cap}} dòng đầu.",
|
||||
"rows": "{{from}}–{{to}} / {{total}}",
|
||||
"prev": "Trang trước",
|
||||
"next": "Trang sau",
|
||||
"empty": "Không có dữ liệu khớp bộ lọc.",
|
||||
"reset": "Đặt lại bộ lọc"
|
||||
},
|
||||
"tokensExCacheReads": "Token (trừ đọc cache)",
|
||||
"cacheReadsSub": "+{{reads}} token đọc cache · tỉ lệ trúng {{pct}}%",
|
||||
"avgPerWeekday30d": "Trung bình theo thứ trong tuần, 30 ngày qua"
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
"today": "Hôm nay {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}",
|
||||
"budget": "Ngân sách {{pct}}%",
|
||||
"budgetTitle": "{{name}}: {{spend}} / {{limit}} kỳ này",
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "đã dùng {{pct}}%",
|
||||
"quotaTitle": "Mức dùng",
|
||||
@@ -80,10 +82,15 @@
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||
"quotaWindow": { "wk": "Tuần", "mo": "Tháng" }
|
||||
"quotaWindow": {
|
||||
"wk": "Tuần",
|
||||
"mo": "Tháng"
|
||||
}
|
||||
},
|
||||
"version": "Phiên bản",
|
||||
"query": "Truy vấn",
|
||||
"openMenu": "Mở menu",
|
||||
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
|
||||
"closeMenu": "Đóng menu",
|
||||
"sidebar": "Thanh bên",
|
||||
"now": "bây giờ"
|
||||
|
||||
@@ -53,6 +53,55 @@
|
||||
"copyId": "Sao chép ID session",
|
||||
"copyFolder": "Sao chép đường dẫn thư mục"
|
||||
},
|
||||
"replay": {
|
||||
"tab": "Phát lại",
|
||||
"loading": "Đang tải sự kiện…",
|
||||
"label": "Phát lại phiên",
|
||||
"truncated": "Đang phát lại {{shown}} sự kiện mới nhất trong {{total}}.",
|
||||
"start": "Về đầu (Home)",
|
||||
"back": "Lùi một bước (←)",
|
||||
"play": "Phát (Space)",
|
||||
"pause": "Tạm dừng (Space)",
|
||||
"forward": "Tiến một bước (→)",
|
||||
"end": "Đến cuối (End)",
|
||||
"speed": "Tốc độ phát",
|
||||
"position": "{{at}} / {{n}}",
|
||||
"scrubber": "Vị trí sự kiện",
|
||||
"minimap": "Mốc vòng đời",
|
||||
"marker": {
|
||||
"SessionStart": "Bắt đầu phiên",
|
||||
"UserPromptSubmit": "Prompt",
|
||||
"Compaction": "Nén ngữ cảnh",
|
||||
"SubagentStop": "Subagent xong",
|
||||
"APIError": "Lỗi API",
|
||||
"Stop": "Kết thúc lượt"
|
||||
},
|
||||
"state": {
|
||||
"status": "Trạng thái",
|
||||
"tool": "Công cụ đang chạy",
|
||||
"prompts": "Prompt",
|
||||
"errors": "Lỗi",
|
||||
"compactions": "Lần nén",
|
||||
"elapsed": "Đã trôi qua",
|
||||
"toolCounts": "Số lần gọi công cụ đến lúc này"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"create": "Snapshot",
|
||||
"hint": "Đóng băng phiên này thành một bản chỉ đọc kèm liên kết chia sẻ",
|
||||
"title": "Tạo snapshot",
|
||||
"body": "Đóng băng phiên như hiện tại — agent, sự kiện, thống kê và chi phí — thành một bản chỉ đọc dưới một liên kết riêng. Hoạt động sau đó không làm nó thay đổi, và nó vẫn còn sau khi Xóa dữ liệu.",
|
||||
"redactPaths": "Che đường dẫn (thư mục làm việc, transcript và đường dẫn tệp)",
|
||||
"confirm": "Tạo và sao chép liên kết",
|
||||
"cancel": "Hủy",
|
||||
"open": "Mở snapshot",
|
||||
"notFound": "Không tìm thấy snapshot",
|
||||
"banner": "Snapshot chụp lúc {{date}}",
|
||||
"bannerBody": "Bản chỉ đọc của phiên tại thời điểm đó. Không có gì ở đây được cập nhật.",
|
||||
"redacted": "Đã che đường dẫn",
|
||||
"openLive": "Mở phiên đang có",
|
||||
"timeline": "Dòng thời gian ({{n}})"
|
||||
},
|
||||
"exportCsv": "CSV",
|
||||
"exportJson": "JSON",
|
||||
"exportCapped": "Tệp chỉ gồm {{shown}} trên {{total}} phiên (giới hạn xuất) — hãy thu hẹp bộ lọc để xuất phần còn lại.",
|
||||
|
||||
@@ -303,6 +303,14 @@
|
||||
"typeToConfirm": "Gõ “{{phrase}}” để xác nhận:",
|
||||
"phrase": "xóa toàn bộ dữ liệu"
|
||||
},
|
||||
"autostart": {
|
||||
"title": "Khởi động",
|
||||
"label": "Tự động khởi động AgentHub",
|
||||
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
|
||||
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
|
||||
},
|
||||
"about": {
|
||||
"title": "Giới thiệu",
|
||||
"description": "Thông tin runtime của máy chủ.",
|
||||
@@ -561,6 +569,11 @@
|
||||
"deleteAndPurge": "Xóa + xóa dữ liệu",
|
||||
"removeKeep": "Xóa (giữ dữ liệu)",
|
||||
"remoteSourceBadgeTitle": "Được thu thập từ một máy từ xa",
|
||||
"fieldSyncCodex": "Kéo cả phiên Codex",
|
||||
"fieldRemoteCodexHome": "Thư mục Codex trên máy từ xa (tùy chọn)",
|
||||
"fieldRemoteCodexHomeHint": "Mặc định ~/.codex (wsl:~/.codex khi thư mục Claude nằm trong WSL). Thư mục sessions/ của nó được sao chép về, và các phiên hiện ra như phiên Codex của nguồn này, kèm token và chi phí.",
|
||||
"codexOn": "+ Codex",
|
||||
"syncCodex": "{{n}} Codex",
|
||||
"fieldLabelPlaceholder": "Máy dev"
|
||||
},
|
||||
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
|
||||
@@ -802,6 +815,14 @@
|
||||
"about": {
|
||||
"title": "Giới thiệu",
|
||||
"description": "Phiên bản máy chủ, thời gian hoạt động và môi trường chạy."
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Snapshot",
|
||||
"description": "Các bản sao chỉ đọc, đã đóng băng của phiên, mỗi bản có liên kết riêng."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Báo cáo",
|
||||
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
|
||||
}
|
||||
},
|
||||
"backPrevious": "Quay lại",
|
||||
@@ -839,6 +860,79 @@
|
||||
"enable": "Bật",
|
||||
"disable": "Tắt"
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Snapshot phiên",
|
||||
"description": "Tạo từ trang của phiên bằng nút Snapshot. Chúng vẫn còn sau khi Xóa dữ liệu; xóa chúng ở đây.",
|
||||
"empty": "Chưa có snapshot nào.",
|
||||
"events": "{{n}} sự kiện",
|
||||
"redacted": "Đã che đường dẫn",
|
||||
"open": "Mở",
|
||||
"copyLink": "Sao chép liên kết",
|
||||
"delete": "Xóa",
|
||||
"deleteTitle": "Xóa snapshot này?",
|
||||
"deleteBody": "Liên kết của nó sẽ không dùng được nữa. Bản thân phiên không bị ảnh hưởng.",
|
||||
"cancel": "Hủy"
|
||||
},
|
||||
"pushIngest": {
|
||||
"title": "Đẩy dữ liệu về (push ingest)",
|
||||
"description": "Cho máy mà dashboard không SSH tới được: máy đó tự đẩy hook của nó tới POST /api/hooks/ingest-batch bằng token tạo ở đây. Các phiên được gắn tên máy gửi lên (push:<tên>) và có trạng thái, sự kiện trực tiếp; chi phí token cần transcript, thứ chỉ nguồn SSH mang về.",
|
||||
"on": "Đang bật — đã có token",
|
||||
"off": "Đang tắt — chưa có token",
|
||||
"create": "Tạo token",
|
||||
"rotate": "Đổi token",
|
||||
"revoke": "Thu hồi",
|
||||
"rotateConfirm": "Tạo token mới? Token hiện tại ngừng hoạt động ngay.",
|
||||
"revokeConfirm": "Thu hồi token và tắt push ingest?",
|
||||
"shownOnce": "Hãy sao chép token này ngay: chỉ bản băm của nó được lưu, nên nó không bao giờ hiện lại.",
|
||||
"copy": "Sao chép",
|
||||
"envOwned": "AGENTHUB_PUSH_TOKEN_SHA256 được đặt bởi môi trường khởi động dashboard; hãy đổi nó ở đó.",
|
||||
"reachHint": "Máy kia phải truy cập được dashboard này: khởi động với DASHBOARD_HOST (và DASHBOARD_ALLOWED_HOSTS cho tên của nó). Qua http thường, token đi trên mạng ở dạng đọc được — chỉ dùng trong mạng tin cậy, hoặc sau TLS. Script chuyển tiếp dưới đây giữ token trong một tệp header, không bao giờ trên dòng lệnh:",
|
||||
"limits": "Tối đa 500 sự kiện và 1 MB mỗi yêu cầu. Hook cho phiên thuộc nguồn khác bị từ chối; đường dẫn transcript bị bỏ (chúng trỏ tới tệp trên máy gửi). Nhận hook Codex; không nhận hook Antigravity."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Quyền riêng tư",
|
||||
"description": "Những gì AgentHub giữ lại từ hook và lịch sử nhập vào. Cả hai mặc định tắt và chỉ áp dụng cho dữ liệu mới.",
|
||||
"redact": "Che bí mật trước khi lưu",
|
||||
"redactHint": "Khóa API và token có dạng đã biết, header bearer, khóa riêng và giá trị password=/TOKEN= trong payload và tóm tắt sự kiện được thay bằng [redacted]. Transcript trên đĩa là tệp của chính CLI và không bị thay đổi.",
|
||||
"ignore": "Thư mục bỏ qua",
|
||||
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
|
||||
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Báo cáo định kỳ",
|
||||
"description": "Bản tóm tắt những ngày gần nhất — phiên, sự kiện, token, chi phí theo model và theo công cụ AI, công cụ dùng nhiều nhất, ngân sách — ghi ra HTML hoặc JSON theo lịch. Báo cáo và lịch sử chạy được giữ lại khi Xóa dữ liệu.",
|
||||
"add": "Báo cáo mới",
|
||||
"defaultName": "Tóm tắt hằng tuần",
|
||||
"empty": "Chưa có báo cáo định kỳ nào.",
|
||||
"name": "Tên",
|
||||
"windowDays": "Số ngày",
|
||||
"frequency": "Tần suất",
|
||||
"daily": "Hằng ngày",
|
||||
"weekly": "Hằng tuần",
|
||||
"weekday": "Ngày",
|
||||
"hour": "Giờ",
|
||||
"windowHint": "Báo cáo gồm các ngày trọn vẹn trước ngày chạy, theo múi giờ của trình duyệt này.",
|
||||
"save": "Lưu",
|
||||
"cancel": "Hủy",
|
||||
"enabled": "Bật",
|
||||
"runNow": "Chạy ngay",
|
||||
"history": "Lịch sử",
|
||||
"edit": "Sửa",
|
||||
"delete": "Xóa",
|
||||
"everyDay": "Mỗi ngày lúc {{time}}",
|
||||
"everyWeek": "Mỗi {{day}} lúc {{time}}",
|
||||
"window_one": "{{count}} ngày gần nhất",
|
||||
"window_other": "{{count}} ngày gần nhất",
|
||||
"lastRun": "Lần chạy gần nhất {{at}}",
|
||||
"noRuns": "Chưa chạy lần nào.",
|
||||
"trigger": {
|
||||
"schedule": "Theo lịch",
|
||||
"manual": "Thủ công"
|
||||
},
|
||||
"download": "Tải {{format}}",
|
||||
"deleteTitle": "Xóa báo cáo này?",
|
||||
"deleteBody": "Lịch chạy, lịch sử chạy và các tệp nó đã ghi sẽ bị xóa."
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "Tùy chọn của trình duyệt này",
|
||||
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file Tests for spendPace — the 7-day burn rate, month to date and the
|
||||
* month-end forecast built from the daily cost series.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spendPace } from "../burnRate";
|
||||
|
||||
describe("spendPace", () => {
|
||||
it("averages the last 7 days and carries the pace to month end", () => {
|
||||
const daily = [
|
||||
{ date: "2026-09-28", cost: 70 }, // before the 7-day window (09-29..10-05)
|
||||
{ date: "2026-09-30", cost: 7 }, // inside the window, last month
|
||||
{ date: "2026-10-01", cost: 7 },
|
||||
{ date: "2026-10-05", cost: 14 },
|
||||
{ date: "2026-10-06", cost: 99 }, // after today: ignored
|
||||
];
|
||||
const p = spendPace(daily, "2026-10-05");
|
||||
expect(p.perDay7).toBe(4); // (7 + 7 + 14) / 7
|
||||
expect(p.monthToDate).toBe(21);
|
||||
expect(p.forecastMonth).toBe(21 + 4 * 26); // 26 days left in October
|
||||
});
|
||||
it("is all zero with no spend", () => {
|
||||
expect(spendPace([], "2026-02-28")).toEqual({ perDay7: 0, monthToDate: 0, forecastMonth: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file dateRange.test.ts
|
||||
* @description The Analytics range presets: `?range=` parsing, a preset's
|
||||
* local bounds (week from Monday, month from the 1st), a custom range, and the query params `applyRange` sets.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyRange, parsePreset, rangeDates } from "../dateRange";
|
||||
|
||||
describe("dateRange", () => {
|
||||
const today = new Date(2026, 9, 5, 12); // local 2026-10-05
|
||||
|
||||
it("parses presets, defaulting to all", () => {
|
||||
expect(parsePreset("30d")).toBe("30d");
|
||||
expect(parsePreset("1y")).toBe("all");
|
||||
expect(parsePreset(null)).toBe("all");
|
||||
});
|
||||
|
||||
it("gives a preset's local bounds, today included", () => {
|
||||
expect(rangeDates("7d", {}, today)).toEqual({ from: "2026-09-29", to: "2026-10-05" });
|
||||
expect(rangeDates("90d", {}, today).from).toBe("2026-07-08");
|
||||
expect(rangeDates("all", {}, today)).toEqual({});
|
||||
});
|
||||
|
||||
it("starts the week on Monday and the month on the 1st", () => {
|
||||
const wed = new Date(2026, 9, 7, 12);
|
||||
const sun = new Date(2026, 9, 11, 12);
|
||||
expect(rangeDates("today", {}, wed)).toEqual({ from: "2026-10-07", to: "2026-10-07" });
|
||||
expect(rangeDates("week", {}, wed)).toEqual({ from: "2026-10-05", to: "2026-10-07" });
|
||||
expect(rangeDates("week", {}, sun)).toEqual({ from: "2026-10-05", to: "2026-10-11" });
|
||||
expect(rangeDates("week", {}, today)).toEqual({ from: "2026-10-05", to: "2026-10-05" });
|
||||
expect(rangeDates("month", {}, wed)).toEqual({ from: "2026-10-01", to: "2026-10-07" });
|
||||
expect(rangeDates("custom", { from: "2026-01-01", to: "" }, today)).toEqual({
|
||||
from: "2026-01-01",
|
||||
to: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets from/to and tz_offset only when there is a bound", () => {
|
||||
expect(applyRange(new URLSearchParams(), {}).toString()).toBe("");
|
||||
const qs = applyRange(new URLSearchParams(), { from: "2026-01-01" });
|
||||
expect(qs.get("from")).toBe("2026-01-01");
|
||||
expect(qs.has("to")).toBe(false);
|
||||
expect(qs.get("tz_offset")).toBe(String(new Date().getTimezoneOffset()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file replay.test.ts
|
||||
* @description Unit tests for the Replay tab's pure derivations: playback
|
||||
* order, lifecycle markers and the session state at a cursor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chronological, lifecycleMarkers, replayState } from "../replay";
|
||||
import type { DashboardEvent } from "../types";
|
||||
|
||||
let nextId = 1;
|
||||
function ev(event_type: string, sec: number, tool_name: string | null = null, agent = "main") {
|
||||
return {
|
||||
id: nextId++,
|
||||
session_id: "s",
|
||||
agent_id: agent,
|
||||
event_type,
|
||||
tool_name,
|
||||
summary: null,
|
||||
data: null,
|
||||
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, sec)).toISOString(),
|
||||
} as unknown as DashboardEvent;
|
||||
}
|
||||
|
||||
const events = [
|
||||
ev("SessionStart", 0),
|
||||
ev("UserPromptSubmit", 1),
|
||||
ev("PreToolUse", 2, "Read"),
|
||||
ev("PostToolUse", 3, "Read"),
|
||||
ev("PreToolUse", 4, "Bash"),
|
||||
ev("PreToolUse", 5, "Read", "sub"),
|
||||
ev("APIError", 6),
|
||||
ev("PostToolUse", 7, "Bash"),
|
||||
ev("Stop", 8),
|
||||
];
|
||||
|
||||
describe("chronological", () => {
|
||||
it("orders by time, then id", () => {
|
||||
const tie = [ev("Stop", 9), ev("Stop", 9)];
|
||||
const out = chronological([tie[1]!, events[2]!, tie[0]!, events[0]!]);
|
||||
expect(out.map((e) => e.id)).toEqual([events[0]!.id, events[2]!.id, tie[0]!.id, tie[1]!.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lifecycleMarkers", () => {
|
||||
it("marks lifecycle events at their sequence position", () => {
|
||||
const m = lifecycleMarkers(events);
|
||||
expect(m.map((x) => x.type)).toEqual(["SessionStart", "UserPromptSubmit", "APIError", "Stop"]);
|
||||
expect(m[0]!.pct).toBe(0);
|
||||
expect(m[3]!.pct).toBe(100);
|
||||
expect(m[2]!.index).toBe(6);
|
||||
});
|
||||
|
||||
it("handles a single event", () => {
|
||||
expect(lifecycleMarkers([events[0]!])).toEqual([{ index: 0, type: "SessionStart", pct: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replayState", () => {
|
||||
it("is empty for no events", () => {
|
||||
const s = replayState([], 0);
|
||||
expect(s.status).toBeNull();
|
||||
expect(s.currentTool).toBeNull();
|
||||
expect(s.toolCounts).toEqual([]);
|
||||
});
|
||||
|
||||
it("tracks the tool in flight and counts up to the cursor", () => {
|
||||
const at4 = replayState(events, 4);
|
||||
expect(at4.currentTool).toBe("Bash");
|
||||
expect(at4.status).toBe("working");
|
||||
expect(at4.prompts).toBe(1);
|
||||
expect(at4.toolCounts).toEqual([
|
||||
{ tool: "Bash", count: 1 },
|
||||
{ tool: "Read", count: 1 },
|
||||
]);
|
||||
expect(at4.elapsedMs).toBe(4000);
|
||||
|
||||
const at6 = replayState(events, 6);
|
||||
expect(at6.currentTool).toBe("Read"); // the subagent's, newest still open
|
||||
expect(at6.errors).toBe(1);
|
||||
expect(at6.status).toBe("error");
|
||||
expect(at6.toolCounts[0]).toEqual({ tool: "Read", count: 2 });
|
||||
});
|
||||
|
||||
it("closes a finished tool and an ended turn", () => {
|
||||
expect(replayState(events, 3).currentTool).toBeNull();
|
||||
// Stop on main leaves only the subagent's Read open.
|
||||
expect(replayState(events, 8).currentTool).toBe("Read");
|
||||
expect(replayState(events, 8).status).toBe("waiting");
|
||||
});
|
||||
|
||||
it("clamps a cursor past the end", () => {
|
||||
expect(replayState(events, 99).elapsedMs).toBe(8000);
|
||||
});
|
||||
});
|
||||
+168
-3
@@ -387,6 +387,8 @@ import type {
|
||||
AlertEvent,
|
||||
AlertRule,
|
||||
Analytics,
|
||||
BudgetStatus,
|
||||
RulePreview,
|
||||
CodexHooksStatus,
|
||||
AntigravityHooksStatus,
|
||||
CostResult,
|
||||
@@ -424,10 +426,17 @@ import type {
|
||||
WorkflowRun,
|
||||
WorkflowRunsResponse,
|
||||
WorkflowRunDetail,
|
||||
SessionSnapshotInfo,
|
||||
SessionSnapshotPayload,
|
||||
QueryPage,
|
||||
ReportDefinition,
|
||||
ReportInput,
|
||||
ReportRun,
|
||||
} from "./types";
|
||||
|
||||
import { activeSourcesParam } from "./dataScope";
|
||||
import i18n from "i18next";
|
||||
import { applyRange, type DateRange } from "./dateRange";
|
||||
|
||||
// Root path all endpoint paths are appended to. Kept relative (no host) so the
|
||||
// same client bundle works behind the Vite dev proxy and in same-origin prod.
|
||||
@@ -979,10 +988,15 @@ export const api = {
|
||||
* day, the viewer's timezone offset is sent so the daily buckets align to
|
||||
* the user's local midnight.
|
||||
*
|
||||
* @param opts.tool Only sessions of this AI tool (`sessions.tool`).
|
||||
* @param opts.range Local `from`/`to` days: counts, tokens and cost follow
|
||||
* it; the daily series keep their own window.
|
||||
* @returns {@link Analytics} — the chart-ready analytics payload.
|
||||
*/
|
||||
get: () => {
|
||||
get: (opts: { tool?: string; range?: DateRange } = {}) => {
|
||||
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
applyRange(qs, opts.range);
|
||||
applyScope(qs);
|
||||
return request<Analytics>(`/analytics?${qs.toString()}`);
|
||||
},
|
||||
@@ -1100,6 +1114,41 @@ export const api = {
|
||||
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
|
||||
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
|
||||
},
|
||||
/** Start the dashboard at boot/login (systemd `--user` / LaunchAgent) — off
|
||||
* by default; a change never starts or stops the running server. */
|
||||
autostart: {
|
||||
/** GET /api/settings/autostart */
|
||||
get: () => request<AutostartStatus>("/settings/autostart"),
|
||||
/** PUT /api/settings/autostart */
|
||||
set: (enabled: boolean) =>
|
||||
request<AutostartStatus>("/settings/autostart", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
},
|
||||
/** Opt-in secret redaction of stored payloads and the folders whose hooks
|
||||
* are dropped (`server/lib/privacy.js`). */
|
||||
privacy: {
|
||||
/** GET /api/settings/privacy */
|
||||
get: () => request<PrivacySettings>("/settings/privacy"),
|
||||
/** PUT /api/settings/privacy — any subset of the two fields */
|
||||
save: (data: { redact_secrets?: boolean; ignore_cwd?: string[] }) =>
|
||||
request<PrivacySettings & { ok: boolean }>("/settings/privacy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
|
||||
* stored: `rotate` returns the new token this once, `get` never does. */
|
||||
pushToken: {
|
||||
/** GET /api/settings/push-token */
|
||||
get: () => request<PushTokenStatus>("/settings/push-token"),
|
||||
/** POST /api/settings/push-token — a new token; the old one stops working */
|
||||
rotate: () =>
|
||||
request<PushTokenStatus & { token: string }>("/settings/push-token", { method: "POST" }),
|
||||
/** DELETE /api/settings/push-token — push ingest off */
|
||||
revoke: () => request<PushTokenStatus>("/settings/push-token", { method: "DELETE" }),
|
||||
},
|
||||
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
|
||||
* only on a click; agy runs it with no trust step. */
|
||||
antigravityHooks: {
|
||||
@@ -1256,6 +1305,8 @@ export const api = {
|
||||
* "all" (which means "no filter"), keeping the default URL param-free.
|
||||
*
|
||||
* @param status Optional lifecycle filter; "all" (or omitted) means no filter.
|
||||
* @param tool Optional AI tool id (`sessions.tool`).
|
||||
* @param range Optional local date range: sessions started in it.
|
||||
* @param sections Optional subset of the bundle to compute (`?sections=`);
|
||||
* the server skips the rest, so a page reading a few cards asks for those.
|
||||
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
|
||||
@@ -1263,10 +1314,14 @@ export const api = {
|
||||
*/
|
||||
get: <K extends keyof WorkflowData = keyof WorkflowData>(
|
||||
status?: string,
|
||||
tool?: string,
|
||||
range?: DateRange,
|
||||
sections?: readonly K[]
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (status && status !== "all") qs.set("status", status);
|
||||
if (tool) qs.set("tool", tool);
|
||||
applyRange(qs, range);
|
||||
if (sections?.length) qs.set("sections", sections.join(","));
|
||||
const q = qs.toString();
|
||||
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
|
||||
@@ -1381,15 +1436,18 @@ export const api = {
|
||||
*
|
||||
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
||||
*/
|
||||
totalCost: (opts: { lane?: number; date?: string } = {}) => {
|
||||
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
|
||||
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
||||
// stats / analytics endpoints — otherwise switching the Data scope selector
|
||||
// left the Dashboard "total cost" showing the un-narrowed global total.
|
||||
// `lane`: only the sessions that workspace owns.
|
||||
// `lane`: only the sessions that workspace owns; `tool`: one AI tool's.
|
||||
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
|
||||
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
|
||||
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
|
||||
if (opts.date) qs.set("date", opts.date);
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
// `range`: total, breakdown and by_tool for those local days only.
|
||||
applyRange(qs, opts.range);
|
||||
applyScope(qs);
|
||||
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
|
||||
},
|
||||
@@ -2066,6 +2124,24 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
|
||||
snapshots: {
|
||||
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
|
||||
/** Freeze a session; `filePaths` redacts paths before anything is stored. */
|
||||
create: (sessionId: string, opts?: { filePaths?: boolean }) =>
|
||||
request<{ snapshot: SessionSnapshotInfo }>("/snapshots", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
redact: { file_paths: opts?.filePaths === true },
|
||||
}),
|
||||
}),
|
||||
get: (token: string) =>
|
||||
request<{ snapshot: SessionSnapshotInfo; payload: SessionSnapshotPayload }>(
|
||||
`/snapshots/${encodeURIComponent(token)}`
|
||||
),
|
||||
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
||||
pets: {
|
||||
list: () => request<PetList>("/pets"),
|
||||
@@ -2329,9 +2405,65 @@ export const api = {
|
||||
*/
|
||||
remove: (id: string) =>
|
||||
request<{ ok: true }>(`/alerts/rules/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/**
|
||||
* POST /api/alerts/rules/preview - what a rule WOULD have fired over the
|
||||
* last `days` (default 7) of recorded history. Saves and fires nothing.
|
||||
*/
|
||||
preview: (body: {
|
||||
rule_type: AlertRule["rule_type"];
|
||||
config: AlertRule["config"];
|
||||
cooldown_seconds?: number;
|
||||
days?: number;
|
||||
}) =>
|
||||
request<RulePreview>("/alerts/rules/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
/** Spend budgets — `budget` alert rules — with their current-period spend. */
|
||||
budgets: {
|
||||
/** GET /api/budgets/status */
|
||||
status: () => request<{ budgets: BudgetStatus[] }>("/budgets/status"),
|
||||
},
|
||||
|
||||
/** Query Explorer (`GET /api/query`, `server/routes/query.js`). */
|
||||
query: {
|
||||
/** One page of the filtered rows; `params` are the page's filters. */
|
||||
page: (params: URLSearchParams) =>
|
||||
request<QueryPage>(`/query?${applyScope(new URLSearchParams(params)).toString()}`),
|
||||
/** A download link for the whole filtered set (≤ `export_cap` rows). */
|
||||
exportUrl: (params: URLSearchParams, format: "csv" | "json") => {
|
||||
const qs = applyScope(new URLSearchParams(params));
|
||||
qs.delete("limit");
|
||||
qs.delete("offset");
|
||||
qs.set("format", format);
|
||||
return `${BASE}/query?${qs.toString()}`;
|
||||
},
|
||||
},
|
||||
|
||||
/** Scheduled reports (`/api/reports`, `server/lib/reports.js`). */
|
||||
reports: {
|
||||
list: () => request<{ reports: ReportDefinition[] }>("/reports"),
|
||||
create: (body: ReportInput) =>
|
||||
request<{ report: ReportDefinition }>("/reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: number, body: Partial<ReportInput>) =>
|
||||
request<{ report: ReportDefinition }>(`/reports/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: number) => request<{ ok: true }>(`/reports/${id}`, { method: "DELETE" }),
|
||||
run: (id: number) => request<{ run: ReportRun }>(`/reports/${id}/run`, { method: "POST" }),
|
||||
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
|
||||
/** A run's file, as a download link. */
|
||||
fileUrl: (id: number, runId: number, format: string) =>
|
||||
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
|
||||
},
|
||||
|
||||
// ───────────────────────────────── Webhooks API ─────────────────────────────
|
||||
/** Outbound webhook target CRUD, provider metadata, test sends, and the
|
||||
* per-target delivery log. */
|
||||
@@ -4172,6 +4304,10 @@ export interface RemoteSource {
|
||||
identity_file: string | null;
|
||||
/** Optional remote CLAUDE_HOME (default `~/.claude`). */
|
||||
remote_home: string | null;
|
||||
/** Whether Codex history (`<remote_codex_home>/sessions`) is pulled too. */
|
||||
sync_codex?: boolean;
|
||||
/** Optional remote CODEX_HOME (default `~/.codex`). */
|
||||
remote_codex_home?: string | null;
|
||||
/** Whether the background poller pulls this source. */
|
||||
enabled: boolean;
|
||||
/** Last known sync state. */
|
||||
@@ -4188,6 +4324,9 @@ export interface RemoteSource {
|
||||
errors?: number;
|
||||
sessions_seen?: number;
|
||||
sessions_tagged?: number;
|
||||
/** Codex journals read / sessions written, when Codex is pulled. */
|
||||
codex_files?: number;
|
||||
codex_sessions?: number;
|
||||
} | null;
|
||||
/** Live number of sessions currently attributed to this source. */
|
||||
session_count?: number;
|
||||
@@ -4202,9 +4341,35 @@ export interface RemoteSourceInput {
|
||||
ssh_port?: number | null;
|
||||
identity_file?: string | null;
|
||||
remote_home?: string | null;
|
||||
sync_codex?: boolean;
|
||||
remote_codex_home?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/settings/autostart. `available` false: no systemd `--user` /
|
||||
* launchd GUI session here; `foreign`: a unit named agenthub AgentHub did not
|
||||
* write, which it never touches. */
|
||||
export interface AutostartStatus {
|
||||
available: boolean;
|
||||
kind: "systemd" | "launchd";
|
||||
enabled: boolean;
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/settings/privacy. A field the environment set is `env_owned`
|
||||
* and cannot be saved from the dashboard. */
|
||||
export interface PrivacySettings {
|
||||
redact_secrets: boolean;
|
||||
ignore_cwd: string[];
|
||||
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
|
||||
export interface PushTokenStatus {
|
||||
enabled: boolean;
|
||||
env_owned: boolean;
|
||||
}
|
||||
|
||||
/** Result of a connectivity probe (POST /:id/test). */
|
||||
export interface RemoteSourceTestResult {
|
||||
ok: boolean;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @file Spend pace from the daily cost series (`daily_costs` of GET
|
||||
* /api/pricing/cost, already in local dates): the average cost per day over
|
||||
* the last 7 days, the month to date, and a month-end forecast that carries
|
||||
* that pace over the days left in the month.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export interface SpendPace {
|
||||
/** Average cost per day over the 7 days ending today (today included). */
|
||||
perDay7: number;
|
||||
/** Spend since the 1st of this month. */
|
||||
monthToDate: number;
|
||||
/** monthToDate + perDay7 × the days left after today. */
|
||||
forecastMonth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param daily `{date: "YYYY-MM-DD", cost}` per local day, any order.
|
||||
* @param today Today's local date, `YYYY-MM-DD`.
|
||||
*/
|
||||
export function spendPace(daily: Array<{ date: string; cost: number }>, today: string): SpendPace {
|
||||
const [y, m, d] = today.split("-").map(Number) as [number, number, number];
|
||||
const day = (offset: number) => {
|
||||
const t = new Date(Date.UTC(y, m - 1, d + offset));
|
||||
return t.toISOString().slice(0, 10);
|
||||
};
|
||||
const weekStart = day(-6);
|
||||
const monthStart = today.slice(0, 8) + "01";
|
||||
let week = 0;
|
||||
let mtd = 0;
|
||||
for (const r of daily) {
|
||||
if (r.date > today) continue;
|
||||
if (r.date >= weekStart) week += r.cost;
|
||||
if (r.date >= monthStart) mtd += r.cost;
|
||||
}
|
||||
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
const perDay7 = week / 7;
|
||||
return { perDay7, monthToDate: mtd, forecastMonth: mtd + perDay7 * (daysInMonth - d) };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file dateRange — the Analytics date range: the preset ids kept in `?range=`
|
||||
* (today / this week from Monday / this month from the 1st / 7d / 30d / 90d /
|
||||
* all / custom), and `rangeDates`, which turns a preset (or
|
||||
* a custom from/to) into the local `YYYY-MM-DD` bounds sent as `from`/`to`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const RANGE_PRESETS = [
|
||||
"today",
|
||||
"week",
|
||||
"month",
|
||||
"7d",
|
||||
"30d",
|
||||
"90d",
|
||||
"all",
|
||||
"custom",
|
||||
] as const;
|
||||
export type RangePreset = (typeof RANGE_PRESETS)[number];
|
||||
|
||||
/** Local, inclusive bounds; an absent end is open. */
|
||||
export interface DateRange {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/** A `?range=` value, or `all` for anything else. */
|
||||
export function parsePreset(v: string | null): RangePreset {
|
||||
return (RANGE_PRESETS as readonly string[]).includes(v ?? "") ? (v as RangePreset) : "all";
|
||||
}
|
||||
|
||||
/** `d` as a local `YYYY-MM-DD`. */
|
||||
export function localDate(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** The bounds of a preset: `7d` = today and the 6 days before it; `week`
|
||||
* starts on Monday, `month` on the 1st, both ending today. */
|
||||
export function rangeDates(
|
||||
preset: RangePreset,
|
||||
custom: DateRange = {},
|
||||
today: Date = new Date()
|
||||
): DateRange {
|
||||
if (preset === "all") return {};
|
||||
if (preset === "custom") {
|
||||
return { from: custom.from || undefined, to: custom.to || undefined };
|
||||
}
|
||||
const start = new Date(today);
|
||||
if (preset === "week") start.setDate(today.getDate() - ((today.getDay() + 6) % 7));
|
||||
else if (preset === "month") start.setDate(1);
|
||||
else if (preset !== "today") start.setDate(today.getDate() - (parseInt(preset, 10) - 1));
|
||||
return { from: localDate(start), to: localDate(today) };
|
||||
}
|
||||
|
||||
/** Set `from`/`to` (and `tz_offset`, which reads them) on a query string. */
|
||||
export function applyRange(qs: URLSearchParams, range?: DateRange): URLSearchParams {
|
||||
if (range?.from) qs.set("from", range.from);
|
||||
if (range?.to) qs.set("to", range.to);
|
||||
if ((range?.from || range?.to) && !qs.has("tz_offset")) {
|
||||
qs.set("tz_offset", String(new Date().getTimezoneOffset()));
|
||||
}
|
||||
return qs;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @file replay.ts
|
||||
* @description Pure derivations behind the session Replay tab: events in
|
||||
* playback order, the lifecycle markers on its mini-map, and the state a
|
||||
* session was in at a cursor (status, tool in flight, tool counts).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { AgentStatus, DashboardEvent } from "./types";
|
||||
import { statusFromEventType } from "./event-grouping";
|
||||
|
||||
export const REPLAY_SPEEDS = [0.5, 1, 2, 5] as const;
|
||||
/** One event per this many ms at 1× — playback walks the sequence, not the
|
||||
* wall clock, so an hour-long pause does not stall the replay. */
|
||||
export const REPLAY_STEP_MS = 500;
|
||||
|
||||
/** Event types drawn on the mini-map, in legend order. */
|
||||
export const LIFECYCLE_TYPES = [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Compaction",
|
||||
"SubagentStop",
|
||||
"APIError",
|
||||
"Stop",
|
||||
] as const;
|
||||
|
||||
/** Oldest first; `id` breaks timestamp ties. */
|
||||
export function chronological(events: DashboardEvent[]): DashboardEvent[] {
|
||||
return [...events].sort(
|
||||
(a, b) => a.created_at.localeCompare(b.created_at) || (a.id ?? 0) - (b.id ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReplayMarker {
|
||||
index: number;
|
||||
type: string;
|
||||
/** Position on the mini-map, 0–100, matching the slider's scale. */
|
||||
pct: number;
|
||||
}
|
||||
|
||||
export function lifecycleMarkers(events: DashboardEvent[]): ReplayMarker[] {
|
||||
const kinds = new Set<string>(LIFECYCLE_TYPES);
|
||||
const last = Math.max(1, events.length - 1);
|
||||
const out: ReplayMarker[] = [];
|
||||
events.forEach((e, index) => {
|
||||
if (kinds.has(e.event_type)) out.push({ index, type: e.event_type, pct: (index / last) * 100 });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ReplayState {
|
||||
status: AgentStatus | null;
|
||||
/** The newest tool started and not yet finished at the cursor. */
|
||||
currentTool: string | null;
|
||||
/** Tool calls (PreToolUse) so far, most used first. */
|
||||
toolCounts: { tool: string; count: number }[];
|
||||
prompts: number;
|
||||
errors: number;
|
||||
compactions: number;
|
||||
/** From the first event to the cursor's. */
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
/** The session as it stood after `events[cursor]` (events in playback order). */
|
||||
export function replayState(events: DashboardEvent[], cursor: number): ReplayState {
|
||||
const end = Math.min(cursor, events.length - 1);
|
||||
const open: { agent: string; tool: string }[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
let prompts = 0;
|
||||
let errors = 0;
|
||||
let compactions = 0;
|
||||
for (let i = 0; i <= end; i++) {
|
||||
const e = events[i]!;
|
||||
const agent = e.agent_id ?? "";
|
||||
switch (e.event_type) {
|
||||
case "PreToolUse":
|
||||
if (e.tool_name) {
|
||||
open.push({ agent, tool: e.tool_name });
|
||||
counts.set(e.tool_name, (counts.get(e.tool_name) ?? 0) + 1);
|
||||
}
|
||||
break;
|
||||
case "PostToolUse":
|
||||
case "PostToolUseFailure": {
|
||||
for (let j = open.length - 1; j >= 0; j--) {
|
||||
if (open[j]!.agent === agent && open[j]!.tool === e.tool_name) {
|
||||
open.splice(j, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (e.event_type === "PostToolUseFailure") errors++;
|
||||
break;
|
||||
}
|
||||
case "Stop":
|
||||
case "SubagentStop":
|
||||
case "SessionEnd":
|
||||
// A turn that ended leaves nothing of that agent in flight.
|
||||
for (let j = open.length - 1; j >= 0; j--) if (open[j]!.agent === agent) open.splice(j, 1);
|
||||
break;
|
||||
case "UserPromptSubmit":
|
||||
prompts++;
|
||||
break;
|
||||
case "APIError":
|
||||
errors++;
|
||||
break;
|
||||
case "Compaction":
|
||||
compactions++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const first = events[0];
|
||||
const at = end >= 0 ? events[end] : undefined;
|
||||
return {
|
||||
status: at ? statusFromEventType(at.event_type) : null,
|
||||
currentTool: open[open.length - 1]?.tool ?? null,
|
||||
toolCounts: [...counts]
|
||||
.map(([tool, count]) => ({ tool, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)),
|
||||
prompts,
|
||||
errors,
|
||||
compactions,
|
||||
elapsedMs:
|
||||
first && at ? Math.max(0, Date.parse(at.created_at) - Date.parse(first.created_at)) : 0,
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,20 @@ export const SETTINGS_INDEX: {
|
||||
to: "#language",
|
||||
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||
},
|
||||
{
|
||||
section: "data",
|
||||
titleKey: "pushIngest.title",
|
||||
descKey: "pushIngest.description",
|
||||
keywords: ["token", "push", "remote", "batch"],
|
||||
to: "#push-ingest",
|
||||
},
|
||||
{
|
||||
section: "data",
|
||||
titleKey: "privacy.title",
|
||||
descKey: "privacy.description",
|
||||
keywords: ["redact", "secrets", "ignore", "folder", "cwd"],
|
||||
to: "#privacy",
|
||||
},
|
||||
{
|
||||
section: "data",
|
||||
titleKey: "lanesSection.title",
|
||||
@@ -90,6 +104,12 @@ export const SETTINGS_INDEX: {
|
||||
titleKey: "about.title",
|
||||
keywords: ["version", "server", "database", "uptime"],
|
||||
},
|
||||
{
|
||||
section: "about",
|
||||
titleKey: "autostart.label",
|
||||
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "sagi.title",
|
||||
@@ -105,6 +125,18 @@ export const SETTINGS_INDEX: {
|
||||
keywords: ["codex-pets", "upload", "import"],
|
||||
to: "#pet",
|
||||
},
|
||||
{
|
||||
section: "snapshots",
|
||||
titleKey: "snapshots.title",
|
||||
descKey: "snapshots.description",
|
||||
keywords: ["share", "freeze", "link", "redact"],
|
||||
},
|
||||
{
|
||||
section: "reports",
|
||||
titleKey: "reports.title",
|
||||
descKey: "reports.description",
|
||||
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
|
||||
},
|
||||
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
|
||||
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
|
||||
{ section: "appearance", titleKey: "appearance.lightThemes" },
|
||||
|
||||
+149
-3
@@ -731,8 +731,8 @@ export interface Session {
|
||||
source?: string;
|
||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||
* journals and hooks) or `"antigravity"` (agy's conversation files and
|
||||
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
||||
* journals and hooks), `"antigravity"` (agy's conversation files and
|
||||
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
||||
tool?: string;
|
||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||
* it; null for a session no workspace started.
|
||||
@@ -1204,6 +1204,9 @@ export interface CostResult {
|
||||
feature_costs?: CostFeatureCosts;
|
||||
/** Present only when at least one model had usage but no pricing rule. */
|
||||
unpriced_models?: UnpricedModel[];
|
||||
/** Cost per AI tool (`sessions.tool`), most expensive first. Only
|
||||
* GET /api/pricing/cost sends it, not the per-session route. */
|
||||
by_tool?: Array<{ tool: string; cost: number }>;
|
||||
}
|
||||
|
||||
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
||||
@@ -1533,7 +1536,13 @@ export interface CodexConfigChangedPayload {
|
||||
/** Kind of condition an {@link AlertRule} evaluates. "event_pattern" and
|
||||
* "token_threshold" are checked on every hook ingest; "inactivity" and
|
||||
* "status_duration" are checked on a periodic server-side sweep. */
|
||||
export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" | "token_threshold";
|
||||
export type AlertRuleType =
|
||||
| "event_pattern"
|
||||
| "inactivity"
|
||||
| "status_duration"
|
||||
| "token_threshold"
|
||||
| "cost_threshold"
|
||||
| "budget";
|
||||
|
||||
/**
|
||||
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
|
||||
@@ -1544,6 +1553,9 @@ export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" |
|
||||
* - inactivity: `minutes` of session silence before firing.
|
||||
* - status_duration: `status` held continuously for `minutes`.
|
||||
* - token_threshold: cumulative `total_tokens` for a session.
|
||||
* - cost_threshold: a session's cost reaching `usd`.
|
||||
* - budget: spend per `period` against `limit_usd`, firing each of
|
||||
* `thresholds` (percent) once per period, optionally for one `tool`/`lane`.
|
||||
*/
|
||||
export interface AlertRuleConfig {
|
||||
/** event_pattern: exact `DashboardEvent.event_type` to match. */
|
||||
@@ -1565,6 +1577,55 @@ export interface AlertRuleConfig {
|
||||
status?: "working" | "waiting";
|
||||
/** token_threshold: cumulative token count that triggers the alert. */
|
||||
total_tokens?: number;
|
||||
/** cost_threshold: a session's cost in USD that triggers the alert. */
|
||||
usd?: number;
|
||||
/** budget: the period spend is measured over (local time). */
|
||||
period?: "day" | "week" | "month";
|
||||
/** budget: the period's limit in USD. */
|
||||
limit_usd?: number;
|
||||
/** budget: percents of the limit that fire, each once per period. */
|
||||
thresholds?: number[];
|
||||
/** budget: only this AI tool's sessions. */
|
||||
tool?: string;
|
||||
/** budget: only the sessions this workspace owns. */
|
||||
lane?: number;
|
||||
/** budget: the browser's getTimezoneOffset() when the rule was saved. */
|
||||
tz_offset?: number;
|
||||
}
|
||||
|
||||
/** One budget from GET /api/budgets/status: its rule plus current-period spend. */
|
||||
export interface BudgetStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
config: AlertRuleConfig;
|
||||
/** Local dates (inclusive) of the current period. */
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
spend: number;
|
||||
/** The spend at the current pace, carried to the period's end. */
|
||||
projected: number;
|
||||
/** Spend as a percent of `limit_usd`. */
|
||||
pct: number;
|
||||
/** Thresholds already reached this period, lowest first. */
|
||||
crossed: number[];
|
||||
}
|
||||
|
||||
/** POST /api/alerts/rules/preview — what a rule would have fired, read-only. */
|
||||
export interface RulePreview {
|
||||
/** False for a type history cannot replay (status_duration). */
|
||||
supported: boolean;
|
||||
window_days: number;
|
||||
fires: number;
|
||||
sessions: number;
|
||||
samples: Array<{ session_id: string; at: string } & Record<string, unknown>>;
|
||||
/** budget only: the current period and the five before it. */
|
||||
periods?: Array<{
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
spend: number;
|
||||
pct: number;
|
||||
crossed: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A user-defined alert rule from GET/POST/PATCH /api/alerts/rules. Couples a
|
||||
@@ -1927,6 +1988,89 @@ export interface SessionStats {
|
||||
};
|
||||
}
|
||||
|
||||
/** A row of `GET /api/snapshots` — a frozen session, listed without its payload. */
|
||||
export interface SessionSnapshotInfo {
|
||||
id: number;
|
||||
/** The unguessable read token: `/snapshot/<token>`. */
|
||||
token: string;
|
||||
session_id: string;
|
||||
created_at: string;
|
||||
session_name?: string | null;
|
||||
/** File paths were replaced at capture. */
|
||||
redacted?: boolean;
|
||||
event_count?: number;
|
||||
/** Size of the stored payload, bytes. */
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
export type QueryEntity = "sessions" | "events" | "agents";
|
||||
|
||||
/** `GET /api/query` — one page of an entity's filtered rows. */
|
||||
export interface QueryPage {
|
||||
entity: QueryEntity;
|
||||
/** Column names, in order (also the export's header). */
|
||||
columns: string[];
|
||||
/** The columns `sort` accepts. */
|
||||
sortable: string[];
|
||||
/** The values `status` accepts (none for events). */
|
||||
statuses: string[];
|
||||
rows: Record<string, string | number | null>[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** An export holds at most this many rows. */
|
||||
export_cap: number;
|
||||
}
|
||||
|
||||
/** A scheduled report definition (`/api/reports`). */
|
||||
export interface ReportInput {
|
||||
name: string;
|
||||
/** Full local days before the run day. */
|
||||
window_days: number;
|
||||
frequency: "daily" | "weekly";
|
||||
/** Local hour, 0–23. */
|
||||
hour: number;
|
||||
/** 0 = Sunday; weekly only. */
|
||||
weekday: number;
|
||||
/** getTimezoneOffset() of the browser that saved it. */
|
||||
tz_offset: number;
|
||||
formats: ("html" | "json")[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ReportRun {
|
||||
id: number;
|
||||
report_id: number;
|
||||
created_at: string;
|
||||
trigger: "schedule" | "manual";
|
||||
status: "ok" | "error";
|
||||
window_from: string | null;
|
||||
window_to: string | null;
|
||||
/** The formats written. */
|
||||
files: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ReportDefinition extends ReportInput {
|
||||
id: number;
|
||||
created_at: string;
|
||||
last_slot: string | null;
|
||||
last_run?: ReportRun | null;
|
||||
}
|
||||
|
||||
/** What `POST /api/snapshots` froze: the session as it stood at `captured_at`. */
|
||||
export interface SessionSnapshotPayload {
|
||||
version: number;
|
||||
captured_at: string;
|
||||
redacted: { file_paths: boolean };
|
||||
session: Session;
|
||||
agents: Agent[];
|
||||
/** Oldest first. */
|
||||
events: DashboardEvent[];
|
||||
stats: SessionStats;
|
||||
cost: CostResult;
|
||||
}
|
||||
|
||||
// ───── Workflow intelligence (events-derived) ─────
|
||||
// The analytics panels on the Workflows page. Every type below is COMPUTED by
|
||||
// the server from `DashboardEvent`/`Agent` rows (server/routes/workflows.js) -
|
||||
@@ -2764,6 +2908,8 @@ export interface LaneTreeEntry {
|
||||
status?: string;
|
||||
/** A folder with a changed or untracked file anywhere under it. */
|
||||
changed?: true;
|
||||
/** Ignored by git (`.gitignore`) — drawn orange in the Files tree. */
|
||||
ignored?: true;
|
||||
}
|
||||
|
||||
/** One row of a rendered diff — both gutters already resolved server-side. */
|
||||
|
||||
@@ -107,9 +107,15 @@ import { AgentStatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Tip } from "../components/Tip";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { Select } from "../components/Select";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { spendPace } from "../lib/burnRate";
|
||||
import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange";
|
||||
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
|
||||
import { statusFromEventType } from "../lib/event-grouping";
|
||||
import type {
|
||||
BudgetStatus,
|
||||
Stats,
|
||||
Agent,
|
||||
DashboardEvent,
|
||||
@@ -700,6 +706,7 @@ function AnalyticsTab() {
|
||||
const { t, i18n } = useTranslation("analytics");
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [costData, setCostData] = useState<CostResult | null>(null);
|
||||
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [loading, setLoading] = useState(true);
|
||||
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
|
||||
@@ -723,6 +730,28 @@ function AnalyticsTab() {
|
||||
);
|
||||
// Global data scope; a change re-runs `load` (api injects the `sources` param).
|
||||
const [scope] = useDataScope();
|
||||
// One AI tool's sessions only ("" = every tool); a change re-runs `load`.
|
||||
const [tool, setTool] = useState("");
|
||||
const aiTools = useAiTools();
|
||||
// The date range, kept in the URL (`?range=`, plus `from`/`to` when custom).
|
||||
// Headline numbers, cost/token cards and by-model/by-tool follow it; the
|
||||
// 52-week and 30-day charts keep their own windows.
|
||||
const preset = parsePreset(params.get("range"));
|
||||
const customFrom = params.get("from") ?? "";
|
||||
const customTo = params.get("to") ?? "";
|
||||
const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo });
|
||||
const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]);
|
||||
const setRangeParam = (patch: Record<string, string>) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) p.set(k, v);
|
||||
else p.delete(k);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// Responses can land out of order (a slow poll after a fast refresh); only
|
||||
@@ -731,14 +760,16 @@ function AnalyticsTab() {
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
try {
|
||||
const [result, cost] = await Promise.all([
|
||||
api.analytics.get(),
|
||||
api.pricing.totalCost().catch(() => null),
|
||||
const [result, cost, budgetRes] = await Promise.all([
|
||||
api.analytics.get({ tool, range }),
|
||||
api.pricing.totalCost({ tool, range }).catch(() => null),
|
||||
api.budgets.status().catch(() => null),
|
||||
]);
|
||||
if (seq !== loadSeq.current) return;
|
||||
setData(result);
|
||||
setCostData(cost);
|
||||
setLoadError(null);
|
||||
if (budgetRes) setBudgets(budgetRes.budgets);
|
||||
setLastUpdate(new Date());
|
||||
} catch (err) {
|
||||
if (seq === loadSeq.current)
|
||||
@@ -746,7 +777,7 @@ function AnalyticsTab() {
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
}, [scope]);
|
||||
}, [scope, tool, range]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -861,6 +892,7 @@ function AnalyticsTab() {
|
||||
dailyCostLast30[0] ?? { date: "", cost: 0 }
|
||||
);
|
||||
const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0);
|
||||
const pace = spendPace(dailyCostsLocal, localDateStr(today));
|
||||
const costBreakdown = [...(costData?.breakdown ?? [])]
|
||||
.filter((b) => b.cost > 0)
|
||||
.sort((a, b) => b.cost - a.cost);
|
||||
@@ -985,6 +1017,53 @@ function AnalyticsTab() {
|
||||
{lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={preset}
|
||||
onChange={(v) =>
|
||||
setRangeParam(
|
||||
v === "custom"
|
||||
? { range: v, from: rangeFrom ?? "", to: rangeTo ?? "" }
|
||||
: { range: v === "all" ? "" : v, from: "", to: "" }
|
||||
)
|
||||
}
|
||||
label={t("range")}
|
||||
options={RANGE_PRESETS.map((v) => ({ value: v, label: t(`rangePreset.${v}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
<>
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm"
|
||||
value={customFrom}
|
||||
max={customTo || undefined}
|
||||
onChange={(e) => setRangeParam({ from: e.target.value })}
|
||||
aria-label={t("rangeFrom")}
|
||||
title={t("rangeFrom")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm"
|
||||
value={customTo}
|
||||
min={customFrom || undefined}
|
||||
onChange={(e) => setRangeParam({ to: e.target.value })}
|
||||
aria-label={t("rangeTo")}
|
||||
title={t("rangeTo")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="w-44">
|
||||
<Select
|
||||
value={tool}
|
||||
onChange={setTool}
|
||||
label={t("toolFilter")}
|
||||
options={[
|
||||
{ value: "", label: t("allTools") },
|
||||
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={load} className="btn-ghost" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("common:refresh")}
|
||||
@@ -1353,6 +1432,107 @@ function AnalyticsTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spend pace: burn rate, month to date, month-end forecast */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
|
||||
{ label: t("monthToDate"), value: pace.monthToDate },
|
||||
{
|
||||
label: t("forecastMonth"),
|
||||
value: pace.forecastMonth,
|
||||
hint: t("forecastHint"),
|
||||
},
|
||||
].map(({ label, value, hint }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span className="text-xs text-fg-secondary" title={hint}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-medium text-status-success">
|
||||
<Tip raw={fmtCostFull(value)}>{fmtCost(value)}</Tip>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budgets (budget alert rules) */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("budgets")}</h3>
|
||||
{budgets.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t("noBudgets")}{" "}
|
||||
<Link to="/settings/notifications" className="text-accent hover:underline">
|
||||
{t("addBudget")}
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{budgets.map((b) => {
|
||||
const over = b.pct >= 100;
|
||||
const warn = b.crossed.length > 0;
|
||||
return (
|
||||
<div key={b.id} className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs gap-2">
|
||||
<span className="text-fg-secondary truncate" title={b.name}>
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary flex-shrink-0">
|
||||
{fmtCost(b.spend)} / {fmtCost(b.config.limit_usd ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-surface-3 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${over ? "bg-status-danger" : warn ? "bg-status-warning" : "bg-status-success"}`}
|
||||
style={{ width: `${Math.min(100, Math.max(2, b.pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-fg-muted">
|
||||
<span>
|
||||
{b.period_start} → {b.period_end}
|
||||
</span>
|
||||
<span title={t("projectedHint")}>
|
||||
{t("projected")} {fmtCost(b.projected)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost by AI tool */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByTool")}</h3>
|
||||
{(costData?.by_tool?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(costData?.by_tool ?? []).map((b) => (
|
||||
<div key={b.tool} className="flex items-center gap-2">
|
||||
<AiToolIcon
|
||||
tool={{ id: b.tool, label: aiTools.label(b.tool) }}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CostBarRow
|
||||
label={aiTools.label(b.tool)}
|
||||
cost={b.cost}
|
||||
max={Math.max(costData?.by_tool?.[0]?.cost ?? 0, 0.0001)}
|
||||
color="bg-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost by weekday */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-1">
|
||||
@@ -1471,7 +1651,7 @@ function AnalyticsTab() {
|
||||
{/* The former Workflows page, folded into this sub-tab. */}
|
||||
<RouteErrorBoundary>
|
||||
<Suspense fallback={<AnalyticsChartsSkeleton />}>
|
||||
<Workflows embedded />
|
||||
<Workflows embedded tool={tool || undefined} range={range} />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</div>
|
||||
@@ -1605,7 +1785,7 @@ function SystemHealthTab() {
|
||||
try {
|
||||
const [infoRes, workflowRes] = await Promise.all([
|
||||
api.settings.info(),
|
||||
api.workflows.get(undefined, HEALTH_SECTIONS),
|
||||
api.workflows.get(undefined, undefined, undefined, HEALTH_SECTIONS),
|
||||
]);
|
||||
setInfo(infoRes as any);
|
||||
setWorkflow(workflowRes);
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @file QueryExplorer.tsx
|
||||
* @description `/query`: filter sessions, events or agents (free text, AI tool,
|
||||
* status, local date range, folder subtree, plus the global data scope), sort
|
||||
* by a column the server allows, page through the rows, and export the whole
|
||||
* filtered set as CSV or JSON (`GET /api/query`, capped server-side).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Download, RotateCcw } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { applyRange } from "../lib/dateRange";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { Select } from "../components/Select";
|
||||
import type { QueryEntity, QueryPage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const ENTITIES: QueryEntity[] = ["sessions", "events", "agents"];
|
||||
const STATUSES: Record<QueryEntity, string[]> = {
|
||||
sessions: ["active", "completed", "error", "abandoned"],
|
||||
events: [],
|
||||
agents: ["working", "waiting", "completed", "error"],
|
||||
};
|
||||
|
||||
export function QueryExplorer() {
|
||||
const { t } = useTranslation("analytics");
|
||||
const aiTools = useAiTools();
|
||||
const [scope] = useDataScope();
|
||||
const [entity, setEntity] = useState<QueryEntity>("sessions");
|
||||
const [q, setQ] = useState("");
|
||||
const [cwd, setCwd] = useState("");
|
||||
const [typed, setTyped] = useState({ q: "", cwd: "" });
|
||||
const [tool, setTool] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [sort, setSort] = useState("");
|
||||
const [dir, setDir] = useState<"asc" | "desc">("desc");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [page, setPage] = useState<QueryPage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Text filters wait for a pause in typing.
|
||||
useEffect(() => {
|
||||
const h = setTimeout(() => {
|
||||
const next = { q: q.trim(), cwd: cwd.trim() };
|
||||
setTyped((prev) => (prev.q === next.q && prev.cwd === next.cwd ? prev : next));
|
||||
setOffset(0);
|
||||
}, 300);
|
||||
return () => clearTimeout(h);
|
||||
}, [q, cwd]);
|
||||
|
||||
/** Every filter change starts over at the first page. */
|
||||
const fresh =
|
||||
<T,>(set: (v: T) => void) =>
|
||||
(v: T) => {
|
||||
set(v);
|
||||
setOffset(0);
|
||||
};
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const p = new URLSearchParams({ entity });
|
||||
if (typed.q) p.set("q", typed.q);
|
||||
if (typed.cwd) p.set("cwd", typed.cwd);
|
||||
if (tool) p.set("tool", tool);
|
||||
if (status) p.set("status", status);
|
||||
if (sort) {
|
||||
p.set("sort", sort);
|
||||
p.set("dir", dir);
|
||||
}
|
||||
return applyRange(p, { from: from || undefined, to: to || undefined });
|
||||
}, [entity, typed, tool, status, sort, dir, from, to]);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const p = new URLSearchParams(filters);
|
||||
p.set("limit", String(PAGE_SIZE));
|
||||
p.set("offset", String(offset));
|
||||
api.query
|
||||
.page(p)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setPage(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [filters, offset, scope]);
|
||||
|
||||
const switchEntity = (v: QueryEntity) => {
|
||||
setEntity(v);
|
||||
setStatus("");
|
||||
setSort("");
|
||||
setDir("desc");
|
||||
setOffset(0);
|
||||
setPage(null);
|
||||
};
|
||||
const reset = () => {
|
||||
setQ("");
|
||||
setCwd("");
|
||||
setTool("");
|
||||
setStatus("");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
setSort("");
|
||||
setDir("desc");
|
||||
setOffset(0);
|
||||
};
|
||||
const sortedBy = sort || page?.sortable[0] || "";
|
||||
// A click on the current column flips it; another column starts descending.
|
||||
const sortOn = (col: string) => {
|
||||
setDir(col === sortedBy && dir === "desc" ? "asc" : "desc");
|
||||
setSort(col);
|
||||
setOffset(0);
|
||||
};
|
||||
|
||||
const total = page?.total ?? 0;
|
||||
const cap = page?.export_cap ?? 50_000;
|
||||
const statuses = STATUSES[entity];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("query.title")}</h1>
|
||||
<p className="text-sm text-fg-muted">{t("query.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 flex flex-wrap items-end gap-3">
|
||||
<div className="w-36">
|
||||
<Select
|
||||
value={entity}
|
||||
onChange={switchEntity}
|
||||
label={t("query.entity")}
|
||||
options={ENTITIES.map((v) => ({ value: v, label: t(`query.entities.${v}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="input text-sm w-56"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t("query.searchPlaceholder")}
|
||||
aria-label={t("query.search")}
|
||||
/>
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={tool}
|
||||
onChange={fresh(setTool)}
|
||||
label={t("toolFilter")}
|
||||
options={[
|
||||
{ value: "", label: t("allTools") },
|
||||
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{statuses.length > 0 && (
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={status}
|
||||
onChange={fresh(setStatus)}
|
||||
label={t("query.status")}
|
||||
options={[
|
||||
{ value: "", label: t("query.allStatuses") },
|
||||
...statuses.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => fresh(setFrom)(e.target.value)}
|
||||
aria-label={t("query.from")}
|
||||
title={t("query.from")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => fresh(setTo)(e.target.value)}
|
||||
aria-label={t("query.to")}
|
||||
title={t("query.to")}
|
||||
/>
|
||||
<input
|
||||
className="input text-sm w-56 font-mono"
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
placeholder={t("query.cwdPlaceholder")}
|
||||
aria-label={t("query.cwd")}
|
||||
title={t("query.cwd")}
|
||||
/>
|
||||
<button type="button" className="btn-ghost" onClick={reset} title={t("query.reset")}>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("query.reset")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{total > cap
|
||||
? t("query.exportTruncated", {
|
||||
total: total.toLocaleString(),
|
||||
cap: cap.toLocaleString(),
|
||||
})
|
||||
: t("query.exportNote", { cap: cap.toLocaleString() })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<a className="btn-ghost" href={api.query.exportUrl(filters, "csv")} download>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("query.exportCsv")}
|
||||
</a>
|
||||
<a className="btn-ghost" href={api.query.exportUrl(filters, "json")} download>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("query.exportJson")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{page && page.rows.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-fg-muted">{t("query.empty")}</p>
|
||||
) : (
|
||||
page && (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="query-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
{page.columns.map((c) => {
|
||||
const sortable = page.sortable.includes(c);
|
||||
return (
|
||||
<th
|
||||
key={c}
|
||||
className="px-3 py-2 text-[11px] font-semibold text-fg-muted font-mono"
|
||||
aria-sort={
|
||||
c === sortedBy ? (dir === "asc" ? "ascending" : "descending") : undefined
|
||||
}
|
||||
>
|
||||
{sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sortOn(c)}
|
||||
className="inline-flex items-center gap-1 hover:text-fg-primary"
|
||||
title={t("query.sortBy", { column: c })}
|
||||
>
|
||||
{c}
|
||||
{c === sortedBy &&
|
||||
(dir === "asc" ? (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
) : (
|
||||
c
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{page.rows.map((r, i) => (
|
||||
<tr key={`${r.id}-${i}`} className="hover:bg-surface-4">
|
||||
{page.columns.map((c) => {
|
||||
const v = r[c];
|
||||
const sid =
|
||||
(entity === "sessions" && c === "id") || c === "session_id" ? v : null;
|
||||
return (
|
||||
<td
|
||||
key={c}
|
||||
className="px-3 py-1.5 max-w-[22rem] truncate text-fg-secondary"
|
||||
title={v == null ? undefined : String(v)}
|
||||
>
|
||||
{sid ? (
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(String(sid))}`}
|
||||
className="font-mono text-accent hover:underline"
|
||||
>
|
||||
{String(sid).slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
(v ?? "")
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{page && total > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 text-xs text-fg-muted">
|
||||
<span>
|
||||
{t("query.rows", {
|
||||
from: (offset + 1).toLocaleString(),
|
||||
to: Math.min(offset + PAGE_SIZE, total).toLocaleString(),
|
||||
total: total.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
title={t("query.prev")}
|
||||
aria-label={t("query.prev")}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost"
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
title={t("query.next")}
|
||||
aria-label={t("query.next")}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file SessionDetail.tsx
|
||||
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view.
|
||||
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view; a Replay tab scrubs through its events and a Snapshot action freezes it under a share link.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -28,7 +28,7 @@
|
||||
* - `../components/conversation/ConversationView`
|
||||
* - `../components/StatusBadge`
|
||||
* - `../lib/types`
|
||||
* - `../components/EventDetail`
|
||||
* - `../components/EventTimeline`, `../components/EventReplay`, `../components/SnapshotButton`
|
||||
* - `../components/EventFilters`
|
||||
* - `../components/EventFiltersInfo`
|
||||
* - `../components/Skeleton`
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
Hourglass,
|
||||
X,
|
||||
Download,
|
||||
Film,
|
||||
} from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
@@ -89,9 +90,12 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { AgentCard } from "../components/AgentCard";
|
||||
import { SessionOverview } from "../components/SessionOverview";
|
||||
import { ConversationView } from "../components/conversation/ConversationView";
|
||||
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
|
||||
import { SnapshotButton } from "../components/SnapshotButton";
|
||||
import { EventTimeline } from "../components/EventTimeline";
|
||||
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
|
||||
import { CopyButton } from "../components/event-views/primitives";
|
||||
import {
|
||||
effectiveSessionStatus,
|
||||
@@ -99,7 +103,6 @@ import {
|
||||
sessionAwaitingReason,
|
||||
AWAITING_REASON_CONFIG,
|
||||
} from "../lib/types";
|
||||
import { EventDetail } from "../components/EventDetail";
|
||||
import {
|
||||
EventFilters,
|
||||
isEmptyFilters,
|
||||
@@ -110,14 +113,6 @@ import {
|
||||
import type { EventFiltersValue } from "../components/EventFilters";
|
||||
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import {
|
||||
agentOriginLabel,
|
||||
buildEventTitle,
|
||||
buildOriginLabel,
|
||||
projectFromCwd,
|
||||
projectFromEvent,
|
||||
statusFromEventType,
|
||||
} from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import {
|
||||
formatDateTime,
|
||||
@@ -139,7 +134,7 @@ import type {
|
||||
} from "../lib/types";
|
||||
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
|
||||
|
||||
type DetailTab = "agents" | "conversation" | "timeline";
|
||||
type DetailTab = "agents" | "conversation" | "timeline" | "replay";
|
||||
|
||||
const EVENTS_INITIAL_BATCH = 50;
|
||||
const EVENTS_MORE_BATCH = 500;
|
||||
@@ -204,16 +199,18 @@ export function SessionDetail() {
|
||||
const [pendingTranscriptId, setPendingTranscriptId] = useState<string | null>(null);
|
||||
const [transcriptNotFound, setTranscriptNotFound] = useState(false);
|
||||
const notFoundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||
|
||||
function toggleEvent(id: number) {
|
||||
setExpandedEvents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// The Replay tab's own copy: every event, oldest first, read on first visit.
|
||||
const [replay, setReplay] = useState<{ events: DashboardEvent[]; total: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (activeTab !== "replay" || replay || !id) return;
|
||||
let live = true;
|
||||
loadReplayEvents(id)
|
||||
.then((r) => live && setReplay(r))
|
||||
.catch(() => live && setReplay({ events: [], total: 0 }));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [activeTab, replay, id]);
|
||||
|
||||
// Refs let the websocket handler access latest state without re-subscribing.
|
||||
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||
@@ -427,13 +424,6 @@ export function SessionDetail() {
|
||||
return map;
|
||||
}, [session?.id, session?.name]);
|
||||
|
||||
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
|
||||
const projectByEventId = useMemo(() => {
|
||||
const map = new Map<number, string | null>();
|
||||
for (const e of events) map.set(e.id, projectFromEvent(e));
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!eventApiParams) return;
|
||||
try {
|
||||
@@ -680,6 +670,7 @@ export function SessionDetail() {
|
||||
) : null;
|
||||
})()}
|
||||
<SessionResumeButton session={session} />
|
||||
<SnapshotButton sessionId={session.id} />
|
||||
<button
|
||||
onClick={load}
|
||||
className="btn-ghost"
|
||||
@@ -822,6 +813,20 @@ export function SessionDetail() {
|
||||
<List className="w-4 h-4" />
|
||||
{t("detail.timeline", { shown: events.length, total: eventsTotal })}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("replay");
|
||||
setTranscriptNotFound(false);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "replay"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Film className="w-4 h-4" />
|
||||
{t("replay.tab")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
@@ -1139,85 +1144,12 @@ export function SessionDetail() {
|
||||
{isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
|
||||
{events.map((event, i) => {
|
||||
const key = event.id ?? i;
|
||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||
return (
|
||||
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
|
||||
<div
|
||||
key={key}
|
||||
style={
|
||||
isOpen
|
||||
? undefined
|
||||
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
|
||||
}
|
||||
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</div>
|
||||
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
|
||||
{(() => {
|
||||
// Session is implicit on this page - project is
|
||||
// still shown so the row identifies the working
|
||||
// directory when you share / search. Events without
|
||||
// their own cwd (e.g. TurnDuration) fall back to the
|
||||
// session's cwd so every row carries the dir prefix.
|
||||
const project =
|
||||
projectByEventId.get(event.id) ?? projectFromCwd(session?.cwd) ?? null;
|
||||
const origin = buildOriginLabel(
|
||||
project,
|
||||
null,
|
||||
agentOriginLabel(event.agent_id, agentInfoById)
|
||||
);
|
||||
return (
|
||||
<span className="text-sm text-fg-secondary flex-1 truncate">
|
||||
{origin && (
|
||||
<span
|
||||
className="text-fg-muted mr-1"
|
||||
title={event.agent_id ?? undefined}
|
||||
>
|
||||
{origin} ·
|
||||
</span>
|
||||
)}
|
||||
{buildEventTitle(event)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{event.tool_name && (
|
||||
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
|
||||
{event.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<EventTimeline
|
||||
events={events}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
fallbackCwd={session?.cwd}
|
||||
/>
|
||||
)}
|
||||
{events.length < eventsTotal && (
|
||||
<div className="flex items-center justify-between mt-3 px-1">
|
||||
@@ -1238,6 +1170,21 @@ export function SessionDetail() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visitedTabs.has("replay") && (
|
||||
<div hidden={activeTab !== "replay"}>
|
||||
{replay ? (
|
||||
<EventReplay
|
||||
events={replay.events}
|
||||
total={replay.total}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">{t("replay.loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -105,6 +105,9 @@ import { notify, playSound } from "../lib/notify";
|
||||
import { Tip } from "../components/Tip";
|
||||
import { ImportHistory } from "../components/ImportHistory";
|
||||
import { RemoteSources } from "../components/RemoteSources";
|
||||
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
|
||||
import { PrivacySettings } from "../components/settings/PrivacySettings";
|
||||
import { AutostartSettings } from "../components/settings/AutostartSettings";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import {
|
||||
@@ -962,6 +965,10 @@ export function Settings({
|
||||
<RemoteSources />
|
||||
</section>
|
||||
|
||||
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
|
||||
<PushIngestSettings />
|
||||
<PrivacySettings />
|
||||
|
||||
{/* ─── SESSION CLEANUP ─── */}
|
||||
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1115,6 +1122,9 @@ export function Settings({
|
||||
|
||||
{section === "about" && (
|
||||
<section id="about" className="scroll-mt-24">
|
||||
<div className="mb-4">
|
||||
<AutostartSettings />
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
|
||||
{sysInfo ? (
|
||||
<div className="card p-5">
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
Database,
|
||||
Info,
|
||||
Bot,
|
||||
Camera,
|
||||
CalendarClock,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
@@ -47,6 +49,8 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -223,6 +227,20 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "snapshots",
|
||||
group: "general",
|
||||
Icon: Camera,
|
||||
...s("snapshots"),
|
||||
render: () => <SnapshotSettings />,
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
group: "general",
|
||||
Icon: CalendarClock,
|
||||
...s("reports"),
|
||||
render: () => <ReportSettings />,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file SnapshotView.tsx
|
||||
* @description The read-only page `/snapshot/:token`: a session as it was frozen
|
||||
* by `POST /api/snapshots` — header, SessionOverview fed the frozen stats, the
|
||||
* agents, and the Replay / Timeline tabs over the frozen events. Nothing here
|
||||
* reads live data.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, Camera, Clock, DollarSign, Film, List, ShieldCheck } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { formatDateTime, fmtCostFull } from "../lib/format";
|
||||
import type { SessionSnapshotInfo, SessionSnapshotPayload } from "../lib/types";
|
||||
import { SessionOverview } from "../components/SessionOverview";
|
||||
import { AgentCard } from "../components/AgentCard";
|
||||
import { EventTimeline } from "../components/EventTimeline";
|
||||
import { EventReplay } from "../components/EventReplay";
|
||||
import { SessionStatusBadge } from "../components/StatusBadge";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
|
||||
type Tab = "overview" | "replay" | "timeline";
|
||||
|
||||
export function SnapshotView() {
|
||||
const { token = "" } = useParams<{ token: string }>();
|
||||
const { t } = useTranslation("sessions");
|
||||
const [data, setData] = useState<{
|
||||
snapshot: SessionSnapshotInfo;
|
||||
payload: SessionSnapshotPayload;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setData(null);
|
||||
setError(null);
|
||||
api.snapshots
|
||||
.get(token)
|
||||
.then((r) => live && setData(r))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const payload = data?.payload;
|
||||
const agentInfoById = useMemo(() => {
|
||||
const map = new Map<string, AgentInfo>();
|
||||
for (const a of payload?.agents ?? []) map.set(a.id, a);
|
||||
return map;
|
||||
}, [payload]);
|
||||
const sessionNameById = useMemo(() => {
|
||||
const s = payload?.session;
|
||||
return new Map(s ? [[s.id, s.name?.trim() || `Session ${s.id.slice(0, 8)}`]] : []);
|
||||
}, [payload]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="mb-2 text-status-danger">{t("snapshot.notFound")}</p>
|
||||
<p className="text-xs text-fg-muted">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!payload) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { session, agents, events, stats, cost } = payload;
|
||||
const tabs: { id: Tab; label: string; Icon: typeof Bot }[] = [
|
||||
{ id: "overview", label: `${t("detail.agents")} (${agents.length})`, Icon: Bot },
|
||||
{ id: "replay", label: t("replay.tab"), Icon: Film },
|
||||
{ id: "timeline", label: t("snapshot.timeline", { n: events.length }), Icon: List },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-accent/30 bg-accent/10 px-4 py-2.5 text-sm">
|
||||
<Camera className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-fg-primary">
|
||||
{t("snapshot.banner", { date: formatDateTime(payload.captured_at) })}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">{t("snapshot.bannerBody")}</div>
|
||||
</div>
|
||||
{payload.redacted.file_paths && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
{t("snapshot.redacted")}
|
||||
</span>
|
||||
)}
|
||||
<Link to={`/sessions/${session.id}`} className="text-[11px] text-accent hover:underline">
|
||||
{t("snapshot.openLive")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-fg-primary">
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</h2>
|
||||
<SessionStatusBadge status={session.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span className="rounded bg-surface-2 px-2 py-1 font-mono text-xs text-fg-muted">
|
||||
{session.id.slice(0, 16)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded bg-surface-2 px-2 py-1 text-xs text-fg-secondary">
|
||||
<Clock className="h-3 w-3 text-fg-muted" />
|
||||
{formatDateTime(session.started_at)}
|
||||
</span>
|
||||
{cost.total_cost > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded bg-status-success/10 px-2 py-1 text-xs font-medium text-status-success">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{fmtCostFull(cost.total_cost).slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
tab === id
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "overview" && (
|
||||
<div>
|
||||
{/* Frozen: its duration ends at the capture, not at "now". */}
|
||||
<SessionOverview
|
||||
session={{ ...session, ended_at: session.ended_at ?? payload.captured_at }}
|
||||
agents={agents}
|
||||
stats={stats}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{[...agents]
|
||||
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""))
|
||||
.map((a) => (
|
||||
<AgentCard
|
||||
key={a.id}
|
||||
agent={a}
|
||||
session={{ ...session, cost: cost.total_cost }}
|
||||
// Without a handler the card opens the LIVE session.
|
||||
onClick={() => undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === "replay" && (
|
||||
<EventReplay
|
||||
events={events}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
)}
|
||||
{tab === "timeline" &&
|
||||
(events.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>
|
||||
) : (
|
||||
<EventTimeline
|
||||
events={[...events].reverse()}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
fallbackCwd={session.cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { DateRange } from "../lib/dateRange";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import type { WorkflowData, WSMessage } from "../lib/types";
|
||||
@@ -91,7 +92,11 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
|
||||
|
||||
type StatusFilter = "all" | "active" | "completed";
|
||||
|
||||
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
export function Workflows({
|
||||
embedded = false,
|
||||
tool,
|
||||
range,
|
||||
}: { embedded?: boolean; tool?: string; range?: DateRange } = {}) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [data, setData] = useState<WorkflowData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -108,7 +113,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const fetchData = useCallback(async () => {
|
||||
const seq = ++fetchSeq.current;
|
||||
try {
|
||||
const result = await api.workflows.get(statusFilter);
|
||||
const result = await api.workflows.get(statusFilter, tool, range);
|
||||
if (seq !== fetchSeq.current) return;
|
||||
setError(null);
|
||||
setData(result);
|
||||
@@ -118,7 +123,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
} finally {
|
||||
if (seq === fetchSeq.current) setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
}, [statusFilter, tool, range?.from, range?.to]);
|
||||
|
||||
const selectSession = useCallback((id: string | null) => {
|
||||
setSelectedSessionId(id);
|
||||
|
||||
@@ -925,10 +925,11 @@ export function Workspace() {
|
||||
<div
|
||||
className={
|
||||
sessionView
|
||||
? // Cancels Layout's page padding; the 1.5rem left is the status bar's.
|
||||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
|
||||
? // Cancels Layout's page padding; the 1.5rem left is the status bar's,
|
||||
// plus the drawer's 2.75rem menu bar below `md` (issue #61).
|
||||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col max-md:h-[calc(100vh-4.25rem)] lg:-m-6"
|
||||
: viewportLocked
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
? "h-[calc(100vh-4rem)] max-md:h-[calc(100vh-6.75rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file QueryExplorer.test.tsx
|
||||
* @description The Query Explorer page: it renders a page of rows with session
|
||||
* links, sorts by an allowed column on a header click (flipping on a second),
|
||||
* switches entity, and points both export links at the current filter.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
|
||||
const { query } = vi.hoisted(() => ({
|
||||
query: {
|
||||
page: vi.fn(),
|
||||
exportUrl: (p: URLSearchParams, f: string) => `/api/query?${p.toString()}&format=${f}`,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/api", () => ({ api: { query } }));
|
||||
vi.mock("../../hooks/useAiTools", () => ({
|
||||
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
|
||||
}));
|
||||
|
||||
import { QueryExplorer } from "../QueryExplorer";
|
||||
|
||||
const page = (entity: string, rows: Record<string, string>[]) => ({
|
||||
entity,
|
||||
columns: Object.keys(rows[0] ?? {}),
|
||||
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
|
||||
statuses: [],
|
||||
rows,
|
||||
total: rows.length,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
export_cap: 50000,
|
||||
});
|
||||
const lastParams = () => {
|
||||
const calls = query.page.mock.calls;
|
||||
return calls[calls.length - 1]![0] as URLSearchParams;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
query.page.mockImplementation((p: URLSearchParams) =>
|
||||
Promise.resolve(
|
||||
p.get("entity") === "events"
|
||||
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
|
||||
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
describe("QueryExplorer", () => {
|
||||
it("shows rows, sorts by an allowed column and exports the filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryExplorer />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("first")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
|
||||
"href",
|
||||
"/sessions/sess-abcdef12"
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "name" }));
|
||||
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
|
||||
expect(lastParams().get("dir")).toBe("desc");
|
||||
await user.click(screen.getByRole("button", { name: "name" }));
|
||||
await waitFor(() => expect(lastParams().get("dir")).toBe("asc"));
|
||||
// A column outside `sortable` has no sort button.
|
||||
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
|
||||
const csv = screen.getByRole("link", { name: /Export CSV/ });
|
||||
expect(csv.getAttribute("href")).toContain("sort=name");
|
||||
expect(csv.getAttribute("href")).toContain("format=csv");
|
||||
});
|
||||
|
||||
it("switches entity and drops the status filter for events", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryExplorer />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await screen.findByText("first");
|
||||
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /^Data/ }));
|
||||
await user.click(screen.getByRole("option", { name: "Events" }));
|
||||
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
|
||||
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||
|
||||
@@ -44,6 +44,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
clearData: () => clearData(),
|
||||
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
|
||||
exportData: () => "/api/settings/export",
|
||||
pushToken: { get: vi.fn(async () => ({ enabled: false, env_owned: false })) },
|
||||
privacy: {
|
||||
get: vi.fn(async () => ({
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -112,7 +120,7 @@ describe("Settings", () => {
|
||||
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
|
||||
const yes = screen.getByRole("button", { name: /yes, clear all/i });
|
||||
expect(yes).toBeDisabled();
|
||||
await user.type(screen.getByRole("textbox"), "clear all data");
|
||||
await user.type(within(dialog).getByRole("textbox"), "clear all data");
|
||||
expect(yes).toBeEnabled();
|
||||
await user.click(yes);
|
||||
await waitFor(() => expect(clearData).toHaveBeenCalled());
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file SnapshotView.test.tsx
|
||||
* @description The read-only `/snapshot/:token` page renders the frozen payload
|
||||
* without reading live session data, and Settings → Snapshots lists and
|
||||
* deletes snapshots behind a confirm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { SnapshotView } from "../SnapshotView";
|
||||
import { SnapshotSettings } from "../../components/settings/SnapshotSettings";
|
||||
|
||||
const stats = {
|
||||
session_id: "s1",
|
||||
total_events: 2,
|
||||
events_by_type: [],
|
||||
tools_used: [{ tool_name: "Read", count: 2 }],
|
||||
error_count: 0,
|
||||
first_event_at: "2026-10-01T10:00:00.000Z",
|
||||
last_event_at: "2026-10-01T10:00:01.000Z",
|
||||
agents: { total: 1, main: 1, subagent: 0, compaction: 0, by_status: {} },
|
||||
subagent_types: [],
|
||||
tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
|
||||
};
|
||||
|
||||
const sessionStats = vi.fn();
|
||||
const remove = vi.fn(() => Promise.resolve({ ok: true }));
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
sessions: { stats: (...a: unknown[]) => sessionStats(...a) },
|
||||
snapshots: {
|
||||
get: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
snapshot: { id: 1, token: "tok", session_id: "s1", created_at: "2026-10-02T00:00:00Z" },
|
||||
payload: {
|
||||
version: 1,
|
||||
captured_at: "2026-10-02T00:00:00.000Z",
|
||||
redacted: { file_paths: true },
|
||||
session: {
|
||||
id: "s1",
|
||||
name: "Frozen session",
|
||||
status: "completed",
|
||||
cwd: "[redacted]",
|
||||
model: null,
|
||||
started_at: "2026-10-01T10:00:00.000Z",
|
||||
ended_at: null,
|
||||
metadata: null,
|
||||
},
|
||||
agents: [],
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: "s1",
|
||||
agent_id: null,
|
||||
event_type: "SessionStart",
|
||||
tool_name: null,
|
||||
summary: "start",
|
||||
data: null,
|
||||
created_at: "2026-10-01T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: "s1",
|
||||
agent_id: null,
|
||||
event_type: "Stop",
|
||||
tool_name: null,
|
||||
summary: "stop",
|
||||
data: null,
|
||||
created_at: "2026-10-01T10:00:01.000Z",
|
||||
},
|
||||
],
|
||||
stats,
|
||||
cost: { total_cost: 0, breakdown: [] },
|
||||
},
|
||||
})
|
||||
),
|
||||
list: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
snapshots: [
|
||||
{
|
||||
id: 7,
|
||||
token: "tok7",
|
||||
session_id: "s1",
|
||||
created_at: "2026-10-02T00:00:00Z",
|
||||
session_name: "Frozen session",
|
||||
redacted: true,
|
||||
event_count: 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
remove: (...a: unknown[]) => remove(...(a as [])),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SnapshotView", () => {
|
||||
it("renders the frozen session and its replay, never the live stats", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/snapshot/tok"]}>
|
||||
<Routes>
|
||||
<Route path="/snapshot/:token" element={<SnapshotView />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Frozen session")).toBeTruthy();
|
||||
expect(screen.getByText("Paths redacted")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Replay/ }));
|
||||
expect(screen.getByTestId("event-replay")).toBeTruthy();
|
||||
expect(sessionStats).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SnapshotSettings", () => {
|
||||
it("lists snapshots and deletes one after confirming", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnapshotSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Frozen session")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
const buttons = screen.getAllByRole("button", { name: "Delete" });
|
||||
fireEvent.click(buttons[buttons.length - 1]!);
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(7));
|
||||
await waitFor(() => expect(screen.queryByText("Frozen session")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -3133,6 +3133,92 @@ exports[`screen snapshots > Dashboard 1`] = `
|
||||
<div
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-40"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Date range"
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
title="Date range"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
All time
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-44"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="AI tool"
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
title="AI tool"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
All AI tools
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost"
|
||||
>
|
||||
@@ -5296,6 +5382,110 @@ exports[`screen snapshots > Dashboard 1`] = `
|
||||
No cost data yet.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary mb-5"
|
||||
>
|
||||
Spend pace
|
||||
</h3>
|
||||
<div
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
title="Average cost per day over the last 7 days, today included"
|
||||
>
|
||||
Burn rate (7-day avg / day)
|
||||
</span>
|
||||
<span
|
||||
class="text-sm font-mono font-medium text-status-success"
|
||||
>
|
||||
<span
|
||||
class="relative inline-block cursor-default"
|
||||
>
|
||||
$0.00
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
>
|
||||
Month to date
|
||||
</span>
|
||||
<span
|
||||
class="text-sm font-mono font-medium text-status-success"
|
||||
>
|
||||
<span
|
||||
class="relative inline-block cursor-default"
|
||||
>
|
||||
$0.00
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
title="Month to date plus the 7-day pace for each day left"
|
||||
>
|
||||
Forecast for this month
|
||||
</span>
|
||||
<span
|
||||
class="text-sm font-mono font-medium text-status-success"
|
||||
>
|
||||
<span
|
||||
class="relative inline-block cursor-default"
|
||||
>
|
||||
$0.00
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary mb-5"
|
||||
>
|
||||
Budgets
|
||||
</h3>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No spend budget yet.
|
||||
|
||||
<a
|
||||
class="text-accent hover:underline"
|
||||
href="/settings/notifications"
|
||||
>
|
||||
Add one in Settings → Notifications
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary mb-5"
|
||||
>
|
||||
Cost by AI tool
|
||||
</h3>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No cost data yet.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
@@ -5795,6 +5985,38 @@ exports[`screen snapshots > Session detail 1`] = `
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-end gap-1"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs"
|
||||
title="Freeze this session into a read-only copy with a share link"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-camera h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="13"
|
||||
r="3"
|
||||
/>
|
||||
</svg>
|
||||
Snapshot
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Refresh"
|
||||
class="btn-ghost"
|
||||
@@ -5930,6 +6152,52 @@ exports[`screen snapshots > Session detail 1`] = `
|
||||
</svg>
|
||||
Timeline (0/0)
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-film w-4 h-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M7 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 7.5h4"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<path
|
||||
d="M3 16.5h4"
|
||||
/>
|
||||
<path
|
||||
d="M17 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M17 7.5h4"
|
||||
/>
|
||||
<path
|
||||
d="M17 16.5h4"
|
||||
/>
|
||||
</svg>
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
@@ -6841,6 +7109,58 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="scroll-mt-24"
|
||||
id="about"
|
||||
>
|
||||
<div
|
||||
class="mb-4"
|
||||
>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="autostart"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Startup
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
@@ -6913,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.2.1 (rebuild client to match)
|
||||
UI build v2.5.2 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -8443,6 +8763,266 @@ exports[`screen snapshots > Settings — data 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
class="scroll-mt-24"
|
||||
id="push-ingest"
|
||||
>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-key-round w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
|
||||
/>
|
||||
<circle
|
||||
cx="16.5"
|
||||
cy="7.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
</svg>
|
||||
Push ingest
|
||||
</span>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:<name>) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="space-y-3 py-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
Off — no token yet
|
||||
</span>
|
||||
<button
|
||||
class="btn-primary text-xs disabled:opacity-40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-refresh-cw w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
|
||||
/>
|
||||
<path
|
||||
d="M21 3v5h-5"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
|
||||
/>
|
||||
<path
|
||||
d="M8 16H3v5"
|
||||
/>
|
||||
</svg>
|
||||
Create token
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:
|
||||
</p>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<pre
|
||||
class="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary"
|
||||
>
|
||||
#!/bin/sh
|
||||
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
|
||||
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
|
||||
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
|
||||
# Authorization: Bearer <token>
|
||||
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
|
||||
'{source: $s, events: [{hook_type: $t, data: .}]}' |
|
||||
curl -s -m 5 -o /dev/null -X POST "http://localhost/api/hooks/ingest-batch" \\
|
||||
-H "@$HOME/.claude/agenthub-push.header" \\
|
||||
-H "Content-Type: application/json" --data-binary @-
|
||||
exit 0
|
||||
</pre>
|
||||
<button
|
||||
class="btn-ghost text-xs absolute top-1.5 right-1.5"
|
||||
title="Copy"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-copy w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="14"
|
||||
rx="2"
|
||||
ry="2"
|
||||
width="14"
|
||||
x="8"
|
||||
y="8"
|
||||
/>
|
||||
<path
|
||||
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div
|
||||
class="scroll-mt-24"
|
||||
id="privacy"
|
||||
>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-shield-check w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
|
||||
/>
|
||||
<path
|
||||
d="m9 12 2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
Privacy
|
||||
</span>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Redact secrets before storing
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Redact secrets before storing"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="py-3 space-y-2"
|
||||
>
|
||||
<label
|
||||
class="block text-sm text-fg-secondary"
|
||||
for="privacy-ignore"
|
||||
>
|
||||
Ignored folders
|
||||
</label>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.
|
||||
</p>
|
||||
<textarea
|
||||
class="input w-full font-mono text-xs min-h-[72px]"
|
||||
id="privacy-ignore"
|
||||
placeholder="/home/you/clients/secret-project
|
||||
~/private"
|
||||
/>
|
||||
<button
|
||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section
|
||||
class="card p-5 space-y-4 scroll-mt-24"
|
||||
id="cleanup"
|
||||
|
||||
@@ -318,6 +318,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
totalCost: r(cost),
|
||||
sessionCost: r(cost),
|
||||
},
|
||||
budgets: { status: r({ budgets: [] }) },
|
||||
settings: {
|
||||
info: r(settingsInfo),
|
||||
claudeHome: {
|
||||
@@ -328,6 +329,23 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
reimport: r({ ok: true, imported: 0, skipped: 0, errors: 0 }),
|
||||
reinstallHooks: r({ ok: true, hooks: { installed: true, hooks: {} } }),
|
||||
resetPricing: r({ ok: true, pricing: [] }),
|
||||
pushToken: {
|
||||
get: r({ enabled: false, env_owned: false }),
|
||||
rotate: r({ token: "t", enabled: true, env_owned: false }),
|
||||
revoke: r({ enabled: false, env_owned: false }),
|
||||
},
|
||||
autostart: {
|
||||
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
|
||||
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
|
||||
},
|
||||
privacy: {
|
||||
get: r({
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
}),
|
||||
save: r({ ok: true }),
|
||||
},
|
||||
exportData: () => "/api/settings/export",
|
||||
cleanup: r({
|
||||
ok: true,
|
||||
@@ -483,6 +501,7 @@ vi.mock("../../lib/push", () => ({
|
||||
}));
|
||||
|
||||
// Page components (imported after the mocks above; vi.mock is hoisted).
|
||||
import { api } from "../../lib/api";
|
||||
import { Dashboard } from "../Dashboard";
|
||||
import { Sessions } from "../Sessions";
|
||||
import { SessionDetail } from "../SessionDetail";
|
||||
@@ -570,6 +589,19 @@ describe("screen snapshots", () => {
|
||||
const headings = [...container.querySelectorAll("h2")].map((h) => h.textContent);
|
||||
expect(headings).toContain(i18n.t("workflows:title"));
|
||||
});
|
||||
it("Dashboard Analytics reads its date range from ?range=", async () => {
|
||||
localStorage.removeItem("dashboard_tab");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?tab=analytics&range=7d"]}>
|
||||
<Dashboard />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const range = { from: "2026-06-04", to: "2026-06-10" };
|
||||
expect(api.analytics.get).toHaveBeenCalledWith({ tool: "", range });
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ tool: "", range });
|
||||
expect(screen.getByRole("button", { name: /Date range/ })).toHaveTextContent("Last 7 days");
|
||||
});
|
||||
it("Sessions", async () => {
|
||||
await snapshot(<Sessions />, "/sessions");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Prometheus + Grafana for AgentHub's /api/metrics (docs/MONITORING.md).
|
||||
#
|
||||
# DASHBOARD_ALLOWED_HOSTS=host.docker.internal agenthub start # or npm start
|
||||
# GF_SECURITY_ADMIN_PASSWORD='<choose one>' docker compose -f docker-compose.monitoring.yml up -d
|
||||
# open http://127.0.0.1:3000 (user admin)
|
||||
#
|
||||
# The dashboard runs on the HOST; Prometheus reaches it via host.docker.internal
|
||||
# (monitoring/prometheus/prometheus.yml). The Prometheus datasource and the three
|
||||
# AgentHub boards are provisioned, so there is nothing to click. Both UIs bind
|
||||
# loopback only.
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v3.13.2@sha256:508729e0e2d18e11fd742a5a5ca70e557b940a93948c3c95fd0123a6fd538b69
|
||||
container_name: agenthub-prometheus
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=90d
|
||||
volumes:
|
||||
- ./monitoring/prometheus:/etc/prometheus:ro
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
# Linux needs the explicit mapping; Docker Desktop resolves it natively.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:13.1.2@sha256:d177053ab62253815f130d81504f77063baf5fd4ca93299d6048453bd31e047a
|
||||
container_name: agenthub-grafana
|
||||
restart: unless-stopped
|
||||
depends_on: [prometheus]
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:?set GF_SECURITY_ADMIN_PASSWORD}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/agenthub-overview.json
|
||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- grafana-data:/var/lib/grafana
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
+128
-5
@@ -20,6 +20,8 @@ Complete REST API and WebSocket documentation for Agent Dashboard.
|
||||
- [Workspaces](#workspaces)
|
||||
- [Notifications](#notifications)
|
||||
- [Remote Data Sources](#remote-data-sources)
|
||||
- [Query Explorer](#query-explorer)
|
||||
- [Scheduled reports](#scheduled-reports)
|
||||
- [WebSocket API](#websocket-api)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Rate Limiting](#rate-limiting)
|
||||
@@ -1169,6 +1171,10 @@ since every call also emits a `PostToolUse` and counting both would double
|
||||
every number. `status` filters by session lifecycle, same convention as
|
||||
`GET /api/workflows`; omit it (or pass `all`) for no filter.
|
||||
|
||||
Both `GET /api/workflows` and `/skill-usage` also take `from=YYYY-MM-DD&to=YYYY-MM-DD`
|
||||
(+ `tz_offset`): only the sessions STARTED in those local days. A bad date, or
|
||||
`from` after `to`, is `400 INVALID_INPUT`.
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
@@ -1328,7 +1334,7 @@ Returns all sessions, ordered by most recent activity.
|
||||
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
|
||||
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
|
||||
| `cwd` | string | - | Exact working directory |
|
||||
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity` |
|
||||
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
|
||||
| `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id |
|
||||
| `archived` | `0` \| `1` | - | `0` = not archived, `1` = archived only; omitted = both |
|
||||
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
|
||||
@@ -1729,6 +1735,15 @@ POST /api/settings/cleanup
|
||||
|
||||
Body `{ abandon_hours?, purge_days?, dry_run? }`. `abandon_hours` marks active sessions silent that long as `abandoned` (and completes their working agents); `purge_days` deletes ended sessions (with their events and agents) that started more than that many days ago. `purge_days` must be a whole number ≥ 1, else `400 INVALID_PURGE_DAYS` — a fraction would purge almost everything. `dry_run: true` answers the same counts (`abandoned`, `purged_sessions`, `purged_events`, `purged_agents`, plus `dry_run: true`) and changes nothing.
|
||||
|
||||
#### Start at boot/login
|
||||
|
||||
```http
|
||||
GET /api/settings/autostart
|
||||
PUT /api/settings/autostart
|
||||
```
|
||||
|
||||
Settings → About's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
|
||||
|
||||
---
|
||||
|
||||
### Tools
|
||||
@@ -1800,6 +1815,7 @@ Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
|
||||
| `agenthub_websocket_clients` | gauge | — | Connected realtime clients |
|
||||
| `agenthub_remote_sources` | gauge | `enabled` (`true`/`false`) | Configured Remote Data Sources |
|
||||
| `agenthub_tokens_total` | counter | `kind` (`input`/`output`/`cache_read`/`cache_write`) | Cumulative token usage |
|
||||
| `agenthub_cost_usd_total` | counter | `tool`, `model` | Cumulative token cost in USD, priced like `GET /api/pricing/cost` (code-execution container time excluded) |
|
||||
|
||||
Status series are always emitted (even at `0`) so a series never disappears from the exposition. The endpoint is mounted under `/api`, so it sits behind the same two guards as every other route: the **Host-header (DNS-rebinding) guard** and the optional **`DASHBOARD_TOKEN`** guard. A scraper that reaches the server as anything other than loopback (e.g. Prometheus in Docker hitting `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS`, or the scrape returns `403 EBADHOST`; if a token is set, the scrape must also send it.
|
||||
|
||||
@@ -1815,6 +1831,8 @@ scrape_configs:
|
||||
# credentials: "<DASHBOARD_TOKEN>"
|
||||
```
|
||||
|
||||
A ready-made Prometheus config, recording rules and three Grafana boards built on these series live in `monitoring/`, runnable with `docker-compose.monitoring.yml` — see [MONITORING.md](./MONITORING.md).
|
||||
|
||||
---
|
||||
|
||||
### Pricing
|
||||
@@ -2003,13 +2021,16 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
|
||||
#### Total Cost
|
||||
|
||||
```http
|
||||
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&date=<YYYY-MM-DD>
|
||||
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>
|
||||
```
|
||||
|
||||
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
|
||||
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch).
|
||||
|
||||
`from` / `to` (local dates, inclusive, read with `tz_offset`; either end may be left open) narrow `total_cost`, `breakdown`, `by_tool`, `feature_costs` and `unpriced_models` to the tokens SPENT on those days (the same split as `daily_costs`). `daily_costs` itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or `from` after `to` is `400 INVALID_INPUT`.
|
||||
|
||||
```bash
|
||||
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
|
||||
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2026-09-30"
|
||||
```
|
||||
|
||||
#### Get Plan Quota
|
||||
@@ -2019,6 +2040,39 @@ GET /api/pricing/quota[?force=1]
|
||||
```
|
||||
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
|
||||
#### Analytics
|
||||
|
||||
```http
|
||||
GET /api/analytics?tz_offset=<minutes>&sources=<csv>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>
|
||||
```
|
||||
|
||||
The Analytics bundle (`tokens`, `total_cost`, `tool_usage`, `daily_events`, `daily_sessions`, `agent_types`, `event_types`, `avg_events_per_session`, `total_subagents`, `overview`, `agents_by_status`, `sessions_by_status`). With `from`/`to` (same rules as above) the shape is unchanged and the numbers narrow: sessions and agents count by `started_at`, events by `created_at`, and `tokens` / `total_cost` come from the cost rows of the days in the range (`token_usage` has no date, so tokens follow the spend-day split). `daily_events` and `daily_sessions` keep their 365-day window.
|
||||
|
||||
#### Spend Budgets
|
||||
|
||||
A budget is an alert rule of type `budget`, created and edited through `/api/alerts/rules`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Monthly",
|
||||
"rule_type": "budget",
|
||||
"config": { "period": "month", "limit_usd": 200, "thresholds": [80, 100], "tool": "codex", "tz_offset": -420 }
|
||||
}
|
||||
```
|
||||
|
||||
`period` is `day` | `week` (from Monday) | `month`, in the local time of `tz_offset` (the browser's `getTimezoneOffset()`, saved with the rule); `thresholds` are 1–10 whole percents (default `[80, 100]`), each firing ONCE per period through the alerts feed, webhooks and push; `tool` / `lane` narrow it to one AI tool's or one workspace's sessions. Spend is priced like `GET /api/pricing/cost`, by the day the tokens were spent. `cost_threshold` (`{ "usd": 5 }`) is the per-session rule.
|
||||
|
||||
```http
|
||||
GET /api/budgets/status
|
||||
```
|
||||
|
||||
Every enabled budget with its current period: `{ budgets: [{ id, name, config, period_start, period_end, spend, projected, pct, crossed }] }` — `projected` carries the pace so far to the period's end, `crossed` lists the thresholds reached.
|
||||
|
||||
```http
|
||||
POST /api/alerts/rules/preview
|
||||
```
|
||||
|
||||
`{ rule_type, config, days?, cooldown_seconds? }` → what the rule WOULD have fired over the last `days` (1–90, default 7): `{ supported, window_days, fires, sessions, samples }`, and for a budget `periods` (this one and the five before). Nothing is saved, fired or broadcast; `status_duration` answers `supported: false` (agent status history is not stored).
|
||||
|
||||
---
|
||||
|
||||
@@ -2074,6 +2128,8 @@ The `/api/remote-sources/*` namespace configures **remote SSH machines** the das
|
||||
"ssh_port": 22,
|
||||
"identity_file": "~/.ssh/id_ed25519",
|
||||
"remote_home": "~/.claude",
|
||||
"sync_codex": true,
|
||||
"remote_codex_home": null,
|
||||
"enabled": true,
|
||||
"status": "ok",
|
||||
"last_error": null,
|
||||
@@ -2091,7 +2147,9 @@ The `/api/remote-sources/*` namespace configures **remote SSH machines** the das
|
||||
}
|
||||
```
|
||||
|
||||
`ssh_port`, `identity_file`, `remote_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
|
||||
`ssh_port`, `identity_file`, `remote_home`, `remote_codex_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
|
||||
|
||||
`sync_codex` (default `false`) also pulls the machine's Codex history: `<remote_codex_home>/sessions` (default `~/.codex`, `wsl:~/.codex` when `remote_home` is a `wsl:` path) and its `session_index.jsonl` are mirrored with their mtimes kept into `<data dir>/remote-sources/<id>/codex/` and read by the same journal reader as local Codex history (`server/lib/codex-usage.js`), so those sessions arrive as `tool = 'codex'` rows tagged with this source, with their tokens; `last_sync_counts` then carries `codex_files` / `codex_sessions`. With Codex on, a machine may have only one of the two CLIs: a sync that finds one is `ok` with the other's failure in `last_error`; finding neither is an `error`.
|
||||
|
||||
#### List Remote Sources
|
||||
|
||||
@@ -2201,6 +2259,24 @@ Pulls history from **every enabled** source sequentially (one SSH connection at
|
||||
{ "ok": true, "synced": 2, "results": [{ "id": "src_a", "ok": true }, { "id": "src_b", "ok": false, "error": "ssh exited with code 255" }] }
|
||||
```
|
||||
|
||||
#### Push ingest (`POST /api/hooks/ingest-batch`)
|
||||
|
||||
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Data → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
|
||||
|
||||
```http
|
||||
POST /api/hooks/ingest-batch
|
||||
Authorization: Bearer ahp_…
|
||||
Content-Type: application/json
|
||||
|
||||
{ "source": "laptop", "events": [ { "hook_type": "SessionStart", "data": { "session_id": "…", "cwd": "/home/me/app" } } ] }
|
||||
```
|
||||
|
||||
- **503** `EPUSHDISABLED` with no token, **401** `EUNAUTHORIZED` on a wrong one (compared in constant time), **400** `EBADSOURCE` / `EBADBATCH`.
|
||||
- `source`: 1–64 of `[A-Za-z0-9._-]`; the sessions are tagged `push:<source>` and appear in the `sources` filter.
|
||||
- 1–500 events, 1 MB per request. `tool` per event is the same allowlist as `/api/hooks/event` (`codex`, else Claude Code; an Antigravity hook is answered `ignored`, since it must name a file under this machine's agy directory).
|
||||
- Each event takes the `/api/hooks/event` path, except: `transcript_path` fields are dropped (they name the sender's files), no workspace or pane is touched, and a session another source owns is refused per item (`EFOREIGN`). Privacy settings apply.
|
||||
- Response: `{ ok, source, stored, results: [{ok} | {ok, ignored} | {ok, duplicate} | {ok: false, error}] }`. Token cost needs the transcripts, which only an SSH source brings.
|
||||
|
||||
#### The `sources` filter
|
||||
|
||||
`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` accept an optional `sources` query parameter: a comma-separated list of source ids to include (omit for all). `GET /api/sessions/facets` correspondingly returns a `sources: string[]` array (alongside `cwds`) listing the distinct `sessions.source` values so the UI can build the filter dropdown.
|
||||
@@ -2438,10 +2514,57 @@ DELETE /api/pets/:id Delete a pet (its folder under the data di
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves. Each row keeps its own pace with a longer hold on its last frame (idle ~220 ms a frame, then ~1.4 s). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||
|
||||
### Session snapshots
|
||||
|
||||
```
|
||||
GET /api/snapshots { snapshots: [{ id, token, session_id, created_at, session_name, redacted, event_count, bytes }] } — newest first, no payload
|
||||
POST /api/snapshots { session_id, redact?: { file_paths?: boolean } } → 201 { snapshot: { id, token, session_id, created_at } }
|
||||
GET /api/snapshots/:token { snapshot, payload } — the frozen copy
|
||||
DELETE /api/snapshots/:id { ok: true }
|
||||
```
|
||||
|
||||
A snapshot freezes a session as it is at that moment into one immutable `session_snapshots` row (`server/routes/snapshots.js`): `payload` is `{ version: 1, captured_at, redacted: { file_paths }, session, agents, events, stats, cost }` — the session row, its agents (each with its own `cost`), every stored event oldest first (`events.data` exactly as stored), the `/api/sessions/:id/stats` body and the `/api/pricing/cost/:id` result priced at the session's start date. Its `token` is 24 random bytes in base64url (32 characters) and is the only way to read it; the page is `/snapshot/<token>`. With `redact.file_paths`, paths are replaced **before anything is stored**: every `cwd`, `transcript_path`, `agent_transcript_path`, `file_path`, `filePath`, `notebook_path` and `path` value (also inside `events.data` and `metadata` JSON) becomes `[redacted]`, and the session's cwd / the home directory inside any other string (a Bash command, a summary) become `<cwd>` / `~`. A row cannot be updated (a trigger aborts it), outlives its session, and survives **Clear Data** — it goes only through `DELETE` (Settings → **Snapshots**). Both writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` without `session_id`, `404 NOT_FOUND` for an unknown session, token or id.
|
||||
|
||||
### Query Explorer
|
||||
|
||||
```http
|
||||
GET /api/query?entity=sessions|events|agents&q=&tool=&status=&cwd=&from=&to=&tz_offset=&sources=&sort=&dir=asc|desc&limit=&offset=&format=csv|json
|
||||
```
|
||||
|
||||
Filters one table and answers one page — `{ entity, columns, sortable, statuses, rows, total, limit, offset, export_cap }` (`limit` 1–500, default 50) — or, with `format=csv|json`, the WHOLE filtered set as a download (`Content-Disposition: attachment`) capped at `export_cap` = 50,000 rows; the headers `X-Total-Count` and `X-Truncated: 1|0` say whether it was cut, and the JSON export is `{ entity, total, truncated, cap, rows }`. `server/routes/query.js`.
|
||||
|
||||
| Entity | Columns | `sort` accepts | Date range bounds | `q` searches | `status` |
|
||||
|---|---|---|---|---|---|
|
||||
| `sessions` (default) | id, name, status, tool, model, cwd, started_at, ended_at | started_at (default), ended_at, name, status, tool, model, cwd | `started_at` | id, name, cwd | active, completed, error, abandoned |
|
||||
| `events` | id, session_id, agent_id, event_type, tool_name, summary, tool, cwd, created_at | created_at (default), event_type, tool_name, session_id, tool | `created_at` | summary, event_type, tool_name, session_id | — |
|
||||
| `agents` | id, session_id, name, type, subagent_type, status, task, tool, cwd, started_at, ended_at | started_at (default), ended_at, name, type, subagent_type, status, tool | `started_at` | id, name, task, subagent_type | working, waiting, completed, error |
|
||||
|
||||
`tool`, `cwd` and `sources` apply to the row's session; `cwd` matches the folder and everything under it. `q` is a literal substring (`%` and `_` are not wildcards). `sort` and `dir` (default `desc`, ties broken by id) must come from the table above — the SQL column and direction are taken from that allowlist, never from the request — and anything else (an unknown entity/sort/dir/status/format, a malformed tool or date) is `400 INVALID_INPUT`. CSV is RFC 4180 (CRLF, a cell with `"`, `,` or a line break quoted, `"` doubled) with a UTF-8 BOM, and a text cell starting with `=`, `+`, `-`, `@`, a tab or a CR gets a leading `'` so a spreadsheet never runs it as a formula.
|
||||
|
||||
```bash
|
||||
curl -OJ "http://localhost:4820/api/query?entity=events&q=Bash&from=2026-09-01&format=csv"
|
||||
```
|
||||
|
||||
### Scheduled reports
|
||||
|
||||
```
|
||||
GET /api/reports { reports: [{ id, name, window_days, frequency, hour, weekday, tz_offset, formats, enabled, last_slot, created_at, last_run }] }
|
||||
POST /api/reports { name, window_days, frequency, hour, weekday?, tz_offset?, formats, enabled? } → 201 { report }
|
||||
PUT /api/reports/:id any of those fields (the rest kept) → { report }
|
||||
DELETE /api/reports/:id { ok: true } — the definition, its runs and its files
|
||||
POST /api/reports/:id/run { run } — run now (also when disabled)
|
||||
GET /api/reports/:id/runs { runs: [{ id, report_id, created_at, trigger, status, window_from, window_to, stamp, files, error }] } — newest 100
|
||||
GET /api/reports/:id/runs/:runId/:format the file (html | json), as an attachment
|
||||
```
|
||||
|
||||
A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local days BEFORE the day it runs, at its `tz_offset` (the browser's `getTimezoneOffset()`, −840…840). `frequency` is `daily` or `weekly` (on `weekday`, 0 = Sunday, default Monday) at local `hour` 0–23; `formats` is a non-empty subset of `html`, `json`. Its content is computed like `GET /api/analytics` and `GET /api/pricing/cost` over that window — sessions (with status counts), agents, events, tokens, cost by model and by AI tool, unpriced models, the top 10 tools — plus every enabled budget's CURRENT period from `GET /api/budgets/status`. Each run writes `<data dir>/reports/<id>/<stamp>.<format>` and a `report_runs` row (`trigger` schedule | manual, `status` ok | error with `error`); the download path is built from the two ids and the stored stamp only (`404 NOT_FOUND` for anything else). Every value in the HTML is escaped.
|
||||
|
||||
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket API
|
||||
|
||||
+11
-1
@@ -21,6 +21,7 @@ The complete guide to `agenthub`, the command-line interface to AgentHub — the
|
||||
- [Import](#import)
|
||||
- [Remote Sources](#remote-sources)
|
||||
- [Workspaces](#workspaces)
|
||||
- [Integrations](#integrations)
|
||||
- [Administration](#administration)
|
||||
- [Safety Model](#safety-model)
|
||||
- [Output & Scripting](#output--scripting)
|
||||
@@ -220,7 +221,7 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `agenthub remote-sources` (alias `remotes`) | List configured sources with id, auto-sync on/off, status, label, host, **session count**, and last-sync time, followed by a totals line (sources / auto-syncing / sessions collected) |
|
||||
| `agenthub remote-sources add --label <name> --host <user@host> [--port N] [--identity <path>] [--remote-home <path>] [--disabled]` | Add a source. `--host` is an ssh destination (`user@host`) or a `~/.ssh/config` alias; `--disabled` skips it in the background poller |
|
||||
| `agenthub remote-sources add --label <name> --host <user@host> [--port N] [--identity <path>] [--remote-home <path>] [--codex] [--codex-home <path>] [--disabled]` | Add a source. `--host` is an ssh destination (`user@host`) or a `~/.ssh/config` alias; `--codex` also pulls its Codex history (from `--codex-home`, default `~/.codex`); `--disabled` skips it in the background poller |
|
||||
| `agenthub remote-sources test <id>` | Probe SSH connectivity and check the remote `~/.claude/projects` exists; exits non-zero on failure |
|
||||
| `agenthub remote-sources sync [id]` | Pull history now — one source by id, or **all** sources when the id is omitted. Prints imported / tagged counts |
|
||||
| `agenthub remote-sources rm <id> [--purge]` | Remove a source (its imported sessions are detached back to `local` by default; `--purge` also **deletes** them) |
|
||||
@@ -265,6 +266,15 @@ Resolution order, shared by every cwd-resolving command: `--workspace` (old name
|
||||
|
||||
Because a workspace's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
|
||||
|
||||
### Integrations
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `agenthub mcp` | Run the read-only MCP server on stdio (`mcp/server.js`). Register it with `claude mcp add agenthub -- agenthub mcp`; never registered automatically. `AGENTHUB_URL` overrides the dashboard it asks — see [MCP.md](MCP.md) |
|
||||
| `agenthub statusline` | Render the Claude Code statusline from the statusline JSON on stdin (what a plugin install's `statusLine` runs) — see [STATUSLINE.md](STATUSLINE.md) |
|
||||
| `agenthub statusline install [--force]` | Write AgentHub's `statusLine` into `$CLAUDE_HOME/settings.json` (default `~/.claude/settings.json`). Refuses (exit 1) when another `statusLine` is there unless `--force`; the old file is backed up beside itself as `settings.json.agenthub-backup-<timestamp>` |
|
||||
| `agenthub statusline uninstall [--force]` | Remove AgentHub's `statusLine` (another one only with `--force`), with the same backup |
|
||||
|
||||
### Administration
|
||||
|
||||
| Command | Description |
|
||||
|
||||
+74
-5
@@ -142,6 +142,8 @@ erDiagram
|
||||
text identity_file "Optional private-key path (NULL = SSH default)"
|
||||
text remote_home "Optional remote Claude home (NULL = remote ~/.claude)"
|
||||
integer enabled "1 = eligible for sync, 0 = disabled"
|
||||
integer sync_codex "1 = also pull Codex history (default 0)"
|
||||
text remote_codex_home "Optional remote Codex home (NULL = ~/.codex)"
|
||||
text status "idle | syncing | ok | error"
|
||||
text last_error "Last failure message, or NULL"
|
||||
text last_sync_at "ISO8601 timestamp of last successful sync, or NULL"
|
||||
@@ -210,8 +212,8 @@ CREATE TABLE sessions (
|
||||
| `awaiting_input_since` | TEXT | YES | ISO 8601 stamp set when the session is **Waiting** (Stop, SessionStart with source `startup`/`resume`/`clear`, permission Notification, or watchdog user-interrupt/Esc recovery). NULL otherwise. A SessionStart with source `compact` (auto-compaction fires mid-turn while Claude is working) leaves this column untouched, so a genuinely-active session is not mislabeled Waiting |
|
||||
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
|
||||
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
||||
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from, or `push:<name>` for a session another machine pushed to `POST /api/hooks/ingest-batch` (a pushed hook may only extend a session its own source created). Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none), `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
|
||||
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
|
||||
|
||||
@@ -474,7 +476,10 @@ CREATE TABLE remote_sources (
|
||||
last_sync_at TEXT,
|
||||
last_sync_counts TEXT,
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
-- added by migration (additive):
|
||||
sync_codex INTEGER NOT NULL DEFAULT 0,
|
||||
remote_codex_home TEXT
|
||||
);
|
||||
```
|
||||
|
||||
@@ -489,10 +494,12 @@ CREATE TABLE remote_sources (
|
||||
| `identity_file` | TEXT | YES | Optional private-key path passed to ssh (`-i`); NULL to omit |
|
||||
| `remote_home` | TEXT | YES | Optional remote Claude home to read transcripts from; NULL defaults to remote `~/.claude` |
|
||||
| `enabled` | INTEGER | NO | `1` = eligible for scheduled/manual syncs, `0` = disabled (default `1`) |
|
||||
| `sync_codex` | INTEGER | NO | `1` = a sync also mirrors the machine's Codex `sessions/` and reads it through `server/lib/codex-usage.js` into `tool = 'codex'` sessions tagged with this source (default `0`) |
|
||||
| `remote_codex_home` | TEXT | YES | Optional remote CODEX_HOME; NULL = `~/.codex` (`wsl:~/.codex` when `remote_home` is a `wsl:` path) |
|
||||
| `status` | TEXT | NO | Last sync status: `idle`, `syncing`, `ok`, or `error` (CHECK-constrained) |
|
||||
| `last_error` | TEXT | YES | Error message from the last failed sync/test, or NULL |
|
||||
| `last_sync_at` | TEXT | YES | ISO 8601 timestamp of the last successful sync, or NULL |
|
||||
| `last_sync_counts` | TEXT | YES | JSON blob of the last sync's counters (imported/skipped/backfilled/errors/sessions_seen/sessions_tagged), or NULL |
|
||||
| `last_sync_counts` | TEXT | YES | JSON blob of the last sync's counters (imported/skipped/backfilled/errors/sessions_seen/sessions_tagged, plus codex_files/codex_sessions with Codex on), or NULL |
|
||||
| `created_at` | TEXT | YES | ISO 8601 creation timestamp |
|
||||
| `updated_at` | TEXT | YES | ISO 8601 timestamp of the last edit |
|
||||
|
||||
@@ -508,7 +515,7 @@ The same buckets as `token_usage` (session, model, speed, inference_geo, service
|
||||
|
||||
### codex_rollouts
|
||||
|
||||
How far `server/lib/codex-usage.js` has read each Codex rollout journal (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). A file whose `size` and `mtime_ms` are unchanged is not read again; a grown one is read from `offset` to its last newline, at most 16 MB per pass (`state.more` marks a file with more to read); a shrunken one from 0. There is one row per `thread_id`: the same journal met under a new path (CODEX_HOME moved, or reached through a symlink) replaces its old row instead of being counted twice. Because it lives in the DB, a Codex session the user deleted stays gone until Codex writes to that journal again, while a fresh database re-imports every journal. `POST /api/settings/clear-data` empties this table (and `antigravity_conversations`) with the sessions, so a cleared session is imported again whole instead of never, or half on its next write.
|
||||
How far `server/lib/codex-usage.js` has read each Codex rollout journal (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). A file whose `size` and `mtime_ms` are unchanged is not read again; a grown one is read from `offset` to its last newline, at most 16 MB per pass (`state.more` marks a file with more to read); a shrunken one from 0. There is one row per `thread_id`: the same journal met under a new path (CODEX_HOME moved, or reached through a symlink) replaces its old row instead of being counted twice. Because it lives in the DB, a Codex session the user deleted stays gone until Codex writes to that journal again, while a fresh database re-imports every journal. `POST /api/settings/clear-data` empties this table (and `antigravity_conversations`, `cursor_transcripts`) with the sessions, so a cleared session is imported again whole instead of never, or half on its next write.
|
||||
|
||||
```sql
|
||||
CREATE TABLE codex_rollouts (
|
||||
@@ -540,6 +547,68 @@ CREATE TABLE antigravity_conversations (
|
||||
);
|
||||
```
|
||||
|
||||
### cursor_transcripts
|
||||
|
||||
Which Cursor agent transcripts `server/lib/cursor-usage.js` has read (`~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`), at what mtime. An unchanged file is not read again (only its `active` session settled once quiet); a changed one rewrites its session row. As with `codex_rollouts`, a session the user deleted stays gone until Cursor writes to that transcript again. Additive: `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE cursor_transcripts (
|
||||
path TEXT PRIMARY KEY, -- absolute transcript path
|
||||
session_id TEXT NOT NULL, -- the file name's id
|
||||
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
### session_snapshots
|
||||
|
||||
Frozen copies of a session (`server/routes/snapshots.js`, `POST /api/snapshots`). `payload` is one JSON document — `{version, captured_at, redacted: {file_paths}, session, agents, events, stats, cost}` — read back only by `token`. A trigger refuses every `UPDATE`; `session_id` has no foreign key, so a snapshot outlives its session and survives Clear Data (deleted only by `DELETE /api/snapshots/:id`). Additive: `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE, -- crypto.randomBytes(24) base64url
|
||||
session_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TRIGGER session_snapshots_immutable BEFORE UPDATE ON session_snapshots
|
||||
BEGIN SELECT RAISE(ABORT, 'session snapshots are immutable'); END;
|
||||
```
|
||||
|
||||
### report_definitions / report_runs
|
||||
|
||||
Scheduled reports (`server/lib/reports.js`, `/api/reports`). A definition names the window (`window_days` full local days before the run day), the schedule (`frequency` daily | weekly, `weekday` 0 = Sunday, local `hour`, at `tz_offset` = the browser's `getTimezoneOffset()`), the `formats` (JSON array of `html` / `json`) and `enabled`. `last_slot` is the scheduled instant last claimed — the scheduler claims a slot with one conditional `UPDATE … WHERE last_slot IS NULL OR last_slot < ?`, so of two dashboards on one database only one runs it. Each run is a `report_runs` row; its files are `<data dir>/reports/<report_id>/<stamp>.<format>` (`files` lists the formats written). Runs cascade with their definition. Both tables survive Clear Data. Additive: `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_definitions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
window_days INTEGER NOT NULL DEFAULT 7,
|
||||
frequency TEXT NOT NULL DEFAULT 'weekly' CHECK(frequency IN ('daily','weekly')),
|
||||
hour INTEGER NOT NULL DEFAULT 8,
|
||||
weekday INTEGER NOT NULL DEFAULT 1,
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
formats TEXT NOT NULL DEFAULT '["html"]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_slot TEXT, -- ISO instant of the last claimed slot
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE report_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
trigger TEXT NOT NULL, -- schedule | manual
|
||||
status TEXT NOT NULL, -- ok | error
|
||||
window_from TEXT, -- local YYYY-MM-DD, inclusive
|
||||
window_to TEXT,
|
||||
stamp TEXT, -- file name stem, made by the server
|
||||
files TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX idx_report_runs_report ON report_runs(report_id, id);
|
||||
```
|
||||
|
||||
### lane_sources (and `lanes.instructions`)
|
||||
|
||||
A workspace's own context, as on its home page (`server/lib/lane-sources.js`). `lanes.instructions` (TEXT, nullable, ≤ 16 000 characters) is appended to every new pane's system prompt; `lane_sources` holds its extra **folders** (the user's directories, never deleted by AgentHub) and uploaded context **files** (AgentHub's own copies under `<data dir>/workspace-context/<lane id>/`, deleted with the row and with the workspace). Both are read at spawn from these rows only (`launchExtras`), never from a request. Both are additive migrations; the rows cascade when the lane row is deleted.
|
||||
|
||||
+13
-14
@@ -189,7 +189,8 @@ npm run dev:server
|
||||
# Terminal 2: Client only
|
||||
npm run dev:client
|
||||
|
||||
# Terminal 3: MCP server (optional)
|
||||
# Terminal 3: MCP server (optional; normally Claude Code starts it — docs/MCP.md)
|
||||
node mcp/server.js
|
||||
```
|
||||
|
||||
---
|
||||
@@ -257,6 +258,8 @@ DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane
|
||||
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
|
||||
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens
|
||||
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
||||
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
|
||||
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
||||
|
||||
# Pricing and saved settings
|
||||
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
|
||||
@@ -532,21 +535,17 @@ graph TB
|
||||
|
||||
### Monitoring Metrics
|
||||
|
||||
```javascript
|
||||
// Add to server/index.js for metrics endpoint
|
||||
app.get('/metrics', (req, res) => {
|
||||
const metrics = {
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
cpu: process.cpuUsage(),
|
||||
sessions: db.prepare('SELECT COUNT(*) as count FROM sessions').get(),
|
||||
agents: db.prepare('SELECT COUNT(*) as count FROM agents').get(),
|
||||
websocket_clients: wss.clients.size
|
||||
};
|
||||
res.json(metrics);
|
||||
});
|
||||
The server already exposes Prometheus metrics at `GET /api/metrics`
|
||||
([API.md § Prometheus exposition](./API.md#prometheus-exposition)). A scrape
|
||||
config, recording rules and three Grafana boards ship in `monitoring/`:
|
||||
|
||||
```bash
|
||||
DASHBOARD_ALLOWED_HOSTS=host.docker.internal npm start
|
||||
GF_SECURITY_ADMIN_PASSWORD='choose-one' docker compose -f docker-compose.monitoring.yml up -d
|
||||
```
|
||||
|
||||
See [MONITORING.md](./MONITORING.md).
|
||||
|
||||
---
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
@@ -23,6 +23,8 @@ Comprehensive guide to integrating with Claude Code's hook system for real-time
|
||||
|
||||
Claude Code provides a hook system that allows external tools to receive real-time events during agent execution. Agent Dashboard uses these hooks to capture session lifecycle, tool executions, and notifications.
|
||||
|
||||
> **Privacy (Settings → Data → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
|
||||
|
||||
> **Cursor (informational):** Live hooks fire from Claude Code. **Cursor** sessions that only exist as JSONL under `~/.claude` (Cursor uses the same paths locally) are still counted — they appear via startup import, continuous project sync, or remote SSH sync, not via hooks.
|
||||
|
||||
```mermaid
|
||||
|
||||
+2
-2
@@ -390,11 +390,11 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
|
||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
||||
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Router) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
|
||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# AgentHub MCP server
|
||||
|
||||
`mcp/server.js` is a small **read-only** [Model Context Protocol](https://modelcontextprotocol.io)
|
||||
server. It lets a Claude Code session (or any MCP client) ask the running
|
||||
AgentHub dashboard about sessions, costs, analytics, workspaces and alerts.
|
||||
|
||||
- Plain Node, no build step and no dependency: JSON-RPC 2.0 over stdio, one JSON
|
||||
message per line (`initialize`, `ping`, `tools/list`, `tools/call`).
|
||||
- Every tool is ONE `GET` against the dashboard's HTTP API, so its numbers are
|
||||
the dashboard's own. It never opens the database and has no write tool.
|
||||
- The dashboard must be running (`agenthub start`). A tool called while it is
|
||||
down answers an `isError` result saying so.
|
||||
|
||||
## Enable it
|
||||
|
||||
The plugin does **not** register the server by itself — every registered MCP
|
||||
server adds its tool list to every session's context, so it is opt-in:
|
||||
|
||||
```bash
|
||||
# Plugin install or checkout with `agenthub` on PATH
|
||||
claude mcp add agenthub -- agenthub mcp
|
||||
|
||||
# Checkout without the CLI linked
|
||||
claude mcp add agenthub -- node /path/to/AgentHub/mcp/server.js
|
||||
```
|
||||
|
||||
Add `--scope user` to make it available in every project. Remove it with
|
||||
`claude mcp remove agenthub`. On a plugin install, `agenthub` is the
|
||||
`~/.local/bin/agenthub` launcher the bootstrap keeps pointed at the current
|
||||
build, so the registration survives plugin updates.
|
||||
|
||||
## Which dashboard it talks to
|
||||
|
||||
| Priority | Source |
|
||||
|---|---|
|
||||
| 1 | `AGENTHUB_URL` (e.g. `http://127.0.0.1:4821`) |
|
||||
| 2 | `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` |
|
||||
| 3 | `~/.claude/.agenthub.json` — the live-dashboard discovery file the CLI uses (`server/lib/dashboard-url.js`) |
|
||||
| 4 | `http://127.0.0.1:4820` |
|
||||
|
||||
When the dashboard sets `DASHBOARD_TOKEN`, give the MCP process the same
|
||||
variable (`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`); it is
|
||||
sent as `Authorization: Bearer`.
|
||||
|
||||
## Tools
|
||||
|
||||
Every result is the API's JSON, pretty-printed as text. `tz_offset` is minutes
|
||||
from UTC as JavaScript's `getTimezoneOffset()` gives them (UTC+7 → `-420`); when
|
||||
left out, the MCP process's own time zone is used.
|
||||
|
||||
| Tool | Arguments | API |
|
||||
|---|---|---|
|
||||
| `get_health` | — | `GET /api/health` |
|
||||
| `get_info` | — | `GET /api/settings/info` |
|
||||
| `get_stats` | `tz_offset`, `tool` | `GET /api/stats` |
|
||||
| `get_total_cost` | `tz_offset`, `lane`, `tool` | `GET /api/pricing/cost` |
|
||||
| `get_session_cost` | `session_id`*, `tz_offset` | `GET /api/pricing/cost/:id` |
|
||||
| `get_lane_cost` | `lane`*, `tz_offset` | `GET /api/pricing/cost?lane=` |
|
||||
| `list_sessions` | `q`, `status`, `tool`, `lane`, `limit` | `GET /api/sessions` |
|
||||
| `get_session` | `session_id`*, `include_events` | `GET /api/sessions/:id` (`?events=0` unless `include_events`) |
|
||||
| `get_session_stats` | `session_id`* | `GET /api/sessions/:id/stats` |
|
||||
| `get_analytics` | `tz_offset`, `tool` | `GET /api/analytics` |
|
||||
| `get_workflows` | `status`, `tool` | `GET /api/workflows` |
|
||||
| `list_alerts` | `unacked`, `limit` | `GET /api/alerts` |
|
||||
| `list_lanes` | — | `GET /api/lanes` (workspaces) |
|
||||
| `list_pricing` | — | `GET /api/pricing` |
|
||||
| `get_budget_status` | — | `GET /api/budgets/status` |
|
||||
|
||||
`*` = required. `tool` filters by AI CLI (`claude`, `codex`, …) on a dashboard
|
||||
that supports the filter and is ignored by one that does not. A route the
|
||||
dashboard does not have (`/api/budgets/status` before budgets ship, or any tool
|
||||
against an older AgentHub) answers `Not available on this dashboard` — this
|
||||
covers both a 404 and the production server's SPA page, which is served with
|
||||
200 for any unknown path.
|
||||
|
||||
## Errors
|
||||
|
||||
- Dashboard unreachable, an API error (`HTTP 404: Session not found`), a missing
|
||||
required argument or an unknown tool → a tool result with `isError: true`.
|
||||
- Unknown JSON-RPC method → error `-32601`; a line that is not JSON → `-32700`.
|
||||
|
||||
## Tests
|
||||
|
||||
`server/__tests__/mcp-server.test.js` drives the server over stdio against a
|
||||
fake API; `server/__tests__/mcp-server-live.test.js` calls every tool against a
|
||||
real in-test dashboard, so a tool whose path drifts from the API fails there.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Prometheus and Grafana for AgentHub
|
||||
|
||||
AgentHub exposes its live counters at `GET /api/metrics` in the Prometheus text
|
||||
format (see [API.md § Prometheus exposition](API.md#prometheus-exposition)).
|
||||
`monitoring/` holds a scrape config, recording rules and three Grafana boards
|
||||
built on those series, and `docker-compose.monitoring.yml` runs both tools with
|
||||
everything provisioned.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `monitoring/prometheus/prometheus.yml` | Scrape config: job `agenthub`, `metrics_path: /api/metrics`, target `host.docker.internal:4820` |
|
||||
| `monitoring/prometheus/agenthub-rules.yml` | Recording rules `agenthub:*` (sessions, agents, event and token rates, cost) |
|
||||
| `monitoring/grafana/dashboards/agenthub-overview.json` | **Overview**: up, version, active sessions, working agents, tokens and spend over 24 h, sessions/agents by status, events/min, token burn, RSS, uptime, websocket clients, remote sources |
|
||||
| `monitoring/grafana/dashboards/agenthub-sessions-agents.json` | **Sessions & agents**: counts by status, errored sessions, agents waiting on you, sessions finished per hour |
|
||||
| `monitoring/grafana/dashboards/agenthub-tokens-cost.json` | **Tokens & cost**: tokens per hour by kind, burn rate, cache-read share, spend all-time / 24 h, spend per hour by tool, spend by tool, top models by spend |
|
||||
| `monitoring/grafana/provisioning/` | The Prometheus datasource and the dashboard provider for the compose stack |
|
||||
| `docker-compose.monitoring.yml` | Prometheus + Grafana, both on loopback only |
|
||||
|
||||
The boards choose their datasource through a `ds` variable, so they also import
|
||||
as they are into an existing Grafana (Dashboards → New → Import).
|
||||
|
||||
## Metrics used
|
||||
|
||||
`agenthub_up`, `agenthub_build_info{version}`, `agenthub_process_uptime_seconds`,
|
||||
`agenthub_process_resident_memory_bytes`, `agenthub_sessions{status}`,
|
||||
`agenthub_agents{status}`, `agenthub_events_total`, `agenthub_websocket_clients`,
|
||||
`agenthub_remote_sources{enabled}`, `agenthub_tokens_total{kind}`.
|
||||
|
||||
The spend panels and the `agenthub:cost_usd:*` rules read
|
||||
**`agenthub_cost_usd_total{tool,model}`** (estimated USD, only ever rising
|
||||
except when pricing is recomputed). A dashboard that does not export it yet
|
||||
leaves those panels empty; each one says so in its description.
|
||||
`server/__tests__/monitoring.test.js` fails if a board or rule queries any other
|
||||
metric `server/routes/metrics.js` does not export.
|
||||
|
||||
## Run it with Docker
|
||||
|
||||
```bash
|
||||
# 1. The dashboard on the host must accept the container's Host header
|
||||
DASHBOARD_ALLOWED_HOSTS=host.docker.internal agenthub start # or npm start
|
||||
# 2. Prometheus + Grafana
|
||||
GF_SECURITY_ADMIN_PASSWORD='choose-one' docker compose -f docker-compose.monitoring.yml up -d
|
||||
# 3. Grafana: http://127.0.0.1:3000 (admin / the password above), folder "AgentHub"
|
||||
# Prometheus: http://127.0.0.1:9090
|
||||
```
|
||||
|
||||
Without step 1 every scrape gets `403 EBADHOST` (the DNS-rebinding guard).
|
||||
When the dashboard sets `DASHBOARD_TOKEN`, put the token in
|
||||
`monitoring/prometheus/dashboard-token` and uncomment the `authorization` block in
|
||||
`prometheus.yml`. Stop with `docker compose -f docker-compose.monitoring.yml down`
|
||||
(`-v` also drops the stored series).
|
||||
|
||||
## Without Docker
|
||||
|
||||
Point an existing Prometheus at the dashboard — copy the `agenthub` job from
|
||||
`prometheus.yml` with the target changed to `127.0.0.1:4820` (no
|
||||
`DASHBOARD_ALLOWED_HOSTS` needed for loopback), add `agenthub-rules.yml` to its
|
||||
`rule_files`, and import the three JSON boards into Grafana.
|
||||
+16
-6
@@ -1,6 +1,6 @@
|
||||
# AgentHub — Plugin Marketplace
|
||||
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It shipped an MCP server until `c7dbaf2`, which dropped it; the dashboard reads Claude Code's MCP configuration in the config explorer but no longer serves one.) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It ships a small read-only MCP server again — `agenthub mcp`, see [MCP.md](./MCP.md) — but does not register it: run `claude mcp add agenthub -- agenthub mcp` to opt in. The same goes for the statusline, `agenthub statusline install`, see [STATUSLINE.md](./STATUSLINE.md).) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
|
||||
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
|
||||
|
||||
@@ -126,12 +126,13 @@ waits on an install:
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
6. **Legacy hook cleanup** — see above.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
9. **Server** — on Linux with `systemd --user` available, install/refresh a
|
||||
`agenthub.service` unit — on macOS with a GUI login, the
|
||||
9. **Server** — when the user has turned **Start AgentHub automatically** on
|
||||
(Settings → About; OFF by default), install/refresh the
|
||||
`agenthub.service` unit on Linux — on macOS with a GUI login, the
|
||||
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
|
||||
of a bare spawn, so the server is supervised and comes back after a reboot
|
||||
with no Claude Code session involved. Everywhere else (Windows, no
|
||||
@@ -242,7 +243,16 @@ throughout: a directory that will not delete is left alone, never an error.
|
||||
|
||||
### Auto-start on boot
|
||||
|
||||
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
|
||||
Opt-in, and off by default: Settings → About → **Start AgentHub automatically**
|
||||
(`GET/PUT /api/settings/autostart`, `server/lib/autostart.js`). The switch IS
|
||||
the unit's enabled state — on writes a unit for the running server when none
|
||||
exists and enables it, off disables it; neither starts or stops the server. A
|
||||
bootstrap only installs or starts a unit that is enabled, never changes that
|
||||
state (`writeUserService({ enable })` keeps it on a rewrite), and with it off
|
||||
the server is the detached spawn below. An install that already had the unit
|
||||
enabled before this switch existed keeps it on.
|
||||
|
||||
While it is on, every plugin bootstrap (not just the first) installs/refreshes a
|
||||
systemd `--user` service (`~/.config/systemd/user/agenthub.service`, managed via
|
||||
`scripts/systemd-service.js` — shared with the checkout path's
|
||||
`agenthub service install`, see the README). Its `ExecStart`/`WorkingDirectory`
|
||||
@@ -632,7 +642,7 @@ These plugins query the Agent Monitor API at `http://localhost:4820`. Key data s
|
||||
11 datasets: `stats`, `orchestration` (DAG), `toolFlow` (transitions), `effectiveness` (subagent success), `patterns` (recurring sequences), `modelDelegation`, `errorPropagation` (by depth), `concurrency` (lanes), `complexity` (score), `compaction` (impact), `cooccurrence` (agent pairs)
|
||||
|
||||
### Alert Rules (`/api/alerts/rules`)
|
||||
Rule types: `token_threshold` (`{ total_tokens }` — the spend-relevant guardrail), `event_pattern`, `inactivity`, `status_duration`.
|
||||
Rule types: `budget` (`{ period: day|week|month, limit_usd, thresholds: [80, 100], tool?, lane?, tz_offset? }` — a spend limit in dollars, each threshold firing once per period; `GET /api/budgets/status` reports the current period), `cost_threshold` (`{ usd }` per session), `token_threshold` (`{ total_tokens }`), `event_pattern`, `inactivity`, `status_duration`. `POST /api/alerts/rules/preview` dry-runs any of them over recorded history.
|
||||
|
||||
### Config Explorer (`/api/cc-config/*`)
|
||||
Read every Claude Code surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, settings, keybindings, statusline, memory). A plugin card's contribution chips are clickable: each one expands to name the skills / subagents / commands / output styles that plugin ships (from `contributes.items`), so the plugin's contents are readable without opening its install directory. `memory` includes the per-project file-based store with `scope: "auto-memory"` (carrying `project`, `name`, `isIndex`, `frontmatter`); those files plus `CLAUDE.md` are mutable via `PUT`/`DELETE /api/cc-config/file` with always-on timestamped backups. The page also **manages plugins and marketplaces**: paste a GitHub `owner/repo`, URL or local path to register a marketplace, then install / enable / disable / update / uninstall its plugins — each button runs the corresponding `claude plugin …` command on the dashboard host (argv array, never a shell string). Hooks and `settings.json` keys are editable per layer (user / project / project-local) through `PATCH /api/cc-config/settings`, a per-key merge that leaves every untouched key — including redacted secrets — exactly as it was.
|
||||
|
||||
+4
-1
@@ -47,7 +47,9 @@ graph TB
|
||||
| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (including `run_stream` / `run_status` / `run_input_ack` for the Run page) | Integration developers |
|
||||
| [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators |
|
||||
| [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers |
|
||||
| [MCP.md](./MCP.md) | MCP server setup and tool reference | MCP integrators |
|
||||
| [MCP.md](./MCP.md) | The read-only MCP server (`agenthub mcp`): setup and tool reference | MCP integrators |
|
||||
| [STATUSLINE.md](./STATUSLINE.md) | The Claude Code statusline (`agenthub statusline install`) | Claude Code users |
|
||||
| [MONITORING.md](./MONITORING.md) | Prometheus rules + Grafana boards for `/api/metrics`, `docker-compose.monitoring.yml` | Operators |
|
||||
| [I18N.md](./I18N.md) | Language architecture, locale strategy, and rollout checklist | Frontend and product teams |
|
||||
| [CLI.md](./CLI.md) | `agenthub` command reference — monitoring, browsing, insights, alerts, pricing, import, administration | Terminal users and CI scripting |
|
||||
|
||||
@@ -139,6 +141,7 @@ graph TB
|
||||
|
||||
- [API.md](./API.md) - Complete API reference
|
||||
- [MCP.md](./MCP.md) - MCP server integration
|
||||
- [MONITORING.md](./MONITORING.md) - Prometheus + Grafana
|
||||
- [HOOKS.md](./HOOKS.md) - Custom hook integration
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# AgentHub statusline for Claude Code
|
||||
|
||||
`statusline/statusline.js` is a Claude Code [statusline](https://docs.claude.com/en/docs/claude-code/statusline)
|
||||
command. It prints one line under the prompt:
|
||||
|
||||
```
|
||||
Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68
|
||||
```
|
||||
|
||||
| Part | Source |
|
||||
|---|---|
|
||||
| model | `model.display_name` from Claude Code's statusline JSON |
|
||||
| branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) |
|
||||
| context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above |
|
||||
| `$1.23` | this session, Claude Code's own `cost.total_cost_usd` |
|
||||
| `today $…` | every session today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>` → today's `daily_costs` row |
|
||||
|
||||
Today's cost has a **1-second timeout** and is cached for 30 s in
|
||||
`<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a
|
||||
stopped dashboard costs one timeout per 30 s, not one per prompt. When the
|
||||
dashboard is down the part is simply left out. The script never throws, stops
|
||||
itself after 2.5 s and always exits 0. The dashboard is found like the CLI finds
|
||||
it (`AGENTHUB_URL`, then `CLAUDE_DASHBOARD_PORT`/`DASHBOARD_PORT`, then
|
||||
`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` is sent when set.
|
||||
|
||||
## Install / uninstall
|
||||
|
||||
Nothing is installed automatically. Run:
|
||||
|
||||
```bash
|
||||
agenthub statusline install # adds statusLine to ~/.claude/settings.json
|
||||
agenthub statusline install --force # also replaces a statusLine that is not AgentHub's
|
||||
agenthub statusline uninstall # removes AgentHub's statusLine only
|
||||
agenthub statusline uninstall --force # removes whatever statusLine is there
|
||||
```
|
||||
|
||||
- The file is `$CLAUDE_HOME/settings.json` (default `~/.claude/settings.json`);
|
||||
every other key is kept.
|
||||
- A `statusLine` that is not AgentHub's is refused (exit 1) unless `--force`.
|
||||
- Before any write the old file is copied beside itself as
|
||||
`settings.json.agenthub-backup-<timestamp>`.
|
||||
- A plugin install registers `"~/.local/bin/agenthub" statusline` (absolute
|
||||
path) — the launcher the bootstrap repoints at every update, because the plugin
|
||||
cache directory is replaced on update. A checkout registers
|
||||
`node "<checkout>/statusline/statusline.js"`.
|
||||
|
||||
`agenthub statusline` with no subcommand renders the line from stdin, which is
|
||||
what the plugin-install registration runs.
|
||||
|
||||
## Tests
|
||||
|
||||
`server/__tests__/statusline.test.js`: the formatter, today's cost against a fake
|
||||
dashboard (cache, cached failure, timeout), the script end to end on stdin, and
|
||||
install/uninstall against a temp `HOME`.
|
||||
@@ -0,0 +1,234 @@
|
||||
# Analytics parity with the upstream agent monitor (hoangsonww)
|
||||
|
||||
Baseline compared: upstream `hoangsonww's upstream agent monitor` at
|
||||
`523170e` (= tag v2.2.6, 2026-10-04) plus its open PRs, against AgentHub `main`
|
||||
at `151dc8a`. Agreed with the user on 2026-10-05: phases 1–6 in full, plus
|
||||
Cursor CLI, a small MCP server, a statusline script and Grafana boards. NOT
|
||||
doing: the deep workflow charts and the web Kanban (dropped on purpose in
|
||||
`4a0f995`), SSE (the websocket covers it), extra UI locales.
|
||||
|
||||
Each phase ships on its own, with tests, docs (`update-project-docs`) and the
|
||||
CLAUDE.md marker. Nothing is committed without the user's ask.
|
||||
|
||||
## Phase 1 — data hygiene ✅
|
||||
- `events.data` trimmed at every insert through the SQL function
|
||||
`trim_event_data(?)` (`server/lib/event-payload.js`, registered in
|
||||
`server/db.js`): Edit/MultiEdit/NotebookEdit/Write drop `originalFile`,
|
||||
`oldString`/`newString` (Write also `content`); Read drops `file.content` and
|
||||
`file.base64`; every string in `tool_input`/`tool_response` over 8 KB keeps a
|
||||
6 KB head + 2 KB tail; `structuredPatch` and everything outside those two
|
||||
fields stay whole; `_trimmed` names what was dropped. The full text stays in
|
||||
the transcript and its snapshot under `<data dir>/transcripts/`.
|
||||
- One-time sweep of stored rows (`sweepEventPayloads`, `user_version` 4, after
|
||||
marker 3), 200 rows per write transaction. No VACUUM (user's choice).
|
||||
Measured on the user's DB: 18,518 rows, 595 MB → 86 MB.
|
||||
- The session list prices every column `/api/pricing/cost/:id` does
|
||||
(`tokensForSessions` in `server/routes/sessions.js`).
|
||||
|
||||
## Phase 2 — cost by tool ✅
|
||||
- `?tool=` on `/api/pricing/cost`, `/api/analytics`, `/api/stats`,
|
||||
`/api/workflows` (+ `/skill-usage`): `parseTool`/`parseScope` in
|
||||
`server/lib/source-filter.js` (id-shaped, else 400), `scoped-stats.js` now
|
||||
takes a `{where, params}` predicate over `sessions`.
|
||||
- `by_tool` in the cost response; Analytics gets an AI-tool select (applies to
|
||||
every sub-tab, Workflow included) and a **Cost by AI tool** card.
|
||||
- `/api/analytics`'s `total_cost` now equals `/api/pricing/cost` (it had
|
||||
ignored the compaction baselines and used UTC start dates).
|
||||
- Codex service tier read from `thread_settings_applied`: fast/priority →
|
||||
`speed = fast` priced with LiteLLM's `*_priority` rates (synced into the fast
|
||||
columns for non-Claude models), flex → `service_tier = flex` at half price.
|
||||
- Prometheus: `agenthub_cost_usd_total{tool,model}`.
|
||||
- DEFERRED (with the reason in `pricing-sync.js`): long-context (>200k/>272k)
|
||||
rates. No current Claude model has one, Codex here runs a 258k window
|
||||
(0 of 17,701 turns over 272k), so a `token_usage` primary-key rebuild would
|
||||
change no number today.
|
||||
|
||||
## Phase 3 — budgets, burn rate, forecast ✅ (budgets as alert rules)
|
||||
- `budgets` table: name, period (day/week/month), limit USD, scope
|
||||
(all / tool / workspace), thresholds (%), enabled. `/api/budgets` CRUD +
|
||||
`GET /api/budgets/status` (spend this period, %, projected end-of-period).
|
||||
- Evaluated on the alert beat; a crossed threshold fires once per period
|
||||
through the existing alerts feed → webhooks → push.
|
||||
- Alert rule type `cost_threshold` (USD per session / per day).
|
||||
- `POST /api/alerts/rules/preview`: dry-run a rule over recorded history.
|
||||
- UI: Settings → Budgets page; burn rate + month-end forecast tiles on
|
||||
Analytics → Cost; budget chip in the status bar when ≥ a threshold.
|
||||
- **As built:** a budget is an alert rule of type `budget` (`server/lib/budgets.js`;
|
||||
config `period` / `limit_usd` / `thresholds` / `tool` / `lane` / `tz_offset`),
|
||||
so there is no `budgets` table, no `/api/budgets` CRUD and no Settings →
|
||||
Budgets page: budgets are created and edited with the other rules in
|
||||
Settings → Notifications → Alert rules (editable in place since v2.3), and
|
||||
`GET /api/budgets/status` is the one budgets route. `cost_threshold`, the
|
||||
preview, the Spend pace + Budgets cards and the status-bar chip are as planned.
|
||||
|
||||
## Phase 4 — analytics UX ✅
|
||||
- **Date range.** `parseRange` (`server/lib/source-filter.js`): `from`/`to` are
|
||||
local `YYYY-MM-DD`, inclusive, read with `tz_offset`, either end open; a bad
|
||||
date (or year 9999, or `from` > `to`) is `400 INVALID_INPUT`.
|
||||
`GET /api/pricing/cost` keeps only the cost rows of those spend days for
|
||||
`total_cost` / `breakdown` / `by_tool` (the existing spend-day split);
|
||||
`daily_costs` stays the whole series so the 30-day charts and the spend pace
|
||||
keep their windows. `GET /api/analytics` counts sessions/agents by
|
||||
`started_at` and events by `created_at` (an optional `range` on the
|
||||
`scoped-stats.js` functions), and takes tokens and cost from the same cost
|
||||
rows (`token_usage` has no date); `daily_events`/`daily_sessions` keep 365
|
||||
days. `GET /api/workflows` (+ `/skill-usage`): sessions started in the range.
|
||||
The bodies are now `computeAnalytics` / `computeCost` (exported). Client:
|
||||
a range select on the Analytics toolbar beside the AI-tool select — 7d /
|
||||
30d / 90d / All (default) / Custom with two `<input type="date">` — in
|
||||
`?range=` (+ `from`/`to`), `client/src/lib/dateRange.ts`; it also reaches
|
||||
the Workflow sub-tab.
|
||||
- **Query Explorer.** `GET /api/query` (`server/routes/query.js`): entity
|
||||
sessions / events / agents, `q` (literal substring), `tool`, `status`,
|
||||
`from`/`to`, `cwd` (folder subtree), `sources`; `sort`/`dir` from a
|
||||
per-entity allowlist (anything else 400 — no request text reaches SQL),
|
||||
ties by id; pages of ≤ 500; `format=csv|json` exports the whole filter
|
||||
capped at 50,000 rows (`X-Total-Count`, `X-Truncated`). CSV: RFC 4180,
|
||||
CRLF, BOM, `'` before a cell starting with = + - @ tab CR. Page `/query`
|
||||
(`client/src/pages/QueryExplorer.tsx`), sidebar entry **Query Explorer**
|
||||
(the sidebar has no analytics item of its own; it sits under Sessions).
|
||||
Column headers show the raw field names, which are also the CSV header.
|
||||
- **Scheduled reports.** Tables `report_definitions` / `report_runs`;
|
||||
`server/lib/reports.js` + `/api/reports` (CRUD, `POST /:id/run`, runs,
|
||||
`GET /:id/runs/:runId/:format` with the path built from ids + the stored
|
||||
stamp). A run covers the `window_days` full local days BEFORE the run day,
|
||||
computed with `computeAnalytics` + `computeCost`, plus the current budget
|
||||
status; files `<data dir>/reports/<id>/<stamp>.{html,json}`, HTML fully
|
||||
escaped. Scheduler: unref'd interval in `startBackgroundServices`
|
||||
(`DASHBOARD_REPORTS_MS`, default 60 s, 0 = off), each slot claimed by one
|
||||
conditional UPDATE (two dashboards on one DB run it once); a slot missed
|
||||
while down runs once on the next start; create/edit/re-enable claims the
|
||||
slot already past. Weekly reports have a `weekday` (default Monday) —
|
||||
needed for "weekly", not in the original list. Writes same-origin.
|
||||
Definitions AND runs survive Clear Data (like alert rules and snapshots).
|
||||
UI: Settings → **Reports** (`components/settings/ReportSettings.tsx`).
|
||||
- Not done: OpenAPI entries for the new routes (snapshots/budgets have none
|
||||
either); report runs are never pruned (one file per run per format).
|
||||
- Upstream's PR branches (#286/#172 `feat/query-explorer`, #174
|
||||
`feat/scheduled-reports`) were not in the reference clone; built from the
|
||||
plan above.
|
||||
- Tests: `analytics-range`, `query-explorer`, `reports` in
|
||||
`server/__tests__/`; client `dateRange`, `QueryExplorer`, `ReportSettings`,
|
||||
and a `?range=` case in `screens.snapshot.test.tsx`.
|
||||
|
||||
## Phase 5 — sessions ✅
|
||||
- Replay: a **Replay** tab on `/sessions/:id` beside Agents / Conversation /
|
||||
Timeline (`client/src/components/EventReplay.tsx`). It reads the session's
|
||||
events through `GET /api/events?session_id=` in pages of 500 (the newest
|
||||
5,000 at most, with a note when there are more), oldest first, on the tab's
|
||||
first visit. A range slider over the event SEQUENCE (not the wall clock — an
|
||||
hour-long pause does not stall it), play/pause at 0.5×/1×/2×/5× (one event
|
||||
per 500 ms at 1×), step back/forward, jump start/end, keys ←/→/Home/End/Space,
|
||||
a mini-map of lifecycle markers (SessionStart, UserPromptSubmit, Compaction,
|
||||
SubagentStop, APIError, Stop) that jump the cursor, a state panel derived up
|
||||
to the cursor (status, tool in flight, prompts, errors, compactions, elapsed,
|
||||
tool-call counts) and the selected event through `EventDetail`. The pure
|
||||
derivations live in `client/src/lib/replay.ts` (unit-tested).
|
||||
- Snapshots: `POST /api/snapshots {session_id, redact?: {file_paths?}}` freezes
|
||||
`{session, agents, events, stats, cost}` into an immutable
|
||||
`session_snapshots` row (a trigger refuses UPDATE) under a 24-byte base64url
|
||||
token; `GET /api/snapshots` (no payload), `GET /api/snapshots/:token`,
|
||||
`DELETE /api/snapshots/:id`; writes behind `sameOriginGuard`. Redaction runs
|
||||
at capture: path-valued keys → `[redacted]`, the session cwd / home dir in
|
||||
any other string → `<cwd>` / `~`. Snapshots survive Clear Data, like alert
|
||||
rules. UI: a **Snapshot** action in the session header (confirm with a
|
||||
"redact file paths" box, default on; copies the link), the read-only page
|
||||
`/snapshot/:token` (`client/src/pages/SnapshotView.tsx`: SessionOverview fed
|
||||
the frozen stats via its new `stats` prop, the agents, Replay and Timeline —
|
||||
the Timeline list now `client/src/components/EventTimeline.tsx`, shared with
|
||||
the analysis page), and Settings → **Snapshots** (`/settings/snapshots`,
|
||||
`components/settings/SnapshotSettings.tsx`: list, open, copy link, delete
|
||||
with confirm, the same on a right-click menu).
|
||||
- Upstream's PR branches (#265 `feat/session-replay`, #173
|
||||
`feat/session-snapshots`) were not in the reference clone; built from the
|
||||
plan above.
|
||||
|
||||
## Phase 6 — ingest breadth ✅
|
||||
- **Remote Codex.** A remote source has an opt-in `sync_codex` (+ optional
|
||||
`remote_codex_home`, default `~/.codex`, `wsl:~/.codex` beside a `wsl:` Claude
|
||||
home) — two additive columns on `remote_sources`, a checkbox in the source
|
||||
form, `--codex`/`--codex-home` on `agenthub remote-sources add`. A sync mirrors
|
||||
`sessions/` with mtimes kept (`scp -r -p`, or tar from WSL) plus
|
||||
`session_index.jsonl` into `<data dir>/remote-sources/<id>/codex/`, then runs
|
||||
`ingestCodexUsage({home, source, activeMs})` — the same journal reader as local
|
||||
Codex (`server/lib/codex-usage.js`), so sessions arrive as `tool = 'codex'`
|
||||
rows tagged with the source id, with tokens and cost. A pass only ever writes
|
||||
rows of its own source; the local settle never touches a remote row; the
|
||||
remote activity window is `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS`. With Codex on, a
|
||||
machine may have only one CLI: one half failing is `ok` + `last_error`.
|
||||
Differs from upstream (which always tries both): opt-in, and no separate
|
||||
Codex import pipeline — AgentHub's journal reader is reused.
|
||||
- **Push ingest.** `POST /api/hooks/ingest-batch` — body `{source, events:
|
||||
[{hook_type, data, tool?}]}`, each event through the SAME function as
|
||||
`POST /api/hooks/event` (`ingestHook` in `server/routes/hooks.js`). Off (503)
|
||||
until Settings → Data → **Push ingest** creates a token: 32 random bytes,
|
||||
shown once, stored only as its SHA-256 (`AGENTHUB_PUSH_TOKEN_SHA256` in the
|
||||
settings file), checked in constant time; rotate/revoke; `GET` never returns
|
||||
it. Sessions tagged `push:<source>`; a pushed hook may only extend a session
|
||||
its own source created (`EFOREIGN`); `transcript_path`s are dropped (they
|
||||
name the sender's files); no lane/pane binding; 500 events / 1 MB. The UI
|
||||
shows a forwarder script keeping the token in a curl header file, not argv.
|
||||
Differs from upstream: upstream's wire format is a collector's own session/
|
||||
token/turn records; AgentHub takes plain hook payloads, so a pushed session
|
||||
has events and live status but no token cost (that needs transcripts).
|
||||
- **Live Codex overlay — not built.** Upstream's `include_transient`
|
||||
(`codex-process-overlay.js`) scans `ps` for an interactive `codex` TUI that
|
||||
has not written a rollout yet, i.e. one started but never prompted. In
|
||||
AgentHub a Codex pane started from the dashboard already shows (its tab, and
|
||||
**New session** in the sidebar from its run), and anything that has done work
|
||||
has a journal the 15 s poll reads (or a SessionStart hook with Codex hooks
|
||||
on). The only row it would add is an idle Codex in an outside terminal with
|
||||
no prompt and no cost — not worth a per-tick process scan.
|
||||
- **Privacy** (`server/lib/privacy.js`, Settings → Data → **Privacy**,
|
||||
`GET/PUT /api/settings/privacy`, both off by default, persisted to the
|
||||
settings file, env-pinnable): (a) **Redact secrets** (`AGENTHUB_REDACT_SECRETS=1`)
|
||||
— chained in front of the trim in `trim_event_data` (so a key is redacted
|
||||
before a trim could cut it) plus a new `redact_text(?)` on every events
|
||||
`summary` insert; known key shapes (Anthropic, OpenAI, GitHub, GitLab, Slack,
|
||||
AWS, Google, npm, JWT), bearer/basic headers, PEM private keys,
|
||||
`password=`/`*_TOKEN:`-style values, and any string under a secret-named JSON
|
||||
key → `[redacted]`, with `_redacted: <n>` on the payload. New data only; the
|
||||
transcript is untouched; session NAMES (first prompt) are not redacted.
|
||||
(b) **Ignored folders** (`AGENTHUB_IGNORE_CWD`, `path.delimiter`-separated,
|
||||
path-boundary match): hooks dropped whole (`ignored: true`), and the history
|
||||
import and the Codex reader skip those sessions too.
|
||||
|
||||
## Phase 7 — extras chosen by the user
|
||||
- Cursor CLI: session + token ingest from `~/.cursor`, Cursor pricing rows.
|
||||
- MCP server (stdio, read-only, ~15 tools: stats, cost, sessions, session
|
||||
detail, analytics, workflows, budgets, alerts).
|
||||
- `statusline/` script for Claude Code (model, branch, context %, session $,
|
||||
today $ from the dashboard).
|
||||
- `monitoring/` Grafana dashboards + Prometheus rules for `/api/metrics`.
|
||||
|
||||
### Phase 7 status — MCP, statusline, monitoring ✅; Cursor ✅ (no cost)
|
||||
- **MCP** (`mcp/server.js`, `agenthub mcp`, docs/MCP.md): JSON-RPC over stdio
|
||||
written directly (no SDK), 15 GET-only tools (health, info, stats, total /
|
||||
session / lane cost, sessions, session, session stats, analytics, workflows,
|
||||
alerts, lanes, pricing, budget status). `AGENTHUB_URL`, else the CLI's
|
||||
discovery, now shared as `server/lib/dashboard-url.js`. A missing route —
|
||||
404 or the production SPA page served with 200 — reads "Not available on
|
||||
this dashboard", which is what `get_budget_status` says until Phase 3 lands.
|
||||
NOT auto-registered by the plugin: `claude mcp add agenthub -- agenthub mcp`.
|
||||
- **Statusline** (`statusline/statusline.js`, `agenthub statusline
|
||||
install|uninstall [--force]`, docs/STATUSLINE.md): model · branch · context
|
||||
bar · session $ · today $ (`/api/pricing/cost`, 1 s timeout, 30 s cache in
|
||||
the OS temp dir, failures cached). Install refuses a foreign `statusLine`
|
||||
without `--force` and backs settings.json up first; a plugin install
|
||||
registers the `~/.local/bin/agenthub` launcher, not the cache dir.
|
||||
- **Monitoring** (`monitoring/`, `docker-compose.monitoring.yml`,
|
||||
docs/MONITORING.md): scrape config, `agenthub:*` recording rules, boards
|
||||
Overview / Sessions & agents / Tokens & cost; the spend panels read Phase 2's
|
||||
`agenthub_cost_usd_total{tool,model}` and say so.
|
||||
- Tests: `mcp-server`, `mcp-server-live`, `statusline`, `monitoring` in
|
||||
`server/__tests__/`.
|
||||
- **Cursor** (`server/lib/cursor-usage.js`): agent transcripts under
|
||||
`~/.cursor/projects/<slug>/agent-transcripts/` become `tool = 'cursor'`
|
||||
sessions (`cursor_transcripts` table, `DASHBOARD_CURSOR_POLL_MS`,
|
||||
`AGENTHUB_CURSOR_DIR`). NOT built: tokens, model and cost — the transcripts
|
||||
record none, and `state.vscdb` (which has them) also holds auth tokens, so
|
||||
it is never read; hence no Cursor pricing rows either.
|
||||
|
||||
## Ported onto main
|
||||
Phases 2–7 were built in a separate worktree on 138e706 and ported onto v2.3.1
|
||||
on 2026-10-06 (phase 1 had shipped on its own), released as v2.4.0.
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file AgentHub's MCP server — a small, READ-ONLY Model Context Protocol server
|
||||
* over stdio (newline-delimited JSON-RPC 2.0: `initialize`, `ping`,
|
||||
* `tools/list`, `tools/call`). Every tool is one GET against the running
|
||||
* dashboard's HTTP API, so the numbers are the dashboard's own; nothing here
|
||||
* opens the database or writes anything. The base URL is `AGENTHUB_URL`, else
|
||||
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN`, when
|
||||
* set, is sent as a bearer token. Run it as `agenthub mcp` or
|
||||
* `node mcp/server.js`; see docs/MCP.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
|
||||
const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
||||
const VERSION = (() => {
|
||||
try {
|
||||
return require(path.join(__dirname, "..", "package.json")).version;
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
})();
|
||||
|
||||
function baseUrl() {
|
||||
if (process.env.AGENTHUB_URL) return process.env.AGENTHUB_URL.replace(/\/+$/, "");
|
||||
return require(path.join(__dirname, "..", "server", "lib", "dashboard-url.js")).dashboardUrl();
|
||||
}
|
||||
|
||||
// ── Tool table ───────────────────────────────────────────────────────────────
|
||||
// Each tool: description, JSON-schema properties, and `path(args)` → the API
|
||||
// path to GET. Only the listed query keys are ever forwarded.
|
||||
|
||||
const str = (description) => ({ type: "string", description });
|
||||
const int = (description) => ({ type: "integer", description });
|
||||
const TZ = int(
|
||||
"Minutes from UTC as JavaScript's getTimezoneOffset() returns them (e.g. -420 for UTC+7). Defaults to this machine's."
|
||||
);
|
||||
const TOOL = str(
|
||||
"AI CLI to scope to: claude, codex, antigravity, … (ignored by dashboards without the filter)"
|
||||
);
|
||||
const SESSION_ID = str("Session id");
|
||||
const LANE = int("Workspace (lane) id, from list_lanes");
|
||||
|
||||
/** `?a=1&b=2` from the given keys of `args`, skipping empty ones. */
|
||||
function query(args, keys) {
|
||||
const q = new URLSearchParams();
|
||||
for (const k of keys) {
|
||||
const v = args[k];
|
||||
if (v !== undefined && v !== null && v !== "") q.set(k, String(v));
|
||||
}
|
||||
const s = q.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
const withTz = (args) => ({ tz_offset: new Date().getTimezoneOffset(), ...args });
|
||||
const id = (v) => encodeURIComponent(String(v));
|
||||
|
||||
const TOOLS = {
|
||||
get_health: {
|
||||
description: "Is the AgentHub dashboard up, and which version.",
|
||||
properties: {},
|
||||
path: () => "/api/health",
|
||||
},
|
||||
get_info: {
|
||||
description:
|
||||
"Dashboard system info: version, database size and row counts, server memory, hook status.",
|
||||
properties: {},
|
||||
path: () => "/api/settings/info",
|
||||
},
|
||||
get_stats: {
|
||||
description: "Overview totals: sessions and agents by status, today's events, active sessions.",
|
||||
properties: { tz_offset: TZ, tool: TOOL },
|
||||
path: (a) => `/api/stats${query(withTz(a), ["tz_offset", "tool"])}`,
|
||||
},
|
||||
get_total_cost: {
|
||||
description:
|
||||
"Estimated USD cost across all sessions, with per-model breakdown and daily costs (daily_costs[].date in the given time zone).",
|
||||
properties: { tz_offset: TZ, lane: LANE, tool: TOOL },
|
||||
path: (a) => `/api/pricing/cost${query(withTz(a), ["tz_offset", "lane", "tool"])}`,
|
||||
},
|
||||
get_session_cost: {
|
||||
description: "Estimated USD cost of one session, per model.",
|
||||
properties: { session_id: SESSION_ID, tz_offset: TZ },
|
||||
required: ["session_id"],
|
||||
path: (a) => `/api/pricing/cost/${id(a.session_id)}${query(withTz(a), ["tz_offset"])}`,
|
||||
},
|
||||
get_lane_cost: {
|
||||
description: "Estimated USD cost of the sessions one workspace (lane) owns.",
|
||||
properties: { lane: LANE, tz_offset: TZ },
|
||||
required: ["lane"],
|
||||
path: (a) => `/api/pricing/cost${query(withTz(a), ["tz_offset", "lane"])}`,
|
||||
},
|
||||
list_sessions: {
|
||||
description: "List sessions, newest first, with cost and token totals.",
|
||||
properties: {
|
||||
q: str("Substring of the session id, name or cwd"),
|
||||
status: { type: "string", enum: ["active", "completed", "error", "abandoned"] },
|
||||
tool: TOOL,
|
||||
lane: LANE,
|
||||
limit: int("Max rows (default 50)"),
|
||||
},
|
||||
path: (a) => `/api/sessions${query(a, ["q", "status", "tool", "lane", "limit"])}`,
|
||||
},
|
||||
get_session: {
|
||||
description:
|
||||
"One session: its row, agents (with each agent's own cost) and Workflow-tool runs. Events are left out unless include_events is true (they can be huge).",
|
||||
properties: {
|
||||
session_id: SESSION_ID,
|
||||
include_events: { type: "boolean", description: "Also return every hook event" },
|
||||
},
|
||||
required: ["session_id"],
|
||||
path: (a) => `/api/sessions/${id(a.session_id)}${a.include_events ? "" : "?events=0"}`,
|
||||
},
|
||||
get_session_stats: {
|
||||
description: "Aggregated counts for one session: tools used, agents, errors, durations.",
|
||||
properties: { session_id: SESSION_ID },
|
||||
required: ["session_id"],
|
||||
path: (a) => `/api/sessions/${id(a.session_id)}/stats`,
|
||||
},
|
||||
get_analytics: {
|
||||
description:
|
||||
"Token totals, tool usage, daily events/sessions, agent types — the Analytics page's data.",
|
||||
properties: { tz_offset: TZ, tool: TOOL },
|
||||
path: (a) => `/api/analytics${query(withTz(a), ["tz_offset", "tool"])}`,
|
||||
},
|
||||
get_workflows: {
|
||||
description:
|
||||
"Workflow intelligence: orchestration, tool flow, subagent effectiveness, patterns.",
|
||||
properties: { status: { type: "string", enum: ["active", "completed"] }, tool: TOOL },
|
||||
path: (a) => `/api/workflows${query(a, ["status", "tool"])}`,
|
||||
},
|
||||
list_alerts: {
|
||||
description: "Fired alerts, newest first.",
|
||||
properties: {
|
||||
unacked: { type: "boolean", description: "Only unacknowledged ones" },
|
||||
limit: int("Max rows (default 50, max 200)"),
|
||||
},
|
||||
path: (a) =>
|
||||
`/api/alerts${query({ ...a, unacked: a.unacked ? "true" : undefined }, ["unacked", "limit"])}`,
|
||||
},
|
||||
list_lanes: {
|
||||
description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.",
|
||||
properties: {},
|
||||
path: () => "/api/lanes",
|
||||
},
|
||||
list_pricing: {
|
||||
description: "Model pricing rules (USD per million tokens) the cost estimates use.",
|
||||
properties: {},
|
||||
path: () => "/api/pricing",
|
||||
},
|
||||
get_budget_status: {
|
||||
description: "Budgets: spend this period, percent of limit, projected end-of-period spend.",
|
||||
properties: {},
|
||||
path: () => "/api/budgets/status",
|
||||
},
|
||||
};
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET a dashboard path; resolves to an MCP tool result, never rejects. */
|
||||
async function callApi(pathname) {
|
||||
const url = `${baseUrl()}${pathname}`;
|
||||
const headers = process.env.DASHBOARD_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
|
||||
: {};
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
`AgentHub dashboard not reachable at ${baseUrl()} (${err?.cause?.code || err?.name || err}). Start it with \`agenthub start\`, or set AGENTHUB_URL.`
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
// The API answers in JSON, its own 404s included. A non-JSON body is
|
||||
// Express's "Cannot GET" 404, or — on a production dashboard — the SPA page
|
||||
// served with 200 for any unknown path: either way, no such route.
|
||||
if (data === undefined && (res.ok || res.status === 404)) {
|
||||
return fail(
|
||||
`Not available on this dashboard: GET ${pathname} does not exist (an older AgentHub?).`
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
return fail(
|
||||
`GET ${pathname} → HTTP ${res.status}: ${data?.error?.message || text.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
|
||||
|
||||
// ── JSON-RPC ────────────────────────────────────────────────────────────────
|
||||
|
||||
function toolList() {
|
||||
return Object.entries(TOOLS).map(([name, t]) => ({
|
||||
name,
|
||||
description: t.description,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: t.properties,
|
||||
...(t.required ? { required: t.required } : {}),
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
}));
|
||||
}
|
||||
|
||||
async function callTool(params) {
|
||||
const tool = TOOLS[params?.name];
|
||||
if (!tool) return fail(`Unknown tool: ${params?.name}`);
|
||||
const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
|
||||
const missing = (tool.required || []).filter((k) => args[k] === undefined || args[k] === "");
|
||||
if (missing.length) return fail(`Missing required argument(s): ${missing.join(", ")}`);
|
||||
return callApi(tool.path(args));
|
||||
}
|
||||
|
||||
/** Answer one parsed message; resolves to the response, or null for a notification. */
|
||||
async function handle(msg) {
|
||||
const reply = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
|
||||
const error = (code, message) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id ?? null,
|
||||
error: { code, message },
|
||||
});
|
||||
if (!msg || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
|
||||
return error(-32600, "Invalid Request");
|
||||
}
|
||||
const isNotification = msg.id === undefined;
|
||||
let out;
|
||||
switch (msg.method) {
|
||||
case "initialize": {
|
||||
const asked = msg.params?.protocolVersion;
|
||||
out = reply({
|
||||
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "agenthub", version: VERSION },
|
||||
instructions:
|
||||
"Read-only access to the local AgentHub dashboard: sessions, costs, analytics, workspaces, alerts.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "ping":
|
||||
out = reply({});
|
||||
break;
|
||||
case "tools/list":
|
||||
out = reply({ tools: toolList() });
|
||||
break;
|
||||
case "tools/call":
|
||||
out = reply(await callTool(msg.params));
|
||||
break;
|
||||
default:
|
||||
out = error(-32601, `Method not found: ${msg.method}`);
|
||||
}
|
||||
return isNotification ? null : out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", async (line) => {
|
||||
if (!line.trim()) return;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
return send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
||||
}
|
||||
try {
|
||||
const out = await handle(msg);
|
||||
if (out) send(out);
|
||||
} catch (err) {
|
||||
if (msg?.id !== undefined) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32603, message: String(err?.message || err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { main, handle, TOOLS };
|
||||
@@ -0,0 +1,666 @@
|
||||
{
|
||||
"uid": "agenthub-overview",
|
||||
"title": "AgentHub — Overview",
|
||||
"description": "Health, activity and spend of one AgentHub dashboard (/api/metrics).",
|
||||
"tags": ["agenthub"],
|
||||
"editable": true,
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timezone": "browser",
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "ds",
|
||||
"label": "Prometheus",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {},
|
||||
"hide": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"title": "AgentHub boards",
|
||||
"type": "dashboards",
|
||||
"tags": ["agenthub"],
|
||||
"asDropdown": true
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Dashboard up",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "max(agenthub_up)",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"mappings": [
|
||||
{
|
||||
"type": "value",
|
||||
"options": {
|
||||
"0": {
|
||||
"text": "DOWN",
|
||||
"color": "red"
|
||||
},
|
||||
"1": {
|
||||
"text": "UP",
|
||||
"color": "green"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Version",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 4,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "agenthub_build_info",
|
||||
"legendFormat": "{{version}}",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "name",
|
||||
"colorMode": "none",
|
||||
"graphMode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Active sessions",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 8,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_sessions{status=\"active\"})",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "Working agents",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_agents{status=\"working\"})",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "stat",
|
||||
"title": "Tokens (24h)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 16,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(increase(agenthub_tokens_total[24h]))",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "stat",
|
||||
"title": "Spend (24h)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 20,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(increase(agenthub_cost_usd_total[24h]))",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyUSD",
|
||||
"decimals": 2
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
},
|
||||
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "timeseries",
|
||||
"title": "Sessions by status",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (status) (agenthub_sessions)",
|
||||
"legendFormat": "{{status}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "timeseries",
|
||||
"title": "Agents by status",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (status) (agenthub_agents)",
|
||||
"legendFormat": "{{status}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "timeseries",
|
||||
"title": "Events per minute",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(rate(agenthub_events_total[5m])) * 60",
|
||||
"legendFormat": "events/min"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "timeseries",
|
||||
"title": "Token burn (per second, by kind)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 12,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (kind) (rate(agenthub_tokens_total[5m]))",
|
||||
"legendFormat": "{{kind}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "normal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "timeseries",
|
||||
"title": "Server memory (RSS)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 20,
|
||||
"w": 8,
|
||||
"h": 7
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "agenthub_process_resident_memory_bytes",
|
||||
"legendFormat": "rss"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "timeseries",
|
||||
"title": "Server uptime",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 8,
|
||||
"y": 20,
|
||||
"w": 8,
|
||||
"h": 7
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "agenthub_process_uptime_seconds",
|
||||
"legendFormat": "uptime"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "timeseries",
|
||||
"title": "Realtime clients / remote sources",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 16,
|
||||
"y": 20,
|
||||
"w": 8,
|
||||
"h": 7
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "agenthub_websocket_clients",
|
||||
"legendFormat": "websocket clients"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (enabled) (agenthub_remote_sources)",
|
||||
"legendFormat": "remote sources (auto-sync {{enabled}})"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
{
|
||||
"uid": "agenthub-sessions-agents",
|
||||
"title": "AgentHub — Sessions & agents",
|
||||
"description": "Session and agent lifecycle counts from /api/metrics.",
|
||||
"tags": ["agenthub"],
|
||||
"editable": true,
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timezone": "browser",
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "ds",
|
||||
"label": "Prometheus",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {},
|
||||
"hide": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"title": "AgentHub boards",
|
||||
"type": "dashboards",
|
||||
"tags": ["agenthub"],
|
||||
"asDropdown": true
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Sessions (all)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_sessions)",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Active",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_sessions{status=\"active\"})",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Errored sessions",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_sessions{status=\"error\"})",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "Waiting agents (need you)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_agents{status=\"waiting\"})",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "bargauge",
|
||||
"title": "Sessions now, by status",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 7
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (status) (agenthub_sessions)",
|
||||
"legendFormat": "{{status}}",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"orientation": "horizontal",
|
||||
"displayMode": "gradient",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "bargauge",
|
||||
"title": "Agents now, by status",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 7
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (status) (agenthub_agents)",
|
||||
"legendFormat": "{{status}}",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"orientation": "horizontal",
|
||||
"displayMode": "gradient",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "timeseries",
|
||||
"title": "Active sessions and working agents",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 11,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_sessions{status=\"active\"})",
|
||||
"legendFormat": "active sessions"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_agents{status=\"working\"})",
|
||||
"legendFormat": "working agents"
|
||||
},
|
||||
{
|
||||
"refId": "C",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_agents{status=\"waiting\"})",
|
||||
"legendFormat": "waiting agents"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "timeseries",
|
||||
"title": "Sessions finished per hour",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 11,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (status) (clamp_min(delta(agenthub_sessions{status=~\"completed|error|abandoned\"}[1h]), 0))",
|
||||
"legendFormat": "{{status}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"custom": {
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 60,
|
||||
"stacking": {
|
||||
"mode": "normal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "timeseries",
|
||||
"title": "Events per minute",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 19,
|
||||
"w": 24,
|
||||
"h": 7
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(rate(agenthub_events_total[5m])) * 60",
|
||||
"legendFormat": "events/min"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
{
|
||||
"uid": "agenthub-tokens-cost",
|
||||
"title": "AgentHub — Tokens & cost",
|
||||
"description": "Token usage by kind and estimated USD spend by tool and model.",
|
||||
"tags": ["agenthub"],
|
||||
"editable": true,
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timezone": "browser",
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "ds",
|
||||
"label": "Prometheus",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {},
|
||||
"hide": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"title": "AgentHub boards",
|
||||
"type": "dashboards",
|
||||
"tags": ["agenthub"],
|
||||
"asDropdown": true
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Tokens (all time)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_tokens_total)",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Cache read share (24h)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(increase(agenthub_tokens_total{kind=\"cache_read\"}[24h])) / sum(increase(agenthub_tokens_total{kind=~\"input|cache_read|cache_write\"}[24h]))",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"decimals": 1
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Spend (all time)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(agenthub_cost_usd_total)",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyUSD",
|
||||
"decimals": 2
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
},
|
||||
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "Spend (24h)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum(increase(agenthub_cost_usd_total[24h]))",
|
||||
"legendFormat": "__auto",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyUSD",
|
||||
"decimals": 2
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"textMode": "auto"
|
||||
},
|
||||
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "timeseries",
|
||||
"title": "Tokens per hour, by kind",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (kind) (increase(agenthub_tokens_total[1h]))",
|
||||
"legendFormat": "{{kind}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"custom": {
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 60,
|
||||
"stacking": {
|
||||
"mode": "normal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"interval": "1h"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "timeseries",
|
||||
"title": "Token burn (per second, by kind)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (kind) (rate(agenthub_tokens_total[5m]))",
|
||||
"legendFormat": "{{kind}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"custom": {
|
||||
"fillOpacity": 15,
|
||||
"lineWidth": 2,
|
||||
"stacking": {
|
||||
"mode": "normal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "timeseries",
|
||||
"title": "Spend per hour, by tool",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (tool) (increase(agenthub_cost_usd_total[1h]))",
|
||||
"legendFormat": "{{tool}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyUSD",
|
||||
"custom": {
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 60,
|
||||
"stacking": {
|
||||
"mode": "normal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"interval": "1h",
|
||||
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "piechart",
|
||||
"title": "Spend by tool (all time)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 12,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "sum by (tool) (agenthub_cost_usd_total)",
|
||||
"legendFormat": "{{tool}}",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyUSD",
|
||||
"decimals": 2
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet.",
|
||||
"options": {
|
||||
"pieType": "donut",
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"values": ["value", "percent"]
|
||||
},
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "bargauge",
|
||||
"title": "Top models by spend (24h)",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 20,
|
||||
"w": 24,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${ds}"
|
||||
},
|
||||
"expr": "topk(10, sum by (model) (increase(agenthub_cost_usd_total[24h])))",
|
||||
"legendFormat": "{{model}}",
|
||||
"instant": true,
|
||||
"range": false
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyUSD",
|
||||
"decimals": 2
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet.",
|
||||
"options": {
|
||||
"orientation": "horizontal",
|
||||
"displayMode": "gradient",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Loads the AgentHub boards (overview, sessions & agents, tokens & cost) from
|
||||
# /var/lib/grafana/dashboards, the compose file's mount of monitoring/grafana/dashboards.
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: AgentHub
|
||||
orgId: 1
|
||||
folder: AgentHub
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
@@ -0,0 +1,13 @@
|
||||
# The Prometheus datasource for docker-compose.monitoring.yml. The boards pick
|
||||
# their datasource through a `ds` variable, so they also import into any other
|
||||
# Grafana as they are.
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
uid: agenthub-prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: true
|
||||
@@ -0,0 +1,27 @@
|
||||
# Recording rules over AgentHub's /api/metrics series (docs/MONITORING.md).
|
||||
# The agenthub:cost_usd:* rules need agenthub_cost_usd_total{tool,model}; on a
|
||||
# dashboard that does not export it they simply record nothing.
|
||||
groups:
|
||||
- name: agenthub_recording
|
||||
interval: 30s
|
||||
rules:
|
||||
- record: agenthub:sessions:total
|
||||
expr: sum(agenthub_sessions)
|
||||
- record: agenthub:sessions:active
|
||||
expr: sum(agenthub_sessions{status="active"})
|
||||
- record: agenthub:agents:working
|
||||
expr: sum(agenthub_agents{status="working"})
|
||||
- record: agenthub:agents:waiting
|
||||
expr: sum(agenthub_agents{status="waiting"})
|
||||
- record: agenthub:events:rate5m
|
||||
expr: sum(rate(agenthub_events_total[5m]))
|
||||
- record: agenthub:tokens:rate5m
|
||||
expr: sum by (kind) (rate(agenthub_tokens_total[5m]))
|
||||
- record: agenthub:tokens:total
|
||||
expr: sum(agenthub_tokens_total)
|
||||
- record: agenthub:cost_usd:total
|
||||
expr: sum(agenthub_cost_usd_total)
|
||||
- record: agenthub:cost_usd:increase1h
|
||||
expr: sum by (tool) (increase(agenthub_cost_usd_total[1h]))
|
||||
- record: agenthub:cost_usd:increase24h
|
||||
expr: sum by (tool, model) (increase(agenthub_cost_usd_total[24h]))
|
||||
@@ -0,0 +1,26 @@
|
||||
# Prometheus scrape config for AgentHub's /api/metrics (see docs/MONITORING.md).
|
||||
#
|
||||
# As shipped it is the config docker-compose.monitoring.yml mounts: Prometheus
|
||||
# runs in a container and reaches the dashboard on the HOST through
|
||||
# host.docker.internal. For a Prometheus running natively on the same machine,
|
||||
# change the target to "127.0.0.1:4820".
|
||||
#
|
||||
# The dashboard's DNS-rebinding guard answers 403 EBADHOST to any Host header
|
||||
# that is not loopback, so a container scrape needs the dashboard started with
|
||||
# DASHBOARD_ALLOWED_HOSTS=host.docker.internal
|
||||
# and, when DASHBOARD_TOKEN is set, the authorization block below.
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
scrape_timeout: 10s
|
||||
|
||||
rule_files:
|
||||
- agenthub-rules.yml
|
||||
|
||||
scrape_configs:
|
||||
- job_name: agenthub
|
||||
metrics_path: /api/metrics
|
||||
static_configs:
|
||||
- targets: ["host.docker.internal:4820"]
|
||||
# authorization:
|
||||
# type: Bearer
|
||||
# credentials_file: /etc/prometheus/dashboard-token
|
||||
+85
-2
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.2.1
|
||||
version: 2.5.2
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -230,7 +230,7 @@ components:
|
||||
format: date-time
|
||||
tool:
|
||||
type: string
|
||||
description: 'The AI coding CLI that produced the session: `claude` (Claude Code''s hooks and imported transcripts), `codex` (Codex''s rollout journals and its opt-in hooks) or `antigravity` (agy''s conversation files and its opt-in hooks).'
|
||||
description: 'The AI coding CLI that produced the session: `claude` (Claude Code''s hooks and imported transcripts), `codex` (Codex''s rollout journals and its opt-in hooks) `antigravity` (agy''s conversation files and its opt-in hooks) or `cursor` (Cursor''s agent transcripts — sessions only, no tokens).'
|
||||
agent_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
@@ -4787,6 +4787,7 @@ paths:
|
||||
- claude
|
||||
- codex
|
||||
- antigravity
|
||||
- cursor
|
||||
description: Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.
|
||||
example: claude
|
||||
- name: awaiting
|
||||
@@ -12584,6 +12585,88 @@ paths:
|
||||
results:
|
||||
- id: src_a
|
||||
ok: true
|
||||
/api/settings/autostart:
|
||||
get:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Whether the dashboard starts at boot/login
|
||||
description: The per-user service's enabled state (a systemd `--user` unit, or a LaunchAgent on macOS). Off by default.
|
||||
operationId: getAutostart
|
||||
responses:
|
||||
'200':
|
||||
description: Start-at-login state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- available
|
||||
- kind
|
||||
- enabled
|
||||
- foreign
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
description: False with no systemd --user (Linux) or GUI login (macOS)
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- systemd
|
||||
- launchd
|
||||
enabled:
|
||||
type: boolean
|
||||
foreign:
|
||||
type: boolean
|
||||
description: A unit named agenthub exists that AgentHub did not write
|
||||
put:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Turn start-at-login on or off
|
||||
description: Enables or disables the unit, writing one for the running server first when none exists. Never starts or stops the server. Same-origin only.
|
||||
operationId: setAutostart
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The new state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- available
|
||||
- kind
|
||||
- enabled
|
||||
- foreign
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
description: False with no systemd --user (Linux) or GUI login (macOS)
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- systemd
|
||||
- launchd
|
||||
enabled:
|
||||
type: boolean
|
||||
foreign:
|
||||
type: boolean
|
||||
description: A unit named agenthub exists that AgentHub did not write
|
||||
'400':
|
||||
description: 'EBADVALUE: `enabled` is not a boolean; EUNAVAILABLE: no systemd --user / launchd GUI session'
|
||||
'403':
|
||||
description: 'EBADORIGIN: another site asked'
|
||||
'409':
|
||||
description: 'EFOREIGN: a unit named agenthub that AgentHub did not write'
|
||||
/api/settings/codex-hooks:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.2.1",
|
||||
"version": "2.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.2.1",
|
||||
"version": "2.5.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.2.1",
|
||||
"version": "2.5.2",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,8 @@ const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const path = require("path");
|
||||
const { execFileSync, spawnSync } = require("child_process");
|
||||
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
|
||||
const { failureText } = require("./plugin-update-helper");
|
||||
|
||||
const [, , runtimeDirArg, planJson] = process.argv;
|
||||
|
||||
@@ -229,7 +231,7 @@ function main() {
|
||||
} catch (err) {
|
||||
// A marketplace that can't be refreshed (offline, auth) does not stop the
|
||||
// batch: the plugins may still have updates from the marketplaces that did.
|
||||
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
finishStep(mktStep, "failed", { error: failureText(err).slice(0, 2000) });
|
||||
}
|
||||
|
||||
const self = plan.selfPlugin;
|
||||
@@ -243,7 +245,7 @@ function main() {
|
||||
run(claude, ["plugin", "update", id, "-y"], 600_000);
|
||||
finishStep(step, "ok");
|
||||
} catch (err) {
|
||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +255,7 @@ function main() {
|
||||
try {
|
||||
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
|
||||
} catch (err) {
|
||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
|
||||
reloadPanes();
|
||||
state.status = "failed";
|
||||
state.finished_at = new Date().toISOString();
|
||||
@@ -281,7 +283,7 @@ function main() {
|
||||
} catch (err) {
|
||||
log(`bootstrap failed: ${err.message}`);
|
||||
state.status = "failed";
|
||||
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
|
||||
state.bootstrap_error = failureText(err).slice(0, 2000);
|
||||
state.note = "the old server was stopped; run /agenthub-update to recover";
|
||||
flush();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
getTranscriptSnapshotDir,
|
||||
} = require("../server/lib/claude-home");
|
||||
const { extractFirstUserText } = require("../server/lib/transcript-cache");
|
||||
const { isIgnoredCwd } = require("../server/lib/privacy");
|
||||
const CLAUDE_DIR = getClaudeHome();
|
||||
// The projects dir is read per call, not at load: CLAUDE_HOME can change while
|
||||
// the dashboard runs (Settings → Claude Code → Overview).
|
||||
@@ -562,7 +563,7 @@ function importCompactions(dbModule, sessionId, mainAgentId, compactions) {
|
||||
if (!compactions || compactions.length === 0) return 0;
|
||||
const { db, stmts } = dbModule;
|
||||
const insertEvent = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
|
||||
);
|
||||
let created = 0;
|
||||
for (let i = 0; i < compactions.length; i++) {
|
||||
@@ -620,7 +621,7 @@ function importSubagents(dbModule, sessionId, mainAgentId, toolUses) {
|
||||
if (!toolUses || toolUses.length === 0) return 0;
|
||||
const { stmts } = dbModule;
|
||||
const insertEvent = dbModule.db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
|
||||
);
|
||||
|
||||
let created = 0;
|
||||
@@ -679,7 +680,7 @@ function importApiErrors(dbModule, sessionId, mainAgentId, apiErrors) {
|
||||
if (!apiErrors || apiErrors.length === 0) return 0;
|
||||
const { db } = dbModule;
|
||||
const insertEvent = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
|
||||
);
|
||||
let created = 0;
|
||||
for (const err of apiErrors) {
|
||||
@@ -996,7 +997,7 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
|
||||
// importSession level (subagents have their own JSONL with separate usage).
|
||||
|
||||
const insertEvent = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
|
||||
);
|
||||
|
||||
// Spawn marker under the parent (main) agent — only emit once per subagent,
|
||||
@@ -1185,6 +1186,8 @@ function reconcileSubagentParents(dbModule, sessionId, mainAgentId, parsedSubage
|
||||
*/
|
||||
function importSession(dbModule, session) {
|
||||
const { db, stmts } = dbModule;
|
||||
// A folder the user asked AgentHub to ignore (Settings → Privacy).
|
||||
if (isIgnoredCwd(session.cwd)) return { skipped: true };
|
||||
const existing = stmts.getSession.get(session.sessionId);
|
||||
if (existing) {
|
||||
const meta = existing.metadata ? JSON.parse(existing.metadata) : {};
|
||||
@@ -1192,7 +1195,7 @@ function importSession(dbModule, session) {
|
||||
|
||||
const mainAgentId = `${session.sessionId}-main`;
|
||||
const insertEvent = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
|
||||
);
|
||||
const importedData = JSON.stringify({ imported: true });
|
||||
let backfilled = false;
|
||||
@@ -1591,7 +1594,7 @@ function importSession(dbModule, session) {
|
||||
// Create synthetic events at actual message timestamps so the activity heatmap
|
||||
// reflects when work actually happened, not just session start/end.
|
||||
const insertEvent = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
|
||||
);
|
||||
const importedData = JSON.stringify({ imported: true });
|
||||
|
||||
|
||||
@@ -105,6 +105,26 @@ function unitIsActive(name) {
|
||||
return res.status === 0 && /^\s*state = running$/m.test(String(res.stdout || ""));
|
||||
}
|
||||
|
||||
/** Is our plist set to load at login — the Settings "start automatically"
|
||||
* switch. A `launchctl disable` override is what turns it off. */
|
||||
function unitIsEnabled(name) {
|
||||
if (!systemctlAvailable() || !unitIsOurs(name)) return false;
|
||||
const res = launchctl(["print-disabled", domain()]);
|
||||
const line = String(res.stdout || "")
|
||||
.split("\n")
|
||||
.find((l) => l.includes(`"${jobLabel(name)}"`));
|
||||
return !line || !/=>\s*(disabled|true)/.test(line);
|
||||
}
|
||||
|
||||
/** Enable or disable loading at login — never loads or unloads the job, so
|
||||
* the server answering the switch stays up either way. */
|
||||
function setUnitEnabled(name, on) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
if (!unitIsOurs(name)) return "foreign";
|
||||
const res = launchctl([on ? "enable" : "disable", `${domain()}/${jobLabel(name)}`]);
|
||||
return res.status === 0 ? "ok" : "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* `argv` is the program and its arguments as separate strings (the systemd
|
||||
* twin's `execStart` is one line; a path with a space would split here).
|
||||
@@ -153,14 +173,15 @@ ${envLines}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the plist and clears any "disabled" override — and starts nothing.
|
||||
* Writes the plist and clears any "disabled" override (unless `enable:
|
||||
* false`, which leaves it as it is) — and starts nothing.
|
||||
* A loaded job keeps running its OLD definition until `restartUserService`;
|
||||
* an unloaded one is picked up at the next login. Same split, same reason, as
|
||||
* the systemd twin.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function writeUserService({ name, workingDir, argv, env = {} }) {
|
||||
function writeUserService({ name, workingDir, argv, env = {}, enable = true }) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const body = unitContent({ name, workingDir, argv, env });
|
||||
const target = unitPath(name);
|
||||
@@ -173,7 +194,7 @@ function writeUserService({ name, workingDir, argv, env = {} }) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(logPath()), { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
|
||||
if (enable) launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
@@ -229,6 +250,8 @@ module.exports = {
|
||||
unitIsStale,
|
||||
unitRoot,
|
||||
unitIsActive,
|
||||
unitIsEnabled,
|
||||
setUnitEnabled,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
|
||||
+82
-49
@@ -8,11 +8,11 @@
|
||||
*
|
||||
* The worker installs runtime dependencies, removes hook entries left behind by
|
||||
* an older `npm run install-hooks` (they would double-count every event), puts
|
||||
* the `agenthub` CLI on PATH, and starts the dashboard server — as a systemd
|
||||
* `--user` service or a macOS LaunchAgent when available (see
|
||||
* `service-manager.js`), so it survives
|
||||
* reboots without Claude Code being opened first; otherwise a detached spawn
|
||||
* scoped to this session, same as before.
|
||||
* the `agenthub` CLI on PATH, and starts the dashboard server — through the
|
||||
* systemd `--user` service or macOS LaunchAgent (see `service-manager.js`)
|
||||
* only while the user has turned start-at-login on (Settings → About, OFF by
|
||||
* default), so it survives reboots without Claude Code being opened first;
|
||||
* otherwise a detached spawn scoped to this session.
|
||||
*
|
||||
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
|
||||
* the plugin cache, which Claude Code replaces wholesale on every plugin
|
||||
@@ -383,6 +383,29 @@ function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath
|
||||
|
||||
/* ------------------------------------------------------------- dependencies */
|
||||
|
||||
/**
|
||||
* npm, run by the node executing this file: `{ file, args, env, shell }` for
|
||||
* `spawnSync`. Its bundled `npm-cli.js` is found from `process.execPath`, so
|
||||
* nothing depends on PATH — the self-update chain runs with the dashboard
|
||||
* server's environment, and on Windows that PATH need not hold the directory
|
||||
* of `npm.cmd` ("'npm.cmd' is not recognized"), even though node itself runs.
|
||||
* PATH is still prefixed with that node's directory so npm's scripts (the UI
|
||||
* build's `tsc`/`vite` shims) find the same node. A node with no bundled npm
|
||||
* (a distro's split nodejs/npm packages) falls back to `npm` on PATH.
|
||||
*/
|
||||
function npmCommand(args, { execPath = process.execPath, env = process.env } = {}) {
|
||||
const dir = path.dirname(execPath);
|
||||
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
|
||||
const childEnv = { ...env, [pathKey]: [dir, env[pathKey]].filter(Boolean).join(path.delimiter) };
|
||||
const cli = [
|
||||
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows layout
|
||||
path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // Unix layout
|
||||
].find((p) => fs.existsSync(p));
|
||||
if (cli) return { file: execPath, args: [cli, ...args], env: childEnv, shell: false };
|
||||
const win = process.platform === "win32";
|
||||
return { file: win ? "npm.cmd" : "npm", args, env: childEnv, shell: win };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
||||
* the root postinstall from pulling the whole Vite client toolchain; the
|
||||
@@ -395,12 +418,13 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
const src = path.join(root, f);
|
||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
||||
}
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const res = spawnSync(
|
||||
npm,
|
||||
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
|
||||
);
|
||||
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
|
||||
const res = spawnSync(npm.file, npm.args, {
|
||||
cwd: rt,
|
||||
stdio: "inherit",
|
||||
env: npm.env,
|
||||
shell: npm.shell,
|
||||
});
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
@@ -738,13 +762,17 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
// the restart is deferred to `pendingSystemdRestart` below, because
|
||||
// starting it now would kill this process mid-bootstrap. Otherwise
|
||||
// there is no cgroup to be reaped by, and install-and-start is fine.
|
||||
const result = systemdOwned
|
||||
? svc.writeUserService(unit)
|
||||
: svc.systemctlAvailable()
|
||||
? svc.installUserService(unit)
|
||||
: "unavailable";
|
||||
// Start-at-login is OPT-IN (Settings → About, `/api/settings/autostart`):
|
||||
// the unit is installed or started only while the user has it enabled,
|
||||
// and its enabled state is never changed here.
|
||||
const autostart = svc.unitIsEnabled("agenthub");
|
||||
let result = "off";
|
||||
if (systemdOwned) result = svc.writeUserService({ ...unit, enable: autostart });
|
||||
else if (autostart)
|
||||
result = needsServer ? svc.installUserService(unit) : svc.writeUserService(unit);
|
||||
else if (unitStale) svc.writeUserService({ ...unit, enable: false });
|
||||
if (result === "written" || result === "unchanged") {
|
||||
if (result === "written") svc.enableLinger();
|
||||
if (result === "written" && autostart) svc.enableLinger();
|
||||
// Only when the RUNNING process has to change. A stale unit beside a
|
||||
// healthy server is now corrected on disk and left to take effect at
|
||||
// the next start — restarting a working dashboard to fix a file that
|
||||
@@ -825,6 +853,43 @@ function main() {
|
||||
child.unref();
|
||||
}
|
||||
|
||||
// Exported before the entry block below, not after it: `bootstrap()` requires
|
||||
// plugin-open.js, which requires this module back (`npmCommand`, `runtimeDir`),
|
||||
// and when this file is the entry that require resolves to whatever is on
|
||||
// module.exports at that moment — an empty object if it is assigned last.
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
isCheckoutHookEntry,
|
||||
stripLegacyHooks,
|
||||
hasLegacyHooks,
|
||||
linkCli,
|
||||
npmCommand,
|
||||
installDeps,
|
||||
serverIsLive,
|
||||
liveServers,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const force = process.argv.includes("--force");
|
||||
if (force || process.argv.includes("--worker")) {
|
||||
@@ -850,35 +915,3 @@ if (require.main === module) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
isCheckoutHookEntry,
|
||||
stripLegacyHooks,
|
||||
hasLegacyHooks,
|
||||
linkCli,
|
||||
installDeps,
|
||||
serverIsLive,
|
||||
liveServers,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user