From 7c06c6085d83600885a7619fc159efc2ce6c1979 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:03:36 +0000 Subject: [PATCH 1/7] chore(goal): initialize draft for Agent Tank has received a significant new update - different/unified JSO From aa7d51422326cffb73c2a57eb8f4e6b1b92a191b Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:34:22 +0000 Subject: [PATCH 2/7] feat(agent-tank): support Agent Tank 0.10 canonical shape and record usage history --- Dockerfile.agent | 6 +- docker/Dockerfile.app.prod | 2 +- docs/operations/managed-diagnostics.md | 2 +- docs/operations/native-provider-log-policy.md | 2 +- docs/static/openapi/propr-api.yaml | 30 ++- packages/api/routeRegistry.ts | 1 + packages/api/routes/configRoutes.ts | 1 + packages/api/routes/configRoutesAgentTank.ts | 59 +++++- .../test/configRoutesAgentTankBundled.test.ts | 4 +- packages/core/src/agents/version/types.ts | 2 +- ...7230000_create_agent_tank_usage_history.js | 25 +++ packages/core/src/index.ts | 13 +- .../src/services/agentTankBundledRunner.ts | 34 +--- .../src/services/agentTankHistoryService.ts | 160 ++++++++++++++++ .../core/src/services/agentTankService.ts | 30 ++- packages/core/src/services/agentTankTypes.ts | 113 ++++++++++++ .../src/services/agentTankUsageHistory.ts | 173 ++++++++++++++++++ .../src/services/agentTankUsageMetrics.ts | 150 +++++++++++++++ packages/shared/src/agentTankHistory.ts | 66 +++++++ packages/shared/src/index.ts | 1 + patches/README.md | 2 +- ...k-0.9.11.patch => agent-tank-0.10.0.patch} | 76 ++++---- propr-ui/src/api/revertApi.ts | 30 ++- propr-ui/src/components/agentTankMetrics.tsx | 78 +++++++- .../usageHistory/usageHistoryModel.ts | 93 ++++++++++ scripts/build-images.sh | 4 +- scripts/verify-agent-tank-image.sh | 42 ++++- test/agentTankImageVerification.test.ts | 70 ++++++- .../agent-tank-verifier-pinned-0.10.0.json | 163 +++++++++++++++++ .../agent-tank-verifier-pinned-0.9.11.json | 62 ------- test/previewAgentImage.test.mjs | 4 +- 31 files changed, 1324 insertions(+), 174 deletions(-) create mode 100644 packages/core/src/db/migrations/20261017230000_create_agent_tank_usage_history.js create mode 100644 packages/core/src/services/agentTankHistoryService.ts create mode 100644 packages/core/src/services/agentTankUsageHistory.ts create mode 100644 packages/core/src/services/agentTankUsageMetrics.ts create mode 100644 packages/shared/src/agentTankHistory.ts rename patches/{agent-tank-0.9.11.patch => agent-tank-0.10.0.patch} (97%) create mode 100644 propr-ui/src/components/usageHistory/usageHistoryModel.ts create mode 100644 test/fixtures/agent-tank-verifier-pinned-0.10.0.json delete mode 100644 test/fixtures/agent-tank-verifier-pinned-0.9.11.json diff --git a/Dockerfile.agent b/Dockerfile.agent index 1cd75eb721..e6065f83c9 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -155,12 +155,12 @@ RUN case "${VIBE_CLI_VERSION}" in \ FROM agent-base AS agent-tank-cli # Local upstream fixes until the next Agent Tank release includes them. -COPY patches/agent-tank-0.9.11.patch /tmp/agent-tank.patch +COPY patches/agent-tank-0.10.0.patch /tmp/agent-tank.patch # Pinned here, not in CI: this literal is part of the Dockerfile content that # feeds the agent bundle content hash, so bumping it produces a new image tag. # An env-only override in CI would NOT change the tag and would silently ship a # different binary under an existing tag - do not do that. -ARG AGENT_TANK_CLI_VERSION=0.9.11 +ARG AGENT_TANK_CLI_VERSION=0.10.0 USER root # node-pty compiles against the toolchain already present in agent-base # (build-essential + python3), which is why this needs no extra apt packages. @@ -215,7 +215,7 @@ ARG CODEX_CLI_VERSION=0.160.0 ARG ANTIGRAVITY_CLI_VERSION=1.4.0 ARG OPENCODE_CLI_VERSION=1.18.31 ARG VIBE_CLI_VERSION=2.25.8 -ARG AGENT_TANK_CLI_VERSION=0.9.11 +ARG AGENT_TANK_CLI_VERSION=0.10.0 LABEL dev.propr.agent-bundle="true" \ dev.propr.agent.claude.version="${CLAUDE_CLI_VERSION}" \ diff --git a/docker/Dockerfile.app.prod b/docker/Dockerfile.app.prod index b4cbdecb52..a3e3a6258e 100644 --- a/docker/Dockerfile.app.prod +++ b/docker/Dockerfile.app.prod @@ -125,7 +125,7 @@ COPY --from=builder /build/docs/mcp.md /build/docs/mcp-coverage.md /build/docs/m COPY Dockerfile.agent ./Dockerfile.agent COPY scripts/agent-entrypoint.sh ./scripts/agent-entrypoint.sh COPY scripts/agent-tank-runtime.mjs ./scripts/agent-tank-runtime.mjs -COPY patches/agent-tank-0.9.11.patch ./patches/agent-tank-0.9.11.patch +COPY patches/agent-tank-0.10.0.patch ./patches/agent-tank-0.10.0.patch COPY scripts/claude-entrypoint.sh ./scripts/claude-entrypoint.sh COPY scripts/codex-entrypoint.sh ./scripts/codex-entrypoint.sh COPY scripts/antigravity-entrypoint.sh ./scripts/antigravity-entrypoint.sh diff --git a/docs/operations/managed-diagnostics.md b/docs/operations/managed-diagnostics.md index 7a569153b1..51710753e9 100644 --- a/docs/operations/managed-diagnostics.md +++ b/docs/operations/managed-diagnostics.md @@ -51,7 +51,7 @@ Audited from `Dockerfile.agent`, `agents/version/types.ts`, `configManagerAgents | Antigravity 1.2.4 (release 6085322963025920) | `M//.gemini`, legacy explicit root → `.gemini-source`; entrypoint may copy selected auth into disposable `/tmp/propr-antigravity-home/.gemini` | In a mounted root: `antigravity-cli/log/*.log`, `antigravity-cli/brain//.system_generated/logs/transcript.jsonl`; managed stdout/stderr includes native-goal segments | `antigravity-cli/antigravity-oauth-token`, settings, arbitrary brain/project/cache contents excluded | Task/goal process. Disposable container-only files are not falsely reported as mounted history. No native rotation/reopen proven; mounted append history is measured but not exported without complete credential context. Existing transient transcript/UI handling remains unchanged. | | OpenCode 1.18.31 | `M//.config/opencode`; sibling `M//.local/share/opencode` or explicit `HOST_OPENCODE_DATA_DIR` supplies credentials. Runtime uses disposable XDG data via `PROPR_EPHEMERAL_STATE=1` | Mounted data root `log/*.log` when present; managed stdout/stderr for every execution | `auth.json`, `account.json`, config excluded; mounted `opencode.db`/WAL/SHM measured/protected, never exported | Runtime native DB/logs can be container-only; no broad copy or SQLite live-file copy. Captured stream is the supported diagnostic history for disposable runs. | | Vibe 2.25.8, Python 3.12 | Explicit `.vibe` source is read-only. Runtime: `VIBE_PROMPT_CACHE_DIR/propr-vibe-runtime/propr-vibe-home-*` assigned by worker → `/tmp/propr-vibe-home`, also `HOME`/`VIBE_HOME` | Assigned runtime `logs/session//messages.jsonl`, plus stdout/stderr; same allowlist in an explicit mounted source | config.toml, credentials.json, .env, prompt cache files, skills and secrets excluded | Task-local runtime; snapshot while active and on child close, before existing cleanup. Does not add or promise Vibe native-goal resumption. | -| agent-tank 0.9.11 | Auxiliary runtime package in agent image, not a sixth `AGENT_TYPES` provider | Its diagnostic messages on the owning provider's captured streams | No extra home scan | Shares execution lifetime and stream policy. | +| agent-tank 0.10.0 | Auxiliary runtime package in agent image, not a sixth `AGENT_TYPES` provider | Its diagnostic messages on the owning provider's captured streams | No extra home scan | Shares execution lifetime and stream policy. | | Shared `/tmp/claude-logs` | Launcher/Claude bind mount, not an isolated per-agent home | **No directory-wide collection**; associated execution stream is already captured with known agent identity | Conversation/output JSON is used by existing UI/session mappings. Unknown ownership is reported as `ownership-unverified`; nothing is removed | Multiple task writers/watchers, including JSON snapshot rewrites; cannot prove tenant/task ownership or safe reopen from filenames alone. | Only these exact structural allowlists are traversed. New provider versions/formats require review of fixtures and this table; unknown/unparseable records are dropped with gaps, not shipped as raw bytes. Missing optional directories are valid. Native state has no archive-retrieval dependency because this policy never prunes it. It does not assert a complete byte inventory of excluded, unrecognized, or container-only state. diff --git a/docs/operations/native-provider-log-policy.md b/docs/operations/native-provider-log-policy.md index 79145ce67c..987c87a73d 100644 --- a/docs/operations/native-provider-log-policy.md +++ b/docs/operations/native-provider-log-policy.md @@ -13,7 +13,7 @@ Image pins in `Dockerfile.agent` are the compatibility baseline. Producer source | Antigravity 1.2.4 (release 6085322963025920) | Mounted `.gemini/antigravity-cli/log/*.log` diagnostics; `brain//.system_generated/logs/transcript.jsonl` session history. Entrypoint uses disposable container runtime too. Goals span invocations. | Mounted originals protected and counted by class. Container-only originals unmeasured. No rotation contract verified. | | OpenCode 1.18.31 | Explicit source `.local/share/opencode`, runtime XDG data in `/tmp/propr-opencode-data/opencode`. Current writer appends `log/opencode.log`. DB/WAL/SHM hold session state. | **`--print-logs` still writes the file**, adding stderr. Older upstream `Log.init` fixtures that returned early on `print` do not match this binary. All originals protected. | | Vibe 2.25.8, Python 3.12 | Assigned worker runtime under `VIBE_PROMPT_CACHE_DIR/propr-vibe-runtime/...`, mounted `/tmp/propr-vibe-home`; `logs/session//messages.jsonl` is conversation history. Source home is read-only. | Session bytes, not disposable debug bytes. Existing task runtime cleanup unchanged. No new native-goal resumption guarantee. Unregistered runtime roots unmeasured. | -| agent-tank 0.9.11 | Auxiliary package; stream diagnostics belong to the containing execution. | No extra directory traversal. | +| agent-tank 0.10.0 | Auxiliary package; stream diagnostics belong to the containing execution. | No extra directory traversal. | | App-created `/tmp/claude-logs` | `issueJobCallbacks.ts` and `prCommentJobHelpers.ts` create conversation snapshots/output text for task/UI handling; launcher shares the bind mount. Multiple writers/readers and restart mappings exist. | Ownership cannot be derived from filenames, dates or completed status. Not scanned/pruned; shared-directory declarations rejected. Growth unbounded/unmeasured. | Credentials/configuration are metadata-only categories: this inventory reads no contents. Known auth/account/credential JSON, config/settings, `.env`, and the Antigravity OAuth store remain unchanged. Unknown files are counted without content reads; unknown directories are not traversed and make inventory incomplete. Existing producer redaction/private export restrictions still apply. diff --git a/docs/static/openapi/propr-api.yaml b/docs/static/openapi/propr-api.yaml index 29692e4493..62edf62cd2 100644 --- a/docs/static/openapi/propr-api.yaml +++ b/docs/static/openapi/propr-api.yaml @@ -29,9 +29,9 @@ info: name: Apache-2.0 identifier: Apache-2.0 x-route-coverage: - total: 303 + total: 304 documented: 68 - undocumented: 235 + undocumented: 236 servers: - url: "{origin}" description: Your ProPR API origin, for example the value of `API_PUBLIC_URL`. @@ -2214,6 +2214,32 @@ paths: x-propr-permission: instance.manage_agents x-undocumented: true x-legacy-error: true + /api/config/agent-tank/history: + get: + operationId: getApiConfigAgentTankHistory + summary: GET /api/config/agent-tank/history + description: |- + This route is registered but not documented yet. + + Requires the `instance.manage_agents` instance permission. + tags: + - Configuration + security: + - sessionCookie: [] + - bearerAuth: [] + responses: + "200": + description: Success. The response body is not documented yet. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_agents + x-undocumented: true + x-legacy-error: true /api/config/agent-tank/refresh: post: operationId: postApiConfigAgentTankRefresh diff --git a/packages/api/routeRegistry.ts b/packages/api/routeRegistry.ts index e8a0c158ea..608abb3f26 100644 --- a/packages/api/routeRegistry.ts +++ b/packages/api/routeRegistry.ts @@ -116,6 +116,7 @@ export function createManagementRouteEntries({ ['post', '/api/config/agent-tank', requireManageAgents, configRoutes.postAgentTankSettings], ['get', '/api/config/agent-tank/status', requireManageAgents, configRoutes.getAgentTankStatus], ['get', '/api/config/agent-tank/usage', requireAgentTankUsageAccess, configRoutes.getAgentTankUsage], + ['get', '/api/config/agent-tank/history', requireAgentTankUsageAccess, configRoutes.getAgentTankHistory], ['post', '/api/config/agent-tank/refresh', requireManageAgents, configRoutes.postAgentTankRefresh], ['get', '/api/config/agent-tank/detect', requireManageAgents, configRoutes.getAgentTankDetect], ['get', '/api/config/preview-storage', requireManageSettings, visualPreviewAuthRoutes.getManagedStorageStatus], diff --git a/packages/api/routes/configRoutes.ts b/packages/api/routes/configRoutes.ts index 27524ee540..90aadb0ca8 100644 --- a/packages/api/routes/configRoutes.ts +++ b/packages/api/routes/configRoutes.ts @@ -408,6 +408,7 @@ export function createConfigRoutes(deps: ConfigRoutesDeps) { triggerIndexing: indexingRoutes.triggerIndexing, triggerReindexAll: indexingRoutes.triggerReindexAll, stopIndexing: indexingRoutes.stopIndexing, getAgentTankSettings: agentTankRoutes.getAgentTankSettings, postAgentTankSettings: agentTankRoutes.postAgentTankSettings, getAgentTankStatus: agentTankRoutes.getAgentTankStatus, getAgentTankUsage: agentTankRoutes.getAgentTankUsage, + getAgentTankHistory: agentTankRoutes.getAgentTankHistory, postAgentTankRefresh: agentTankRoutes.postAgentTankRefresh, getAgentTankDetect: agentTankRoutes.getAgentTankDetect }; } diff --git a/packages/api/routes/configRoutesAgentTank.ts b/packages/api/routes/configRoutesAgentTank.ts index de274a775c..b062c1280a 100644 --- a/packages/api/routes/configRoutesAgentTank.ts +++ b/packages/api/routes/configRoutesAgentTank.ts @@ -5,9 +5,10 @@ import { getAgentTankStatuses, hasAgentTankStatuses, hasUsableAgentTankStatuses, + parseAgentTankStatusMap, refreshBundledStatuses } from '@propr/core'; -import { AGENT_TANK_MODES, isAgentTankMode, normalizeAgentTankMode } from '@propr/shared'; +import { AGENT_TANK_HISTORY_RETENTION_DAYS, AGENT_TANK_MODES, isAgentTankMode, normalizeAgentTankMode } from '@propr/shared'; /** * Tells every open tab that capacity may have moved. @@ -26,6 +27,41 @@ function publishUsageChanged(): void { } } +const DEFAULT_HISTORY_RANGE_MS = 24 * 60 * 60 * 1000; + +/** Epoch ms or an ISO 8601 string; undefined when absent, NaN when unreadable. */ +function parseTime(value: unknown): number | undefined { + if (value === undefined || value === '') return undefined; + if (typeof value !== 'string') return Number.NaN; + return /^\d+$/.test(value) ? Number(value) : Date.parse(value); +} + +/** + * `?since=&until=` (epoch ms or ISO 8601), `?agent=claude,codex`, `?metric=`. + * The range defaults to the last day and is clamped to what history retains. + */ +export function parseHistoryQuery( + query: Record, + now: number = Date.now() +): { since: number; until: number; agents?: string[]; metric?: string } | { error: string } { + const until = parseTime(query.until) ?? now; + const since = parseTime(query.since) ?? until - DEFAULT_HISTORY_RANGE_MS; + if (!Number.isFinite(since) || !Number.isFinite(until)) { + return { error: 'since and until must be epoch milliseconds or ISO 8601 timestamps' }; + } + if (since > until) return { error: 'since must not be after until' }; + const agents = typeof query.agent === 'string' + ? query.agent.split(',').map(agent => agent.trim()).filter(Boolean) + : undefined; + const metric = typeof query.metric === 'string' && query.metric ? query.metric : undefined; + return { + since: Math.max(since, now - AGENT_TANK_HISTORY_RETENTION_DAYS * DEFAULT_HISTORY_RANGE_MS), + until, + ...(agents?.length ? { agents } : {}), + ...(metric ? { metric } : {}), + }; +} + export function createAgentTankRoutes() { async function getAgentTankSettings(_req: Request, res: Response): Promise { try { @@ -153,6 +189,20 @@ export function createAgentTankRoutes() { } } + async function getAgentTankHistory(req: Request, res: Response): Promise { + try { + const query = parseHistoryQuery(req.query as Record); + if ('error' in query) { + res.status(400).json({ error: query.error }); + return; + } + res.json(await configManager.readAgentTankUsageHistory(query)); + } catch (error) { + console.error('Error in /api/config/agent-tank/history GET:', error); + res.status(500).json({ error: 'Failed to read Agent Tank usage history' }); + } + } + async function postAgentTankRefresh(_req: Request, res: Response): Promise { try { const settings = await configManager.loadAgentTankSettings(); @@ -226,9 +276,9 @@ export function createAgentTankRoutes() { const response = await fetch(`${DEFAULT_URL}/status`, { signal: controller.signal }); clearTimeout(timer); if (response.ok) { - const data = await response.json(); - // Check if we got valid agent data - const hasAgents = data && typeof data === 'object' && Object.keys(data).length > 0; + // Either response generation: 0.10's `{ providers: [...] }` or the + // 0.9.x map. An empty `providers` list is not an installation to offer. + const hasAgents = hasAgentTankStatuses(parseAgentTankStatusMap(await response.json())); if (hasAgents) { res.json({ detected: true, mode: 'external', url: DEFAULT_URL }); return; @@ -252,6 +302,7 @@ export function createAgentTankRoutes() { postAgentTankSettings, getAgentTankStatus, getAgentTankUsage, + getAgentTankHistory, postAgentTankRefresh, getAgentTankDetect }; diff --git a/packages/api/test/configRoutesAgentTankBundled.test.ts b/packages/api/test/configRoutesAgentTankBundled.test.ts index e307853449..35932c1c71 100644 --- a/packages/api/test/configRoutesAgentTankBundled.test.ts +++ b/packages/api/test/configRoutesAgentTankBundled.test.ts @@ -18,7 +18,7 @@ import assert from 'node:assert/strict'; process.env.NODE_ENV = 'test'; -const { hasAgentTankStatuses, hasUsableAgentTankStatuses } = await import('../../core/src/services/agentTankTypes.js'); +const { hasAgentTankStatuses, hasUsableAgentTankStatuses, parseAgentTankStatusMap } = await import('../../core/src/services/agentTankTypes.js'); let snapshot: Record | undefined; const refreshCalls: Array<{ force?: boolean }> = []; @@ -34,6 +34,8 @@ await mock.module('@propr/core', { canRunBundledAgentTank: async () => false, hasAgentTankStatuses, hasUsableAgentTankStatuses, + parseAgentTankStatusMap, + readAgentTankUsageHistory: async () => ({ enabled: true, source: 'propr', agents: [] }), }, }); diff --git a/packages/core/src/agents/version/types.ts b/packages/core/src/agents/version/types.ts index c137f83c7e..1703ce5b77 100644 --- a/packages/core/src/agents/version/types.ts +++ b/packages/core/src/agents/version/types.ts @@ -55,7 +55,7 @@ export const AGENT_BUNDLE_CONTENT_FILES = [ 'Dockerfile.agent', 'scripts/agent-entrypoint.sh', 'scripts/agent-tank-runtime.mjs', - 'patches/agent-tank-0.9.11.patch', + 'patches/agent-tank-0.10.0.patch', 'scripts/claude-entrypoint.sh', 'scripts/codex-entrypoint.sh', 'scripts/antigravity-entrypoint.sh', diff --git a/packages/core/src/db/migrations/20261017230000_create_agent_tank_usage_history.js b/packages/core/src/db/migrations/20261017230000_create_agent_tank_usage_history.js new file mode 100644 index 0000000000..f70f169037 --- /dev/null +++ b/packages/core/src/db/migrations/20261017230000_create_agent_tank_usage_history.js @@ -0,0 +1,25 @@ +// Agent Tank usage history recorded by ProPR itself. Bundled Agent Tank runs in +// a disposable container whose own history database is discarded after every +// run, so the usage line charts need a store that outlives it. One row per +// metric per observed snapshot, in the same shape as Agent Tank 0.10's +// `usage_metrics` table so either source answers the same series query. +export async function up(knex) { + await knex.schema.createTable('agent_tank_usage_history', table => { + table.increments('id').primary(); + // ProPR-facing provider name: claude | codex | antigravity. + table.string('agent', 64).notNullable(); + table.string('metric_key', 255).notNullable(); + table.string('metric_label', 255).notNullable(); + table.string('model', 255); + table.string('cycle', 32); + table.float('percent_used').notNullable(); + table.string('recorded_at', 32).notNullable(); + table.bigInteger('recorded_at_ms').notNullable(); + table.index(['agent', 'recorded_at_ms'], 'idx_agent_tank_usage_history_agent_time'); + table.index(['agent', 'metric_key', 'recorded_at_ms'], 'idx_agent_tank_usage_history_series'); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('agent_tank_usage_history'); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26b6a57d61..04bd455739 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -470,9 +470,18 @@ export { agentTankUsageFingerprint, observeAgentTankUsage, observeAgentTankUsageSnapshot, - resetAgentTankUsageTracking + resetAgentTankUsageTracking, + parseAgentTankStatus, + parseAgentTankStatusMap } from './services/agentTankService.js'; -export type { AgentStatusResponse } from './services/agentTankService.js'; +export type { AgentStatusResponse, AgentTankUsageWindow } from './services/agentTankService.js'; +export { readAgentTankUsageHistory, downsampleHistoryPoints } from './services/agentTankHistoryService.js'; +export { + recordAgentTankUsageHistory, + readLocalAgentTankUsageHistory, + resetAgentTankUsageHistoryTracking +} from './services/agentTankUsageHistory.js'; +export { extractAgentTankUsageMetrics } from './services/agentTankUsageMetrics.js'; export { buildBundledAgentTankConfig, canRunBundledAgentTank, diff --git a/packages/core/src/services/agentTankBundledRunner.ts b/packages/core/src/services/agentTankBundledRunner.ts index 6472920a4e..f99cf64e67 100644 --- a/packages/core/src/services/agentTankBundledRunner.ts +++ b/packages/core/src/services/agentTankBundledRunner.ts @@ -18,7 +18,7 @@ import { executeDockerCommand } from '../claude/docker/dockerExecutor.js'; import { loadAgents, resolveConfigPath, resolveCodexConfigPath } from '../config/configManager.js'; import { CONTAINER_CONFIG_PATHS } from '../agents/types.js'; import type { AgentConfig } from '../agents/types.js'; -import { toAgentTankAgent, type AgentStatusResponse } from './agentTankTypes.js'; +import { parseAgentTankStatusMap, toAgentTankAgent, type AgentStatusResponse } from './agentTankTypes.js'; /** * A bundled refresh starts a container and drives interactive `/usage` calls @@ -263,10 +263,11 @@ export function buildBundledAgentTankConfig(entries: BundledAgentTankEntry[]): s } /** - * ONE OF TWO PLACES that know the Agent Tank output schema. Upstream - * `--once --json` prints `watcher.getStatus()`, a bare map keyed by agent id; - * we also accept an `{ agents: {...} }` envelope so a minor upstream wrapper - * change does not break the integration. + * ONE OF TWO PLACES that know the Agent Tank output schema. Agent Tank 0.10 + * `--once --json` prints `{ providers: [...] }` in its canonical shape, keyed + * here by the pinned `id`; 0.9.x printed a bare map keyed by agent id, and an + * `{ agents: {...} }` envelope is accepted too, so an image built against + * either release keeps working (see `parseAgentTankStatusMap`). */ export function parseBundledAgentTankOutput(stdout: string): Record { const trimmed = stdout.trim(); @@ -275,31 +276,14 @@ export function parseBundledAgentTankOutput(stdout: string): Record; + let parsed: unknown; try { - parsed = JSON.parse(trimmed.slice(start)) as Record; + parsed = JSON.parse(trimmed.slice(start)); } catch (error) { logger.warn({ error: (error as Error).message }, 'Bundled Agent Tank produced unparseable JSON'); return {}; } - const source = (parsed.agents && typeof parsed.agents === 'object') - ? parsed.agents as Record - : parsed; - const agents: Record = {}; - for (const [key, value] of Object.entries(source)) { - if (!value || typeof value !== 'object' || Array.isArray(value)) continue; - const status = value as Partial; - agents[key] = { - name: typeof status.name === 'string' ? status.name : key, - usage: (status.usage && typeof status.usage === 'object') - ? status.usage as Record - : {}, - metadata: status.metadata, - lastUpdated: status.lastUpdated, - error: typeof status.error === 'string' ? status.error : null, - }; - } - return agents; + return parseAgentTankStatusMap(parsed); } /** diff --git a/packages/core/src/services/agentTankHistoryService.ts b/packages/core/src/services/agentTankHistoryService.ts new file mode 100644 index 0000000000..fba1022e3b --- /dev/null +++ b/packages/core/src/services/agentTankHistoryService.ts @@ -0,0 +1,160 @@ +/** + * Usage history for the Agent Tank charts, from whichever source has it. + * + * An external Agent Tank 0.10+ daemon keeps its own SQLite history and refreshes + * on its own schedule, so it knows more than ProPR saw; its series are used per + * agent whenever it answers with any. Everything else - bundled mode, an older + * daemon, a daemon with history switched off or unreachable - falls back to + * what ProPR recorded from the snapshots it observed. Both sources use the same + * metric keys, so the client cannot tell them apart except by `source`. + */ + +import { + AGENT_TANK_HISTORY_RETENTION_DAYS, + type AgentTankHistoryAgent, + type AgentTankHistoryPoint, + type AgentTankHistoryResponse, + type AgentTankHistorySeries, + type AgentTankHistorySource, +} from '@propr/shared'; +import { loadAgentTankSettings } from '../config/configManager.js'; +import logger from '../utils/logger.js'; +import { getAllStatuses } from './agentTankService.js'; +import { toAgentTankAgent, toProprAgent } from './agentTankTypes.js'; +import { readLocalAgentTankUsageHistory, type UsageHistoryQuery } from './agentTankUsageHistory.js'; + +const DAEMON_HISTORY_TIMEOUT_MS = 5000; +/** More points than a chart can draw at sidebar or page width. */ +export const MAX_HISTORY_POINTS_PER_SERIES = 600; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Thin a series to at most `maxPoints` by keeping the last point of each equal + * time bucket. The first and last points always survive, so the line still + * spans the whole range and ends on the current value. + */ +export function downsampleHistoryPoints( + points: AgentTankHistoryPoint[], + maxPoints: number = MAX_HISTORY_POINTS_PER_SERIES, +): AgentTankHistoryPoint[] { + if (points.length <= maxPoints || maxPoints < 3) return points; + const first = Date.parse(points[0].t); + const last = Date.parse(points[points.length - 1].t); + const span = last - first; + if (!Number.isFinite(span) || span <= 0) return points.slice(-maxPoints); + const buckets = maxPoints - 2; + const kept: AgentTankHistoryPoint[] = [points[0]]; + let currentBucket = -1; + for (let index = 1; index < points.length - 1; index++) { + const bucket = Math.min(buckets - 1, Math.floor(((Date.parse(points[index].t) - first) / span) * buckets)); + if (bucket === currentBucket) kept[kept.length - 1] = points[index]; + else kept.push(points[index]); + currentBucket = bucket; + } + kept.push(points[points.length - 1]); + return kept; +} + +/** Normalize one daemon series, keeping only plottable percentage points. */ +function parseDaemonSeries(value: unknown): AgentTankHistorySeries | undefined { + if (!isRecord(value) || typeof value.key !== 'string') return undefined; + if (typeof value.unit === 'string' && value.unit !== 'percent') return undefined; + const points = Array.isArray(value.points) + ? value.points.filter(isRecord) + .filter(point => typeof point.t === 'string' && typeof point.percent === 'number' && Number.isFinite(point.percent)) + .map(point => ({ t: point.t as string, percent: point.percent as number })) + : []; + if (points.length === 0) return undefined; + return { + key: value.key, + label: typeof value.label === 'string' && value.label ? value.label : value.key, + model: typeof value.model === 'string' ? value.model : null, + cycle: typeof value.cycle === 'string' ? value.cycle : null, + points, + }; +} + +/** One agent's history from an external daemon; undefined when it has none. */ +async function readDaemonHistory( + baseUrl: string, + agent: string, + query: UsageHistoryQuery, +): Promise { + const params = new URLSearchParams({ since: String(query.since), until: String(query.until) }); + if (query.metric) params.set('metric', query.metric); + const url = `${baseUrl}/history/${encodeURIComponent(toAgentTankAgent(agent))}?${params}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DAEMON_HISTORY_TIMEOUT_MS); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) return undefined; + const body: unknown = await response.json(); + // 0.9.x answered with a list of raw snapshots and no `store`; a daemon + // with history off reports `store: "none"`. Neither is a series answer. + if (!isRecord(body) || body.store !== 'sqlite' || !Array.isArray(body.series)) return undefined; + const series = body.series.map(parseDaemonSeries).filter((entry): entry is AgentTankHistorySeries => !!entry); + return series.length > 0 ? series : undefined; + } catch (error) { + logger.debug({ agent, error: (error as Error).message }, 'Agent Tank history unavailable; using recorded history'); + return undefined; + } finally { + clearTimeout(timer); + } +} + +function withDownsampledPoints(agents: AgentTankHistoryAgent[]): AgentTankHistoryAgent[] { + return agents.map(entry => ({ + agent: entry.agent, + series: entry.series.map(series => ({ ...series, points: downsampleHistoryPoints(series.points) })), + })); +} + +/** + * Usage history for the charts. + * + * @example + * await readAgentTankUsageHistory({ since: Date.now() - 10 * 3600_000, until: Date.now(), agents: ['claude'] }); + * // => { enabled: true, source: 'propr', agents: [{ agent: 'claude', series: [{ key: 'session', ... }] }] } + */ +export async function readAgentTankUsageHistory(query: UsageHistoryQuery): Promise { + const settings = await loadAgentTankSettings(); + if (settings.mode === 'disabled') return { enabled: false, agents: [] }; + + const local = await readLocalAgentTankUsageHistory(query); + if (settings.mode !== 'external') { + return { + enabled: true, + source: 'propr', + retentionDays: AGENT_TANK_HISTORY_RETENTION_DAYS, + agents: withDownsampledPoints(local), + }; + } + + // The daemon is asked about every agent it currently reports plus every + // agent ProPR has history for, so neither source can hide the other's. + const requested = query.agents?.length + ? query.agents + : [...new Set([...Object.keys(await getAllStatuses() ?? {}), ...local.map(entry => entry.agent)])]; + const answers = await Promise.all(requested.map(async agent => ({ + agent, + daemon: await readDaemonHistory(settings.url, agent, query), + }))); + const localByAgent = new Map(local.map(entry => [entry.agent, entry.series])); + let source: AgentTankHistorySource = 'propr'; + const agents: AgentTankHistoryAgent[] = []; + for (const { agent, daemon } of answers) { + const proprAgent = toProprAgent(agent); + if (daemon) source = 'agent-tank'; + const series = daemon ?? localByAgent.get(proprAgent); + if (series?.length) agents.push({ agent: proprAgent, series }); + } + return { + enabled: true, + source, + retentionDays: AGENT_TANK_HISTORY_RETENTION_DAYS, + agents: withDownsampledPoints(agents), + }; +} diff --git a/packages/core/src/services/agentTankService.ts b/packages/core/src/services/agentTankService.ts index a6357fedb0..93af704f14 100644 --- a/packages/core/src/services/agentTankService.ts +++ b/packages/core/src/services/agentTankService.ts @@ -11,6 +11,8 @@ import { import { normalizeAgentTankAgents, normalizeAgentTankStatus, + parseAgentTankStatus, + parseAgentTankStatusMap, toAgentTankAgent, type AgentStatusResponse, } from './agentTankTypes.js'; @@ -23,10 +25,12 @@ export { isUsableAgentTankStatus, normalizeAgentTankAgents, normalizeAgentTankStatus, + parseAgentTankStatus, + parseAgentTankStatusMap, toAgentTankAgent, toProprAgent, } from './agentTankTypes.js'; -export type { AgentStatusResponse } from './agentTankTypes.js'; +export type { AgentStatusResponse, AgentTankUsageWindow } from './agentTankTypes.js'; // Refresh can take 15-20 seconds when CLI agent needs cold start const DEFAULT_TIMEOUT_MS = 25000; @@ -146,7 +150,10 @@ export async function getStatus( if (!response.ok) { throw new Error(`Agent Tank returned HTTP ${response.status}: ${response.statusText}`); } - const data = (await response.json()) as AgentStatusResponse; + // `/status/:id` answers one canonical provider object from 0.10 on, + // and the bare legacy payload before that. + const data = parseAgentTankStatus(await response.json(), tankAgent); + if (!data) throw new Error('Agent Tank returned an unreadable status document'); const normalized = normalizeAgentTankStatus(data); // Published here rather than on a timer: this is the only component that // reads the external service, so it is the only one that can tell a @@ -210,6 +217,22 @@ export async function getStatusForAlias( */ export async function getAllStatuses( options: { refresh?: boolean } = {} +): Promise | undefined> { + const agents = await readAllStatuses(options); + // Every aggregate read - the sidebar, the usage watcher, the MCP tool - is + // also a history sample, which is the only history bundled mode has. The + // recorder is loaded lazily so the database stays out of this module's + // static graph: per-call usage tracking imports this router in processes + // (and tests) that never open the database. + if (agents) { + const { recordAgentTankUsageHistory } = await import('./agentTankUsageHistory.js'); + await recordAgentTankUsageHistory(agents); + } + return agents; +} + +async function readAllStatuses( + options: { refresh?: boolean } ): Promise | undefined> { const settings = await loadAgentTankSettings(); if (settings.mode === 'disabled') return undefined; @@ -223,8 +246,7 @@ export async function getAllStatuses( try { const response = await fetch(`${settings.url}/status`, { signal: controller.signal }); if (!response.ok) return undefined; - const data = await response.json() as Record; - return normalizeAgentTankAgents(data); + return normalizeAgentTankAgents(parseAgentTankStatusMap(await response.json())); } catch { return undefined; } finally { diff --git a/packages/core/src/services/agentTankTypes.ts b/packages/core/src/services/agentTankTypes.ts index 7da8f11281..d6818f914c 100644 --- a/packages/core/src/services/agentTankTypes.ts +++ b/packages/core/src/services/agentTankTypes.ts @@ -44,6 +44,25 @@ export interface AgentStatusResponse { lastUpdated?: string; error?: string | null; isRefreshing?: boolean; + /** + * Agent Tank 0.10+ canonical fields, carried next to the legacy `usage` + * payload (which 0.10 keeps under `raw`). Absent against a 0.9.x daemon. + */ + windows?: AgentTankUsageWindow[]; + plan?: string | null; +} + +/** One rate-limit window in Agent Tank 0.10's canonical provider shape. */ +export interface AgentTankUsageWindow { + /** `session`, `five_hour` or `weekly` upstream; kept open for new cycles. */ + type: string; + label: string; + /** Model or model group the window is specific to; null for all models. */ + model: string | null; + used_percent: number | null; + remaining_percent: number | null; + resets_at: string | null; + resets_in_seconds: number | null; } /** @@ -70,6 +89,7 @@ export function hasAgentTankStatuses(agents: Record */ export function isUsableAgentTankStatus(status: AgentStatusResponse | undefined): boolean { if (!status || status.error) return false; + if (status.windows?.some(window => typeof window.used_percent === 'number')) return true; return !!status.usage && typeof status.usage === 'object' && Object.keys(status.usage).length > 0; } @@ -98,3 +118,96 @@ export function normalizeAgentTankAgents(agents: Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function finiteOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function parseWindows(value: unknown): AgentTankUsageWindow[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord).map(window => ({ + type: typeof window.type === 'string' ? window.type : 'session', + label: typeof window.label === 'string' ? window.label : '', + model: stringOrNull(window.model), + used_percent: finiteOrNull(window.used_percent), + remaining_percent: finiteOrNull(window.remaining_percent), + resets_at: stringOrNull(window.resets_at), + resets_in_seconds: finiteOrNull(window.resets_in_seconds), + })); +} + +/** Whether one status object is in Agent Tank 0.10's canonical provider shape. */ +export function isCanonicalAgentTankProvider(value: unknown): value is Record { + return isRecord(value) && Array.isArray(value.windows) && ('raw' in value || 'provider' in value); +} + +/** A 0.9.x per-provider payload, as `raw` in 0.10 or the whole object before. */ +function parseLegacyStatus(value: Record, fallbackName: string): AgentStatusResponse { + const status: AgentStatusResponse = { + name: typeof value.name === 'string' && value.name ? value.name : fallbackName, + usage: isRecord(value.usage) ? value.usage : {}, + error: typeof value.error === 'string' && value.error ? value.error : null, + }; + if (isRecord(value.metadata)) status.metadata = value.metadata; + if (typeof value.lastUpdated === 'string') status.lastUpdated = value.lastUpdated; + if (value.isRefreshing === true) status.isRefreshing = true; + return status; +} + +/** + * Read one provider status in either Agent Tank response generation. + * + * 0.10 moved every provider onto one canonical object (`id`, `windows`, ...) + * and kept the 0.9.x payload, untouched, under `raw`. Every ProPR consumer - + * the sidebar, per-call deltas, synthetic routing - reads that legacy `usage` + * shape, so it stays the primary field and the canonical windows ride along. + * A daemon that has not been upgraded keeps answering in the old shape, which + * passes through unchanged. + */ +export function parseAgentTankStatus(value: unknown, fallbackName = ''): AgentStatusResponse | undefined { + if (!isRecord(value)) return undefined; + if (!isCanonicalAgentTankProvider(value)) return parseLegacyStatus(value, fallbackName); + const id = typeof value.id === 'string' && value.id ? value.id : fallbackName; + const status = parseLegacyStatus(isRecord(value.raw) ? value.raw : {}, id || stringOrNull(value.provider) || ''); + // The canonical object is the newer, authoritative view of freshness and failure. + if (typeof value.last_updated === 'string') status.lastUpdated = value.last_updated; + if (typeof value.error === 'string' && value.error) status.error = value.error; + if (value.status === 'refreshing') status.isRefreshing = true; + status.windows = parseWindows(value.windows); + status.plan = stringOrNull(value.plan); + return status; +} + +/** + * Read a `GET /status` (or `--once --json`) document in either generation: + * 0.10's `{ providers: [...] }`, the 0.9.x map keyed by agent id, or an + * `{ agents: {...} }` wrapper around that map. + */ +export function parseAgentTankStatusMap(value: unknown): Record { + if (!isRecord(value)) return {}; + const agents: Record = {}; + if (Array.isArray(value.providers)) { + for (const provider of value.providers) { + if (!isRecord(provider)) continue; + const key = stringOrNull(provider.id) || stringOrNull(provider.provider); + if (!key) continue; + const status = parseAgentTankStatus(provider, key); + if (status) agents[key] = status; + } + return agents; + } + const source = isRecord(value.agents) ? value.agents : value; + for (const [key, entry] of Object.entries(source)) { + const status = parseAgentTankStatus(entry, key); + if (status) agents[key] = status; + } + return agents; +} diff --git a/packages/core/src/services/agentTankUsageHistory.ts b/packages/core/src/services/agentTankUsageHistory.ts new file mode 100644 index 0000000000..6d2fc3a116 --- /dev/null +++ b/packages/core/src/services/agentTankUsageHistory.ts @@ -0,0 +1,173 @@ +/** + * ProPR's own record of Agent Tank usage over time. + * + * Bundled Agent Tank runs `--once` in a disposable container, so the history + * database Agent Tank 0.10 keeps is thrown away after every run; an external + * daemon may predate history or have it switched off. Every snapshot ProPR + * observes is therefore also written here, one row per metric, and the usage + * charts read whichever source actually has history (see `readUsageHistory` in + * `agentTankService.ts`). + * + * Writes are deduplicated per series: a re-read of the same capture is never + * stored twice, and an unchanged value is stored at most once per heartbeat so + * a flat line stays anchored without filling the table with copies. + */ + +import type { Knex } from 'knex'; +import { + AGENT_TANK_HISTORY_RETENTION_DAYS, + type AgentTankHistoryAgent, + type AgentTankHistorySeries, +} from '@propr/shared'; +import { db } from '../db/connection.js'; +import logger from '../utils/logger.js'; +import { extractAgentTankUsageMetrics } from './agentTankUsageMetrics.js'; +import type { AgentStatusResponse } from './agentTankTypes.js'; + +const TABLE = 'agent_tank_usage_history'; +const DAY_MS = 24 * 60 * 60 * 1000; +/** An unchanged value is re-recorded this often so its line stays anchored. */ +const UNCHANGED_HEARTBEAT_MS = 10 * 60 * 1000; +const PRUNE_INTERVAL_MS = 60 * 60 * 1000; +/** A capture time this far in the future is a skewed clock, not a capture. */ +const MAX_CLOCK_SKEW_MS = 60 * 1000; + +interface LastRecorded { + capturedAtMs: number; + percent: number; +} + +/** Last row written per `${agent}\u0000${metric}`, process-local by design. */ +const lastRecorded = new Map(); +let lastPrunedAt = 0; + +export const AGENT_TANK_HISTORY_RETENTION_MS = AGENT_TANK_HISTORY_RETENTION_DAYS * DAY_MS; + +/** When the usage in this status was captured, falling back to now. */ +function captureTime(status: AgentStatusResponse, now: number): number { + const parsed = status.lastUpdated ? Date.parse(status.lastUpdated) : Number.NaN; + return Number.isFinite(parsed) && parsed <= now + MAX_CLOCK_SKEW_MS ? Math.min(parsed, now) : now; +} + +interface HistoryRow { + agent: string; + metric_key: string; + metric_label: string; + model: string | null; + cycle: string | null; + percent_used: number; + recorded_at: string; + recorded_at_ms: number; +} + +/** Rows this snapshot adds, after per-series deduplication. */ +function rowsToRecord(agents: Record, now: number): HistoryRow[] { + const rows: HistoryRow[] = []; + for (const [key, status] of Object.entries(agents)) { + // A failed read keeps cached usage upstream; recording it again would + // claim a fresh observation of a value nobody re-measured. + if (!status || status.error) continue; + const agent = status.name || key; + const capturedAtMs = captureTime(status, now); + for (const metric of extractAgentTankUsageMetrics(agent, status.usage)) { + const seriesKey = `${agent}\u0000${metric.key}`; + const previous = lastRecorded.get(seriesKey); + if (previous) { + if (capturedAtMs <= previous.capturedAtMs) continue; + const unchanged = previous.percent === metric.percentUsed; + if (unchanged && capturedAtMs - previous.capturedAtMs < UNCHANGED_HEARTBEAT_MS) continue; + } + lastRecorded.set(seriesKey, { capturedAtMs, percent: metric.percentUsed }); + rows.push({ + agent, + metric_key: metric.key, + metric_label: metric.label, + model: metric.model, + cycle: metric.cycle, + percent_used: metric.percentUsed, + recorded_at: new Date(capturedAtMs).toISOString(), + recorded_at_ms: capturedAtMs, + }); + } + } + return rows; +} + +/** + * Record one observed snapshot (ProPR-facing agent names). Best-effort: usage + * history must never fail the read that observed it. + * + * @returns the number of rows written + */ +export async function recordAgentTankUsageHistory( + agents: Record | undefined, + { database = db, now = Date.now() }: { database?: Knex; now?: number } = {}, +): Promise { + if (!agents) return 0; + try { + const rows = rowsToRecord(agents, now); + if (rows.length > 0) await database(TABLE).insert(rows); + if (now - lastPrunedAt >= PRUNE_INTERVAL_MS) { + lastPrunedAt = now; + await database(TABLE).where('recorded_at_ms', '<', now - AGENT_TANK_HISTORY_RETENTION_MS).delete(); + } + return rows.length; + } catch (error) { + logger.warn({ error: (error as Error).message }, 'Could not record Agent Tank usage history'); + return 0; + } +} + +export interface UsageHistoryQuery { + /** Inclusive lower bound, epoch ms. */ + since: number; + /** Inclusive upper bound, epoch ms. */ + until: number; + /** ProPR-facing agent names to include; all when omitted. */ + agents?: string[]; + /** Only this metric key. */ + metric?: string; +} + +/** Locally recorded series, grouped per agent and ordered by time. */ +export async function readLocalAgentTankUsageHistory( + query: UsageHistoryQuery, + database: Knex = db, +): Promise { + let builder = database(TABLE) + .select('agent', 'metric_key', 'metric_label', 'model', 'cycle', 'percent_used', 'recorded_at') + .where('recorded_at_ms', '>=', query.since) + .andWhere('recorded_at_ms', '<=', query.until); + if (query.agents?.length) builder = builder.whereIn('agent', query.agents); + if (query.metric) builder = builder.andWhere('metric_key', query.metric); + const rows = await builder.orderBy([ + { column: 'agent' }, { column: 'metric_key' }, { column: 'recorded_at_ms' }, { column: 'id' }, + ]) as Array>; + + const agents: AgentTankHistoryAgent[] = []; + let currentAgent: AgentTankHistoryAgent | undefined; + let currentSeries: AgentTankHistorySeries | undefined; + for (const row of rows) { + if (currentAgent?.agent !== row.agent) { + currentAgent = { agent: row.agent, series: [] }; + agents.push(currentAgent); + currentSeries = undefined; + } + if (currentSeries?.key !== row.metric_key) { + currentSeries = { key: row.metric_key, label: row.metric_label, model: null, cycle: null, points: [] }; + currentAgent.series.push(currentSeries); + } + // Rows are time-ordered, so the descriptive fields end up as the latest. + currentSeries.label = row.metric_label; + currentSeries.model = row.model; + currentSeries.cycle = row.cycle; + currentSeries.points.push({ t: row.recorded_at, percent: Number(row.percent_used) }); + } + return agents; +} + +/** Forget the per-series write baselines. Test-only seam. */ +export function resetAgentTankUsageHistoryTracking(): void { + lastRecorded.clear(); + lastPrunedAt = 0; +} diff --git a/packages/core/src/services/agentTankUsageMetrics.ts b/packages/core/src/services/agentTankUsageMetrics.ts new file mode 100644 index 0000000000..2a0f5f15f9 --- /dev/null +++ b/packages/core/src/services/agentTankUsageMetrics.ts @@ -0,0 +1,150 @@ +/** + * Flatten an Agent Tank provider's legacy `usage` payload into one row per + * limit, for ProPR's own usage history. + * + * Ported from Agent Tank 0.10 `src/snapshot-metrics.js` so locally recorded + * series carry exactly the keys the daemon's `GET /history/:id` uses + * (`session`, `weeklyAll`, `fiveHour`, `model:`, ...). The + * two sources then plot and look up identically, and switching an install + * between bundled and external mode does not fork its series. + * + * Only percentage rows are produced: credit balances and other values are not + * plotted on a 0-100 usage chart. + */ + +import { toAgentTankAgent } from './agentTankTypes.js'; + +export interface AgentTankUsageMetric { + key: string; + label: string; + model: string | null; + cycle: string | null; + percentUsed: number; +} + +type UsageRecord = Record; + +function isRecord(value: unknown): value is UsageRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function round2(value: number): number { + return Math.round(Math.min(100, Math.max(0, value)) * 100) / 100; +} + +function metric( + fields: Omit, + percentUsed: unknown, +): AgentTankUsageMetric | null { + if (!isFiniteNumber(percentUsed)) return null; + return { ...fields, percentUsed: round2(percentUsed) }; +} + +// Claude reports distinct limits under fixed keys. `weekly` is the single +// weekly row of older Claude builds and `weeklySonnet` the pre-Fable model row. +const CLAUDE_SECTIONS = [ + { key: 'session', label: 'Current session', model: null, cycle: 'session' }, + { key: 'weeklyAll', label: 'Current week (all models)', model: null, cycle: 'weekly' }, + { key: 'weeklyFable', label: 'Current week (Fable)', model: 'Fable', cycle: 'weekly' }, + { key: 'weeklySonnet', label: 'Current week (Sonnet)', model: 'Sonnet', cycle: 'weekly' }, + { key: 'weekly', label: 'Current week', model: null, cycle: 'weekly' }, +] as const; + +function claudeMetrics(usage: UsageRecord): AgentTankUsageMetric[] { + const rows: AgentTankUsageMetric[] = []; + for (const section of CLAUDE_SECTIONS) { + const entry = usage[section.key]; + if (!isRecord(entry)) continue; + const label = typeof entry.label === 'string' && entry.label ? entry.label : section.label; + const row = metric({ key: section.key, label, model: section.model, cycle: section.cycle }, entry.percent); + if (row) rows.push(row); + } + return rows; +} + +/** Antigravity cycle names mapped onto canonical window types. */ +const AGY_CYCLES: Record = { + weekly: 'weekly', + fiveHour: 'five_hour', + sessionAgy: 'session', + daily: 'daily', +}; + +/** + * Antigravity reports the remaining quota, so `percentUsed` is derived from + * `usageLeft` when it is present. Grouped pools - including the separately + * tracked Gemini 4 Argon quota - keep their group as `model`. + */ +function antigravityMetrics(usage: UsageRecord): AgentTankUsageMetric[] { + if (!Array.isArray(usage.models)) return []; + const rows: AgentTankUsageMetric[] = []; + for (const entry of usage.models) { + if (!isRecord(entry) || typeof entry.model !== 'string' || !entry.model) continue; + const percentUsed = isFiniteNumber(entry.usageLeft) ? 100 - entry.usageLeft : entry.percentUsed; + const group = typeof entry.group === 'string' && entry.group ? entry.group : entry.model; + const cycle = typeof entry.cycle === 'string' ? (AGY_CYCLES[entry.cycle] ?? 'session') : 'session'; + const row = metric({ key: `model:${entry.model}`, label: entry.model, model: group, cycle }, percentUsed); + if (row) rows.push(row); + } + return rows; +} + +const CODEX_SECTIONS = [ + { key: 'fiveHour', label: '5h limit', cycle: 'five_hour' }, + { key: 'weekly', label: 'Weekly limit', cycle: 'weekly' }, +] as const; + +function codexSectionMetrics(source: UsageRecord, model: string | null): AgentTankUsageMetric[] { + const rows: AgentTankUsageMetric[] = []; + for (const section of CODEX_SECTIONS) { + const entry = source[section.key]; + if (!isRecord(entry)) continue; + const percentUsed = isFiniteNumber(entry.percentUsed) + ? entry.percentUsed + : (isFiniteNumber(entry.percentLeft) ? 100 - entry.percentLeft : null); + const label = typeof entry.label === 'string' && entry.label ? entry.label : section.label; + const row = metric({ + key: model ? `${model}:${section.key}` : section.key, + label: model ? `${model} ${label}` : label, + model, + cycle: section.cycle, + }, percentUsed); + if (row) rows.push(row); + } + return rows; +} + +function codexMetrics(usage: UsageRecord): AgentTankUsageMetric[] { + const rows = codexSectionMetrics(usage, null); + if (Array.isArray(usage.modelLimits)) { + for (const limit of usage.modelLimits) { + if (!isRecord(limit)) continue; + rows.push(...codexSectionMetrics(limit, typeof limit.name === 'string' && limit.name ? limit.name : 'Model')); + } + } + return rows; +} + +const EXTRACTORS: Record AgentTankUsageMetric[]> = { + claude: claudeMetrics, + agy: antigravityMetrics, + codex: codexMetrics, +}; + +/** + * Percentage metric rows for one provider's usage payload; empty for providers + * Agent Tank does not track or for an empty payload. + * + * @example + * extractAgentTankUsageMetrics('claude', { session: { percent: 42 } }); + * // => [{ key: 'session', label: 'Current session', model: null, cycle: 'session', percentUsed: 42 }] + */ +export function extractAgentTankUsageMetrics(agent: string, usage: unknown): AgentTankUsageMetric[] { + const extractor = EXTRACTORS[toAgentTankAgent(agent)]; + if (!extractor || !isRecord(usage)) return []; + return extractor(usage); +} diff --git a/packages/shared/src/agentTankHistory.ts b/packages/shared/src/agentTankHistory.ts new file mode 100644 index 0000000000..85a8297f56 --- /dev/null +++ b/packages/shared/src/agentTankHistory.ts @@ -0,0 +1,66 @@ +/** + * Agent Tank usage history, as served by `GET /api/config/agent-tank/history`. + * + * The series shape mirrors Agent Tank 0.10's `GET /history/:id` so the daemon's + * own history can be passed through unchanged; ProPR's locally recorded history + * (bundled mode, or a daemon without a history store) uses the same metric keys + * (`session`, `weeklyAll`, `fiveHour`, `model:`, ...). + */ + +export interface AgentTankHistoryPoint { + /** ISO 8601 capture time. */ + t: string; + /** Percent of the window used, 0-100; null for non-percentage metrics. */ + percent: number | null; +} + +export interface AgentTankHistorySeries { + /** Stable series identity; a CLI relabel changes `label`, never `key`. */ + key: string; + label: string; + model: string | null; + /** `session`, `five_hour`, `weekly`, `daily`, ... or null when not a window. */ + cycle: string | null; + points: AgentTankHistoryPoint[]; +} + +export interface AgentTankHistoryAgent { + /** ProPR-facing provider name (`claude`, `codex`, `antigravity`). */ + agent: string; + series: AgentTankHistorySeries[]; +} + +/** Where a history answer came from. */ +export type AgentTankHistorySource = 'agent-tank' | 'propr'; + +export interface AgentTankHistoryResponse { + enabled: boolean; + source?: AgentTankHistorySource; + /** How far back history can reach, in days. */ + retentionDays?: number; + agents: AgentTankHistoryAgent[]; + error?: string; +} + +const HOUR_MS = 60 * 60 * 1000; + +/** + * Length of one quota period per Agent Tank cycle. A Claude "session" and a + * Codex "five_hour" window are both five hours; Antigravity's un-windowed + * per-model quotas refresh daily. + */ +export const AGENT_TANK_CYCLE_DURATIONS_MS: Readonly> = Object.freeze({ + session: 5 * HOUR_MS, + five_hour: 5 * HOUR_MS, + daily: 24 * HOUR_MS, + weekly: 7 * 24 * HOUR_MS, + monthly: 30 * 24 * HOUR_MS, +}); + +/** One period of the given cycle, defaulting to the five-hour session. */ +export function agentTankCyclePeriodMs(cycle: string | null | undefined): number { + return (cycle && AGENT_TANK_CYCLE_DURATIONS_MS[cycle]) || AGENT_TANK_CYCLE_DURATIONS_MS.session; +} + +/** Recorded history is kept long enough to show two weekly periods. */ +export const AGENT_TANK_HISTORY_RETENTION_DAYS = 15; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 48af41a991..6221708e59 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -545,6 +545,7 @@ export { supportsAgentTankModes, type AgentTankMode, } from './agentTank.js'; +export * from './agentTankHistory.js'; export * from './usageTips.js'; export * from './taskReplacement.js'; diff --git a/patches/README.md b/patches/README.md index a6fc6b94b0..62d165ae0c 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,4 +1,4 @@ -`agent-tank-0.9.11.patch` carries the Agent Tank provider fixes developed in +`agent-tank-0.10.0.patch` carries the Agent Tank provider fixes developed in `integry/agent-tank` until they are included in a published package version. It adds native Antigravity configuration isolation, current Claude quota-schema support, startup/partial-render handling, and provider refresh throttling. diff --git a/patches/agent-tank-0.9.11.patch b/patches/agent-tank-0.10.0.patch similarity index 97% rename from patches/agent-tank-0.9.11.patch rename to patches/agent-tank-0.10.0.patch index 932a618c18..7d4191bb8f 100644 --- a/patches/agent-tank-0.9.11.patch +++ b/patches/agent-tank-0.10.0.patch @@ -1,5 +1,5 @@ diff --git a/src/agents/agy.js b/src/agents/agy.js -index a3f0b23..eeb66f9 100644 +index f6ca9f2..fa697e6 100644 --- a/src/agents/agy.js +++ b/src/agents/agy.js @@ -1,3 +1,6 @@ @@ -9,7 +9,7 @@ index a3f0b23..eeb66f9 100644 const { BaseAgent } = require('./base.js'); const { calculatePace } = require('../pace-evaluator.js'); const { CYCLE_DURATIONS } = require('../usage-formatters.js'); -@@ -18,6 +21,7 @@ class AgyAgent extends BaseAgent { +@@ -13,6 +16,7 @@ class AgyAgent extends BaseAgent { super('agy', 'agy', ['--dangerously-skip-permissions']); this.configPath = options.configPath || null; this._aboutSent = false; @@ -17,7 +17,7 @@ index a3f0b23..eeb66f9 100644 } getTimeout() { -@@ -32,10 +36,28 @@ class AgyAgent extends BaseAgent { +@@ -27,10 +31,28 @@ class AgyAgent extends BaseAgent { TERM: 'xterm-256color', COLORTERM: 'truecolor', }; @@ -79,8 +79,40 @@ index d009d59..7debfd7 100644 if (raw.extra_usage?.is_enabled) { norm.extraUsage = { percentUsed: Math.round(raw.extra_usage.utilization), +diff --git a/src/agents/claude-usage-cache.js b/src/agents/claude-usage-cache.js +new file mode 100644 +index 0000000..5be2e0e +--- /dev/null ++++ b/src/agents/claude-usage-cache.js +@@ -0,0 +1,26 @@ ++const fs = require('node:fs'); ++const os = require('node:os'); ++const path = require('node:path'); ++const { parseApiResponse } = require('./api-response-parser'); ++ ++// Claude can render its primary quotas before the model allowance arrives. ++// Its own structured response cache can fill that row without another request. ++// Accept only the current account and the CLI's short-lived (one minute) data. ++function readRecentFableAllowance(configPath, now = Date.now()) { ++ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return null; ++ const directory = configPath || process.env.CLAUDE_CONFIG_DIR; ++ const file = directory ? path.join(directory, '.claude.json') : path.join(os.homedir(), '.claude.json'); ++ try { ++ const profile = JSON.parse(fs.readFileSync(file, 'utf8')); ++ const cache = profile.cachedUsageUtilization; ++ if (!cache || !Number.isFinite(cache.fetchedAtMs)) return null; ++ const age = now - cache.fetchedAtMs; ++ if (age < 0 || age > 60000 || !cache.accountUuid || cache.accountUuid !== profile.oauthAccount?.accountUuid) return null; ++ const allowance = parseApiResponse(cache.utilization).weeklyFable; ++ return allowance ? { ...allowance, sampledAt: new Date(cache.fetchedAtMs).toISOString() } : null; ++ } catch { ++ return null; ++ } ++} ++ ++module.exports = { readRecentFableAllowance }; diff --git a/src/agents/claude.js b/src/agents/claude.js -index a2a1850..47cf6e6 100644 +index 0390da1..e8ce8fa 100644 --- a/src/agents/claude.js +++ b/src/agents/claude.js @@ -4,11 +4,12 @@ const { BaseAgent } = require('./base.js'); @@ -177,10 +209,10 @@ index a2a1850..47cf6e6 100644 // Fetch metadata by sending /status command once on first refresh diff --git a/src/index.js b/src/index.js -index 5e44b95..2374982 100644 +index fec9282..f24b3b4 100644 --- a/src/index.js +++ b/src/index.js -@@ -424,16 +424,19 @@ class AgentTank { +@@ -449,16 +449,19 @@ class AgentTank { } _getRefreshCooldownRemainingMs(name, now = Date.now()) { @@ -204,35 +236,3 @@ index 5e44b95..2374982 100644 } async _refreshAgentWithCooldown(name) { -diff --git a/src/agents/claude-usage-cache.js b/src/agents/claude-usage-cache.js -new file mode 100644 -index 0000000..5be2e0e ---- /dev/null -+++ b/src/agents/claude-usage-cache.js -@@ -0,0 +1,26 @@ -+const fs = require('node:fs'); -+const os = require('node:os'); -+const path = require('node:path'); -+const { parseApiResponse } = require('./api-response-parser'); -+ -+// Claude can render its primary quotas before the model allowance arrives. -+// Its own structured response cache can fill that row without another request. -+// Accept only the current account and the CLI's short-lived (one minute) data. -+function readRecentFableAllowance(configPath, now = Date.now()) { -+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return null; -+ const directory = configPath || process.env.CLAUDE_CONFIG_DIR; -+ const file = directory ? path.join(directory, '.claude.json') : path.join(os.homedir(), '.claude.json'); -+ try { -+ const profile = JSON.parse(fs.readFileSync(file, 'utf8')); -+ const cache = profile.cachedUsageUtilization; -+ if (!cache || !Number.isFinite(cache.fetchedAtMs)) return null; -+ const age = now - cache.fetchedAtMs; -+ if (age < 0 || age > 60000 || !cache.accountUuid || cache.accountUuid !== profile.oauthAccount?.accountUuid) return null; -+ const allowance = parseApiResponse(cache.utilization).weeklyFable; -+ return allowance ? { ...allowance, sampledAt: new Date(cache.fetchedAtMs).toISOString() } : null; -+ } catch { -+ return null; -+ } -+} -+ -+module.exports = { readRecentFableAllowance }; diff --git a/propr-ui/src/api/revertApi.ts b/propr-ui/src/api/revertApi.ts index 00d59bb60a..79ea860253 100644 --- a/propr-ui/src/api/revertApi.ts +++ b/propr-ui/src/api/revertApi.ts @@ -3,11 +3,13 @@ import { AGENT_TANK_LEGACY_BACKEND_MESSAGE, buildAgentTankSettingsRequest, supportsAgentTankModes, + type AgentTankHistoryResponse, type AgentTankMode, } from '@propr/shared'; import type { PostFollowupResponse, SummarizationSettings } from './proprTypes'; export type { SummarizationSettings }; +export type { AgentTankHistoryAgent, AgentTankHistoryPoint, AgentTankHistoryResponse, AgentTankHistorySeries } from '@propr/shared'; export interface RevertParams { repo: string; @@ -141,7 +143,9 @@ export interface AgentUsageData { weeklySonnet?: AgentUsageMetric; weeklyFable?: AgentUsageMetric; weekly?: AgentUsageMetric; - models?: Array<{ model: string; percentUsed: number; resetsIn?: string }>; + // `group`/`cycle` arrive from Agent Tank 0.9.9+ for grouped Antigravity + // pools, including the separately tracked Gemini 4 Argon quota. + models?: Array<{ model: string; percentUsed: number; resetsIn?: string; group?: string; cycle?: string }>; fiveHour?: { percentUsed: number; resetsIn?: string }; } | null; error?: string | null; @@ -165,6 +169,30 @@ export const getAgentTankUsage = async ( return response.json(); }; +export interface AgentTankHistoryQuery { + /** Inclusive range, epoch ms. */ + since: number; + until?: number; + /** ProPR-facing agent names; all agents when omitted. */ + agents?: string[]; + /** Only this Agent Tank metric key (e.g. `session`, `model:Gemini 4 Argon Weekly Limit`). */ + metric?: string; + signal?: AbortSignal; +} + +export const getAgentTankHistory = async (query: AgentTankHistoryQuery): Promise => { + const params = new URLSearchParams({ since: String(Math.floor(query.since)) }); + if (query.until !== undefined) params.set('until', String(Math.floor(query.until))); + if (query.agents?.length) params.set('agent', query.agents.join(',')); + if (query.metric) params.set('metric', query.metric); + const response = await apiFetch(`${API_BASE_URL}/api/config/agent-tank/history?${params}`, { + credentials: 'include', + signal: query.signal, + }); + await handleApiResponse(response); + return response.json(); +}; + export const refreshAgentTank = async (): Promise<{ success: boolean; error?: string }> => { const response = await apiFetch(`${API_BASE_URL}/api/config/agent-tank/refresh`, { method: 'POST', diff --git a/propr-ui/src/components/agentTankMetrics.tsx b/propr-ui/src/components/agentTankMetrics.tsx index 5819e96ce3..95b5905453 100644 --- a/propr-ui/src/components/agentTankMetrics.tsx +++ b/propr-ui/src/components/agentTankMetrics.tsx @@ -106,6 +106,45 @@ export interface UsageMetric { group?: string; percent: number; resetsIn?: string; + // Agent Tank history series key (`session`, `weeklyAll`, `fiveHour`, + // `model:`), so the row can look up its own usage line. + historyKey?: string; + // Quota cycle (`session`, `five_hour`, `weekly`, `daily`), which sets how + // long one period of that line is. + cycle?: string; +} + +// Antigravity reports its cycles under CLI names; history uses window types. +const ANTIGRAVITY_CYCLES: Record = { + weekly: 'weekly', + fiveHour: 'five_hour', + sessionAgy: 'session', + daily: 'daily', +}; + +const ANTIGRAVITY_CYCLE_LABELS: Record = { + weekly: 'Weekly', + fiveHour: '5h', +}; + +// A grouped Antigravity quota names its pool explicitly. Agent Tank qualifies a +// shared limit as " · Weekly Limit", but a pool tracked on its own - the +// separate Gemini 4 Argon quota - arrives as "Gemini 4 Argon Weekly Limit" with +// `group` set, which the "·" pattern cannot split. +function groupedAntigravityQuota( + model: { model: string; group?: string; cycle?: string }, + quotaName: string, +): { group: string; label: string } | null { + if (!model.group) return null; + const rest = quotaName.toLowerCase().startsWith(model.group.toLowerCase()) + ? quotaName.slice(model.group.length).replace(/^\s*·?\s*/, '').replace(/\s+Limit$/i, '').trim() + : ''; + if (!rest || /^(weekly|five hour|5h)$/i.test(rest)) { + const label = (model.cycle && ANTIGRAVITY_CYCLE_LABELS[model.cycle]) + ?? (rest ? (ANTIGRAVITY_WINDOW_LABELS[rest.toLowerCase()] ?? rest) : null); + if (label) return { group: model.group, label }; + } + return null; } // Extract all usage metrics from agent data @@ -118,46 +157,59 @@ export function getAllMetrics(agent: AgentUsageData): UsageMetric[] { metrics.push({ label: 'Session', percent: agent.usage.session.percent, - resetsIn: agent.usage.session.resetsIn + resetsIn: agent.usage.session.resetsIn, + historyKey: 'session', + cycle: 'session' }); } if (agent.usage.weeklyAll) { metrics.push({ label: 'Weekly', percent: agent.usage.weeklyAll.percent, - resetsIn: agent.usage.weeklyAll.resetsIn + resetsIn: agent.usage.weeklyAll.resetsIn, + historyKey: 'weeklyAll', + cycle: 'weekly' }); } if (agent.usage.weeklySonnet) { metrics.push({ label: 'Sonnet', percent: agent.usage.weeklySonnet.percent, - resetsIn: agent.usage.weeklySonnet.resetsIn + resetsIn: agent.usage.weeklySonnet.resetsIn, + historyKey: 'weeklySonnet', + cycle: 'weekly' }); } if (agent.usage.weeklyFable) { metrics.push({ label: 'Fable', percent: agent.usage.weeklyFable.percent, - resetsIn: agent.usage.weeklyFable.resetsIn + resetsIn: agent.usage.weeklyFable.resetsIn, + historyKey: 'weeklyFable', + cycle: 'weekly' }); } // Gemini / Antigravity models if (agent.usage.models) { const isAntigravity = agent.name.toLowerCase() === 'antigravity'; for (const model of agent.usage.models) { + const history = { + historyKey: `model:${model.model}`, + cycle: (model.cycle && ANTIGRAVITY_CYCLES[model.cycle]) ?? 'session', + }; if (isAntigravity) { // Quota windows already carry product labels, not model IDs. Preserve // their spelling (e.g. "Claude and GPT") before formatting model IDs. const quotaName = stripRemainingSuffix(model.model); - const quotaWindow = parseAntigravityQuotaWindow(quotaName); + const quotaWindow = parseAntigravityQuotaWindow(quotaName) ?? groupedAntigravityQuota(model, quotaName); if (quotaWindow) { metrics.push({ label: quotaWindow.label, group: quotaWindow.group, title: quotaName, percent: model.percentUsed, - resetsIn: model.resetsIn + resetsIn: model.resetsIn, + ...history }); continue; } @@ -170,13 +222,15 @@ export function getAllMetrics(agent: AgentUsageData): UsageMetric[] { displayLabel: display, title: fullName, percent: model.percentUsed, - resetsIn: model.resetsIn + resetsIn: model.resetsIn, + ...history }); } else { metrics.push({ label: stripRemainingSuffix(getModelDisplayName(model.model, { compactGemini: true })), percent: model.percentUsed, - resetsIn: model.resetsIn + resetsIn: model.resetsIn, + ...history }); } } @@ -187,7 +241,9 @@ export function getAllMetrics(agent: AgentUsageData): UsageMetric[] { metrics.push({ label: 'Session', percent: agent.usage.fiveHour.percentUsed, - resetsIn: agent.usage.fiveHour.resetsIn + resetsIn: agent.usage.fiveHour.resetsIn, + historyKey: 'fiveHour', + cycle: 'five_hour' }); } // Codex weekly uses percentUsed, not percent @@ -196,7 +252,9 @@ export function getAllMetrics(agent: AgentUsageData): UsageMetric[] { metrics.push({ label: 'Weekly', percent: weeklyData.percentUsed ?? weeklyData.percent ?? 0, - resetsIn: weeklyData.resetsIn + resetsIn: weeklyData.resetsIn, + historyKey: 'weekly', + cycle: 'weekly' }); } diff --git a/propr-ui/src/components/usageHistory/usageHistoryModel.ts b/propr-ui/src/components/usageHistory/usageHistoryModel.ts new file mode 100644 index 0000000000..357e54a301 --- /dev/null +++ b/propr-ui/src/components/usageHistory/usageHistoryModel.ts @@ -0,0 +1,93 @@ +/** + * Shared vocabulary for the Agent Tank usage charts: the Analytics usage chart + * and the sidebar's per-metric overlay read history through the same helpers so + * a series has one identity, one label and one color wherever it is drawn. + */ + +import { agentTankCyclePeriodMs } from '@propr/shared'; +import type { AgentTankHistoryAgent, AgentTankHistorySeries } from '../../api/revertApi'; +import { getProviderDisplayName, getProviderRank } from '../agentTankMetrics'; +import { stripRemainingSuffix } from '../../utils/usageMetricLabels'; + +/** + * Categorical slots in fixed order (validated: adjacent CVD ΔE ≥ 9.1, normal + * vision ≥ 19.6 on the white surface). Aqua, yellow and magenta sit under 3:1 + * contrast, so every chart that uses them ships a legend with values and a + * table view. A ninth series reuses no hue; callers fold it away instead. + */ +export const USAGE_SERIES_COLORS = [ + '#2a78d6', // blue + '#eb6834', // orange + '#1baf7a', // aqua + '#eda100', // yellow + '#e87ba4', // magenta + '#008300', // green + '#4a3aa7', // violet + '#e34948', // red +] as const; + +export const MAX_USAGE_SERIES = USAGE_SERIES_COLORS.length; + +/** Stable identity of one agent's metric series. */ +export function usageSeriesId(agent: string, key: string): string { + return `${agent}:${key}`; +} + +// Claude's and Codex's metric keys read better as the sidebar's short names. +const METRIC_LABELS: Record = { + session: 'Session', + weeklyAll: 'Weekly', + weeklyFable: 'Fable', + weeklySonnet: 'Sonnet', + weekly: 'Weekly', + fiveHour: 'Session', +}; + +/** Short label of a series within its agent ("Session", "Gemini 4 Argon · Weekly"). */ +export function usageMetricLabel(series: Pick): string { + if (METRIC_LABELS[series.key]) return METRIC_LABELS[series.key]; + return stripRemainingSuffix(series.label || series.key).replace(/\s+Limit$/i, ''); +} + +/** Full label naming the agent too ("Claude · Session"). */ +export function usageSeriesLabel(agent: string, series: Pick): string { + return `${getProviderDisplayName(agent)} · ${usageMetricLabel(series)}`; +} + +export interface FlatUsageSeries extends AgentTankHistorySeries { + id: string; + agent: string; + displayLabel: string; +} + +/** Every series of a history answer, flattened in provider order. */ +export function flattenUsageHistory(agents: AgentTankHistoryAgent[]): FlatUsageSeries[] { + return [...agents] + .sort((a, b) => getProviderRank(a.agent) - getProviderRank(b.agent)) + .flatMap(entry => entry.series.map(series => ({ + ...series, + id: usageSeriesId(entry.agent, series.key), + agent: entry.agent, + displayLabel: usageSeriesLabel(entry.agent, series), + }))); +} + +/** + * Colors keyed by series id, assigned from a stable ordering of every known id + * (not of the visible ones), so hiding a series never repaints the others. + */ +export function assignUsageSeriesColors(ids: string[]): Map { + const colors = new Map(); + ids.forEach((id, index) => { + if (index < USAGE_SERIES_COLORS.length) colors.set(id, USAGE_SERIES_COLORS[index]); + }); + return colors; +} + +/** The overlay's range: the last two periods of the metric's cycle, ending now. */ +export function twoPeriodWindow(cycle: string | null | undefined, now: number = Date.now()): { + since: number; until: number; periodMs: number; +} { + const periodMs = agentTankCyclePeriodMs(cycle); + return { since: now - 2 * periodMs, until: now, periodMs }; +} diff --git a/scripts/build-images.sh b/scripts/build-images.sh index 94e8ba0721..239df4a06c 100755 --- a/scripts/build-images.sh +++ b/scripts/build-images.sh @@ -40,7 +40,7 @@ VIBE_CLI_VERSION="${VIBE_CLI_VERSION:-2.25.8}" # mismatch here would ship a different Agent Tank build under an existing tag. # `assert_agent_tank_version_matches_pin` enforces that, including for an # environment override, rather than trusting the convention. -AGENT_TANK_CLI_VERSION="${AGENT_TANK_CLI_VERSION:-0.9.11}" +AGENT_TANK_CLI_VERSION="${AGENT_TANK_CLI_VERSION:-0.10.0}" PUSH_LATEST="${PUSH_LATEST:-true}" VERSION="$(node -p "require('./package.json').version")" @@ -55,7 +55,7 @@ AGENT_BUNDLE_CONTENT_FILES=( Dockerfile.agent scripts/agent-entrypoint.sh scripts/agent-tank-runtime.mjs - patches/agent-tank-0.9.11.patch + patches/agent-tank-0.10.0.patch scripts/claude-entrypoint.sh scripts/codex-entrypoint.sh scripts/antigravity-entrypoint.sh diff --git a/scripts/verify-agent-tank-image.sh b/scripts/verify-agent-tank-image.sh index 3e7c44cec2..d4f8a4c7f9 100755 --- a/scripts/verify-agent-tank-image.sh +++ b/scripts/verify-agent-tank-image.sh @@ -317,7 +317,34 @@ if ! usage_summary="$( process.stderr.write(`Agent Tank produced unparseable JSON: ${error.message}\n`); process.exit(1); } - const agents = parsed && parsed.agents && typeof parsed.agents === "object" ? parsed.agents : parsed; + // Agent Tank 0.10.0+ emits the canonical { providers: [...] } envelope, + // with the 0.9.x per-provider payload kept unchanged under `raw`. The + // legacy map ({ claude: {...} } or { agents: {...} }) is still accepted. + // Canonical entries are indexed by `id` (ProPR pins it to the provider + // key), falling back to `provider`; for Antigravity the canonical + // `provider` is "antigravity", so `id` must win. + const byProvider = {}; + if (parsed && Array.isArray(parsed.providers)) { + for (const entry of parsed.providers) { + if (!entry || typeof entry !== "object") continue; + const raw = entry.raw && typeof entry.raw === "object" ? entry.raw : {}; + const key = typeof entry.id === "string" && entry.id + ? entry.id + : (typeof entry.provider === "string" ? entry.provider : null); + if (!key || byProvider[key]) continue; + byProvider[key] = { + error: typeof entry.error === "string" ? entry.error : raw.error, + usage: raw.usage, + windows: Array.isArray(entry.windows) ? entry.windows : [], + }; + } + } else { + const agents = parsed && parsed.agents && typeof parsed.agents === "object" ? parsed.agents : parsed; + for (const [key, status] of Object.entries(agents || {})) { + if (!status || typeof status !== "object") continue; + byProvider[key] = { error: status.error, usage: status.usage, windows: [] }; + } + } // Only metric names and numbers are printed: a status payload can carry // account metadata, which has no place in CI logs. const metrics = value => { @@ -336,10 +363,17 @@ if ! usage_summary="$( } return found; }; + const windowMetrics = windows => windows + .filter(window => window && typeof window === "object" && typeof window.used_percent === "number") + .map((window, index) => { + const type = typeof window.type === "string" ? window.type : "window"; + const model = typeof window.model === "string" ? window.model : String(index); + return `windows[${type}/${model}].used_percent=${window.used_percent}`; + }); const lines = []; for (const provider of expected) { - const status = agents?.[provider]; - if (!status || typeof status !== "object") { + const status = byProvider[provider]; + if (!status) { process.stderr.write(`Agent Tank reported nothing for provider ${provider}\n`); process.exit(1); } @@ -347,7 +381,7 @@ if ! usage_summary="$( process.stderr.write(`Agent Tank reported an error for provider ${provider}: ${status.error}\n`); process.exit(1); } - const found = metrics(status.usage); + const found = [...metrics(status.usage), ...windowMetrics(status.windows)]; if (found.length === 0) { process.stderr.write(`Agent Tank reported no usage numbers for provider ${provider}\n`); process.exit(1); diff --git a/test/agentTankImageVerification.test.ts b/test/agentTankImageVerification.test.ts index 8ad9645a94..3edb0b941a 100644 --- a/test/agentTankImageVerification.test.ts +++ b/test/agentTankImageVerification.test.ts @@ -38,11 +38,21 @@ const integrationScript = readFileSync('scripts/integration-test-images.sh', 'ut const smokeScript = readFileSync('scripts/smoke-test-images.sh', 'utf8'); const releaseImageWorkflow = readFileSync('.github/workflows/docker-images.yml', 'utf8'); const prBuildWorkflow = readFileSync('.github/workflows/pr-build-check.yml', 'utf8'); -const fixturePath = new URL('./fixtures/agent-tank-verifier-pinned-0.9.11.json', import.meta.url) +const fixturePath = new URL('./fixtures/agent-tank-verifier-pinned-0.10.0.json', import.meta.url) .pathname; const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as { cliVersion: string; - status: Record }>; + /** The 0.10.0 canonical `--once --json` envelope. */ + status: { + providers: { + id: string; + provider: string; + status: string; + error: string | null; + windows: Record[]; + raw: { name: string; usage: Record; error: string | null }; + }[]; + }; }; const PINNED_VERSION = fixture.cliVersion; @@ -54,7 +64,15 @@ interface VerificationOptions { /** What `agent-tank --version` reports from inside the image. */ bundledVersion?: 'pinned' | 'older' | 'missing'; /** What the Agent Tank run prints. */ - usage?: 'canonical' | 'banner' | 'error' | 'empty-usage' | 'missing-provider' | 'unparseable' | 'crash'; + usage?: + | 'canonical' + | 'legacy-map' + | 'banner' + | 'error' + | 'empty-usage' + | 'missing-provider' + | 'unparseable' + | 'crash'; /** What the read-only mount probe reports. */ mounts?: 'read-only' | 'writable' | 'silent' | 'root-user'; /** Simulates a mount that was not actually read-only. */ @@ -135,20 +153,34 @@ if (evidence === 'unparseable') { process.exit(0); } -const status = {}; +// Agent Tank 0.10.0 canonical envelope: { providers: [...] }, keyed by the +// configured id, with the 0.9.x payload under \`raw\`. +const providers = []; for (const provider of mounted) { if (evidence === 'missing-provider' && provider === 'codex') continue; - const reported = JSON.parse(JSON.stringify(fixture.status[provider])); + const canonical = fixture.status.providers.find(entry => entry.id === provider); + const reported = JSON.parse(JSON.stringify(canonical)); if (evidence === 'error') { - reported.usage = {}; + reported.status = 'error'; reported.error = 'Timeout waiting for usage data'; + reported.windows = []; + reported.raw.usage = {}; + reported.raw.error = reported.error; } - if (evidence === 'empty-usage') reported.usage = {}; - status[provider] = reported; + if (evidence === 'empty-usage') { + reported.windows = []; + reported.raw.usage = {}; + } + providers.push(reported); } +// 0.9.x printed a map keyed by provider id holding what is now \`raw\`. +const document = evidence === 'legacy-map' + ? Object.fromEntries(providers.map(entry => [entry.id, entry.raw])) + : { providers }; + const banner = evidence === 'banner' ? 'Agent Tank starting\\u2026\\n' : ''; -process.stdout.write(\`\${banner}\${JSON.stringify(status, null, 2)}\\n\`); +process.stdout.write(\`\${banner}\${JSON.stringify(document, null, 2)}\\n\`); `; function runVerification(options: VerificationOptions = {}): VerificationRun { @@ -419,6 +451,26 @@ test('leading banner output is tolerated exactly as the production parser tolera assert.match(result.stdout, /claude: session\.percent=42/); }); +test('the legacy 0.9.x provider map is still accepted', () => { + const result = runVerification({ usage: 'legacy-map' }); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(result.stdout, /claude: session\.percent=42, weeklyAll\.percent=31/); + assert.match(result.stdout, /codex: fiveHour\.percentUsed=9/); + assert.match(result.stdout, /agy: models\[Gemini 3\.8 Flash\]\.percentUsed=16/); + // Canonical windows only exist in the 0.10.0 envelope. + assert.doesNotMatch(result.stdout, /windows\[/); +}); + +test('canonical windows count as usage evidence and Antigravity is matched by id', () => { + const result = runVerification(); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(result.stdout, /claude: session\.percent=42, weeklyAll\.percent=31, windows\[session\/0\]\.used_percent=42/); + // The canonical provider is "antigravity"; the configured id "agy" is the key. + assert.match(result.stdout, /agy: models\[Gemini 3\.8 Flash\]\.percentUsed=16.*windows\[session\/Gemini 3\.8 Flash\]\.used_percent=16/); +}); + test('a credential mount that is not read-only fails the verification', async t => { const cases = [ ['writable mount', { mounts: 'writable' as const }, /a mounted credential directory was writable inside the container/], diff --git a/test/fixtures/agent-tank-verifier-pinned-0.10.0.json b/test/fixtures/agent-tank-verifier-pinned-0.10.0.json new file mode 100644 index 0000000000..871f252740 --- /dev/null +++ b/test/fixtures/agent-tank-verifier-pinned-0.10.0.json @@ -0,0 +1,163 @@ +{ + "cliVersion": "0.10.0", + "status": { + "providers": [ + { + "id": "claude", + "provider": "claude", + "plan": null, + "status": "ok", + "last_updated": "2026-09-27T12:00:00.000Z", + "error": null, + "windows": [ + { + "type": "session", + "label": "Current session", + "model": null, + "used_percent": 42, + "remaining_percent": 58, + "resets_at": "2026-09-27T12:12:44.000Z", + "resets_in_seconds": 759, + "pace": null + }, + { + "type": "weekly", + "label": "Current week (all models)", + "model": null, + "used_percent": 31, + "remaining_percent": 69, + "resets_at": "2026-10-01T17:12:44.000Z", + "resets_in_seconds": 364359, + "pace": null + } + ], + "context_window": null, + "credits": null, + "raw": { + "name": "claude", + "usage": { + "session": { + "label": "Current session", + "percent": 42, + "resetsInSeconds": 764 + }, + "weeklyAll": { + "label": "Current week (all models)", + "percent": 31, + "resetsInSeconds": 364364 + } + }, + "lastUpdated": "2026-09-27T12:00:00.000Z", + "error": null, + "id": "claude" + } + }, + { + "id": "codex", + "provider": "codex", + "plan": null, + "status": "ok", + "last_updated": "2026-09-27T12:00:01.000Z", + "error": null, + "windows": [ + { + "type": "five_hour", + "label": "5h limit", + "model": null, + "used_percent": 9, + "remaining_percent": 91, + "resets_at": "2026-09-27T14:35:01.000Z", + "resets_in_seconds": 9296, + "pace": null + }, + { + "type": "weekly", + "label": "Weekly limit", + "model": null, + "used_percent": 22, + "remaining_percent": 78, + "resets_at": "2026-10-01T19:20:01.000Z", + "resets_in_seconds": 371996, + "pace": null + } + ], + "context_window": null, + "credits": null, + "raw": { + "name": "codex", + "usage": { + "fiveHour": { + "label": "5h limit", + "percentLeft": 91, + "percentUsed": 9, + "resetsInSeconds": 9300 + }, + "weekly": { + "label": "Weekly limit", + "percentLeft": 78, + "percentUsed": 22, + "resetsInSeconds": 372000 + } + }, + "lastUpdated": "2026-09-27T12:00:01.000Z", + "error": null, + "id": "codex" + } + }, + { + "id": "agy", + "provider": "antigravity", + "plan": null, + "status": "ok", + "last_updated": "2026-09-27T12:00:02.000Z", + "error": null, + "windows": [ + { + "type": "session", + "label": "Gemini 3.8 Flash", + "model": "Gemini 3.8 Flash", + "used_percent": 16, + "remaining_percent": 84, + "resets_at": null, + "resets_in_seconds": null, + "pace": null + }, + { + "type": "session", + "label": "Claude Sonnet 4.6", + "model": "Claude Sonnet 4.6", + "used_percent": 0, + "remaining_percent": 100, + "resets_at": null, + "resets_in_seconds": null, + "pace": null + } + ], + "context_window": null, + "credits": null, + "raw": { + "name": "agy", + "usage": { + "models": [ + { + "model": "Gemini 3.8 Flash", + "usageLeft": 84, + "percentUsed": 16, + "cycle": "daily" + }, + { + "model": "Claude Sonnet 4.6", + "usageLeft": 100, + "percentUsed": 0, + "cycle": "daily" + } + ] + }, + "lastUpdated": "2026-09-27T12:00:02.000Z", + "error": null, + "id": "agy" + } + } + ] + } +} diff --git a/test/fixtures/agent-tank-verifier-pinned-0.9.11.json b/test/fixtures/agent-tank-verifier-pinned-0.9.11.json deleted file mode 100644 index 5ac409a6fd..0000000000 --- a/test/fixtures/agent-tank-verifier-pinned-0.9.11.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "cliVersion": "0.9.11", - "status": { - "claude": { - "name": "claude", - "usage": { - "session": { - "label": "Current session", - "percent": 42, - "resetsInSeconds": 764 - }, - "weeklyAll": { - "label": "Current week (all models)", - "percent": 31, - "resetsInSeconds": 364364 - } - }, - "lastUpdated": "2026-09-27T12:00:00.000Z", - "error": null - }, - "codex": { - "name": "codex", - "usage": { - "fiveHour": { - "label": "5h limit", - "percentLeft": 91, - "percentUsed": 9, - "resetsInSeconds": 9300 - }, - "weekly": { - "label": "Weekly limit", - "percentLeft": 78, - "percentUsed": 22, - "resetsInSeconds": 372000 - } - }, - "lastUpdated": "2026-09-27T12:00:01.000Z", - "error": null - }, - "agy": { - "name": "agy", - "usage": { - "models": [ - { - "model": "Gemini 3.8 Flash", - "usageLeft": 84, - "percentUsed": 16, - "cycle": "daily" - }, - { - "model": "Claude Sonnet 4.6", - "usageLeft": 100, - "percentUsed": 0, - "cycle": "daily" - } - ] - }, - "lastUpdated": "2026-09-27T12:00:02.000Z", - "error": null - } - } -} diff --git a/test/previewAgentImage.test.mjs b/test/previewAgentImage.test.mjs index 24902d2e2e..7af6f3f4e6 100644 --- a/test/previewAgentImage.test.mjs +++ b/test/previewAgentImage.test.mjs @@ -34,12 +34,12 @@ const labels = (overrides = {}) => ({ 'dev.propr.agent.antigravity.version': '1.2.4', 'dev.propr.agent.opencode.version': '1.18.31', 'dev.propr.agent.vibe.version': '2.25.8', - 'dev.propr.agent-tank.version': '0.9.11', + 'dev.propr.agent-tank.version': '0.10.0', ...overrides, }); const cliVersions = () => ({ - claude: '2.1.284', codex: '0.160.0', antigravity: '1.2.4', opencode: '1.18.31', vibe: '2.25.8', 'agent-tank': '0.9.11', + claude: '2.1.284', codex: '0.160.0', antigravity: '1.2.4', opencode: '1.18.31', vibe: '2.25.8', 'agent-tank': '0.10.0', }); const smokeEvidence = (overrides = {}) => ({ From 851e99de150f1b888621ae9f458e9f8d8d6935fa Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:00:31 +0000 Subject: [PATCH 3/7] feat(ui): Agent Tank usage history chart in Analytics and per-metric sidebar overlay --- CHANGELOG.md | 14 + docs/docs/operations/agent-tank.md | 12 +- .../api/test/agentTankHistoryRoute.test.ts | 167 ++++++++ propr-ui/e2e/agent-tank-usage-history.pw.ts | 168 ++++++++ propr-ui/src/components/AgentTankSidebar.tsx | 6 +- .../Analytics/AgentUsageHistory.test.tsx | 156 ++++++++ .../Analytics/AgentUsageHistory.tsx | 229 +++++++++++ .../Analytics/AgentUsageHistoryChart.tsx | 117 ++++++ .../Analytics/AgentUsageHistoryTable.tsx | 44 +++ .../components/Analytics/usageHistoryView.ts | 114 ++++++ .../usageHistory/UsageMetricHistoryChart.tsx | 111 ++++++ .../UsageMetricHistoryOverlay.test.tsx | 229 +++++++++++ .../UsageMetricHistoryOverlay.tsx | 285 ++++++++++++++ .../usageHistory/usageHistoryModel.ts | 78 +++- .../usageHistory/useUsageMetricHistory.ts | 80 ++++ propr-ui/src/pages/AnalyticsPage.test.tsx | 5 + propr-ui/src/pages/AnalyticsPage.tsx | 3 + test/agentTankCanonicalShape.test.ts | 360 ++++++++++++++++++ test/agentTankUsageHistory.test.ts | 133 +++++++ 19 files changed, 2308 insertions(+), 3 deletions(-) create mode 100644 packages/api/test/agentTankHistoryRoute.test.ts create mode 100644 propr-ui/e2e/agent-tank-usage-history.pw.ts create mode 100644 propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx create mode 100644 propr-ui/src/components/Analytics/AgentUsageHistory.tsx create mode 100644 propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx create mode 100644 propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx create mode 100644 propr-ui/src/components/Analytics/usageHistoryView.ts create mode 100644 propr-ui/src/components/usageHistory/UsageMetricHistoryChart.tsx create mode 100644 propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx create mode 100644 propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx create mode 100644 propr-ui/src/components/usageHistory/useUsageMetricHistory.ts create mode 100644 test/agentTankCanonicalShape.test.ts create mode 100644 test/agentTankUsageHistory.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c271965df6..d4febd5e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Agent Tank usage history**: a unified usage chart on the Analytics page + plots every provider's session, weekly and per-model metrics over the last + 24 hours to 14 days, with provider and metric toggles. Hovering (or tapping) + a metric in the sidebar Usage section opens a chart of that metric over its + last two periods: 10 hours for a 5-hour session, 14 days for a weekly limit. + History comes from an external Agent Tank 0.10+ daemon when it keeps one. + Otherwise ProPR records its own, in a new `agent_tank_usage_history` table + kept for 15 days, served by `GET /api/config/agent-tank/history`. +- **Agent Tank 0.10.0**: the bundled CLI moves to 0.10.0, and ProPR reads its + canonical `{ "providers": [...] }` response format as well as the 0.9.x map, + so an external daemon can be upgraded independently. Antigravity's separately + tracked **Gemini 4 Argon** quota appears in the sidebar under its own + *Gemini 4 Argon* group. + - **Epic merge progress**: when a child PR merges into an epic branch, ProPR updates a single "x of y PRs merged" tracking comment on the epic PR in place, and posts a one-time *Epic fully merged* confirmation comment once diff --git a/docs/docs/operations/agent-tank.md b/docs/docs/operations/agent-tank.md index ec49b974aa..172a2ec46c 100644 --- a/docs/docs/operations/agent-tank.md +++ b/docs/docs/operations/agent-tank.md @@ -16,7 +16,7 @@ It is **not** an API-spend tracker. For pay-as-you-go API key billing or per-req |---|---|---| | Claude (`claude`) | Runs the CLI's `/usage` command, or the Anthropic OAuth usage API in `--claude-api` mode | Current session, weekly (all models), and per-model weekly windows (Sonnet/Opus/Haiku) | | Codex (`codex`) | JSON-RPC `account/rateLimits/read`, falling back to `/status` | 5-hour session limit and weekly limit | -| Antigravity (`agy`) | Runs the CLI's `/usage` command | Per-model quota availability and reset windows | +| Antigravity (`agy`) | Runs the CLI's `/usage` command | Per-model and per-pool quota availability and reset windows, including the separately tracked **Gemini 4 Argon** weekly quota | OpenCode and Vibe are not tracked: neither CLI exposes a subscription usage endpoint for Agent Tank to read. @@ -140,6 +140,8 @@ Because this is a backend setting rather than a stack container, `propr tank` ta ## What You See Once Enabled - **Sidebar usage bars.** A per-provider Usage section shows each provider's windows (Claude session/weekly, Codex 5-hour/weekly, Antigravity per-model) as color-coded bars with reset countdowns, refreshed every 60 seconds, with a manual refresh button. +- **Usage history overlay.** Hover a metric row in the sidebar (tap it on a touch screen) to see how that metric moved over its last two periods: a 5-hour session shows the last 10 hours, a weekly limit the last 14 days. A dashed marker separates the previous period from the current one. +- **Usage chart in Analytics.** The Analytics page plots every provider's metrics on one 0–100% usage chart over the last 24 hours to 14 days, with toggles to show only some providers or metrics, and a table view of each series' latest, lowest and highest value. - **Per-call usage deltas.** Around each agent run ProPR snapshots usage before and after the call, computes the delta per metric, and stores it next to the [LLM Log](./metrics.md) entry. The task detail context strip shows a compact session/weekly delta chip for the run. - **Agents cost gate.** Before an unattended [agent](../features/agents.md#cost-control) run (scheduled, API, CLI or MCP trigger, or an `auto` acting step) starts, ProPR compares the agent provider's session and weekly usage with the instance's pause threshold (`agent_run_usage_pause_percent`, default 90%). Over the threshold, the run is deferred until the session window resets or skipped. The **Run now** button on the Agents page warns first when the provider is over the threshold. The gate only reads the snapshot Agent Tank already has (at most an hour old) and never starts a refresh or a bundled container. - **Capacity in your metrics.** Provider capacity pressure becomes a first-class signal alongside cost and cycle time — see [Metrics](./metrics.md). @@ -148,6 +150,14 @@ In bundled mode a baseline must be fresh when acquired near the start of the cal {/* SCREENSHOT PLACEHOLDER (P3 — needs a running Agent Tank instance; interim: the site's ui-agent-tank.png): Capture the sidebar Usage section with Agent Tank enabled, showing provider rows (for example Claude and Codex) with colored usage bars and percentages, and one provider expanded to show its session and weekly metrics. Requires a running Agent Tank instance configured in Settings. */} +### Where Usage History Comes From + +ProPR records a point for every metric each time it reads Agent Tank (the sidebar, the 60-second usage watcher while anyone is connected, and the MCP usage tool), storing a changed value at once and an unchanged one at most every 10 minutes. It keeps 15 days, enough for two weekly periods. Bundled mode relies on this record alone, because its Agent Tank container is discarded after every run. + +In external mode, ProPR prefers the daemon's own history when the daemon is Agent Tank 0.10 or newer with history enabled (`GET /history/:id` reports `store: "sqlite"`): the daemon keeps refreshing while no ProPR tab is open, so its history has fewer gaps. An older daemon, or one with `--no-history`, falls back to ProPR's record. Both use the same metric keys, so switching modes doesn't split a series. + +ProPR reads both Agent Tank response formats: the 0.10 `{ "providers": [...] }` format and the 0.9.x map keyed by agent. An external daemon can be upgraded before or after ProPR. The bundled image ships Agent Tank 0.10.0. + ## Best-Effort By Design The integration never delays starting the model call. Returning its result may wait for the bounded post-call usage refresh. If Agent Tank is disabled, unreachable, slow, or — in bundled mode — the agent image is missing or the container fails: diff --git a/packages/api/test/agentTankHistoryRoute.test.ts b/packages/api/test/agentTankHistoryRoute.test.ts new file mode 100644 index 0000000000..163027d804 --- /dev/null +++ b/packages/api/test/agentTankHistoryRoute.test.ts @@ -0,0 +1,167 @@ +/** + * `GET /api/config/agent-tank/history` query parsing and handler wiring. + * + * The usage charts send `?since=&until=` as epoch milliseconds or ISO 8601 and + * an optional comma-separated `?agent=` list. A range the store cannot answer - + * unreadable or inverted - must be rejected with 400 instead of silently + * returning an empty chart, and a range reaching past retention is clamped. + */ + +import { mock, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AGENT_TANK_HISTORY_RETENTION_DAYS } from '@propr/shared'; + +process.env.NODE_ENV = 'test'; + +const { hasAgentTankStatuses, hasUsableAgentTankStatuses, parseAgentTankStatusMap } = + await import('../../core/src/services/agentTankTypes.js'); + +const historyCalls: unknown[] = []; +let historyFailure: Error | undefined; + +await mock.module('@propr/core', { + namedExports: { + loadAgentTankSettings: async () => ({ mode: 'bundled', enabled: true, url: 'http://0.0.0.0:3456' }), + saveAgentTankSettings: async () => {}, + refreshBundledStatuses: async () => undefined, + getAgentTankStatuses: async () => undefined, + canRunBundledAgentTank: async () => false, + observeAgentTankUsageSnapshot: async () => {}, + getEventPublisher: () => ({ publishUsageUpdate: async () => {} }), + hasAgentTankStatuses, + hasUsableAgentTankStatuses, + parseAgentTankStatusMap, + readAgentTankUsageHistory: async (query: unknown) => { + historyCalls.push(query); + if (historyFailure) throw historyFailure; + return { enabled: true, source: 'propr', agents: [] }; + }, + }, +}); + +const { createAgentTankRoutes, parseHistoryQuery } = await import('../routes/configRoutesAgentTank.js'); + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = Date.UTC(2026, 9, 17, 12, 0, 0); + +function responseSpy() { + return { + statusCode: 200, + body: undefined as Record | undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: Record) { + this.body = payload; + return this; + }, + }; +} + +test('an empty query covers the last 24 hours with no filters', () => { + assert.deepEqual(parseHistoryQuery({}, NOW), { since: NOW - DAY_MS, until: NOW }); +}); + +test('empty-string bounds fall back to the defaults', () => { + assert.deepEqual(parseHistoryQuery({ since: '', until: '' }, NOW), { since: NOW - DAY_MS, until: NOW }); +}); + +test('since defaults to one day before an explicit until', () => { + const until = NOW - 2 * DAY_MS; + assert.deepEqual(parseHistoryQuery({ until: String(until) }, NOW), { since: until - DAY_MS, until }); +}); + +test('accepts epoch milliseconds and ISO 8601 interchangeably', () => { + const since = NOW - 3 * 60 * 60 * 1000; + assert.deepEqual( + parseHistoryQuery({ since: String(since), until: new Date(NOW).toISOString() }, NOW), + { since, until: NOW }, + ); + assert.deepEqual( + parseHistoryQuery({ since: new Date(since).toISOString(), until: String(NOW) }, NOW), + { since, until: NOW }, + ); +}); + +test('unreadable timestamps are an error', () => { + for (const query of [{ since: 'yesterday' }, { until: 'not-a-date' }, { since: ['1', '2'] }, { until: 5 }]) { + const result = parseHistoryQuery(query, NOW); + assert.ok('error' in result, `expected an error for ${JSON.stringify(query)}`); + assert.match(result.error, /epoch milliseconds or ISO 8601/); + } +}); + +test('since after until is an error', () => { + const result = parseHistoryQuery({ since: String(NOW), until: String(NOW - 1) }, NOW); + assert.deepEqual(result, { error: 'since must not be after until' }); +}); + +test('since equal to until is a valid single-instant range', () => { + assert.deepEqual(parseHistoryQuery({ since: String(NOW), until: String(NOW) }, NOW), { since: NOW, until: NOW }); +}); + +test('a range reaching past retention is clamped to what history keeps', () => { + const result = parseHistoryQuery({ since: '0' }, NOW); + assert.deepEqual(result, { since: NOW - AGENT_TANK_HISTORY_RETENTION_DAYS * DAY_MS, until: NOW }); +}); + +test('agent lists are split, trimmed and stripped of empty entries', () => { + const result = parseHistoryQuery({ agent: ' claude, ,codex,antigravity ,' }, NOW); + assert.deepEqual(result, { since: NOW - DAY_MS, until: NOW, agents: ['claude', 'codex', 'antigravity'] }); +}); + +test('an agent list with no names and an empty metric add no filters', () => { + assert.deepEqual(parseHistoryQuery({ agent: ' , ', metric: '' }, NOW), { since: NOW - DAY_MS, until: NOW }); +}); + +test('a metric filter is passed through', () => { + const result = parseHistoryQuery({ agent: 'antigravity', metric: 'model:Gemini 4 Argon Weekly Limit' }, NOW); + assert.deepEqual(result, { + since: NOW - DAY_MS, + until: NOW, + agents: ['antigravity'], + metric: 'model:Gemini 4 Argon Weekly Limit', + }); +}); + +test('the handler answers 400 for an invalid range without reading history', async () => { + historyCalls.length = 0; + const res = responseSpy(); + await createAgentTankRoutes().getAgentTankHistory( + { query: { since: 'later', until: 'earlier' } } as never, + res as never, + ); + assert.equal(res.statusCode, 400); + assert.match(String(res.body?.error), /epoch milliseconds or ISO 8601/); + assert.equal(historyCalls.length, 0); +}); + +test('the handler forwards the parsed query and returns the history answer', async () => { + historyCalls.length = 0; + const res = responseSpy(); + const since = Date.now() - 60 * 60 * 1000; + const until = Date.now(); + await createAgentTankRoutes().getAgentTankHistory( + { query: { since: String(since), until: String(until), agent: 'claude' } } as never, + res as never, + ); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { enabled: true, source: 'propr', agents: [] }); + assert.deepEqual(historyCalls, [{ since, until, agents: ['claude'] }]); +}); + +test('the handler answers 500 when history cannot be read', async () => { + historyFailure = new Error('database is locked'); + const originalError = console.error; + console.error = () => {}; + try { + const res = responseSpy(); + await createAgentTankRoutes().getAgentTankHistory({ query: {} } as never, res as never); + assert.equal(res.statusCode, 500); + assert.deepEqual(res.body, { error: 'Failed to read Agent Tank usage history' }); + } finally { + console.error = originalError; + historyFailure = undefined; + } +}); diff --git a/propr-ui/e2e/agent-tank-usage-history.pw.ts b/propr-ui/e2e/agent-tank-usage-history.pw.ts new file mode 100644 index 0000000000..08f8dec7d7 --- /dev/null +++ b/propr-ui/e2e/agent-tank-usage-history.pw.ts @@ -0,0 +1,168 @@ +/** + * Agent Tank usage history: the Analytics usage chart and the sidebar's + * per-metric overlay, against stubbed HTTP like the dashboard layout suites. + */ + +import { expect, test, type Page, type Route } from '@playwright/test'; +import { fixture, now } from './dashboard-sections.fixture'; + +const HOUR = 3_600_000; +const PREVIEWS = '../.propr/previews'; + +const usage = { enabled: true, agents: { + claude: { name: 'claude', usage: { session: { percent: 34, resetsIn: '2h 10m' }, weeklyAll: { percent: 61, resetsIn: '3d 4h' }, weeklyFable: { percent: 44, resetsIn: '3d 4h' } } }, + codex: { name: 'codex', usage: { fiveHour: { percentUsed: 12, resetsIn: '1h 05m' }, weekly: { percentUsed: 47, resetsIn: '4d 2h' } } }, + antigravity: { name: 'antigravity', usage: { models: [ + { model: 'Gemini · Weekly Limit Remaining', group: 'Gemini', cycle: 'weekly', percentUsed: 22, resetsIn: '120h 4m' }, + { model: 'Gemini 4 Argon Weekly Limit', group: 'Gemini 4 Argon', cycle: 'weekly', percentUsed: 38, resetsIn: '88h 30m' }, + ] } }, +} }; + +/** A quota that climbs within each period and drops back to zero at every reset. */ +function sawtooth(periodHours: number, peak: number, until: number, hours: number, resetOffsetHours = 0) { + const points = []; + for (let t = until - hours * HOUR; t <= until; t += Math.max(HOUR / 4, (hours * HOUR) / 240)) { + const phase = (((t / HOUR + resetOffsetHours) % periodHours) + periodHours) % periodHours / periodHours; + const wobble = Math.sin(t / (2.7 * HOUR)) * 3; + points.push({ t: new Date(t).toISOString(), percent: Math.max(0, Math.min(100, Math.round((phase * peak + wobble) * 10) / 10)) }); + } + return points; +} + +const SERIES: Record> = { + claude: [ + { key: 'session', label: 'Current session', cycle: 'session', period: 5, peak: 78, offset: 2.2 }, + { key: 'weeklyAll', label: 'Current week (all models)', cycle: 'weekly', period: 168, peak: 92, offset: 92 }, + { key: 'weeklyFable', label: 'Current week (Fable)', cycle: 'weekly', period: 168, peak: 66, offset: 92 }, + ], + codex: [ + { key: 'fiveHour', label: '5h limit', cycle: 'five_hour', period: 5, peak: 41, offset: 3.9 }, + { key: 'weekly', label: 'Weekly limit', cycle: 'weekly', period: 168, peak: 70, offset: 70 }, + ], + antigravity: [ + { key: 'model:Gemini · Weekly Limit Remaining', label: 'Gemini · Weekly Limit Remaining', cycle: 'weekly', period: 168, peak: 35, offset: 48 }, + { key: 'model:Gemini 4 Argon Weekly Limit', label: 'Gemini 4 Argon Weekly Limit', cycle: 'weekly', period: 168, peak: 58, offset: 80 }, + ], +}; + +async function stubHistory(page: Page, requests: URL[] = []) { + await page.route('**/api/config/agent-tank/usage', route => route.fulfill({ json: usage })); + await page.route('**/api/config/agent-tank/history?**', (route: Route) => { + const url = new URL(route.request().url()); + requests.push(url); + const since = Number(url.searchParams.get('since')); + const until = Number(url.searchParams.get('until') ?? now); + const hours = (until - since) / HOUR; + const agents = url.searchParams.get('agent')?.split(',') ?? Object.keys(SERIES); + const metric = url.searchParams.get('metric'); + return route.fulfill({ json: { enabled: true, source: 'propr', retentionDays: 15, agents: agents.map(agent => ({ + agent, + series: (SERIES[agent] ?? []).filter(entry => !metric || entry.key === metric).map(entry => ({ + key: entry.key, label: entry.label, model: null, cycle: entry.cycle, + points: sawtooth(entry.period, entry.peak, until, hours, entry.offset), + })), + })) } }); + }); + await page.route('**/api/stats/{tasks,repositories,overview,review-scores}*', route => { + const pathname = new URL(route.request().url()).pathname; + const bodies: Record = { + '/api/stats/tasks': { dailyCounts: [], statusDistribution: [], avgProcessingTime: [], summary: { total: 0, completed: 0, failed: 0 } }, + '/api/stats/repositories': { repositories: [] }, + '/api/stats/review-scores': { period: '30d', repository: 'all', prs_scored: 0, scores_recorded: 0, models: [] }, + '/api/stats/overview': { tasks: { completed: 0, planned: 0, pr_iterations_avg: 0, merged_prs: 0, total_followups: 0 }, + usage: { total_tokens: 0, input_tokens: 0, output_tokens: 0, total_cost_usd: 0, models: {} }, model_usage: [], system: { repos_indexed: 0 } }, + }; + return route.fulfill({ json: bodies[pathname] ?? {} }); + }); +} + +test('the Analytics page plots every agent and metric, and narrows to a few', async ({ page }) => { + await fixture(page, { width: 1440, height: 900 }); + const requests: URL[] = []; + await stubHistory(page, requests); + await page.goto('/analytics'); + + const pane = page.getByTestId('analytics-usage-history'); + await expect(pane.getByTestId('agent-usage-history-chart').locator('.recharts-line')).toHaveCount(7); + await expect(pane.getByRole('button', { name: /Antigravity · Gemini 4 Argon Weekly/ })).toBeVisible(); + + await pane.getByRole('button', { name: 'Codex', exact: true }).click(); + await expect(pane.getByTestId('agent-usage-history-chart').locator('.recharts-line')).toHaveCount(5); + await expect(page).toHaveURL(/usageHideAgents=codex/); + await pane.getByRole('button', { name: 'Show all' }).click(); + + await pane.getByTestId('agent-usage-history-chart').hover({ position: { x: 900, y: 120 } }); + await expect(page.locator('.recharts-tooltip-wrapper')).toContainText('Claude · Session'); + await page.screenshot({ path: `${PREVIEWS}/analytics-usage-desktop.png`, clip: await pane.boundingBox() ?? undefined }); +}); + +test('the Analytics usage chart fits a phone', async ({ page }) => { + await fixture(page, { width: 390, height: 844 }); + await stubHistory(page); + await page.goto('/analytics?usageRange=3d'); + + const pane = page.getByTestId('analytics-usage-history'); + await expect(pane.getByTestId('agent-usage-history-chart').locator('.recharts-line')).toHaveCount(7); + const width = await page.evaluate(() => document.documentElement.scrollWidth); + expect(width).toBeLessThanOrEqual(390); + await pane.evaluate(element => element.scrollIntoView({ block: 'start' })); + await page.screenshot({ path: `${PREVIEWS}/analytics-usage-mobile.png` }); +}); + +test('hovering a sidebar metric charts its last two periods', async ({ page }) => { + await fixture(page, { width: 1440, height: 900 }); + const requests: URL[] = []; + await stubHistory(page, requests); + await page.goto('/analytics'); + + const sidebarUsage = page.locator('aside, nav').getByRole('button', { name: /^Claude/ }).first(); + await sidebarUsage.click(); + const session = page.getByRole('button', { name: /^Session/ }).first(); + await session.hover(); + const overlay = page.getByRole('dialog', { name: 'Claude · Session usage history' }); + await expect(overlay).toBeVisible(); + await expect(overlay).toContainText('Last 10 hours'); + await expect(overlay.getByTestId('usage-metric-history-chart').locator('.recharts-area')).toHaveCount(1); + const query = requests.find(url => url.searchParams.get('metric') === 'session')!; + expect(Number(query.searchParams.get('until')) - Number(query.searchParams.get('since'))).toBe(10 * HOUR); + + await overlay.getByTestId('usage-metric-history-chart').hover({ position: { x: 200, y: 60 } }); + await page.waitForTimeout(100); + await page.screenshot({ path: `${PREVIEWS}/sidebar-usage-overlay-desktop.png`, clip: { x: 0, y: 380, width: 640, height: 520 } }); + + await page.keyboard.press('Escape'); + await expect(overlay).toBeHidden(); +}); + +test('tapping a weekly metric in the mobile usage sheet charts two weeks', async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); + const page = await context.newPage(); + await fixture(page, { width: 390, height: 844 }); + const requests: URL[] = []; + await stubHistory(page, requests); + await page.goto('/analytics'); + + await page.getByRole('button', { name: 'More' }).tap(); + await page.getByRole('button', { name: /API & quota usage/ }).tap(); + const sheet = page.getByRole('dialog', { name: 'API & quota usage' }); + await expect(sheet.getByText('Gemini 4 Argon', { exact: true })).toBeVisible(); + // The Argon pool's only window row, under its own sub-header. + const argon = sheet.getByRole('button', { name: /^Weekly\s*38%/ }); + await argon.tap(); + + const overlay = page.getByRole('dialog', { name: /Antigravity · Gemini 4 Argon Weekly Limit usage history/ }); + await expect(overlay).toBeVisible(); + await expect(overlay).toContainText('Last 14 days'); + const query = requests.find(url => url.searchParams.get('metric') === 'model:Gemini 4 Argon Weekly Limit')!; + expect(Number(query.searchParams.get('until')) - Number(query.searchParams.get('since'))).toBe(14 * 24 * HOUR); + await expect(overlay.getByRole('button', { name: 'Close usage history' })).toBeVisible(); + await expect(overlay.getByTestId('usage-metric-history-chart')).toBeVisible(); + // A tap inside the overlay must not dismiss the sheet behind it. + await overlay.getByTestId('usage-metric-history-chart').tap(); + await expect(sheet).toBeVisible(); + await expect(overlay).toBeVisible(); + + // Screenshots reset touch emulation, so capture last. + await page.screenshot({ path: `${PREVIEWS}/sidebar-usage-overlay-mobile.png` }); + await context.close(); +}); diff --git a/propr-ui/src/components/AgentTankSidebar.tsx b/propr-ui/src/components/AgentTankSidebar.tsx index 608135fad5..0476babff3 100644 --- a/propr-ui/src/components/AgentTankSidebar.tsx +++ b/propr-ui/src/components/AgentTankSidebar.tsx @@ -14,6 +14,7 @@ import { } from './agentTankMetrics'; import { SIDEBAR_ICON_STROKE_WIDTH, SIDEBAR_ICON_STROKE_CLASS } from './icons/sidebarIconStroke'; import { useLiveResource } from '../hooks/useLiveResource'; +import { UsageMetricHistoryTrigger } from './usageHistory/UsageMetricHistoryOverlay'; // Which provider rows are expanded is a preference, not session state: someone // watching Claude's weekly quotas wants the same rows open after a reload. @@ -193,7 +194,10 @@ export const AgentRow: React.FC = ({ agent, expanded, onToggle }) }`} /> {child.metric ? ( - + // Hovering or tapping a metric opens its last two periods of history. + + + ) : (
diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx new file mode 100644 index 0000000000..e83ef5e9dd --- /dev/null +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAgentTankHistory, type AgentTankHistoryResponse } from '../../api/revertApi'; +import AgentUsageHistory from './AgentUsageHistory'; +import { buildChartRows, parseUsageHistoryView, toPlottedSeries, writeUsageHistoryView } from './usageHistoryView'; + +vi.mock('../../api/revertApi', () => ({ getAgentTankHistory: vi.fn() })); + +const mockHistory = vi.mocked(getAgentTankHistory); +const HOUR = 3_600_000; +const DAY = 24 * HOUR; + +function series(key: string, label: string, percents: number[]) { + const now = Date.now(); + return { + key, label, model: null, cycle: 'session', + points: percents.map((percent, index) => ({ t: new Date(now - (percents.length - index) * HOUR).toISOString(), percent })), + }; +} + +const response = (): AgentTankHistoryResponse => ({ + enabled: true, + source: 'propr', + agents: [ + { agent: 'codex', series: [series('fiveHour', '5h limit', [3, 9, 12]), series('weekly', 'Weekly limit', [40, 41, 42])] }, + { agent: 'claude', series: [series('session', 'Current session', [10, 30, 17]), series('weeklyAll', 'Current week (all models)', [50, 55, 58])] }, + { agent: 'antigravity', series: [series('model:Gemini 4 Argon Weekly Limit', 'Gemini 4 Argon Weekly Limit', [20, 25, 30])] }, + ], +}); + +let location = ''; +const LocationProbe = () => { + location = useLocation().search; + return null; +}; + +function renderPane(search = '') { + return render( + + + + , + ); +} + +beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}); + +beforeEach(() => { + mockHistory.mockReset(); + mockHistory.mockResolvedValue(response()); +}); + +describe('AgentUsageHistory', () => { + it('plots every agent and metric by default, over the last 7 days', async () => { + renderPane(); + + expect(await screen.findByTestId('agent-usage-history-chart')).toBeInTheDocument(); + const query = mockHistory.mock.calls[0][0]; + expect((query.until ?? 0) - query.since).toBe(7 * DAY); + expect(query.agents).toBeUndefined(); + + const filters = screen.getByRole('group', { name: 'Filter usage history' }); + // Providers in sidebar order, then one chip per series with its latest value. + for (const name of ['Claude', 'Codex', 'Antigravity']) { + expect(within(filters).getByRole('button', { name, pressed: true })).toBeInTheDocument(); + } + expect(within(filters).getByRole('button', { name: /Claude · Session 17%/, pressed: true })).toBeInTheDocument(); + expect(within(filters).getByRole('button', { name: /Antigravity · Gemini 4 Argon Weekly 30%/ })).toBeInTheDocument(); + }); + + it('hides an agent and a metric, and keeps the choice in the URL', async () => { + renderPane(); + const filters = await screen.findByRole('group', { name: 'Filter usage history' }); + + fireEvent.click(within(filters).getByRole('button', { name: 'Codex' })); + expect(within(filters).queryByRole('button', { name: /Codex · Session/ })).not.toBeInTheDocument(); + fireEvent.click(within(filters).getByRole('button', { name: /Claude · Weekly/ })); + expect(within(filters).getByRole('button', { name: /Claude · Weekly/, pressed: false })).toBeInTheDocument(); + + expect(new URLSearchParams(location).get('usageHideAgents')).toBe('codex'); + expect(new URLSearchParams(location).get('usageHide')).toBe('claude:weeklyAll'); + + fireEvent.click(screen.getByRole('button', { name: 'Show all' })); + expect(location).toBe(''); + }); + + it('reads a new range and restores filters from the URL', async () => { + renderPane('?usageRange=24h&usageHide=claude:session'); + await screen.findByTestId('agent-usage-history-chart'); + expect((mockHistory.mock.calls[0][0].until ?? 0) - mockHistory.mock.calls[0][0].since).toBe(DAY); + expect(screen.getByRole('button', { name: /Claude · Session/, pressed: false })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Quota usage, last 14 days' })); + await waitFor(() => expect(mockHistory).toHaveBeenCalledTimes(2)); + expect((mockHistory.mock.calls[1][0].until ?? 0) - mockHistory.mock.calls[1][0].since).toBe(14 * DAY); + }); + + it('offers the numbers as a table', async () => { + renderPane(); + fireEvent.click(await screen.findByRole('button', { name: 'Table' })); + + const table = screen.getByTestId('agent-usage-history-table'); + const row = within(table).getByRole('row', { name: /Claude · Session/ }); + expect(row).toHaveTextContent('17%'); + expect(row).toHaveTextContent('10%'); + expect(row).toHaveTextContent('30%'); + }); + + it('renders nothing when Agent Tank is off or usage is not readable', async () => { + mockHistory.mockResolvedValueOnce({ enabled: false, agents: [] }); + const { container, unmount } = renderPane(); + await waitFor(() => expect(container.querySelector('[data-testid="analytics-usage-history"]')).toBeNull()); + unmount(); + + mockHistory.mockRejectedValueOnce(new Error('This action requires the instance.manage_agents permission')); + const second = renderPane(); + await waitFor(() => expect(mockHistory).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(second.container.querySelector('[data-testid="analytics-usage-history"]')).toBeNull()); + }); + + it('explains an empty history', async () => { + mockHistory.mockResolvedValue({ enabled: true, agents: [] }); + renderPane(); + expect(await screen.findByText(/No usage history yet/)).toBeInTheDocument(); + }); +}); + +describe('usage history view', () => { + it('round-trips the view through the URL, omitting defaults', () => { + const view = parseUsageHistoryView(new URLSearchParams('usageRange=3d&usageHideAgents=codex')); + expect(view.range).toBe('3d'); + expect([...view.hiddenAgents]).toEqual(['codex']); + expect(parseUsageHistoryView(new URLSearchParams('usageRange=1y')).range).toBe('7d'); + expect(writeUsageHistoryView(new URLSearchParams('period=7d'), { ...view, range: '7d', hiddenAgents: new Set() }).toString()) + .toBe('period=7d'); + }); + + it('carries each series forward between its own readings and not past them', () => { + const a = toPlottedSeries({ ...series('a', 'a', []), id: 'x:a', agent: 'x', displayLabel: 'a', + points: [{ t: new Date(1000).toISOString(), percent: 10 }, { t: new Date(3000).toISOString(), percent: 30 }] }, '#000'); + const b = toPlottedSeries({ ...series('b', 'b', []), id: 'x:b', agent: 'x', displayLabel: 'b', + points: [{ t: new Date(2000).toISOString(), percent: 50 }] }, '#111'); + expect(buildChartRows([a, b])).toEqual([ + { t: 1000, 'x:a': 10, 'x:b': null }, + { t: 2000, 'x:a': 10, 'x:b': 50 }, + { t: 3000, 'x:a': 30, 'x:b': null }, + ]); + }); +}); diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx new file mode 100644 index 0000000000..f41914c4a2 --- /dev/null +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx @@ -0,0 +1,229 @@ +/** + * Analytics: provider quota usage over time, from Agent Tank history. + * + * Every agent's session, weekly and per-model windows share one chart, so it + * shows which quota is actually filling up. The provider chips narrow it to + * some agents; the series chips are the legend as well as the metric filter, + * each with its latest value, so identity is never color alone. Colors come + * from the order of every series the answer holds, not of the visible ones, + * so hiding a line never repaints the others. Past eight series there is no + * ninth hue: the extra series start hidden and showing one means hiding another. + * + * History only reaches back about two weeks, so the pane has its own range + * instead of following the page timeframe. It renders nothing when Agent Tank + * is off or the viewer may not read usage, like the sidebar's Usage section. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { getAgentTankHistory, type AgentTankHistoryResponse } from '../../api/revertApi'; +import { getProviderDisplayName } from '../agentTankMetrics'; +import { SectionHeading } from '../Dashboard/sectionPrimitives'; +import { SkeletonBlock } from '../ui/Skeleton'; +import { SystemAlert } from '../ui/SystemAlert'; +import { MAX_USAGE_SERIES, assignUsageSeriesColors, flattenUsageHistory, usageMetricLabel } from '../usageHistory/usageHistoryModel'; +import AgentUsageHistoryChart from './AgentUsageHistoryChart'; +import AgentUsageHistoryTable from './AgentUsageHistoryTable'; +import { + USAGE_RANGES, + USAGE_RANGE_LABELS, + USAGE_RANGE_MS, + parseUsageHistoryView, + toPlottedSeries, + writeUsageHistoryView, + type UsageHistoryView, + type UsageRange, +} from './usageHistoryView'; + +type ReadState = + | { status: 'loading' } + | { status: 'hidden' } + | { status: 'error'; message: string } + | { status: 'ready'; data: AgentTankHistoryResponse; since: number; until: number }; + +/** A permission refusal means "not for this viewer", not a failure to report. */ +function isPermissionError(error: unknown): boolean { + return error instanceof Error && /instance\.manage_|permission|forbidden/i.test(error.message); +} + +function useUsageHistoryRead(range: UsageRange): ReadState { + const [state, setState] = useState({ status: 'loading' }); + useEffect(() => { + const controller = new AbortController(); + const until = Date.now(); + const since = until - USAGE_RANGE_MS[range]; + setState(current => (current.status === 'hidden' ? current : { status: 'loading' })); + getAgentTankHistory({ since, until, signal: controller.signal }) + .then(data => { + if (controller.signal.aborted) return; + setState(data.enabled ? { status: 'ready', data, since, until } : { status: 'hidden' }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setState(isPermissionError(error) + ? { status: 'hidden' } + : { status: 'error', message: error instanceof Error ? error.message : 'Failed to load usage history' }); + }); + return () => controller.abort(); + }, [range]); + return state; +} + +const chipClass = (on: boolean) => `inline-flex min-h-7 items-center gap-1.5 rounded-full border px-2.5 text-xs transition-colors ${ + on ? 'border-slate-300 bg-white text-slate-700' : 'border-slate-200 bg-slate-50 text-slate-400 line-through decoration-slate-300' +}`; + +const RangeSelector: React.FC<{ value: UsageRange; onChange: (range: UsageRange) => void }> = ({ value, onChange }) => ( +
+ {USAGE_RANGES.map(option => ( + + ))} +
+); + +const AgentUsageHistory: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const view = parseUsageHistoryView(searchParams); + const read = useUsageHistoryRead(view.range); + const [showTable, setShowTable] = useState(false); + + const updateView = useCallback((change: (current: UsageHistoryView) => UsageHistoryView) => { + setSearchParams(current => writeUsageHistoryView(current, change(parseUsageHistoryView(current))), { replace: true }); + }, [setSearchParams]); + + const all = useMemo(() => { + if (read.status !== 'ready') return []; + const flat = flattenUsageHistory(read.data.agents); + const colors = assignUsageSeriesColors(flat.map(series => series.id)); + return flat.map(series => toPlottedSeries(series, colors.get(series.id) ?? '#94A3B8')); + }, [read]); + const agents = useMemo(() => [...new Set(all.map(series => series.agent))], [all]); + // Series past the palette have no color of their own and stay off the chart. + const colored = all.slice(0, MAX_USAGE_SERIES); + const uncolored = all.length - colored.length; + const offered = colored.filter(series => !view.hiddenAgents.has(series.agent)); + const visible = offered.filter(series => !view.hiddenSeries.has(series.id)); + + if (read.status === 'hidden') return null; + + const toggleAgent = (agent: string) => updateView(current => { + const hiddenAgents = new Set(current.hiddenAgents); + if (hiddenAgents.has(agent)) hiddenAgents.delete(agent); + else hiddenAgents.add(agent); + return { ...current, hiddenAgents }; + }); + const toggleSeries = (id: string) => updateView(current => { + const hiddenSeries = new Set(current.hiddenSeries); + if (hiddenSeries.has(id)) hiddenSeries.delete(id); + else hiddenSeries.add(id); + return { ...current, hiddenSeries }; + }); + const showAll = () => updateView(current => ({ ...current, hiddenAgents: new Set(), hiddenSeries: new Set() })); + const filtered = view.hiddenAgents.size > 0 || view.hiddenSeries.size > 0; + + let body: React.ReactNode; + if (read.status === 'loading') { + body = ; + } else if (read.status === 'error') { + body = {read.message}; + } else if (all.length === 0) { + body = ( +

+ No usage history yet. ProPR records a point each time it reads Agent Tank. +

+ ); + } else if (visible.length === 0) { + body =

Every metric is hidden. Choose one above to plot it.

; + } else { + body = showTable + ? + : ; + } + + return ( +
+ +
+ + updateView(current => ({ ...current, range }))} /> +
+
+
+ {all.length > 0 && ( +
+ {/* Each provider chip leads its own metric chips, so a metric needs + only its short name and the row stays narrow on a phone. */} + {agents.map(agent => { + const agentOn = !view.hiddenAgents.has(agent); + return ( +
+ {agents.length > 1 && ( + + )} + {offered.filter(series => series.agent === agent).map(series => { + const on = !view.hiddenSeries.has(series.id); + const value = series.latest === null ? '' : ` ${Math.round(series.latest)}%`; + return ( + + ); + })} +
+ ); + })} + {filtered && ( + + )} +
+ )} + {uncolored > 0 && ( +

+ {uncolored} more {uncolored === 1 ? 'metric is' : 'metrics are'} not plotted. The chart shows up to {MAX_USAGE_SERIES} at once. +

+ )} + {body} +
+
+ ); +}; + +export default AgentUsageHistory; diff --git a/propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx b/propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx new file mode 100644 index 0000000000..4c25368a19 --- /dev/null +++ b/propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx @@ -0,0 +1,117 @@ +/** + * Every visible usage series on one 0-100% scale. All metrics are "percent of + * the quota used", so one axis is honest and nothing needs a second scale; a + * session line climbing while its weekly line barely moves is the comparison + * the chart exists for. Lines carry their value from one reading to the next, + * since a quota holds its last measured level until it is measured again. The + * dashed 50% and 80% rules are the sidebar's warning and critical levels. + */ + +import React, { useMemo } from 'react'; +import { CartesianGrid, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { axisProps, tooltipStyle } from '../chartConstants'; +import { formatHistoryTick, formatHistoryTime } from '../usageHistory/usageHistoryModel'; +import { buildChartRows, type ChartRow, type PlottedSeries } from './usageHistoryView'; + +interface AgentUsageHistoryChartProps { + series: PlottedSeries[]; + since: number; + until: number; +} + +function evenTicks(since: number, until: number, count: number): number[] { + const step = (until - since) / (count - 1); + return Array.from({ length: count }, (_, index) => Math.round(since + step * index)); +} + +const UsageTooltip: React.FC<{ + active?: boolean; + payload?: Array<{ payload: ChartRow }>; + series: PlottedSeries[]; +}> = ({ active, payload, series }) => { + const row = active ? payload?.[0]?.payload : undefined; + if (!row) return null; + const values = series + .map(entry => ({ entry, value: row[entry.id] })) + .filter((item): item is { entry: PlottedSeries; value: number } => typeof item.value === 'number') + .sort((a, b) => b.value - a.value); + if (values.length === 0) return null; + return ( +
+
{formatHistoryTime(row.t)}
+
    + {values.map(({ entry, value }) => ( +
  • +
  • + ))} +
+
+ ); +}; + +const AgentUsageHistoryChart: React.FC = ({ series, since, until }) => { + const rows = useMemo(() => buildChartRows(series), [series]); + const span = until - since; + const ticks = useMemo(() => evenTicks(since, until, 5), [since, until]); + + return ( +
+ + + + formatHistoryTick(Number(value), span)} + stroke={axisProps.stroke} + tick={{ ...axisProps.tick, fontSize: 11 }} + tickLine={false} + axisLine={false} + minTickGap={24} + allowDataOverflow + /> + `${value}%`} + stroke={axisProps.stroke} + tick={{ ...axisProps.tick, fontSize: 11 }} + tickLine={false} + axisLine={false} + width={48} + /> + + + } + cursor={{ stroke: '#94A3B8', strokeWidth: 1 }} + isAnimationActive={false} + /> + {series.map(entry => ( + + ))} + + +
+ ); +}; + +export default AgentUsageHistoryChart; diff --git a/propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx b/propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx new file mode 100644 index 0000000000..add65738b8 --- /dev/null +++ b/propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx @@ -0,0 +1,44 @@ +/** + * The usage chart as numbers: each visible series' latest, lowest and highest + * reading over the range, for anyone who cannot or would rather not read lines + * by color. + */ + +import React from 'react'; +import type { PlottedSeries } from './usageHistoryView'; + +const percent = (value: number | null) => (value === null ? '—' : `${Math.round(value * 10) / 10}%`); + +const AgentUsageHistoryTable: React.FC<{ series: PlottedSeries[] }> = ({ series }) => ( +
+ + + + + + + + + + + + {series.map(entry => ( + + + + + + + + ))} + +
MetricLatestLowestHighestReadings
+ + + {percent(entry.latest)}{percent(entry.min)}{percent(entry.max)}{entry.plotted.length}
+
+); + +export default AgentUsageHistoryTable; diff --git a/propr-ui/src/components/Analytics/usageHistoryView.ts b/propr-ui/src/components/Analytics/usageHistoryView.ts new file mode 100644 index 0000000000..971cbf6c54 --- /dev/null +++ b/propr-ui/src/components/Analytics/usageHistoryView.ts @@ -0,0 +1,114 @@ +/** + * View state and data shaping for the Analytics usage-history chart. + * + * The selection is stored as what is *hidden*, so a metric that first appears + * after the view was saved (a new Antigravity pool, a newly enabled agent) + * shows up by default instead of being silently filtered away. + */ + +import type { FlatUsageSeries } from '../usageHistory/usageHistoryModel'; + +const HOUR_MS = 60 * 60 * 1000; + +export const USAGE_RANGES = ['24h', '3d', '7d', '14d'] as const; +export type UsageRange = typeof USAGE_RANGES[number]; +export const DEFAULT_USAGE_RANGE: UsageRange = '7d'; + +export const USAGE_RANGE_MS: Record = { + '24h': 24 * HOUR_MS, + '3d': 3 * 24 * HOUR_MS, + '7d': 7 * 24 * HOUR_MS, + '14d': 14 * 24 * HOUR_MS, +}; + +export const USAGE_RANGE_LABELS: Record = { + '24h': 'Last 24 hours', + '3d': 'Last 3 days', + '7d': 'Last 7 days', + '14d': 'Last 14 days', +}; + +export const USAGE_PARAMS = { + range: 'usageRange', + hiddenAgents: 'usageHideAgents', + hiddenSeries: 'usageHide', +} as const; + +export interface UsageHistoryView { + range: UsageRange; + hiddenAgents: Set; + hiddenSeries: Set; +} + +function parseList(value: string | null): Set { + return new Set((value ?? '').split(',').map(item => item.trim()).filter(Boolean)); +} + +export function parseUsageHistoryView(params: URLSearchParams): UsageHistoryView { + const range = params.get(USAGE_PARAMS.range); + return { + range: (USAGE_RANGES as readonly string[]).includes(range ?? '') ? range as UsageRange : DEFAULT_USAGE_RANGE, + hiddenAgents: parseList(params.get(USAGE_PARAMS.hiddenAgents)), + hiddenSeries: parseList(params.get(USAGE_PARAMS.hiddenSeries)), + }; +} + +/** Write the view into `params`, omitting every value that is the default. */ +export function writeUsageHistoryView(params: URLSearchParams, view: UsageHistoryView): URLSearchParams { + const next = new URLSearchParams(params); + const set = (key: string, value: string) => (value ? next.set(key, value) : next.delete(key)); + set(USAGE_PARAMS.range, view.range === DEFAULT_USAGE_RANGE ? '' : view.range); + set(USAGE_PARAMS.hiddenAgents, [...view.hiddenAgents].join(',')); + set(USAGE_PARAMS.hiddenSeries, [...view.hiddenSeries].join(',')); + return next; +} + +export interface PlottedSeries extends FlatUsageSeries { + color: string; + /** Points as epoch ms, time-ordered. */ + plotted: Array<{ t: number; percent: number }>; + latest: number | null; + min: number | null; + max: number | null; +} + +export function toPlottedSeries(series: FlatUsageSeries, color: string): PlottedSeries { + const plotted = series.points + .map(point => ({ t: Date.parse(point.t), percent: point.percent })) + .filter((point): point is { t: number; percent: number } => Number.isFinite(point.t) && typeof point.percent === 'number') + .sort((a, b) => a.t - b.t); + const values = plotted.map(point => point.percent); + return { + ...series, + color, + plotted, + latest: values.length ? values[values.length - 1] : null, + min: values.length ? Math.min(...values) : null, + max: values.length ? Math.max(...values) : null, + }; +} + +export type ChartRow = { t: number } & Record; + +/** + * One row per distinct timestamp across the visible series, each carrying + * every series' value at that moment (its last reading at or before it), so the + * shared tooltip can read all of them from the hovered row. A series is null + * before its first point and after its last, rather than invented there. + */ +export function buildChartRows(series: PlottedSeries[]): ChartRow[] { + const times = [...new Set(series.flatMap(entry => entry.plotted.map(point => point.t)))].sort((a, b) => a - b); + // One cursor per series walks forward with the shared timeline: linear, not + // a lookup per row. + const cursors = series.map(() => -1); + return times.map(t => { + const row: ChartRow = { t }; + series.forEach((entry, index) => { + const points = entry.plotted; + while (cursors[index] + 1 < points.length && points[cursors[index] + 1].t <= t) cursors[index] += 1; + const inRange = cursors[index] >= 0 && t <= points[points.length - 1].t; + row[entry.id] = inRange ? points[cursors[index]].percent : null; + }); + return row; + }); +} diff --git a/propr-ui/src/components/usageHistory/UsageMetricHistoryChart.tsx b/propr-ui/src/components/usageHistory/UsageMetricHistoryChart.tsx new file mode 100644 index 0000000000..d331f03d5d --- /dev/null +++ b/propr-ui/src/components/usageHistory/UsageMetricHistoryChart.tsx @@ -0,0 +1,111 @@ +/** + * One metric's usage over its last two periods, drawn small enough for the + * sidebar overlay. A single series needs no legend: the overlay's title names + * it. The y-axis is fixed at 0-100% so a 20% session never looks as full as an + * 80% one, and a dashed rule marks where the current period began, which is + * where a session or weekly quota drops back to zero. + */ + +import React, { useMemo } from 'react'; +import { Area, AreaChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import type { AgentTankHistoryPoint } from '../../api/revertApi'; +import { axisProps, tooltipStyle } from '../chartConstants'; +import { USAGE_SERIES_COLORS, formatHistoryTick, formatHistoryTime, toChartPoints, type ChartPoint } from './usageHistoryModel'; + +const LINE_COLOR = USAGE_SERIES_COLORS[0]; + +interface UsageMetricHistoryChartProps { + points: AgentTankHistoryPoint[]; + since: number; + until: number; + /** Where the current period began. */ + periodStart: number; + /** The live value the sidebar row shows, so the line ends on it. */ + currentPercent: number; + height?: number; +} + +const HistoryTooltip: React.FC<{ active?: boolean; payload?: Array<{ payload: ChartPoint }> }> = ({ active, payload }) => { + const point = active ? payload?.[0]?.payload : undefined; + if (!point) return null; + return ( +
+
{formatHistoryTime(point.t)}
+
{Math.round(point.percent * 10) / 10}% used
+
+ ); +}; + +const UsageMetricHistoryChart: React.FC = ({ + points, since, until, periodStart, currentPercent, height = 132, +}) => { + const data = useMemo(() => toChartPoints(points, since, until, currentPercent), [points, since, until, currentPercent]); + const span = until - since; + const ticks = useMemo(() => [since, since + span / 2, until], [since, span, until]); + const showBoundary = periodStart > since && periodStart < until; + + return ( +
+ + + + + + + + + + formatHistoryTick(Number(value), span)} + stroke={axisProps.stroke} + tick={{ ...axisProps.tick, fontSize: 10 }} + tickLine={false} + axisLine={false} + interval={0} + allowDataOverflow + /> + `${value}%`} + stroke={axisProps.stroke} + tick={{ ...axisProps.tick, fontSize: 10 }} + tickLine={false} + axisLine={false} + width={34} + /> + {showBoundary && ( + + )} + } + cursor={{ stroke: '#94A3B8', strokeWidth: 1 }} + isAnimationActive={false} + /> + + + +
+ ); +}; + +export default UsageMetricHistoryChart; diff --git a/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx new file mode 100644 index 0000000000..a7f0e4a02c --- /dev/null +++ b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx @@ -0,0 +1,229 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAgentTankHistory, getAgentTankUsage, refreshAgentTank } from '../../api/revertApi'; +import type { AgentTankHistoryResponse, AgentTankUsageResponse } from '../../api/revertApi'; +import AgentTankSidebar from '../AgentTankSidebar'; +import { HOVER_OPEN_DELAY_MS } from './UsageMetricHistoryOverlay'; +import { resetUsageMetricHistoryCache } from './useUsageMetricHistory'; +import { currentPeriodStart, parseResetCountdown } from './usageHistoryModel'; + +vi.mock('../../api/revertApi', () => ({ + getAgentTankUsage: vi.fn(), + refreshAgentTank: vi.fn(), + getAgentTankHistory: vi.fn(), +})); + +vi.mock('../../contexts/useSocket', () => ({ + useSocket: () => ({ + isConnected: true, + onActivityUpdate: () => () => undefined, + onNotificationUpdate: () => () => undefined, + onUsageUpdate: () => () => undefined, + }), +})); + +const HOUR = 3_600_000; +const mockUsage = vi.mocked(getAgentTankUsage); +const mockHistory = vi.mocked(getAgentTankHistory); + +const usage = (): AgentTankUsageResponse => ({ + enabled: true, + agents: { + claude: { + name: 'claude', + usage: { + session: { percent: 17, resetsIn: '2h 14m' }, + weeklyAll: { percent: 58, resetsIn: '3d 4h' }, + }, + }, + antigravity: { + name: 'antigravity', + usage: { + models: [ + { model: 'Gemini 4 Argon Weekly Limit', group: 'Gemini 4 Argon', cycle: 'weekly', percentUsed: 30, resetsIn: '96h 2m' }, + ], + }, + }, + }, +}); + +function history(agent: string, key: string, percents: number[]): AgentTankHistoryResponse { + const now = Date.now(); + return { + enabled: true, + source: 'propr', + agents: [{ + agent, + series: [{ + key, label: key, model: null, cycle: 'session', + points: percents.map((percent, index) => ({ t: new Date(now - (percents.length - index) * HOUR).toISOString(), percent })), + }], + }], + }; +} + +beforeAll(() => { + // jsdom has neither a layout observer for recharts nor PointerEvent.pointerType. + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + if (!('PointerEvent' in window)) { + class PointerEventPolyfill extends MouseEvent { + pointerType: string; + constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) { + super(type, init); + this.pointerType = init.pointerType ?? 'mouse'; + } + } + Object.assign(window, { PointerEvent: PointerEventPolyfill }); + } +}); + +beforeEach(() => { + window.localStorage.clear(); + resetUsageMetricHistoryCache(); + mockUsage.mockResolvedValue(usage()); + vi.mocked(refreshAgentTank).mockResolvedValue({ success: true }); + mockHistory.mockImplementation(async query => history(query.agents?.[0] ?? '', query.metric ?? '', [5, 12, 17])); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +async function expandProvider(name: RegExp): Promise { + await screen.findByText('Usage'); + fireEvent.click(screen.getByRole('button', { name })); +} + +function metricRow(label: string): HTMLElement { + return screen.getByText(label).closest('[aria-haspopup="dialog"]') as HTMLElement; +} + +describe('sidebar usage history overlay', () => { + it('opens on hover after the intent delay and reads the last two sessions', async () => { + render(); + await expandProvider(/^Claude/); + const row = metricRow('Session'); + + fireEvent.pointerEnter(row, { pointerType: 'mouse' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockHistory).not.toHaveBeenCalled(); + + const dialog = await screen.findByRole('dialog', {}, { timeout: HOVER_OPEN_DELAY_MS + 1000 }); + expect(dialog).toHaveAccessibleName('Claude · Session usage history'); + expect(dialog).toHaveTextContent('Last 10 hours'); + expect(dialog).toHaveTextContent('17%'); + expect(row).toHaveAttribute('aria-expanded', 'true'); + + await waitFor(() => expect(mockHistory).toHaveBeenCalledTimes(1)); + const query = mockHistory.mock.calls[0][0]; + expect(query).toMatchObject({ agents: ['claude'], metric: 'session' }); + expect((query.until ?? 0) - query.since).toBe(10 * HOUR); + expect(await screen.findByTestId('usage-metric-history-chart')).toBeInTheDocument(); + }); + + it('does not open when the pointer passes over the row', async () => { + render(); + await expandProvider(/^Claude/); + const row = metricRow('Session'); + + fireEvent.pointerEnter(row, { pointerType: 'mouse' }); + fireEvent.pointerLeave(row, { pointerType: 'mouse' }); + await new Promise(resolve => setTimeout(resolve, HOVER_OPEN_DELAY_MS + 50)); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockHistory).not.toHaveBeenCalled(); + }); + + it('reads two weeks for a weekly limit, toggled by tap', async () => { + render(); + await expandProvider(/^Claude/); + const row = metricRow('Weekly'); + + fireEvent.pointerEnter(row, { pointerType: 'touch' }); + fireEvent.click(row); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('Last 14 days'); + await waitFor(() => expect(mockHistory).toHaveBeenCalled()); + const query = mockHistory.mock.calls[0][0]; + expect(query.metric).toBe('weeklyAll'); + expect((query.until ?? 0) - query.since).toBe(14 * 24 * HOUR); + + fireEvent.click(row); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes on Escape and with the close button', async () => { + render(); + await expandProvider(/^Claude/); + + fireEvent.click(metricRow('Session')); + await screen.findByRole('dialog'); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + fireEvent.keyDown(metricRow('Session'), { key: 'Enter' }); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: 'Close usage history' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('groups Gemini 4 Argon under its own pool and charts its weekly quota', async () => { + render(); + await expandProvider(/^Antigravity/); + + expect(screen.getByText('Gemini 4 Argon')).toBeInTheDocument(); + fireEvent.click(metricRow('Weekly')); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveAccessibleName('Antigravity · Gemini 4 Argon Weekly Limit usage history'); + await waitFor(() => expect(mockHistory).toHaveBeenCalled()); + const query = mockHistory.mock.calls[0][0]; + expect(query).toMatchObject({ agents: ['antigravity'], metric: 'model:Gemini 4 Argon Weekly Limit' }); + expect((query.until ?? 0) - query.since).toBe(14 * 24 * HOUR); + }); + + it('says so when there is not enough history yet', async () => { + mockHistory.mockResolvedValue(history('claude', 'session', [17])); + render(); + await expandProvider(/^Claude/); + + fireEvent.click(metricRow('Session')); + + expect(await screen.findByText(/Not enough history yet/)).toBeInTheDocument(); + }); + + it('stops offering history once the backend reports it disabled', async () => { + mockHistory.mockResolvedValue({ enabled: false, agents: [] }); + const { unmount } = render(); + await expandProvider(/^Claude/); + fireEvent.click(metricRow('Session')); + expect(await screen.findByText(/not available/)).toBeInTheDocument(); + await act(async () => { unmount(); }); + + render(); + await screen.findByText('Usage'); + expect(document.querySelector('[aria-haspopup="dialog"]')).toBeNull(); + }); +}); + +describe('usage history model', () => { + it('reads Agent Tank countdowns', () => { + expect(parseResetCountdown('2h 14m')).toBe(2 * 3600 + 14 * 60); + expect(parseResetCountdown('3d 4h')).toBe(3 * 86400 + 4 * 3600); + expect(parseResetCountdown('136h 1m')).toBe(136 * 3600 + 60); + expect(parseResetCountdown('soon')).toBeNull(); + }); + + it('starts the current period one period before the next reset', () => { + const until = 100 * HOUR; + expect(currentPeriodStart({ until, periodMs: 5 * HOUR }, '2h')).toBe(until + 2 * HOUR - 5 * HOUR); + // No countdown, or one longer than a period, falls back to one period ago. + expect(currentPeriodStart({ until, periodMs: 5 * HOUR })).toBe(until - 5 * HOUR); + expect(currentPeriodStart({ until, periodMs: 5 * HOUR }, '9h')).toBe(until - 5 * HOUR); + }); +}); diff --git a/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx new file mode 100644 index 0000000000..78619497f8 --- /dev/null +++ b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx @@ -0,0 +1,285 @@ +/** + * The sidebar's per-metric usage history: hover a metric row (or tap it on a + * touch screen) to see how that quota moved over its last two periods - ten + * hours for a five-hour session, two weeks for a weekly limit. + * + * Hover opens after a short intent delay, so sweeping the pointer down the + * sidebar does not fire a request per row, and the overlay stays open while + * the pointer travels from the row into it. A click or tap pins it open until + * Escape, the close button, a second tap or a tap outside. On a wide screen it + * sits beside the sidebar; on a narrow one it spans the screen just above or + * below the row, so it never covers the row it describes. + */ + +import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; +import { getProviderDisplayName, getTextColor, type UsageMetric } from '../agentTankMetrics'; +import { SkeletonBlock } from '../ui/Skeleton'; +import UsageMetricHistoryChart from './UsageMetricHistoryChart'; +import { currentPeriodStart, twoPeriodWindow } from './usageHistoryModel'; +import { isUsageHistoryDisabled, useUsageMetricHistory, type UsageMetricHistoryState } from './useUsageMetricHistory'; + +export const HOVER_OPEN_DELAY_MS = 250; +const HOVER_CLOSE_GRACE_MS = 150; +const OVERLAY_WIDTH = 300; +const VIEWPORT_MARGIN = 8; +const ANCHOR_GAP = 12; +/** Below this width the overlay spans the screen instead of sitting beside the row. */ +const NARROW_VIEWPORT = 640; + +function rangeLabel(periodMs: number): string { + const hours = (2 * periodMs) / 3_600_000; + return hours <= 48 ? `Last ${Math.round(hours)} hours` : `Last ${Math.round(hours / 24)} days`; +} + +interface Position { + top: number; + left: number; + width: number; +} + +function placeOverlay(anchor: DOMRect, height: number): Position { + const vw = window.innerWidth; + const vh = window.innerHeight; + if (vw < NARROW_VIEWPORT) { + const width = vw - 2 * VIEWPORT_MARGIN; + const below = anchor.bottom + 6; + const top = below + height <= vh - VIEWPORT_MARGIN ? below : Math.max(VIEWPORT_MARGIN, anchor.top - 6 - height); + return { top, left: VIEWPORT_MARGIN, width }; + } + let left = anchor.right + ANCHOR_GAP; + if (left + OVERLAY_WIDTH > vw - VIEWPORT_MARGIN) left = anchor.left - ANCHOR_GAP - OVERLAY_WIDTH; + left = Math.min(Math.max(VIEWPORT_MARGIN, left), vw - VIEWPORT_MARGIN - OVERLAY_WIDTH); + const centered = anchor.top + anchor.height / 2 - height / 2; + const top = Math.min(Math.max(VIEWPORT_MARGIN, centered), Math.max(VIEWPORT_MARGIN, vh - VIEWPORT_MARGIN - height)); + return { top, left, width: OVERLAY_WIDTH }; +} + +const OverlayBody: React.FC<{ state: UsageMetricHistoryState | null; metric: UsageMetric }> = ({ state, metric }) => { + if (!state || state.status === 'loading') { + return ; + } + if (state.status === 'error') { + return

{state.message}

; + } + if (!state.enabled) { + return

Usage history is not available.

; + } + const points = state.series?.points ?? []; + if (points.length < 2) { + return ( +

+ Not enough history yet. ProPR records a point each time it reads Agent Tank. +

+ ); + } + return ( + + ); +}; + +interface OverlayPanelProps { + id: string; + title: string; + metric: UsageMetric; + state: UsageMetricHistoryState | null; + pinned: boolean; + position: Position | null; + onClose: () => void; + onPointerEnter: (event: React.PointerEvent) => void; + onPointerLeave: (event: React.PointerEvent) => void; +} + +const OverlayPanel = React.forwardRef(({ + id, title, metric, state, pinned, position, onClose, onPointerEnter, onPointerLeave, +}, ref) => ( + +)); +OverlayPanel.displayName = 'OverlayPanel'; + +interface UsageMetricHistoryTriggerProps { + agent: string; + metric: UsageMetric; + children: React.ReactNode; + className?: string; +} + +/** Wraps a metric row so it opens that metric's history; inert without a history key. */ +export const UsageMetricHistoryTrigger: React.FC = ({ + agent, metric, children, className = '', +}) => { + const historyKey = metric.historyKey; + const [open, setOpen] = useState(false); + const [pinned, setPinned] = useState(false); + const [position, setPosition] = useState(null); + const anchorRef = useRef(null); + const overlayRef = useRef(null); + const openTimer = useRef(undefined); + const closeTimer = useRef(undefined); + const dialogId = useId(); + const state = useUsageMetricHistory(agent, historyKey ?? '', metric.cycle, open && !!historyKey); + + const clearTimers = useCallback(() => { + window.clearTimeout(openTimer.current); + window.clearTimeout(closeTimer.current); + }, []); + const close = useCallback(() => { + clearTimers(); + setOpen(false); + setPinned(false); + }, [clearTimers]); + + useEffect(() => clearTimers, [clearTimers]); + + const reposition = useCallback(() => { + if (!anchorRef.current) return; + const height = overlayRef.current?.offsetHeight ?? 200; + setPosition(placeOverlay(anchorRef.current.getBoundingClientRect(), height)); + }, []); + + useLayoutEffect(() => { + if (!open) return undefined; + reposition(); + window.addEventListener('resize', reposition); + window.addEventListener('scroll', reposition, true); + return () => { + window.removeEventListener('resize', reposition); + window.removeEventListener('scroll', reposition, true); + }; + }, [open, reposition, state?.status]); + + useEffect(() => { + if (!open) return undefined; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if (anchorRef.current?.contains(target) || overlayRef.current?.contains(target)) return; + close(); + }; + document.addEventListener('keydown', onKeyDown); + document.addEventListener('pointerdown', onPointerDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('pointerdown', onPointerDown); + }; + }, [open, close]); + + // An overlay that is already open keeps showing why history is unavailable. + if (!historyKey || (isUsageHistoryDisabled() && !open)) return
{children}
; + + const hoverIn = (event: React.PointerEvent) => { + if (event.pointerType !== 'mouse') return; + window.clearTimeout(closeTimer.current); + if (open) return; + openTimer.current = window.setTimeout(() => setOpen(true), HOVER_OPEN_DELAY_MS); + }; + const hoverOut = (event: React.PointerEvent) => { + if (event.pointerType !== 'mouse') return; + window.clearTimeout(openTimer.current); + if (pinned) return; + closeTimer.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_GRACE_MS); + }; + const toggle = () => { + clearTimers(); + if (open && pinned) { + close(); + return; + } + setOpen(true); + setPinned(true); + }; + + const title = `${getProviderDisplayName(agent)} · ${metric.title ?? metric.label}`; + + return ( + <> +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(); + } + }} + > + {children} +
+ {open && createPortal( + { + if (event.pointerType === 'mouse') window.clearTimeout(closeTimer.current); + }} + onPointerLeave={hoverOut} + />, + document.body, + )} + + ); +}; diff --git a/propr-ui/src/components/usageHistory/usageHistoryModel.ts b/propr-ui/src/components/usageHistory/usageHistoryModel.ts index 357e54a301..b22624e95c 100644 --- a/propr-ui/src/components/usageHistory/usageHistoryModel.ts +++ b/propr-ui/src/components/usageHistory/usageHistoryModel.ts @@ -5,7 +5,7 @@ */ import { agentTankCyclePeriodMs } from '@propr/shared'; -import type { AgentTankHistoryAgent, AgentTankHistorySeries } from '../../api/revertApi'; +import type { AgentTankHistoryAgent, AgentTankHistoryPoint, AgentTankHistorySeries } from '../../api/revertApi'; import { getProviderDisplayName, getProviderRank } from '../agentTankMetrics'; import { stripRemainingSuffix } from '../../utils/usageMetricLabels'; @@ -91,3 +91,79 @@ export function twoPeriodWindow(cycle: string | null | undefined, now: number = const periodMs = agentTankCyclePeriodMs(cycle); return { since: now - 2 * periodMs, until: now, periodMs }; } + +/** Seconds in an Agent Tank countdown ("2h 14m", "3d 4h", "136h 1m", "45m"); null when unreadable. */ +export function parseResetCountdown(text: string | undefined): number | null { + if (!text) return null; + const units: Record = { d: 86_400, h: 3_600, m: 60, s: 1 }; + let seconds = 0; + let matched = false; + for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*([dhms])\b/gi)) { + seconds += Number(match[1]) * units[match[2].toLowerCase()]; + matched = true; + } + return matched ? seconds : null; +} + +/** + * Where the current period began, from the live reset countdown when there is + * one, otherwise one period before `until`. + */ +export function currentPeriodStart( + window: { until: number; periodMs: number }, + resetsIn?: string, +): number { + const seconds = parseResetCountdown(resetsIn); + if (seconds === null || seconds * 1000 > window.periodMs) return window.until - window.periodMs; + return window.until + seconds * 1000 - window.periodMs; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Axis tick: clock time up to two days, weekday and hour up to four (where a + * bare date would repeat across neighbouring ticks), a date beyond that. + */ +export function formatHistoryTick(t: number, spanMs: number): string { + const date = new Date(t); + if (spanMs <= 2 * DAY_MS) return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + if (spanMs <= 4 * DAY_MS) return date.toLocaleString(undefined, { weekday: 'short', hour: 'numeric' }); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +/** Tooltip time, always with both date and clock time. */ +export function formatHistoryTime(t: number): string { + return new Date(t).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} + +/** The last recorded value at or before `t`, or null before the series starts. */ +export function valueAt(points: Array<{ t: number; percent: number }>, t: number): number | null { + let value: number | null = null; + for (const point of points) { + if (point.t > t) break; + value = point.percent; + } + return value; +} + +export interface ChartPoint { + t: number; + percent: number; +} + +export function toChartPoints( + points: AgentTankHistoryPoint[], + since: number, + until: number, + currentPercent: number, +): ChartPoint[] { + const plotted = points + .map(point => ({ t: Date.parse(point.t), percent: point.percent })) + .filter((point): point is ChartPoint => Number.isFinite(point.t) && typeof point.percent === 'number' + && point.t >= since && point.t <= until) + .sort((a, b) => a.t - b.t); + // Extend to now with the live value, so the line ends where the row's bar is. + const last = plotted[plotted.length - 1]; + if (!last || last.t < until) plotted.push({ t: until, percent: currentPercent }); + return plotted; +} diff --git a/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts b/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts new file mode 100644 index 0000000000..e0c6b15ecd --- /dev/null +++ b/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { getAgentTankHistory, type AgentTankHistorySeries } from '../../api/revertApi'; +import { twoPeriodWindow } from './usageHistoryModel'; + +/** Re-hovering a row within this long reuses the answer instead of refetching. */ +const CACHE_TTL_MS = 60_000; + +interface CachedHistory { + fetchedAt: number; + since: number; + until: number; + series: AgentTankHistorySeries | null; + enabled: boolean; +} + +const cache = new Map(); + +/** Set once the backend reports history is off, so rows stop offering it. */ +let historyDisabled = false; + +export function isUsageHistoryDisabled(): boolean { + return historyDisabled; +} + +/** Test-only seam. */ +export function resetUsageMetricHistoryCache(): void { + cache.clear(); + historyDisabled = false; +} + +export type UsageMetricHistoryState = + | { status: 'loading'; since: number; until: number; periodMs: number } + | { status: 'error'; since: number; until: number; periodMs: number; message: string } + | { status: 'ready'; since: number; until: number; periodMs: number; series: AgentTankHistorySeries | null; enabled: boolean }; + +/** + * One metric's history over its last two periods, read when `active` turns on. + * The request is aborted if the overlay closes first. + */ +export function useUsageMetricHistory( + agent: string, + historyKey: string, + cycle: string | undefined, + active: boolean, +): UsageMetricHistoryState | null { + const [state, setState] = useState(null); + + useEffect(() => { + if (!active) return undefined; + const { since, until, periodMs } = twoPeriodWindow(cycle); + const cacheKey = `${agent}\u0000${historyKey}`; + const cached = cache.get(cacheKey); + if (cached && until - cached.fetchedAt < CACHE_TTL_MS) { + setState({ status: 'ready', since: cached.since, until: cached.until, periodMs, series: cached.series, enabled: cached.enabled }); + return undefined; + } + const controller = new AbortController(); + setState({ status: 'loading', since, until, periodMs }); + getAgentTankHistory({ since, until, agents: [agent], metric: historyKey, signal: controller.signal }) + .then(response => { + if (controller.signal.aborted) return; + const series = response.agents + .find(entry => entry.agent === agent)?.series + .find(entry => entry.key === historyKey) ?? null; + if (!response.enabled) historyDisabled = true; + cache.set(cacheKey, { fetchedAt: until, since, until, series, enabled: response.enabled }); + setState({ status: 'ready', since, until, periodMs, series, enabled: response.enabled }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setState({ + status: 'error', since, until, periodMs, + message: error instanceof Error && error.message ? error.message : 'Could not load usage history', + }); + }); + return () => controller.abort(); + }, [agent, historyKey, cycle, active]); + + return active ? state : null; +} diff --git a/propr-ui/src/pages/AnalyticsPage.test.tsx b/propr-ui/src/pages/AnalyticsPage.test.tsx index 2a8a6f7f6e..fbfd81222f 100644 --- a/propr-ui/src/pages/AnalyticsPage.test.tsx +++ b/propr-ui/src/pages/AnalyticsPage.test.tsx @@ -11,6 +11,11 @@ vi.mock('../api/taskStatsApi', () => ({ getStatsOverview: vi.fn(), getReviewScoreSummary: vi.fn(), })); +// Agent Tank is off unless a test turns it on, so the usage pane stays hidden. +vi.mock('../api/revertApi', () => ({ + getAgentTankHistory: vi.fn(async () => ({ enabled: false, agents: [] })), +})); + vi.mock('../contexts/useSocket', () => ({ useSocket: () => ({ isConnected: false, onTaskUpdate: () => () => {} }), })); diff --git a/propr-ui/src/pages/AnalyticsPage.tsx b/propr-ui/src/pages/AnalyticsPage.tsx index afffc34fe4..6cdc011581 100644 --- a/propr-ui/src/pages/AnalyticsPage.tsx +++ b/propr-ui/src/pages/AnalyticsPage.tsx @@ -34,6 +34,7 @@ import ActivitySparkline, { ActivityLegend } from '../components/ActivitySparkli import RepositoryBreakdown from '../components/RepositoryBreakdown'; import TopModels from '../components/TopModels'; import TokenConsumption from '../components/Analytics/TokenConsumption'; +import AgentUsageHistory from '../components/Analytics/AgentUsageHistory'; import ReviewQualityByModel from '../components/Analytics/ReviewQualityByModel'; import AnalyticsTimeframeSelector from '../components/Analytics/AnalyticsTimeframeSelector'; import { AnalyticsMetricStrip, UNAVAILABLE, type AnalyticsMetric } from '../components/Analytics/AnalyticsMetricStrip'; @@ -200,6 +201,8 @@ const AnalyticsPage: React.FC = () => { + {/* Provider quota over time, full width: a time series reads best wide. */} +
diff --git a/test/agentTankCanonicalShape.test.ts b/test/agentTankCanonicalShape.test.ts new file mode 100644 index 0000000000..1aded7b9f0 --- /dev/null +++ b/test/agentTankCanonicalShape.test.ts @@ -0,0 +1,360 @@ +/** + * Agent Tank 0.10 canonical response shape. + * + * 0.10 answers `{ providers: [{ id, provider, status, windows, raw, ... }] }` + * with the 0.9.x per-agent payload kept under `raw`. Every ProPR consumer reads + * that legacy `usage`, so both generations must parse to the same status map, + * and the canonical fields (freshness, failure, windows) must win where they + * overlap. + */ + +import { after, beforeEach, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { AgentTankMode } from '@propr/shared'; +import type { AgentStatusResponse } from '../packages/core/src/services/agentTankTypes.js'; + +process.env.NODE_ENV = 'test'; + +await mock.module('../packages/core/src/utils/logger.js', { + defaultExport: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {} }, +}); + +await mock.module('../packages/core/src/claude/docker/dockerExecutor.js', { + namedExports: { + executeDockerCommand: async () => { throw new Error('external-mode tests must not start containers'); }, + }, +}); + +let mode: AgentTankMode = 'external'; +const realConfigPaths = await import('../packages/core/src/config/agentConfigPaths.js'); +await mock.module('../packages/core/src/config/configManager.js', { + namedExports: { + loadAgentTankSettings: async () => ({ mode, enabled: mode !== 'disabled', url: 'http://tank.test:3456' }), + loadAgents: async () => [], + resolveConfigPath: realConfigPaths.resolveConfigPath, + resolveCodexConfigPath: realConfigPaths.resolveCodexConfigPath, + }, +}); + +const recordedSnapshots: Array | undefined> = []; +await mock.module('../packages/core/src/services/agentTankUsageHistory.js', { + namedExports: { + recordAgentTankUsageHistory: async (agents: Record | undefined) => { + recordedSnapshots.push(agents); + return 0; + }, + }, +}); + +const { isUsableAgentTankStatus, parseAgentTankStatus, parseAgentTankStatusMap } = + await import('../packages/core/src/services/agentTankTypes.js'); +const { extractAgentTankUsageMetrics } = await import('../packages/core/src/services/agentTankUsageMetrics.js'); +const { parseBundledAgentTankOutput } = await import('../packages/core/src/services/agentTankBundledRunner.js'); +const { getAllStatuses, getStatus } = await import('../packages/core/src/services/agentTankService.js'); +const { closeConnection } = await import('../packages/core/src/db/connection.js'); + +const CLAUDE_RAW = { + name: 'claude', + usage: { session: { percent: 42, resetsInSeconds: 3600 }, weeklyAll: { percent: 17 } }, + metadata: { plan: 'max' }, + lastUpdated: '2026-10-17T10:00:00.000Z', + error: null, +}; + +function window(overrides: Record = {}) { + return { + type: 'session', + label: 'Current session', + model: null, + used_percent: 42, + remaining_percent: 58, + resets_at: '2026-10-17T11:00:00.000Z', + resets_in_seconds: 3600, + pace: { status: 'on_track' }, + ...overrides, + }; +} + +function provider(overrides: Record = {}) { + return { + id: 'claude', + provider: 'claude', + plan: 'max', + status: 'ok', + last_updated: '2026-10-17T10:05:00.000Z', + error: null, + windows: [window()], + context_window: null, + credits: null, + raw: CLAUDE_RAW, + ...overrides, + }; +} + +const AGY_PROVIDER = provider({ + id: 'agy', + provider: 'antigravity', + plan: null, + status: 'refreshing', + windows: [window({ type: 'weekly', label: 'Gemini 4 Argon', model: 'Gemini 4 Argon', used_percent: 30, remaining_percent: 70 })], + raw: { name: 'agy', usage: { models: [{ model: 'Gemini 4 Argon Weekly Limit', usageLeft: 70 }] } }, +}); + +const CANONICAL = { + providers: [ + provider(), + AGY_PROVIDER, + provider({ + id: 'codex', + provider: 'codex', + status: 'error', + error: 'Timeout waiting for usage data', + windows: [], + raw: { name: 'codex', usage: {}, error: null }, + }), + ], +}; + +const originalFetch = globalThis.fetch; +let fetchCalls: string[] = []; +let fetchBody: unknown = {}; + +beforeEach(() => { + mode = 'external'; + fetchCalls = []; + recordedSnapshots.length = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + fetchCalls.push(input.toString()); + return new Response(JSON.stringify(fetchBody), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; +}); + +after(async () => { + globalThis.fetch = originalFetch; + await closeConnection(); +}); + +test('canonical providers parse to a map keyed by id carrying the raw legacy usage', () => { + const agents = parseAgentTankStatusMap(CANONICAL); + + assert.deepEqual(Object.keys(agents), ['claude', 'agy', 'codex']); + assert.equal(agents.claude.name, 'claude'); + assert.deepEqual(agents.claude.usage, CLAUDE_RAW.usage); + assert.deepEqual(agents.claude.metadata, { plan: 'max' }); + assert.equal(agents.claude.plan, 'max'); + assert.equal(agents.claude.error, null); + assert.equal(agents.claude.isRefreshing, undefined); + // The canonical freshness is authoritative over the raw payload's own. + assert.equal(agents.claude.lastUpdated, '2026-10-17T10:05:00.000Z'); +}); + +test('canonical windows are parsed field by field, dropping unknown extras', () => { + const [parsed] = parseAgentTankStatusMap(CANONICAL).claude.windows ?? []; + assert.deepEqual(parsed, { + type: 'session', + label: 'Current session', + model: null, + used_percent: 42, + remaining_percent: 58, + resets_at: '2026-10-17T11:00:00.000Z', + resets_in_seconds: 3600, + }); +}); + +test('malformed window fields degrade to defaults and nulls', () => { + const status = parseAgentTankStatus(provider({ + windows: [{ used_percent: Number.NaN, model: 7, resets_in_seconds: '60' }, 'junk', null], + })); + assert.deepEqual(status?.windows, [{ + type: 'session', + label: '', + model: null, + used_percent: null, + remaining_percent: null, + resets_at: null, + resets_in_seconds: null, + }]); +}); + +test('antigravity is keyed by its agy id, refreshing is surfaced, and windows ride along', () => { + const agy = parseAgentTankStatusMap(CANONICAL).agy; + assert.equal(agy.name, 'agy'); + assert.equal(agy.isRefreshing, true); + assert.equal(agy.plan, null); + assert.deepEqual(agy.usage, { models: [{ model: 'Gemini 4 Argon Weekly Limit', usageLeft: 70 }] }); + assert.equal(agy.windows?.[0].model, 'Gemini 4 Argon'); + assert.equal(agy.windows?.[0].used_percent, 30); +}); + +test('a canonical error overrides a clean raw payload', () => { + const codex = parseAgentTankStatusMap(CANONICAL).codex; + assert.equal(codex.error, 'Timeout waiting for usage data'); + assert.deepEqual(codex.windows, []); + assert.equal(isUsableAgentTankStatus(codex), false); +}); + +test('a provider without id falls back to the provider name; one without either is dropped', () => { + const agents = parseAgentTankStatusMap({ + providers: [provider({ id: undefined, provider: 'codex', raw: undefined }), provider({ id: '', provider: null }), 'junk'], + }); + assert.deepEqual(Object.keys(agents), ['codex']); + assert.equal(agents.codex.name, 'codex'); + assert.deepEqual(agents.codex.usage, {}); +}); + +test('an empty providers list parses to an empty map', () => { + assert.deepEqual(parseAgentTankStatusMap({ providers: [] }), {}); +}); + +test('non-object documents parse to an empty map', () => { + for (const value of [null, undefined, 'text', 42, [CLAUDE_RAW]]) { + assert.deepEqual(parseAgentTankStatusMap(value), {}); + } +}); + +test('the 0.9.x map keyed by agent passes through unchanged', () => { + const agents = parseAgentTankStatusMap({ claude: CLAUDE_RAW, agy: { usage: { models: [] }, isRefreshing: true }, bogus: 3 }); + assert.deepEqual(Object.keys(agents), ['claude', 'agy']); + assert.deepEqual(agents.claude, { + name: 'claude', + usage: CLAUDE_RAW.usage, + metadata: { plan: 'max' }, + lastUpdated: '2026-10-17T10:00:00.000Z', + error: null, + }); + assert.equal(agents.agy.name, 'agy'); + assert.equal(agents.agy.isRefreshing, true); + assert.equal(agents.agy.windows, undefined); +}); + +test('an { agents: {...} } envelope around the legacy map is unwrapped', () => { + const agents = parseAgentTankStatusMap({ agents: { claude: CLAUDE_RAW } }); + assert.deepEqual(Object.keys(agents), ['claude']); + assert.deepEqual(agents.claude.usage, CLAUDE_RAW.usage); +}); + +test('bundled --once output with banner lines before canonical JSON parses', () => { + const stdout = `Agent Tank v0.10.0\nRefreshing 2 providers...\n${JSON.stringify(CANONICAL, null, 2)}\n`; + const agents = parseBundledAgentTankOutput(stdout); + assert.deepEqual(Object.keys(agents), ['claude', 'agy', 'codex']); + assert.equal(agents.agy.isRefreshing, true); + assert.equal(agents.claude.windows?.length, 1); +}); + +test('bundled output that is empty, brace-less or broken parses to an empty map', () => { + assert.deepEqual(parseBundledAgentTankOutput(''), {}); + assert.deepEqual(parseBundledAgentTankOutput('no json here'), {}); + assert.deepEqual(parseBundledAgentTankOutput('banner\n{"providers": ['), {}); +}); + +test('windows with a used percentage are usable even when legacy usage is empty', () => { + const base: AgentStatusResponse = { name: 'claude', usage: {} }; + assert.equal(isUsableAgentTankStatus(base), false); + assert.equal(isUsableAgentTankStatus({ ...base, windows: [window({ used_percent: null })] as never }), false); + assert.equal(isUsableAgentTankStatus({ ...base, windows: [window({ used_percent: 0 })] as never }), true); + assert.equal( + isUsableAgentTankStatus({ ...base, error: 'boom', windows: [window()] as never }), + false, + 'an error still disqualifies a status that carries windows', + ); +}); + +test('external getAllStatuses reads the 0.10 shape, maps agy to antigravity and records history', async () => { + fetchBody = CANONICAL; + const agents = await getAllStatuses(); + + assert.deepEqual(fetchCalls, ['http://tank.test:3456/status']); + assert.deepEqual(Object.keys(agents ?? {}).sort(), ['antigravity', 'claude', 'codex']); + assert.equal(agents?.antigravity.name, 'antigravity'); + assert.equal(agents?.antigravity.isRefreshing, true); + assert.deepEqual(agents?.claude.usage, CLAUDE_RAW.usage); + assert.equal(agents?.codex.error, 'Timeout waiting for usage data'); + assert.deepEqual(recordedSnapshots, [agents]); +}); + +test('external getStatus reads one canonical provider object', async () => { + fetchBody = AGY_PROVIDER; + const status = await getStatus('antigravity'); + + assert.deepEqual(fetchCalls, ['http://tank.test:3456/status/agy']); + assert.equal(status.name, 'antigravity'); + assert.equal(status.isRefreshing, true); + assert.equal(status.windows?.[0].model, 'Gemini 4 Argon'); + assert.deepEqual(status.usage, { models: [{ model: 'Gemini 4 Argon Weekly Limit', usageLeft: 70 }] }); +}); + +test('external getStatus still accepts a bare 0.9.x payload', async () => { + fetchBody = { usage: { session: { percent: 9 } } }; + const status = await getStatus('claude'); + + assert.equal(status.name, 'claude'); + assert.deepEqual(status.usage, { session: { percent: 9 } }); + assert.equal(status.windows, undefined); +}); + +test('external getStatus rejects a document that is not an object', async () => { + fetchBody = [1, 2]; + await assert.rejects(() => getStatus('claude'), /unreadable status document/); +}); + +test('claude usage flattens to one percentage row per known section', () => { + const rows = extractAgentTankUsageMetrics('claude', { + session: { percent: 42.456 }, + weeklyAll: { percent: 17, label: 'All models' }, + weeklyFable: { percent: 120 }, + weeklySonnet: { percent: 'n/a' }, + extraUsage: { percent: 99 }, + }); + assert.deepEqual(rows, [ + { key: 'session', label: 'Current session', model: null, cycle: 'session', percentUsed: 42.46 }, + { key: 'weeklyAll', label: 'All models', model: null, cycle: 'weekly', percentUsed: 17 }, + { key: 'weeklyFable', label: 'Current week (Fable)', model: 'Fable', cycle: 'weekly', percentUsed: 100 }, + ]); +}); + +test('codex usage reads percentUsed or percentLeft, including per-model limits', () => { + const rows = extractAgentTankUsageMetrics('codex', { + fiveHour: { percentUsed: 12 }, + weekly: { percentLeft: 75 }, + credits: { balance: 40 }, + modelLimits: [ + { name: 'GPT-6 Spark', fiveHour: { percentLeft: 90 }, weekly: { percentUsed: 5, label: 'Week' } }, + { fiveHour: { percentUsed: 1 } }, + 'junk', + ], + }); + assert.deepEqual(rows, [ + { key: 'fiveHour', label: '5h limit', model: null, cycle: 'five_hour', percentUsed: 12 }, + { key: 'weekly', label: 'Weekly limit', model: null, cycle: 'weekly', percentUsed: 25 }, + { key: 'GPT-6 Spark:fiveHour', label: 'GPT-6 Spark 5h limit', model: 'GPT-6 Spark', cycle: 'five_hour', percentUsed: 10 }, + { key: 'GPT-6 Spark:weekly', label: 'GPT-6 Spark Week', model: 'GPT-6 Spark', cycle: 'weekly', percentUsed: 5 }, + { key: 'Model:fiveHour', label: 'Model 5h limit', model: 'Model', cycle: 'five_hour', percentUsed: 1 }, + ]); +}); + +test('antigravity models derive usage from usageLeft and keep the Gemini 4 Argon group', () => { + const usage = { + models: [ + { model: 'Gemini 4 Argon Weekly Limit', group: 'Gemini 4 Argon', cycle: 'weekly', usageLeft: 70, percentUsed: 30 }, + { model: 'Gemini 3 Pro', cycle: 'fiveHour', percentUsed: 55 }, + { model: 'Claude Fable', cycle: 'unknown', usageLeft: 100 }, + { model: '', usageLeft: 10 }, + { model: 'No data' }, + ], + }; + const expected = [ + { key: 'model:Gemini 4 Argon Weekly Limit', label: 'Gemini 4 Argon Weekly Limit', model: 'Gemini 4 Argon', cycle: 'weekly', percentUsed: 30 }, + { key: 'model:Gemini 3 Pro', label: 'Gemini 3 Pro', model: 'Gemini 3 Pro', cycle: 'five_hour', percentUsed: 55 }, + { key: 'model:Claude Fable', label: 'Claude Fable', model: 'Claude Fable', cycle: 'session', percentUsed: 0 }, + ]; + // Both the ProPR name and the Agent Tank id select the antigravity extractor. + assert.deepEqual(extractAgentTankUsageMetrics('antigravity', usage), expected); + assert.deepEqual(extractAgentTankUsageMetrics('agy', usage), expected); +}); + +test('unknown agents and non-object usage produce no metrics', () => { + assert.deepEqual(extractAgentTankUsageMetrics('cursor', { session: { percent: 5 } }), []); + assert.deepEqual(extractAgentTankUsageMetrics('claude', null), []); + assert.deepEqual(extractAgentTankUsageMetrics('claude', [{ percent: 5 }]), []); + assert.deepEqual(extractAgentTankUsageMetrics('antigravity', { models: 'none' }), []); +}); diff --git a/test/agentTankUsageHistory.test.ts b/test/agentTankUsageHistory.test.ts new file mode 100644 index 0000000000..9d07f14317 --- /dev/null +++ b/test/agentTankUsageHistory.test.ts @@ -0,0 +1,133 @@ +import { after, afterEach, beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import knex, { type Knex } from 'knex'; + +process.env.NODE_ENV = 'test'; + +const { up } = await import('../packages/core/src/db/migrations/20261017230000_create_agent_tank_usage_history.js'); +const { + AGENT_TANK_HISTORY_RETENTION_MS, + readLocalAgentTankUsageHistory, + recordAgentTankUsageHistory, + resetAgentTankUsageHistoryTracking, +} = await import('../packages/core/src/services/agentTankUsageHistory.js'); +const { downsampleHistoryPoints } = await import('../packages/core/src/services/agentTankHistoryService.js'); +const { closeConnection } = await import('../packages/core/src/db/connection.js'); + +const MINUTE = 60_000; +const T0 = Date.parse('2026-10-17T12:00:00.000Z'); + +let database: Knex; + +beforeEach(async () => { + database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await up(database); + resetAgentTankUsageHistoryTracking(); +}); + +afterEach(async () => { + await database.destroy(); +}); + +after(async () => { + await closeConnection(); +}); + +const claude = (session: number, capturedAt: number, extra: Record = {}) => ({ + claude: { + name: 'claude', + usage: { session: { percent: session }, weeklyAll: { percent: 40 } }, + lastUpdated: new Date(capturedAt).toISOString(), + ...extra, + }, +}); + +const range = { since: T0 - 60 * MINUTE, until: T0 + 24 * 60 * MINUTE }; + +test('a snapshot is stored per metric and read back as series', async () => { + const written = await recordAgentTankUsageHistory(claude(10, T0), { database, now: T0 }); + assert.equal(written, 2); + await recordAgentTankUsageHistory(claude(25, T0 + MINUTE), { database, now: T0 + MINUTE }); + + const history = await readLocalAgentTankUsageHistory(range, database); + assert.equal(history.length, 1); + assert.equal(history[0].agent, 'claude'); + const session = history[0].series.find(series => series.key === 'session'); + assert.deepEqual(session, { + key: 'session', label: 'Current session', model: null, cycle: 'session', + points: [ + { t: new Date(T0).toISOString(), percent: 10 }, + { t: new Date(T0 + MINUTE).toISOString(), percent: 25 }, + ], + }); +}); + +test('the same capture, an older one, or an unchanged value inside the heartbeat is not stored again', async () => { + await recordAgentTankUsageHistory(claude(10, T0), { database, now: T0 }); + assert.equal(await recordAgentTankUsageHistory(claude(10, T0), { database, now: T0 + MINUTE }), 0); + assert.equal(await recordAgentTankUsageHistory(claude(12, T0 - MINUTE), { database, now: T0 + MINUTE }), 0); + assert.equal(await recordAgentTankUsageHistory(claude(10, T0 + 5 * MINUTE), { database, now: T0 + 5 * MINUTE }), 0); + // Past the 10-minute heartbeat an unchanged value anchors the line again. + assert.equal(await recordAgentTankUsageHistory(claude(10, T0 + 11 * MINUTE), { database, now: T0 + 11 * MINUTE }), 2); +}); + +test('a changed value is stored at once while its unchanged sibling is not', async () => { + await recordAgentTankUsageHistory(claude(10, T0), { database, now: T0 }); + const written = await recordAgentTankUsageHistory(claude(11, T0 + MINUTE), { database, now: T0 + MINUTE }); + assert.equal(written, 1); +}); + +test('a failed read keeps cached usage upstream and is not recorded', async () => { + const written = await recordAgentTankUsageHistory(claude(10, T0, { error: 'Timeout waiting for usage data' }), { database, now: T0 }); + assert.equal(written, 0); +}); + +test('a capture time in the future falls back to the observation time', async () => { + await recordAgentTankUsageHistory(claude(10, T0 + 60 * MINUTE), { database, now: T0 }); + const [row] = await database('agent_tank_usage_history').select('recorded_at_ms'); + assert.equal(Number(row.recorded_at_ms), T0); +}); + +test('rows older than retention are pruned', async () => { + await database('agent_tank_usage_history').insert({ + agent: 'claude', metric_key: 'session', metric_label: 'Current session', model: null, cycle: 'session', + percent_used: 5, recorded_at: new Date(T0 - AGENT_TANK_HISTORY_RETENTION_MS - MINUTE).toISOString(), + recorded_at_ms: T0 - AGENT_TANK_HISTORY_RETENTION_MS - MINUTE, + }); + await recordAgentTankUsageHistory(claude(10, T0), { database, now: T0 }); + const rows = await database('agent_tank_usage_history').count({ count: '*' }); + assert.equal(Number(rows[0].count), 2); +}); + +test('reads filter by agent, metric and time range', async () => { + await recordAgentTankUsageHistory({ + ...claude(10, T0), + antigravity: { + name: 'antigravity', + usage: { models: [{ model: 'Gemini 4 Argon Weekly Limit', group: 'Gemini 4 Argon', cycle: 'weekly', usageLeft: 70, percentUsed: 30 }] }, + lastUpdated: new Date(T0).toISOString(), + }, + }, { database, now: T0 }); + + const argon = await readLocalAgentTankUsageHistory({ ...range, agents: ['antigravity'] }, database); + assert.deepEqual(argon.map(entry => entry.agent), ['antigravity']); + assert.deepEqual(argon[0].series[0], { + key: 'model:Gemini 4 Argon Weekly Limit', label: 'Gemini 4 Argon Weekly Limit', + model: 'Gemini 4 Argon', cycle: 'weekly', points: [{ t: new Date(T0).toISOString(), percent: 30 }], + }); + + const weekly = await readLocalAgentTankUsageHistory({ ...range, metric: 'weeklyAll' }, database); + assert.deepEqual(weekly.map(entry => entry.series.map(series => series.key)), [['weeklyAll']]); + + const before = await readLocalAgentTankUsageHistory({ since: T0 - 120 * MINUTE, until: T0 - MINUTE }, database); + assert.deepEqual(before, []); +}); + +test('downsampling keeps the first and last point and at most the cap', () => { + const points = Array.from({ length: 1000 }, (_, index) => ({ t: new Date(T0 + index * MINUTE).toISOString(), percent: index % 100 })); + const thinned = downsampleHistoryPoints(points, 50); + assert.ok(thinned.length <= 50); + assert.deepEqual(thinned[0], points[0]); + assert.deepEqual(thinned[thinned.length - 1], points[points.length - 1]); + assert.deepEqual(downsampleHistoryPoints(points.slice(0, 10), 50), points.slice(0, 10)); +}); From c3765b2117f604e98263ce8c4bd79031e27fbb9c Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:28:59 +0000 Subject: [PATCH 4/7] feat(ai): F1, F2 and F3 are fixed, each with regression tests. Every new backend and UI unit test failed against the old code and passes now. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1, F2 and F3 are fixed, each with regression tests. Every new backend and UI unit test failed against the old code and passes now. **F1: metrics past eight can now be selected** (`AgentUsageHistory.tsx`, `usageHistoryView.ts`) - Every series gets a chip. The eight-line limit now applies only to what the chart draws: the first eight selected series. The table lists every selected series. - A new helper, `planPlottedSeries`, picks which series are drawn and their colors. Drawn series keep their usual color, so nothing changes when there are eight or fewer. A series past the palette takes the color of a line you've hidden, so hiding one metric lets an extra one onto the chart without recoloring the rest. - A selected series that isn't drawn shows a hollow dot and a "not plotted" label. The message now reads "N selected metrics are not plotted… hide one to plot another, or see them all in the table". - Tests: a component test with 10 series (all chips present, swapping a line in, table shows all selected), a test for the color rule, and a new Playwright case. **F2: lowest and highest readings now match the recorded data** (`downsampleHistoryPoints`) - Thinning now keeps the lowest and highest reading in each time bucket, in time order, plus the first and last points. It stays within the point limit. - Every recorded extreme reaches the client, so the table's lowest and highest values are exact. Short spikes also stay visible on the chart. - Points with no percentage are never picked as an extreme. Points that all share one timestamp are handled too. - Test: your example (1,200 readings, a 100% spike at the second reading) now keeps 100%, plus an interior minimum and the same-timestamp case. **F3: a failed write no longer blocks retries** (`agentTankUsageHistory.ts`) - The duplicate check no longer updates its record of the last stored reading per metric while scanning. It saves those values only after the database insert succeeds. If the insert fails, nothing is saved, so the same cached snapshot is retried later. - Recording attempts now run one at a time. A later attempt waits for the one in progress, so it never deduplicates against a write that hasn't succeeded yet, and a failed attempt can't overwrite a newer successful one. - Tests: a database that rejects inserts, then recovers and stores the same snapshot; and two concurrent attempts where the first fails, the second succeeds, and the failed older snapshot isn't stored afterwards. **Checks run:** - History storage suite: 11/11 pass. - History endpoint suite: 14/14 pass (it needs `--experimental-test-module-mocks`). - Analytics and usage-history UI tests: 62/62 pass. - The Playwright spec: 5/5 pass in Chromium. - `tsc` passes for core and the UI, and eslint with zero warnings passes on the changed core, UI and e2e files. The root `test/` file isn't covered by any eslint config. **Separate issue, not fixed:** the cleanup of rows older than 15 days records its run time before the delete runs. If the delete fails, it won't retry for an hour. That's outside F3, which is about the duplicate check. I attached a preview of the Analytics pane with ten series, after one line was swapped for an extra metric. It uses mocked data. PR: #3218 Comment by: @integry (ID: 6254080496) Model: Claude Opus 5.5 --- .../src/services/agentTankHistoryService.ts | 45 +++++++++--- .../src/services/agentTankUsageHistory.ts | 40 +++++++++-- propr-ui/e2e/agent-tank-usage-history.pw.ts | 31 +++++++- .../Analytics/AgentUsageHistory.test.tsx | 51 ++++++++++++- .../Analytics/AgentUsageHistory.tsx | 36 ++++++---- .../components/Analytics/usageHistoryView.ts | 22 +++++- test/agentTankUsageHistory.test.ts | 72 +++++++++++++++++++ 7 files changed, 263 insertions(+), 34 deletions(-) diff --git a/packages/core/src/services/agentTankHistoryService.ts b/packages/core/src/services/agentTankHistoryService.ts index fba1022e3b..de45c219fd 100644 --- a/packages/core/src/services/agentTankHistoryService.ts +++ b/packages/core/src/services/agentTankHistoryService.ts @@ -32,28 +32,53 @@ function isRecord(value: unknown): value is Record { } /** - * Thin a series to at most `maxPoints` by keeping the last point of each equal - * time bucket. The first and last points always survive, so the line still - * spans the whole range and ends on the current value. + * Thin a series to at most `maxPoints` by keeping the lowest and the highest + * reading of each equal time bucket, in time order. Every recorded extreme + * therefore survives, so a short spike still reaches the chart and the range's + * lowest and highest readings are the recorded ones. The first and last points + * always survive too, so the line still spans the whole range and ends on the + * current value. */ export function downsampleHistoryPoints( points: AgentTankHistoryPoint[], maxPoints: number = MAX_HISTORY_POINTS_PER_SERIES, ): AgentTankHistoryPoint[] { - if (points.length <= maxPoints || maxPoints < 3) return points; + if (points.length <= maxPoints || maxPoints < 4) return points; const first = Date.parse(points[0].t); const last = Date.parse(points[points.length - 1].t); const span = last - first; - if (!Number.isFinite(span) || span <= 0) return points.slice(-maxPoints); - const buckets = maxPoints - 2; + // Without a usable time span every interior point shares one bucket. + const timed = Number.isFinite(span) && span > 0; + const buckets = Math.floor((maxPoints - 2) / 2); const kept: AgentTankHistoryPoint[] = [points[0]]; + let bucketStart = 1; + const flush = (end: number) => { + if (end <= bucketStart) return; + let low = bucketStart; + let high = bucketStart; + for (let index = bucketStart + 1; index < end; index++) { + const value = points[index].percent; + if (value === null) continue; + const lowest = points[low].percent; + const highest = points[high].percent; + if (lowest === null || value < lowest) low = index; + if (highest === null || value > highest) high = index; + } + kept.push(points[Math.min(low, high)]); + if (low !== high) kept.push(points[Math.max(low, high)]); + }; let currentBucket = -1; for (let index = 1; index < points.length - 1; index++) { - const bucket = Math.min(buckets - 1, Math.floor(((Date.parse(points[index].t) - first) / span) * buckets)); - if (bucket === currentBucket) kept[kept.length - 1] = points[index]; - else kept.push(points[index]); - currentBucket = bucket; + const bucket = timed + ? Math.max(0, Math.min(buckets - 1, Math.floor(((Date.parse(points[index].t) - first) / span) * buckets))) + : 0; + if (bucket !== currentBucket) { + flush(index); + bucketStart = index; + currentBucket = bucket; + } } + flush(points.length - 1); kept.push(points[points.length - 1]); return kept; } diff --git a/packages/core/src/services/agentTankUsageHistory.ts b/packages/core/src/services/agentTankUsageHistory.ts index 6d2fc3a116..d0698b1c54 100644 --- a/packages/core/src/services/agentTankUsageHistory.ts +++ b/packages/core/src/services/agentTankUsageHistory.ts @@ -40,6 +40,12 @@ interface LastRecorded { /** Last row written per `${agent}\u0000${metric}`, process-local by design. */ const lastRecorded = new Map(); let lastPrunedAt = 0; +/** + * Tail of the recording queue. Each attempt reads the baselines, writes, and + * only then advances them, so a concurrent attempt must not read baselines a + * write still in flight may yet fail to earn. + */ +let recordingTail: Promise = Promise.resolve(); export const AGENT_TANK_HISTORY_RETENTION_MS = AGENT_TANK_HISTORY_RETENTION_DAYS * DAY_MS; @@ -60,9 +66,20 @@ interface HistoryRow { recorded_at_ms: number; } -/** Rows this snapshot adds, after per-series deduplication. */ -function rowsToRecord(agents: Record, now: number): HistoryRow[] { +interface PendingRecord { + rows: HistoryRow[]; + /** Baselines to commit once the rows are stored. */ + baselines: Map; +} + +/** + * Rows this snapshot adds, after per-series deduplication. Reads the + * baselines without moving them: an insert that fails must leave the + * snapshot eligible for the next attempt. + */ +function rowsToRecord(agents: Record, now: number): PendingRecord { const rows: HistoryRow[] = []; + const baselines = new Map(); for (const [key, status] of Object.entries(agents)) { // A failed read keeps cached usage upstream; recording it again would // claim a fresh observation of a value nobody re-measured. @@ -71,13 +88,13 @@ function rowsToRecord(agents: Record, now: number): const capturedAtMs = captureTime(status, now); for (const metric of extractAgentTankUsageMetrics(agent, status.usage)) { const seriesKey = `${agent}\u0000${metric.key}`; - const previous = lastRecorded.get(seriesKey); + const previous = baselines.get(seriesKey) ?? lastRecorded.get(seriesKey); if (previous) { if (capturedAtMs <= previous.capturedAtMs) continue; const unchanged = previous.percent === metric.percentUsed; if (unchanged && capturedAtMs - previous.capturedAtMs < UNCHANGED_HEARTBEAT_MS) continue; } - lastRecorded.set(seriesKey, { capturedAtMs, percent: metric.percentUsed }); + baselines.set(seriesKey, { capturedAtMs, percent: metric.percentUsed }); rows.push({ agent, metric_key: metric.key, @@ -90,7 +107,7 @@ function rowsToRecord(agents: Record, now: number): }); } } - return rows; + return { rows, baselines }; } /** @@ -104,9 +121,18 @@ export async function recordAgentTankUsageHistory( { database = db, now = Date.now() }: { database?: Knex; now?: number } = {}, ): Promise { if (!agents) return 0; + const attempt = recordingTail.then(() => recordSnapshot(agents, database, now)); + recordingTail = attempt; + return attempt; +} + +async function recordSnapshot(agents: Record, database: Knex, now: number): Promise { try { - const rows = rowsToRecord(agents, now); - if (rows.length > 0) await database(TABLE).insert(rows); + const { rows, baselines } = rowsToRecord(agents, now); + if (rows.length > 0) { + await database(TABLE).insert(rows); + for (const [seriesKey, baseline] of baselines) lastRecorded.set(seriesKey, baseline); + } if (now - lastPrunedAt >= PRUNE_INTERVAL_MS) { lastPrunedAt = now; await database(TABLE).where('recorded_at_ms', '<', now - AGENT_TANK_HISTORY_RETENTION_MS).delete(); diff --git a/propr-ui/e2e/agent-tank-usage-history.pw.ts b/propr-ui/e2e/agent-tank-usage-history.pw.ts index 08f8dec7d7..08a76c35e9 100644 --- a/propr-ui/e2e/agent-tank-usage-history.pw.ts +++ b/propr-ui/e2e/agent-tank-usage-history.pw.ts @@ -45,7 +45,12 @@ const SERIES: Record ({ + key: `model:${name} Weekly Limit`, label: `${name} Weekly Limit`, cycle: 'weekly', period: 168, peak: 20 + index * 12, offset: 30 + index * 25, +})); + +async function stubHistory(page: Page, requests: URL[] = [], extra: typeof SERIES = {}) { await page.route('**/api/config/agent-tank/usage', route => route.fulfill({ json: usage })); await page.route('**/api/config/agent-tank/history?**', (route: Route) => { const url = new URL(route.request().url()); @@ -57,7 +62,7 @@ async function stubHistory(page: Page, requests: URL[] = []) { const metric = url.searchParams.get('metric'); return route.fulfill({ json: { enabled: true, source: 'propr', retentionDays: 15, agents: agents.map(agent => ({ agent, - series: (SERIES[agent] ?? []).filter(entry => !metric || entry.key === metric).map(entry => ({ + series: [...(SERIES[agent] ?? []), ...(extra[agent] ?? [])].filter(entry => !metric || entry.key === metric).map(entry => ({ key: entry.key, label: entry.label, model: null, cycle: entry.cycle, points: sawtooth(entry.period, entry.peak, until, hours, entry.offset), })), @@ -96,6 +101,28 @@ test('the Analytics page plots every agent and metric, and narrows to a few', as await page.screenshot({ path: `${PREVIEWS}/analytics-usage-desktop.png`, clip: await pane.boundingBox() ?? undefined }); }); +test('metrics past the eight-line limit stay selectable and can replace a drawn line', async ({ page }) => { + await fixture(page, { width: 1440, height: 900 }); + await stubHistory(page, [], { antigravity: EXTRA_POOLS }); + await page.goto('/analytics'); + + const pane = page.getByTestId('analytics-usage-history'); + const lines = pane.getByTestId('agent-usage-history-chart').locator('.recharts-line'); + await expect(lines).toHaveCount(8); + await expect(pane.getByRole('button', { name: /, not plotted$/ })).toHaveCount(2); + await expect(pane.getByText('2 selected metrics are not plotted.', { exact: false })).toBeVisible(); + + await pane.getByRole('button', { name: /^Claude · Fable/ }).click(); + await expect(lines).toHaveCount(8); + await expect(pane.getByRole('button', { name: /GPT-OSS Weekly \d+%, not plotted$/ })).toBeVisible(); + await expect(pane.getByRole('button', { name: /Gemini 4 Flash Weekly \d+%$/ })).toBeVisible(); + await page.screenshot({ path: `${PREVIEWS}/analytics-usage-over-limit.png`, clip: await pane.boundingBox() ?? undefined }); + + await pane.getByRole('button', { name: 'Table' }).click(); + await expect(pane.getByTestId('agent-usage-history-table').getByRole('row')).toHaveCount(1 + 9); + await expect(pane.getByTestId('agent-usage-history-table').getByRole('row', { name: /GPT-OSS/ })).toBeVisible(); +}); + test('the Analytics usage chart fits a phone', async ({ page }) => { await fixture(page, { width: 390, height: 844 }); await stubHistory(page); diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx index e83ef5e9dd..d1deedd661 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx @@ -3,7 +3,15 @@ import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAgentTankHistory, type AgentTankHistoryResponse } from '../../api/revertApi'; import AgentUsageHistory from './AgentUsageHistory'; -import { buildChartRows, parseUsageHistoryView, toPlottedSeries, writeUsageHistoryView } from './usageHistoryView'; +import { USAGE_SERIES_COLORS } from '../usageHistory/usageHistoryModel'; +import { + UNPLOTTED_SERIES_COLOR, + buildChartRows, + parseUsageHistoryView, + planPlottedSeries, + toPlottedSeries, + writeUsageHistoryView, +} from './usageHistoryView'; vi.mock('../../api/revertApi', () => ({ getAgentTankHistory: vi.fn() })); @@ -125,6 +133,31 @@ describe('AgentUsageHistory', () => { await waitFor(() => expect(second.container.querySelector('[data-testid="analytics-usage-history"]')).toBeNull()); }); + it('keeps metrics past the eight-line limit selectable and swaps them onto the chart', async () => { + const many = Array.from({ length: 10 }, (_, index) => series(`model:Pool ${index + 1}`, `Pool ${index + 1}`, [index, index + 1])); + mockHistory.mockResolvedValue({ enabled: true, source: 'propr', agents: [{ agent: 'antigravity', series: many }] }); + renderPane(); + const filters = await screen.findByRole('group', { name: 'Filter usage history' }); + + // Every metric has a chip; the two past the palette are selected but not drawn. + expect(within(filters).getAllByRole('button', { name: /Antigravity · Pool/ })).toHaveLength(10); + expect(within(filters).getByRole('button', { name: /Pool 9 9%, not plotted/, pressed: true })).toBeInTheDocument(); + expect(within(filters).getByRole('button', { name: /Pool 10 10%, not plotted/, pressed: true })).toBeInTheDocument(); + expect(screen.getByText(/2 selected metrics are not plotted/)).toBeInTheDocument(); + + // Hiding a drawn metric hands its line to the next selected one. + fireEvent.click(within(filters).getByRole('button', { name: /Pool 1 1%/ })); + expect(within(filters).getByRole('button', { name: /^Antigravity · Pool 9 9%$/, pressed: true })).toBeInTheDocument(); + expect(within(filters).getByRole('button', { name: /Pool 10 10%, not plotted/ })).toBeInTheDocument(); + expect(screen.getByText(/1 selected metric is not plotted/)).toBeInTheDocument(); + + // The table lists every selected metric, drawn or not. + fireEvent.click(screen.getByRole('button', { name: 'Table' })); + const table = screen.getByTestId('agent-usage-history-table'); + expect(within(table).getAllByRole('row')).toHaveLength(1 + 9); + expect(within(table).getByRole('row', { name: /Pool 10/ })).toHaveTextContent('10%'); + }); + it('explains an empty history', async () => { mockHistory.mockResolvedValue({ enabled: true, agents: [] }); renderPane(); @@ -142,6 +175,22 @@ describe('usage history view', () => { .toBe('period=7d'); }); + it('gives a series past the palette a color no drawn series is using', () => { + const make = (index: number, color: string) => toPlottedSeries({ + ...series(`k${index}`, `k${index}`, [1]), id: `x:k${index}`, agent: 'x', displayLabel: `k${index}`, + }, color); + const palette = [...USAGE_SERIES_COLORS]; + const all = [...palette.map((color, index) => make(index, color)), make(8, UNPLOTTED_SERIES_COLOR), make(9, UNPLOTTED_SERIES_COLOR)]; + + const full = planPlottedSeries(all); + expect(full.drawn.map(entry => entry.color)).toEqual(palette); + expect(full.undrawn.map(entry => entry.id)).toEqual(['x:k8', 'x:k9']); + + const swapped = planPlottedSeries(all.filter(entry => entry.id !== 'x:k2')); + expect(swapped.drawn.map(entry => entry.color)).toEqual([...palette.filter((_, index) => index !== 2), palette[2]]); + expect(swapped.undrawn.map(entry => [entry.id, entry.color])).toEqual([['x:k9', UNPLOTTED_SERIES_COLOR]]); + }); + it('carries each series forward between its own readings and not past them', () => { const a = toPlottedSeries({ ...series('a', 'a', []), id: 'x:a', agent: 'x', displayLabel: 'a', points: [{ t: new Date(1000).toISOString(), percent: 10 }, { t: new Date(3000).toISOString(), percent: 30 }] }, '#000'); diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx index f41914c4a2..f45720ff07 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx @@ -7,7 +7,8 @@ * each with its latest value, so identity is never color alone. Colors come * from the order of every series the answer holds, not of the visible ones, * so hiding a line never repaints the others. Past eight series there is no - * ninth hue: the extra series start hidden and showing one means hiding another. + * ninth hue: every series stays selectable and in the table, but the chart draws + * the first eight selected ones, and hiding one lets the next take its color. * * History only reaches back about two weeks, so the pane has its own range * instead of following the page timeframe. It renders nothing when Agent Tank @@ -28,7 +29,9 @@ import { USAGE_RANGES, USAGE_RANGE_LABELS, USAGE_RANGE_MS, + UNPLOTTED_SERIES_COLOR, parseUsageHistoryView, + planPlottedSeries, toPlottedSeries, writeUsageHistoryView, type UsageHistoryView, @@ -107,14 +110,14 @@ const AgentUsageHistory: React.FC = () => { if (read.status !== 'ready') return []; const flat = flattenUsageHistory(read.data.agents); const colors = assignUsageSeriesColors(flat.map(series => series.id)); - return flat.map(series => toPlottedSeries(series, colors.get(series.id) ?? '#94A3B8')); + return flat.map(series => toPlottedSeries(series, colors.get(series.id) ?? UNPLOTTED_SERIES_COLOR)); }, [read]); const agents = useMemo(() => [...new Set(all.map(series => series.agent))], [all]); - // Series past the palette have no color of their own and stay off the chart. - const colored = all.slice(0, MAX_USAGE_SERIES); - const uncolored = all.length - colored.length; - const offered = colored.filter(series => !view.hiddenAgents.has(series.agent)); - const visible = offered.filter(series => !view.hiddenSeries.has(series.id)); + const offered = all.filter(series => !view.hiddenAgents.has(series.agent)); + const { drawn, undrawn } = planPlottedSeries(offered.filter(series => !view.hiddenSeries.has(series.id))); + // The table lists every selected series; only the chart is capped. + const visible = [...drawn, ...undrawn]; + const drawnColors = new Map(drawn.map(series => [series.id, series.color])); if (read.status === 'hidden') return null; @@ -149,7 +152,7 @@ const AgentUsageHistory: React.FC = () => { } else { body = showTable ? - : ; + : ; } return ( @@ -189,17 +192,23 @@ const AgentUsageHistory: React.FC = () => { {offered.filter(series => series.agent === agent).map(series => { const on = !view.hiddenSeries.has(series.id); const value = series.latest === null ? '' : ` ${Math.round(series.latest)}%`; + const color = drawnColors.get(series.id); + const unplotted = on && !color; return ( @@ -215,9 +224,10 @@ const AgentUsageHistory: React.FC = () => { )}
)} - {uncolored > 0 && ( + {undrawn.length > 0 && !showTable && (

- {uncolored} more {uncolored === 1 ? 'metric is' : 'metrics are'} not plotted. The chart shows up to {MAX_USAGE_SERIES} at once. + {undrawn.length} selected {undrawn.length === 1 ? 'metric is' : 'metrics are'} not plotted. The chart shows + up to {MAX_USAGE_SERIES} at once: hide one to plot another, or see them all in the table.

)} {body} diff --git a/propr-ui/src/components/Analytics/usageHistoryView.ts b/propr-ui/src/components/Analytics/usageHistoryView.ts index 971cbf6c54..c323bb72d6 100644 --- a/propr-ui/src/components/Analytics/usageHistoryView.ts +++ b/propr-ui/src/components/Analytics/usageHistoryView.ts @@ -6,7 +6,7 @@ * shows up by default instead of being silently filtered away. */ -import type { FlatUsageSeries } from '../usageHistory/usageHistoryModel'; +import { MAX_USAGE_SERIES, USAGE_SERIES_COLORS, type FlatUsageSeries } from '../usageHistory/usageHistoryModel'; const HOUR_MS = 60 * 60 * 1000; @@ -88,6 +88,26 @@ export function toPlottedSeries(series: FlatUsageSeries, color: string): Plotted }; } +/** Swatch of a series that is listed but has no line on the chart. */ +export const UNPLOTTED_SERIES_COLOR = '#94A3B8'; + +/** + * Which selected series the chart draws, and in what color. The chart draws + * the first `MAX_USAGE_SERIES` selected ones; the rest stay selected (and in + * the table) without a line. A drawn series keeps the color it was assigned + * from the whole answer; one past the palette takes a color no other drawn + * series is using, so swapping a line in never repaints the others. + */ +export function planPlottedSeries(selected: PlottedSeries[]): { drawn: PlottedSeries[]; undrawn: PlottedSeries[] } { + const drawn = selected.slice(0, MAX_USAGE_SERIES); + const used = new Set(drawn.map(entry => entry.color)); + const free = USAGE_SERIES_COLORS.filter(color => !used.has(color)); + return { + drawn: drawn.map(entry => (entry.color === UNPLOTTED_SERIES_COLOR ? { ...entry, color: free.shift() ?? entry.color } : entry)), + undrawn: selected.slice(MAX_USAGE_SERIES).map(entry => ({ ...entry, color: UNPLOTTED_SERIES_COLOR })), + }; +} + export type ChartRow = { t: number } & Record; /** diff --git a/test/agentTankUsageHistory.test.ts b/test/agentTankUsageHistory.test.ts index 9d07f14317..fc4cbe2277 100644 --- a/test/agentTankUsageHistory.test.ts +++ b/test/agentTankUsageHistory.test.ts @@ -131,3 +131,75 @@ test('downsampling keeps the first and last point and at most the cap', () => { assert.deepEqual(thinned[thinned.length - 1], points[points.length - 1]); assert.deepEqual(downsampleHistoryPoints(points.slice(0, 10), 50), points.slice(0, 10)); }); + +test('downsampling keeps every recorded extreme, not just the endpoints', () => { + const points = Array.from({ length: 1200 }, (_, index) => ({ + t: new Date(T0 + index * MINUTE).toISOString(), + percent: index === 1 ? 100 : index === 700 ? -1 : 0, + })); + const thinned = downsampleHistoryPoints(points); + assert.ok(thinned.length <= 600); + assert.equal(Math.max(...thinned.map(point => point.percent)), 100); + assert.equal(Math.min(...thinned.map(point => point.percent)), -1); + const times = thinned.map(point => Date.parse(point.t)); + assert.deepEqual(times, [...times].sort((a, b) => a - b)); + + const sameInstant = points.map(point => ({ ...point, t: points[0].t })); + const collapsed = downsampleHistoryPoints(sameInstant, 50); + assert.ok(collapsed.length <= 50); + assert.equal(Math.max(...collapsed.map(point => point.percent)), 100); + assert.equal(Math.min(...collapsed.map(point => point.percent)), -1); +}); + +/** A database whose inserts reject while `failing` is set. */ +function flakyDatabase(): { database: Knex; failing: { value: boolean } } { + const failing = { value: true }; + const wrapped = ((table: string) => { + const builder = database(table); + if (!failing.value) return builder; + return Object.assign(builder, { insert: () => Promise.reject(new Error('database is unavailable')) }); + }) as unknown as Knex; + return { database: wrapped, failing }; +} + +test('a failed write leaves the snapshot eligible for the next attempt', async () => { + const flaky = flakyDatabase(); + assert.equal(await recordAgentTankUsageHistory(claude(10, T0), { database: flaky.database, now: T0 }), 0); + + flaky.failing.value = false; + // The same cached capture is retried once storage recovers. + assert.equal(await recordAgentTankUsageHistory(claude(10, T0), { database: flaky.database, now: T0 + MINUTE }), 2); + assert.equal(await recordAgentTankUsageHistory(claude(10, T0), { database: flaky.database, now: T0 + 2 * MINUTE }), 0); + const rows = await database('agent_tank_usage_history').count({ count: '*' }); + assert.equal(Number(rows[0].count), 2); +}); + +test('concurrent attempts are serialized, so a failed one cannot undo a later success', async () => { + let releaseFirst: () => void = () => {}; + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + let call = 0; + const gated = ((table: string) => { + const builder = database(table); + return Object.assign(builder, { + insert: (rows: unknown) => { + call += 1; + if (call === 1) return firstGate.then(() => { throw new Error('database is unavailable'); }); + return database(table).insert(rows); + }, + }); + }) as unknown as Knex; + + const first = recordAgentTankUsageHistory(claude(10, T0), { database: gated, now: T0 }); + const second = recordAgentTankUsageHistory(claude(20, T0 + MINUTE), { database: gated, now: T0 + MINUTE }); + // The second attempt waits for the first instead of reading its uncommitted baselines. + await new Promise(resolve => setImmediate(resolve)); + assert.equal(call, 1); + releaseFirst(); + assert.equal(await first, 0); + assert.equal(await second, 2); + + // The successful, newer capture is the baseline: re-reading it stores nothing. + assert.equal(await recordAgentTankUsageHistory(claude(20, T0 + MINUTE), { database, now: T0 + 2 * MINUTE }), 0); + // The failed older capture is not resurrected behind it. + assert.equal(await recordAgentTankUsageHistory(claude(10, T0), { database, now: T0 + 2 * MINUTE }), 0); +}); From 3455113ff6ea742fe7ad7455abb9e0a446508996 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:47:50 +0000 Subject: [PATCH 5/7] feat(ai): I fixed F4, F5 and F6. Each new unit test failed on the previous code and passes now. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I fixed F4, F5 and F6. Each new unit test failed on the previous code and passes now. **F4: demo access exposed real usage history** - `GET /api/config/agent-tank/history` now requires the agent-management permission (`packages/api/routeRegistry.ts:121`). I didn't find any synthetic history to return, so refusing demo viewers was the safe choice. Demo viewers can still read the synthetic usage feed. - The UI side: in demo mode the sidebar metric rows no longer open a history overlay, so demo viewers don't get a 403 error inside it. The Analytics pane already hides itself when the request is refused. - Regressions: the route test now expects a 403 for demo viewers on the history endpoint, includes it in the member-denied and admin-allowed lists, and a UI test covers the demo sidebar. **F5: disabling history switched sidebar triggers off for good** - The "history is off" flag now: - turns off by itself after 60 seconds, the same as the cache. - clears as soon as the sidebar reads usage with Agent Tank enabled. - clears when any history response comes back enabled. - Clearing it also drops cached "off" answers, so the next hover or tap really asks the server again. - Rows now update straight away when the flag changes. Before, closed rows only noticed on their next render. - Regressions cover rows going inactive while history is off, coming back after re-enabling (with a fresh request), and the 60-second expiry for rows outside the sidebar. - One timing gap remains and I left it: if an older "enabled" usage read lands after history was reported off, the flag clears early. The only cost is one extra request when someone next hovers a row. **F6: Analytics showed raw model identifiers** - A new `readableSeriesLabel` in `usageHistoryModel.ts` puts model-derived labels through `getModelDisplayName`, the same helper the sidebar uses. `gemini-2.5-pro` now shows as "Gemini 2.5 Pro". - The same fix covers Codex per-model limits, which had the same raw-name problem. - Quota pool names such as "Claude and GPT · Weekly" and "Gemini 4 Argon Weekly" keep their wording. Series keys, colors and URL filters are unchanged. - The display name is now used for the chip text, accessible name, tooltip, chart legend and table. **Verification** - UI: typecheck and lint are clean, and 302 tests pass across the usage-history, Analytics, sidebar and Settings suites. - Backend: the route authorization, OpenAPI route coverage and history route suites pass (35 tests). - End-to-end: `agent-tank-usage-history.pw.ts` passes 5 of 5. **Something else I ran into:** the end-to-end spec's shared fixture signs in as a demo user. Its two sidebar-overlay tests only passed because of the F4 leak, so with the fix they failed. I changed that spec to sign in as a user with agent-management permission. I didn't attach previews. With the mocked data, none of these fixes changes anything you'd see on screen. PR: #3218 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- packages/api/routeRegistry.ts | 4 +- packages/api/test/routeAuthorization.test.ts | 4 +- propr-ui/e2e/agent-tank-usage-history.pw.ts | 6 +- propr-ui/src/components/AgentTankSidebar.tsx | 7 +- .../Analytics/AgentUsageHistory.test.tsx | 35 ++++++++++ .../Analytics/AgentUsageHistory.tsx | 4 +- .../UsageMetricHistoryOverlay.test.tsx | 68 ++++++++++++++++++- .../UsageMetricHistoryOverlay.tsx | 12 ++-- .../usageHistory/usageHistoryModel.ts | 33 +++++++-- .../usageHistory/useUsageMetricHistory.ts | 41 +++++++++-- 10 files changed, 194 insertions(+), 20 deletions(-) diff --git a/packages/api/routeRegistry.ts b/packages/api/routeRegistry.ts index ec34d11d5a..b8cbb298a7 100644 --- a/packages/api/routeRegistry.ts +++ b/packages/api/routeRegistry.ts @@ -116,7 +116,9 @@ export function createManagementRouteEntries({ ['post', '/api/config/agent-tank', requireManageAgents, configRoutes.postAgentTankSettings], ['get', '/api/config/agent-tank/status', requireManageAgents, configRoutes.getAgentTankStatus], ['get', '/api/config/agent-tank/usage', requireAgentTankUsageAccess, configRoutes.getAgentTankUsage], - ['get', '/api/config/agent-tank/history', requireAgentTankUsageAccess, configRoutes.getAgentTankHistory], + // History is read from this installation's own records and daemon; there is + // no synthetic demo history, so the demo exception does not extend to it. + ['get', '/api/config/agent-tank/history', requireManageAgents, configRoutes.getAgentTankHistory], ['post', '/api/config/agent-tank/refresh', requireManageAgents, configRoutes.postAgentTankRefresh], ['get', '/api/config/agent-tank/detect', requireManageAgents, configRoutes.getAgentTankDetect], ['get', '/api/config/preview-storage', requireManageSettings, visualPreviewAuthRoutes.getManagedStorageStatus], diff --git a/packages/api/test/routeAuthorization.test.ts b/packages/api/test/routeAuthorization.test.ts index 27fb492103..74de6046a3 100644 --- a/packages/api/test/routeAuthorization.test.ts +++ b/packages/api/test/routeAuthorization.test.ts @@ -86,6 +86,7 @@ const managementRequests = [ ['GET', '/api/config/synthetic-agents'], ['POST', '/api/config/synthetic-agents'], ['GET', '/api/config/agent-tank/usage'], + ['GET', '/api/config/agent-tank/history'], ['GET', '/api/admin/members'], ['GET', '/api/admin/followup-handoffs'], ['GET', '/api/admin/followup-handoffs/integry/propr/42'], @@ -153,7 +154,8 @@ describe('assembled instance permission routes', () => { const usageResponse = await fetch(`${origin}/api/config/agent-tank/usage`, { headers }); assert.equal(usageResponse.status, 200); - for (const path of ['/api/config/agent-tank', '/api/config/agent-tank/status']) { + // History is real installation data, not the synthetic usage feed. + for (const path of ['/api/config/agent-tank', '/api/config/agent-tank/status', '/api/config/agent-tank/history']) { const response = await fetch(`${origin}${path}`, { headers }); assert.equal(response.status, 403, path); } diff --git a/propr-ui/e2e/agent-tank-usage-history.pw.ts b/propr-ui/e2e/agent-tank-usage-history.pw.ts index 08a76c35e9..07d655f0e2 100644 --- a/propr-ui/e2e/agent-tank-usage-history.pw.ts +++ b/propr-ui/e2e/agent-tank-usage-history.pw.ts @@ -4,7 +4,7 @@ */ import { expect, test, type Page, type Route } from '@playwright/test'; -import { fixture, now } from './dashboard-sections.fixture'; +import { fixture, now, user } from './dashboard-sections.fixture'; const HOUR = 3_600_000; const PREVIEWS = '../.propr/previews'; @@ -51,6 +51,10 @@ const EXTRA_POOLS = ['Claude Fable 5.1', 'Gemini 4 Flash', 'GPT-OSS'].map((name, })); async function stubHistory(page: Page, requests: URL[] = [], extra: typeof SERIES = {}) { + // History is real installation data, so it is read by an agent manager, not + // the fixture's demo viewer (who sees only the synthetic usage feed). + await page.route('**/api/auth/demo-mode', route => route.fulfill({ json: { demoMode: false } })); + await page.route('**/api/auth/user', route => route.fulfill({ json: { ...user, permissions: ['instance.manage_agents'] } })); await page.route('**/api/config/agent-tank/usage', route => route.fulfill({ json: usage })); await page.route('**/api/config/agent-tank/history?**', (route: Route) => { const url = new URL(route.request().url()); diff --git a/propr-ui/src/components/AgentTankSidebar.tsx b/propr-ui/src/components/AgentTankSidebar.tsx index 0476babff3..11e06d9bac 100644 --- a/propr-ui/src/components/AgentTankSidebar.tsx +++ b/propr-ui/src/components/AgentTankSidebar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { ChevronDown, ChevronRight, Gauge, RefreshCw } from 'lucide-react'; import { getAgentTankUsage, refreshAgentTank, AgentTankUsageResponse, AgentUsageData } from '../api/revertApi'; import { ProviderLogo } from './ui/ProviderLogo'; @@ -15,6 +15,7 @@ import { import { SIDEBAR_ICON_STROKE_WIDTH, SIDEBAR_ICON_STROKE_CLASS } from './icons/sidebarIconStroke'; import { useLiveResource } from '../hooks/useLiveResource'; import { UsageMetricHistoryTrigger } from './usageHistory/UsageMetricHistoryOverlay'; +import { noteAgentTankUsageEnabled } from './usageHistory/useUsageMetricHistory'; // Which provider rows are expanded is a preference, not session state: someone // watching Claude's weekly quotas wants the same rows open after a reload. @@ -246,6 +247,10 @@ const AgentTankSidebar: React.FC = ({ // A failed read keeps the last good numbers; with nothing read yet the widget // stays out of the sidebar rather than showing an empty frame. const data = usage ?? (usageError ? { enabled: false } : null); + // Enabled usage means history is back too, after an earlier read found it off. + useEffect(() => { + if (usage?.enabled) noteAgentTankUsageEnabled(); + }, [usage]); const handleManualRefresh = useCallback(async () => { setRefreshing(true); diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx index d1deedd661..9a5895bf69 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx @@ -121,6 +121,41 @@ describe('AgentUsageHistory', () => { expect(row).toHaveTextContent('30%'); }); + it('names model-derived series by display name, keeping quota pool wording', async () => { + mockHistory.mockResolvedValue({ + enabled: true, + source: 'propr', + agents: [ + { + agent: 'antigravity', + series: [ + series('model:gemini-2.5-pro', 'gemini-2.5-pro', [10, 20]), + series('model:Claude and GPT · Weekly Limit Remaining', 'Claude and GPT · Weekly Limit Remaining', [5, 6]), + series('model:Gemini 4 Argon Weekly Limit', 'Gemini 4 Argon Weekly Limit', [20, 30]), + ], + }, + { agent: 'codex', series: [{ ...series('gpt-5.3-codex:weekly', 'gpt-5.3-codex Weekly limit', [7, 8]), model: 'gpt-5.3-codex' }] }, + ], + }); + renderPane(); + const filters = await screen.findByRole('group', { name: 'Filter usage history' }); + + const gemini = within(filters).getByRole('button', { name: /^Antigravity · Gemini 2\.5 Pro 20%/ }); + expect(gemini).toHaveTextContent('Gemini 2.5 Pro'); + expect(gemini).toHaveAttribute('title', 'Gemini 2.5 Pro'); + const codex = within(filters).getByRole('button', { name: /^Codex · GPT-5\.3 Codex Weekly 8%/ }); + expect(codex).toHaveAttribute('title', 'GPT-5.3 Codex Weekly limit'); + expect(within(filters).getByRole('button', { name: /^Antigravity · Claude and GPT · Weekly 6%/ })) + .toHaveAttribute('title', 'Claude and GPT · Weekly Limit'); + expect(within(filters).getByRole('button', { name: /^Antigravity · Gemini 4 Argon Weekly 30%/ })).toBeInTheDocument(); + expect(filters.innerHTML).not.toMatch(/gemini-2\.5-pro|gpt-5\.3-codex/); + + fireEvent.click(screen.getByRole('button', { name: 'Table' })); + const table = screen.getByTestId('agent-usage-history-table'); + expect(within(table).getByRole('row', { name: /Antigravity · Gemini 2\.5 Pro/ })).toBeInTheDocument(); + expect(table.innerHTML).not.toMatch(/gemini-2\.5-pro|gpt-5\.3-codex/); + }); + it('renders nothing when Agent Tank is off or usage is not readable', async () => { mockHistory.mockResolvedValueOnce({ enabled: false, agents: [] }); const { container, unmount } = renderPane(); diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx index f45720ff07..d3ad92cd12 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx @@ -22,7 +22,7 @@ import { getProviderDisplayName } from '../agentTankMetrics'; import { SectionHeading } from '../Dashboard/sectionPrimitives'; import { SkeletonBlock } from '../ui/Skeleton'; import { SystemAlert } from '../ui/SystemAlert'; -import { MAX_USAGE_SERIES, assignUsageSeriesColors, flattenUsageHistory, usageMetricLabel } from '../usageHistory/usageHistoryModel'; +import { MAX_USAGE_SERIES, assignUsageSeriesColors, flattenUsageHistory, readableSeriesLabel, usageMetricLabel } from '../usageHistory/usageHistoryModel'; import AgentUsageHistoryChart from './AgentUsageHistoryChart'; import AgentUsageHistoryTable from './AgentUsageHistoryTable'; import { @@ -202,7 +202,7 @@ const AgentUsageHistory: React.FC = () => { aria-label={`${series.displayLabel}${value}${unplotted ? ', not plotted' : ''}`} onClick={() => toggleSeries(series.id)} className={chipClass(on)} - title={unplotted ? `${series.label} (not plotted: hide another metric to plot it)` : series.label} + title={unplotted ? `${readableSeriesLabel(series)} (not plotted: hide another metric to plot it)` : readableSeriesLabel(series)} > { expect(await screen.findByText(/Not enough history yet/)).toBeInTheDocument(); }); - it('stops offering history once the backend reports it disabled', async () => { + it('stops offering history while the backend reports it disabled', async () => { + mockHistory.mockResolvedValue({ enabled: false, agents: [] }); + render(); + await expandProvider(/^Claude/); + fireEvent.click(metricRow('Session')); + expect(await screen.findByText(/not available/)).toBeInTheDocument(); + + // The other, closed rows go inert at once rather than on their next render. + expect(screen.getByText('Weekly').closest('[aria-haspopup="dialog"]')).toBeNull(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(document.querySelector('[aria-haspopup="dialog"]')).toBeNull(); + }); + + it('offers history again once Agent Tank is re-enabled', async () => { mockHistory.mockResolvedValue({ enabled: false, agents: [] }); const { unmount } = render(); await expandProvider(/^Claude/); @@ -205,9 +219,59 @@ describe('sidebar usage history overlay', () => { expect(await screen.findByText(/not available/)).toBeInTheDocument(); await act(async () => { unmount(); }); + // Re-enabled: the sidebar reads enabled usage again, and history is back. + mockHistory.mockImplementation(async query => history(query.agents?.[0] ?? '', query.metric ?? '', [5, 12, 17])); render(); await screen.findByText('Usage'); + await waitFor(() => expect(metricRow('Session')).not.toBeNull()); + fireEvent.click(metricRow('Session')); + + // The remembered "off" answer is not replayed: the row asks again. + expect(await screen.findByTestId('usage-metric-history-chart')).toBeInTheDocument(); + expect(mockHistory).toHaveBeenCalledTimes(2); + }); + + it('lets a disabled answer lapse, so rows outside the sidebar can retry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldAdvanceTime: true }); + try { + mockHistory.mockResolvedValue({ enabled: false, agents: [] }); + const metric = { label: 'Session', percent: 17, historyKey: 'session', cycle: 'session' }; + render( + <> + Claude session + Codex session + , + ); + fireEvent.click(metricRow('Claude session')); + expect(await screen.findByText(/not available/)).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(document.querySelector('[aria-haspopup="dialog"]')).toBeNull(); + + mockHistory.mockImplementation(async query => history(query.agents?.[0] ?? '', query.metric ?? '', [5, 12, 17])); + await act(async () => { vi.advanceTimersByTime(60_000); }); + + expect(document.querySelectorAll('[aria-haspopup="dialog"]')).toHaveLength(2); + fireEvent.click(metricRow('Claude session')); + expect(await screen.findByTestId('usage-metric-history-chart')).toBeInTheDocument(); + expect(mockHistory).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('does not offer real history to demo viewers', async () => { + render( + + + , + ); + await expandProvider(/^Claude/); + + expect(screen.getByText('Session')).toBeInTheDocument(); expect(document.querySelector('[aria-haspopup="dialog"]')).toBeNull(); + fireEvent.click(screen.getByText('Session')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockHistory).not.toHaveBeenCalled(); }); }); diff --git a/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx index 78619497f8..ee2d0ac434 100644 --- a/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx +++ b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.tsx @@ -18,7 +18,8 @@ import { getProviderDisplayName, getTextColor, type UsageMetric } from '../agent import { SkeletonBlock } from '../ui/Skeleton'; import UsageMetricHistoryChart from './UsageMetricHistoryChart'; import { currentPeriodStart, twoPeriodWindow } from './usageHistoryModel'; -import { isUsageHistoryDisabled, useUsageMetricHistory, type UsageMetricHistoryState } from './useUsageMetricHistory'; +import { useDemoMode } from '../../contexts/DemoModeContext'; +import { useUsageHistoryDisabled, useUsageMetricHistory, type UsageMetricHistoryState } from './useUsageMetricHistory'; export const HOVER_OPEN_DELAY_MS = 250; const HOVER_CLOSE_GRACE_MS = 150; @@ -149,11 +150,14 @@ interface UsageMetricHistoryTriggerProps { className?: string; } -/** Wraps a metric row so it opens that metric's history; inert without a history key. */ +/** Wraps a metric row so it opens that metric's history; inert without a history key or in demo mode. */ export const UsageMetricHistoryTrigger: React.FC = ({ agent, metric, children, className = '', }) => { - const historyKey = metric.historyKey; + const { isDemoMode } = useDemoMode(); + // Demo viewers see the synthetic usage feed but may not read real history. + const historyKey = isDemoMode ? undefined : metric.historyKey; + const historyDisabled = useUsageHistoryDisabled(); const [open, setOpen] = useState(false); const [pinned, setPinned] = useState(false); const [position, setPosition] = useState(null); @@ -212,7 +216,7 @@ export const UsageMetricHistoryTrigger: React.FC }, [open, close]); // An overlay that is already open keeps showing why history is unavailable. - if (!historyKey || (isUsageHistoryDisabled() && !open)) return
{children}
; + if (!historyKey || (historyDisabled && !open)) return
{children}
; const hoverIn = (event: React.PointerEvent) => { if (event.pointerType !== 'mouse') return; diff --git a/propr-ui/src/components/usageHistory/usageHistoryModel.ts b/propr-ui/src/components/usageHistory/usageHistoryModel.ts index b22624e95c..eef4d709fe 100644 --- a/propr-ui/src/components/usageHistory/usageHistoryModel.ts +++ b/propr-ui/src/components/usageHistory/usageHistoryModel.ts @@ -7,6 +7,7 @@ import { agentTankCyclePeriodMs } from '@propr/shared'; import type { AgentTankHistoryAgent, AgentTankHistoryPoint, AgentTankHistorySeries } from '../../api/revertApi'; import { getProviderDisplayName, getProviderRank } from '../agentTankMetrics'; +import { getModelDisplayName } from '../../utils/modelDisplay'; import { stripRemainingSuffix } from '../../utils/usageMetricLabels'; /** @@ -43,14 +44,38 @@ const METRIC_LABELS: Record = { fiveHour: 'Session', }; -/** Short label of a series within its agent ("Session", "Gemini 4 Argon · Weekly"). */ -export function usageMetricLabel(series: Pick): string { +type SeriesNaming = Pick; + +// Antigravity quota pools carry product wording ("Claude and GPT · Weekly +// Limit", "Gemini 4 Argon Weekly Limit") that a model formatter would mangle. +const isQuotaName = (name: string) => name.includes(' · ') || /\sLimit$/i.test(name); + +/** + * The series' full label, with any model identifier in it shown by its display + * name, as the sidebar shows it: an Antigravity `model:` series is labelled by + * its model id ("gemini-2.5-pro" → "Gemini 2.5 Pro"), a Codex per-model limit + * by the model followed by its window. + */ +export function readableSeriesLabel(series: SeriesNaming): string { + const label = series.label || series.key; + if (series.key.startsWith('model:')) { + const quotaName = stripRemainingSuffix(label); + return isQuotaName(quotaName) ? quotaName : stripRemainingSuffix(getModelDisplayName(label)); + } + if (series.model && series.key.startsWith(`${series.model}:`) && label.startsWith(`${series.model} `)) { + return stripRemainingSuffix(`${getModelDisplayName(series.model)}${label.slice(series.model.length)}`); + } + return stripRemainingSuffix(label); +} + +/** Short label of a series within its agent ("Session", "Gemini 4 Argon Weekly"). */ +export function usageMetricLabel(series: SeriesNaming): string { if (METRIC_LABELS[series.key]) return METRIC_LABELS[series.key]; - return stripRemainingSuffix(series.label || series.key).replace(/\s+Limit$/i, ''); + return readableSeriesLabel(series).replace(/\s+Limit$/i, ''); } /** Full label naming the agent too ("Claude · Session"). */ -export function usageSeriesLabel(agent: string, series: Pick): string { +export function usageSeriesLabel(agent: string, series: SeriesNaming): string { return `${getProviderDisplayName(agent)} · ${usageMetricLabel(series)}`; } diff --git a/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts b/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts index e0c6b15ecd..8649a54e59 100644 --- a/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts +++ b/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useSyncExternalStore } from 'react'; import { getAgentTankHistory, type AgentTankHistorySeries } from '../../api/revertApi'; import { twoPeriodWindow } from './usageHistoryModel'; @@ -15,17 +15,50 @@ interface CachedHistory { const cache = new Map(); -/** Set once the backend reports history is off, so rows stop offering it. */ +/** + * Set when the backend reports history is off, so closed rows stop offering + * it. It lapses with the cache, and clears as soon as anything sees Agent Tank + * enabled again, so re-enabling never needs a reload to bring history back. + */ let historyDisabled = false; +let disabledExpiry: ReturnType | undefined; +const listeners = new Set<() => void>(); + +function setHistoryDisabled(disabled: boolean): void { + clearTimeout(disabledExpiry); + disabledExpiry = disabled ? setTimeout(() => setHistoryDisabled(false), CACHE_TTL_MS) : undefined; + if (!disabled) { + // A remembered "off" answer would otherwise keep showing for its TTL. + for (const [key, entry] of cache) if (!entry.enabled) cache.delete(key); + } + if (historyDisabled === disabled) return; + historyDisabled = disabled; + listeners.forEach(listener => listener()); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} export function isUsageHistoryDisabled(): boolean { return historyDisabled; } +/** Re-renders when history becomes unavailable or available again. */ +export function useUsageHistoryDisabled(): boolean { + return useSyncExternalStore(subscribe, isUsageHistoryDisabled); +} + +/** Agent Tank reported enabled usage, so history is worth offering again. */ +export function noteAgentTankUsageEnabled(): void { + setHistoryDisabled(false); +} + /** Test-only seam. */ export function resetUsageMetricHistoryCache(): void { cache.clear(); - historyDisabled = false; + setHistoryDisabled(false); } export type UsageMetricHistoryState = @@ -62,8 +95,8 @@ export function useUsageMetricHistory( const series = response.agents .find(entry => entry.agent === agent)?.series .find(entry => entry.key === historyKey) ?? null; - if (!response.enabled) historyDisabled = true; cache.set(cacheKey, { fetchedAt: until, since, until, series, enabled: response.enabled }); + setHistoryDisabled(!response.enabled); setState({ status: 'ready', since, until, periodMs, series, enabled: response.enabled }); }) .catch((error: unknown) => { From bd54fe9de8417f408f89a410212958cc32e38099 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:11:10 +0000 Subject: [PATCH 6/7] feat(ai): I fixed F7: the Analytics usage chart now draws every selected metric. The eight-line limit is gone. I fixed F7: the Analytics usage chart now draws every selected metric. The eight-line limit is gone. **What changed** - **Line styles** (`usageHistoryModel.ts`): the first eight series stay solid in the existing colors. Each further group of eight reuses those colors with a dash pattern (long dash, then dotted, then dash-dot), so up to 32 series all look different. After 32 the styles repeat, but the labels on the metric buttons still tell them apart. As before, each metric's style comes from its position in the whole history, so hiding one never restyles the others. - **Rendering** (`usageHistoryView.ts`, `AgentUsageHistory.tsx`, `AgentUsageHistoryChart.tsx`): the chart now gets every selected series. I removed `planPlottedSeries`, the grey "not plotted" color, the "not plotted" labels on the metric buttons and the notice under the filters. The provider and metric toggles and "Show all" work as before. - **Matching samples:** a new `UsageSeriesSwatch.tsx` shows a short sample of each line, in its color and dash, on the metric buttons, in the chart tooltip and in the table. A dashed line's sample is dashed too, so they all match the chart. **Same limit elsewhere:** nothing else had it. The cap was only in the Analytics chart; the sidebar overlay draws a single line and was never capped. **Tests** - I replaced the unit tests that enforced the cap: - With ten metrics, all ten lines are drawn, each with a different color and dash pair. - The 9th metric's button shows the same dash as its line. - Hiding a metric removes only its line and leaves the others' styles unchanged. - The table still lists every selected metric. - The first 32 series all get different styles. - To make the chart draw lines in the unit tests, I gave it a fixed size in that test file. The new ten-line test would fail on the old code, which drew only eight; I didn't run it against the old code to confirm. - The Playwright test for more than eight metrics now expects all ten lines, with the last two dashed. - Results: - The Analytics, usage-history and Analytics page unit suites pass, 66 tests. - All 5 tests in the usage-history Playwright spec pass. - `tsc --noEmit` and ESLint with zero warnings allowed are clean on the changed folders. I captured a preview of the desktop chart with all ten metrics drawn, using mocked data. The two extra lines are dashed, and their buttons and tooltip rows show the same dash. PR: #3218 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- propr-ui/e2e/agent-tank-usage-history.pw.ts | 21 +++--- .../Analytics/AgentUsageHistory.test.tsx | 74 ++++++++++--------- .../Analytics/AgentUsageHistory.tsx | 39 +++------- .../Analytics/AgentUsageHistoryChart.tsx | 6 +- .../Analytics/AgentUsageHistoryTable.tsx | 3 +- .../Analytics/UsageSeriesSwatch.tsx | 15 ++++ .../components/Analytics/usageHistoryView.ts | 29 +------- .../usageHistory/usageHistoryModel.ts | 30 +++++--- 8 files changed, 108 insertions(+), 109 deletions(-) create mode 100644 propr-ui/src/components/Analytics/UsageSeriesSwatch.tsx diff --git a/propr-ui/e2e/agent-tank-usage-history.pw.ts b/propr-ui/e2e/agent-tank-usage-history.pw.ts index 07d655f0e2..c726f2b754 100644 --- a/propr-ui/e2e/agent-tank-usage-history.pw.ts +++ b/propr-ui/e2e/agent-tank-usage-history.pw.ts @@ -105,23 +105,24 @@ test('the Analytics page plots every agent and metric, and narrows to a few', as await page.screenshot({ path: `${PREVIEWS}/analytics-usage-desktop.png`, clip: await pane.boundingBox() ?? undefined }); }); -test('metrics past the eight-line limit stay selectable and can replace a drawn line', async ({ page }) => { +test('metrics past the eight-hue palette are all plotted, repeated hues dashed', async ({ page }) => { await fixture(page, { width: 1440, height: 900 }); await stubHistory(page, [], { antigravity: EXTRA_POOLS }); await page.goto('/analytics'); const pane = page.getByTestId('analytics-usage-history'); - const lines = pane.getByTestId('agent-usage-history-chart').locator('.recharts-line'); - await expect(lines).toHaveCount(8); - await expect(pane.getByRole('button', { name: /, not plotted$/ })).toHaveCount(2); - await expect(pane.getByText('2 selected metrics are not plotted.', { exact: false })).toBeVisible(); + const lines = pane.getByTestId('agent-usage-history-chart').locator('.recharts-line-curve'); + await expect(lines).toHaveCount(10); + await expect(lines.nth(8)).toHaveAttribute('stroke-dasharray', /\d/); + await expect(lines.nth(9)).toHaveAttribute('stroke-dasharray', /\d/); + await expect(pane.getByText(/not plotted/)).toHaveCount(0); - await pane.getByRole('button', { name: /^Claude · Fable/ }).click(); - await expect(lines).toHaveCount(8); - await expect(pane.getByRole('button', { name: /GPT-OSS Weekly \d+%, not plotted$/ })).toBeVisible(); - await expect(pane.getByRole('button', { name: /Gemini 4 Flash Weekly \d+%$/ })).toBeVisible(); - await page.screenshot({ path: `${PREVIEWS}/analytics-usage-over-limit.png`, clip: await pane.boundingBox() ?? undefined }); + await pane.getByTestId('agent-usage-history-chart').hover({ position: { x: 900, y: 120 } }); + await expect(page.locator('.recharts-tooltip-wrapper')).toContainText('GPT-OSS Weekly'); + await page.screenshot({ path: `${PREVIEWS}/analytics-usage-all-metrics.png`, clip: await pane.boundingBox() ?? undefined }); + await pane.getByRole('button', { name: /^Claude · Fable/ }).click(); + await expect(lines).toHaveCount(9); await pane.getByRole('button', { name: 'Table' }).click(); await expect(pane.getByTestId('agent-usage-history-table').getByRole('row')).toHaveCount(1 + 9); await expect(pane.getByTestId('agent-usage-history-table').getByRole('row', { name: /GPT-OSS/ })).toBeVisible(); diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx index 9a5895bf69..1a56f1e13b 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx @@ -3,17 +3,25 @@ import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAgentTankHistory, type AgentTankHistoryResponse } from '../../api/revertApi'; import AgentUsageHistory from './AgentUsageHistory'; -import { USAGE_SERIES_COLORS } from '../usageHistory/usageHistoryModel'; +import { USAGE_SERIES_COLORS, usageSeriesStyle } from '../usageHistory/usageHistoryModel'; import { - UNPLOTTED_SERIES_COLOR, buildChartRows, parseUsageHistoryView, - planPlottedSeries, toPlottedSeries, writeUsageHistoryView, } from './usageHistoryView'; vi.mock('../../api/revertApi', () => ({ getAgentTankHistory: vi.fn() })); +// jsdom has no layout, so give the chart a fixed size and it draws its lines. +vi.mock('recharts', async importOriginal => { + const actual = await importOriginal(); + const { cloneElement } = await import('react'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactElement<{ width?: number; height?: number }> }) => + cloneElement(children, { width: 800, height: 280 }), + }; +}); const mockHistory = vi.mocked(getAgentTankHistory); const HOUR = 3_600_000; @@ -168,28 +176,37 @@ describe('AgentUsageHistory', () => { await waitFor(() => expect(second.container.querySelector('[data-testid="analytics-usage-history"]')).toBeNull()); }); - it('keeps metrics past the eight-line limit selectable and swaps them onto the chart', async () => { + it('plots every selected metric past the eight-hue palette, telling repeated hues apart by dash', async () => { const many = Array.from({ length: 10 }, (_, index) => series(`model:Pool ${index + 1}`, `Pool ${index + 1}`, [index, index + 1])); mockHistory.mockResolvedValue({ enabled: true, source: 'propr', agents: [{ agent: 'antigravity', series: many }] }); - renderPane(); + const { container } = renderPane(); const filters = await screen.findByRole('group', { name: 'Filter usage history' }); - // Every metric has a chip; the two past the palette are selected but not drawn. - expect(within(filters).getAllByRole('button', { name: /Antigravity · Pool/ })).toHaveLength(10); - expect(within(filters).getByRole('button', { name: /Pool 9 9%, not plotted/, pressed: true })).toBeInTheDocument(); - expect(within(filters).getByRole('button', { name: /Pool 10 10%, not plotted/, pressed: true })).toBeInTheDocument(); - expect(screen.getByText(/2 selected metrics are not plotted/)).toBeInTheDocument(); - - // Hiding a drawn metric hands its line to the next selected one. + // All ten metrics are drawn; nothing is left "not plotted". + const strokes = () => [...container.querySelectorAll('.recharts-line-curve')] + .map(path => [path.getAttribute('stroke'), path.getAttribute('stroke-dasharray')]); + await waitFor(() => expect(strokes()).toHaveLength(10)); + expect(new Set(strokes().map(stroke => stroke.join('|'))).size).toBe(10); + expect(strokes()[0]).toEqual([USAGE_SERIES_COLORS[0], null]); + expect(strokes()[8]).toEqual([USAGE_SERIES_COLORS[0], usageSeriesStyle(8).dash]); + expect(screen.queryByText(/not plotted/)).not.toBeInTheDocument(); + expect(within(filters).getAllByRole('button', { name: /Antigravity · Pool/, pressed: true })).toHaveLength(10); + // A chip's swatch shows the line's dash, so the legend matches the chart. + expect(within(filters).getByRole('button', { name: /Pool 9 9%/ }).querySelector('line')) + .toHaveAttribute('stroke-dasharray', usageSeriesStyle(8).dash); + + // Hiding one metric removes only its line; the others keep their style. fireEvent.click(within(filters).getByRole('button', { name: /Pool 1 1%/ })); - expect(within(filters).getByRole('button', { name: /^Antigravity · Pool 9 9%$/, pressed: true })).toBeInTheDocument(); - expect(within(filters).getByRole('button', { name: /Pool 10 10%, not plotted/ })).toBeInTheDocument(); - expect(screen.getByText(/1 selected metric is not plotted/)).toBeInTheDocument(); + await waitFor(() => expect(strokes()).toHaveLength(9)); + expect(strokes()[7]).toEqual([USAGE_SERIES_COLORS[0], usageSeriesStyle(8).dash]); - // The table lists every selected metric, drawn or not. + fireEvent.click(screen.getByRole('button', { name: 'Show all' })); + await waitFor(() => expect(strokes()).toHaveLength(10)); + + // The table lists every selected metric too. fireEvent.click(screen.getByRole('button', { name: 'Table' })); const table = screen.getByTestId('agent-usage-history-table'); - expect(within(table).getAllByRole('row')).toHaveLength(1 + 9); + expect(within(table).getAllByRole('row')).toHaveLength(1 + 10); expect(within(table).getByRole('row', { name: /Pool 10/ })).toHaveTextContent('10%'); }); @@ -210,27 +227,18 @@ describe('usage history view', () => { .toBe('period=7d'); }); - it('gives a series past the palette a color no drawn series is using', () => { - const make = (index: number, color: string) => toPlottedSeries({ - ...series(`k${index}`, `k${index}`, [1]), id: `x:k${index}`, agent: 'x', displayLabel: `k${index}`, - }, color); - const palette = [...USAGE_SERIES_COLORS]; - const all = [...palette.map((color, index) => make(index, color)), make(8, UNPLOTTED_SERIES_COLOR), make(9, UNPLOTTED_SERIES_COLOR)]; - - const full = planPlottedSeries(all); - expect(full.drawn.map(entry => entry.color)).toEqual(palette); - expect(full.undrawn.map(entry => entry.id)).toEqual(['x:k8', 'x:k9']); - - const swapped = planPlottedSeries(all.filter(entry => entry.id !== 'x:k2')); - expect(swapped.drawn.map(entry => entry.color)).toEqual([...palette.filter((_, index) => index !== 2), palette[2]]); - expect(swapped.undrawn.map(entry => [entry.id, entry.color])).toEqual([['x:k9', UNPLOTTED_SERIES_COLOR]]); + it('gives every series up to 32 a distinct style, solid for the first eight', () => { + const styles = Array.from({ length: 32 }, (_, index) => usageSeriesStyle(index)); + expect(styles.slice(0, 8)).toEqual(USAGE_SERIES_COLORS.map(color => ({ color }))); + expect(styles.slice(8).every(style => style.dash)).toBe(true); + expect(new Set(styles.map(style => `${style.color}|${style.dash ?? ''}`)).size).toBe(32); }); it('carries each series forward between its own readings and not past them', () => { const a = toPlottedSeries({ ...series('a', 'a', []), id: 'x:a', agent: 'x', displayLabel: 'a', - points: [{ t: new Date(1000).toISOString(), percent: 10 }, { t: new Date(3000).toISOString(), percent: 30 }] }, '#000'); + points: [{ t: new Date(1000).toISOString(), percent: 10 }, { t: new Date(3000).toISOString(), percent: 30 }] }, { color: '#000' }); const b = toPlottedSeries({ ...series('b', 'b', []), id: 'x:b', agent: 'x', displayLabel: 'b', - points: [{ t: new Date(2000).toISOString(), percent: 50 }] }, '#111'); + points: [{ t: new Date(2000).toISOString(), percent: 50 }] }, { color: '#111' }); expect(buildChartRows([a, b])).toEqual([ { t: 1000, 'x:a': 10, 'x:b': null }, { t: 2000, 'x:a': 10, 'x:b': 50 }, diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx index d3ad92cd12..af8dc2675b 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx @@ -6,9 +6,8 @@ * some agents; the series chips are the legend as well as the metric filter, * each with its latest value, so identity is never color alone. Colors come * from the order of every series the answer holds, not of the visible ones, - * so hiding a line never repaints the others. Past eight series there is no - * ninth hue: every series stays selectable and in the table, but the chart draws - * the first eight selected ones, and hiding one lets the next take its color. + * so hiding a line never repaints the others. Past eight series the hues + * repeat with a dash pattern, so the chart can draw every selected series. * * History only reaches back about two weeks, so the pane has its own range * instead of following the page timeframe. It renders nothing when Agent Tank @@ -22,16 +21,15 @@ import { getProviderDisplayName } from '../agentTankMetrics'; import { SectionHeading } from '../Dashboard/sectionPrimitives'; import { SkeletonBlock } from '../ui/Skeleton'; import { SystemAlert } from '../ui/SystemAlert'; -import { MAX_USAGE_SERIES, assignUsageSeriesColors, flattenUsageHistory, readableSeriesLabel, usageMetricLabel } from '../usageHistory/usageHistoryModel'; +import { flattenUsageHistory, readableSeriesLabel, usageMetricLabel, usageSeriesStyle } from '../usageHistory/usageHistoryModel'; import AgentUsageHistoryChart from './AgentUsageHistoryChart'; import AgentUsageHistoryTable from './AgentUsageHistoryTable'; +import UsageSeriesSwatch from './UsageSeriesSwatch'; import { USAGE_RANGES, USAGE_RANGE_LABELS, USAGE_RANGE_MS, - UNPLOTTED_SERIES_COLOR, parseUsageHistoryView, - planPlottedSeries, toPlottedSeries, writeUsageHistoryView, type UsageHistoryView, @@ -108,16 +106,11 @@ const AgentUsageHistory: React.FC = () => { const all = useMemo(() => { if (read.status !== 'ready') return []; - const flat = flattenUsageHistory(read.data.agents); - const colors = assignUsageSeriesColors(flat.map(series => series.id)); - return flat.map(series => toPlottedSeries(series, colors.get(series.id) ?? UNPLOTTED_SERIES_COLOR)); + return flattenUsageHistory(read.data.agents).map((series, index) => toPlottedSeries(series, usageSeriesStyle(index))); }, [read]); const agents = useMemo(() => [...new Set(all.map(series => series.agent))], [all]); const offered = all.filter(series => !view.hiddenAgents.has(series.agent)); - const { drawn, undrawn } = planPlottedSeries(offered.filter(series => !view.hiddenSeries.has(series.id))); - // The table lists every selected series; only the chart is capped. - const visible = [...drawn, ...undrawn]; - const drawnColors = new Map(drawn.map(series => [series.id, series.color])); + const visible = offered.filter(series => !view.hiddenSeries.has(series.id)); if (read.status === 'hidden') return null; @@ -152,7 +145,7 @@ const AgentUsageHistory: React.FC = () => { } else { body = showTable ? - : ; + : ; } return ( @@ -192,23 +185,17 @@ const AgentUsageHistory: React.FC = () => { {offered.filter(series => series.agent === agent).map(series => { const on = !view.hiddenSeries.has(series.id); const value = series.latest === null ? '' : ` ${Math.round(series.latest)}%`; - const color = drawnColors.get(series.id); - const unplotted = on && !color; return ( @@ -224,12 +211,6 @@ const AgentUsageHistory: React.FC = () => { )}
)} - {undrawn.length > 0 && !showTable && ( -

- {undrawn.length} selected {undrawn.length === 1 ? 'metric is' : 'metrics are'} not plotted. The chart shows - up to {MAX_USAGE_SERIES} at once: hide one to plot another, or see them all in the table. -

- )} {body}
diff --git a/propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx b/propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx index 4c25368a19..07cd345110 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistoryChart.tsx @@ -5,12 +5,15 @@ * the chart exists for. Lines carry their value from one reading to the next, * since a quota holds its last measured level until it is measured again. The * dashed 50% and 80% rules are the sidebar's warning and critical levels. + * Every selected series is drawn; past eight, lines repeat a hue with a dash + * pattern rather than being left off. */ import React, { useMemo } from 'react'; import { CartesianGrid, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { axisProps, tooltipStyle } from '../chartConstants'; import { formatHistoryTick, formatHistoryTime } from '../usageHistory/usageHistoryModel'; +import UsageSeriesSwatch from './UsageSeriesSwatch'; import { buildChartRows, type ChartRow, type PlottedSeries } from './usageHistoryView'; interface AgentUsageHistoryChartProps { @@ -42,7 +45,7 @@ const UsageTooltip: React.FC<{
    {values.map(({ entry, value }) => (
  • -
  • @@ -101,6 +104,7 @@ const AgentUsageHistoryChart: React.FC = ({ series, dataKey={entry.id} name={entry.displayLabel} stroke={entry.color} + strokeDasharray={entry.dash} strokeWidth={2} dot={false} activeDot={{ r: 4, stroke: '#FFFFFF', strokeWidth: 2 }} diff --git a/propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx b/propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx index add65738b8..5f73fa71e8 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistoryTable.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import UsageSeriesSwatch from './UsageSeriesSwatch'; import type { PlottedSeries } from './usageHistoryView'; const percent = (value: number | null) => (value === null ? '—' : `${Math.round(value * 10) / 10}%`); @@ -26,7 +27,7 @@ const AgentUsageHistoryTable: React.FC<{ series: PlottedSeries[] }> = ({ series - diff --git a/propr-ui/src/components/Analytics/UsageSeriesSwatch.tsx b/propr-ui/src/components/Analytics/UsageSeriesSwatch.tsx new file mode 100644 index 0000000000..870aa3c401 --- /dev/null +++ b/propr-ui/src/components/Analytics/UsageSeriesSwatch.tsx @@ -0,0 +1,15 @@ +/** + * A short sample of a usage series' line, in its color and dash pattern, so a + * chip, tooltip row or table row names the line exactly as the chart draws it. + */ + +import React from 'react'; +import type { UsageSeriesStyle } from '../usageHistory/usageHistoryModel'; + +const UsageSeriesSwatch: React.FC = ({ color, dash }) => ( + +); + +export default UsageSeriesSwatch; diff --git a/propr-ui/src/components/Analytics/usageHistoryView.ts b/propr-ui/src/components/Analytics/usageHistoryView.ts index c323bb72d6..1ac850a819 100644 --- a/propr-ui/src/components/Analytics/usageHistoryView.ts +++ b/propr-ui/src/components/Analytics/usageHistoryView.ts @@ -6,7 +6,7 @@ * shows up by default instead of being silently filtered away. */ -import { MAX_USAGE_SERIES, USAGE_SERIES_COLORS, type FlatUsageSeries } from '../usageHistory/usageHistoryModel'; +import type { FlatUsageSeries, UsageSeriesStyle } from '../usageHistory/usageHistoryModel'; const HOUR_MS = 60 * 60 * 1000; @@ -63,8 +63,7 @@ export function writeUsageHistoryView(params: URLSearchParams, view: UsageHistor return next; } -export interface PlottedSeries extends FlatUsageSeries { - color: string; +export interface PlottedSeries extends FlatUsageSeries, UsageSeriesStyle { /** Points as epoch ms, time-ordered. */ plotted: Array<{ t: number; percent: number }>; latest: number | null; @@ -72,7 +71,7 @@ export interface PlottedSeries extends FlatUsageSeries { max: number | null; } -export function toPlottedSeries(series: FlatUsageSeries, color: string): PlottedSeries { +export function toPlottedSeries(series: FlatUsageSeries, style: UsageSeriesStyle): PlottedSeries { const plotted = series.points .map(point => ({ t: Date.parse(point.t), percent: point.percent })) .filter((point): point is { t: number; percent: number } => Number.isFinite(point.t) && typeof point.percent === 'number') @@ -80,7 +79,7 @@ export function toPlottedSeries(series: FlatUsageSeries, color: string): Plotted const values = plotted.map(point => point.percent); return { ...series, - color, + ...style, plotted, latest: values.length ? values[values.length - 1] : null, min: values.length ? Math.min(...values) : null, @@ -88,26 +87,6 @@ export function toPlottedSeries(series: FlatUsageSeries, color: string): Plotted }; } -/** Swatch of a series that is listed but has no line on the chart. */ -export const UNPLOTTED_SERIES_COLOR = '#94A3B8'; - -/** - * Which selected series the chart draws, and in what color. The chart draws - * the first `MAX_USAGE_SERIES` selected ones; the rest stay selected (and in - * the table) without a line. A drawn series keeps the color it was assigned - * from the whole answer; one past the palette takes a color no other drawn - * series is using, so swapping a line in never repaints the others. - */ -export function planPlottedSeries(selected: PlottedSeries[]): { drawn: PlottedSeries[]; undrawn: PlottedSeries[] } { - const drawn = selected.slice(0, MAX_USAGE_SERIES); - const used = new Set(drawn.map(entry => entry.color)); - const free = USAGE_SERIES_COLORS.filter(color => !used.has(color)); - return { - drawn: drawn.map(entry => (entry.color === UNPLOTTED_SERIES_COLOR ? { ...entry, color: free.shift() ?? entry.color } : entry)), - undrawn: selected.slice(MAX_USAGE_SERIES).map(entry => ({ ...entry, color: UNPLOTTED_SERIES_COLOR })), - }; -} - export type ChartRow = { t: number } & Record; /** diff --git a/propr-ui/src/components/usageHistory/usageHistoryModel.ts b/propr-ui/src/components/usageHistory/usageHistoryModel.ts index eef4d709fe..01d0619377 100644 --- a/propr-ui/src/components/usageHistory/usageHistoryModel.ts +++ b/propr-ui/src/components/usageHistory/usageHistoryModel.ts @@ -14,7 +14,8 @@ import { stripRemainingSuffix } from '../../utils/usageMetricLabels'; * Categorical slots in fixed order (validated: adjacent CVD ΔE ≥ 9.1, normal * vision ≥ 19.6 on the white surface). Aqua, yellow and magenta sit under 3:1 * contrast, so every chart that uses them ships a legend with values and a - * table view. A ninth series reuses no hue; callers fold it away instead. + * table view. Past the eighth series the hues repeat with a dash pattern, so + * every series can be drawn without two lines looking alike. */ export const USAGE_SERIES_COLORS = [ '#2a78d6', // blue @@ -27,7 +28,14 @@ export const USAGE_SERIES_COLORS = [ '#e34948', // red ] as const; -export const MAX_USAGE_SERIES = USAGE_SERIES_COLORS.length; +/** Stroke patterns for each further round of the palette; solid comes first. */ +export const USAGE_SERIES_DASHES = [undefined, '6 4', '2 3', '10 3 2 3'] as const; + +/** How a series is drawn: its hue, and a dash pattern once the hues run out. */ +export interface UsageSeriesStyle { + color: string; + dash?: string; +} /** Stable identity of one agent's metric series. */ export function usageSeriesId(agent: string, key: string): string { @@ -98,15 +106,17 @@ export function flattenUsageHistory(agents: AgentTankHistoryAgent[]): FlatUsageS } /** - * Colors keyed by series id, assigned from a stable ordering of every known id - * (not of the visible ones), so hiding a series never repaints the others. + * The style of the series at `index` in a stable ordering of every known id + * (not of the visible ones), so hiding a series never repaints the others. The + * first eight are solid; each later round of eight reuses the hues with the + * next dash pattern, so every series gets a style no other one has (up to 32, + * past which the rounds repeat; the legend's labels still tell them apart). */ -export function assignUsageSeriesColors(ids: string[]): Map { - const colors = new Map(); - ids.forEach((id, index) => { - if (index < USAGE_SERIES_COLORS.length) colors.set(id, USAGE_SERIES_COLORS[index]); - }); - return colors; +export function usageSeriesStyle(index: number): UsageSeriesStyle { + const hues = USAGE_SERIES_COLORS.length; + const color = USAGE_SERIES_COLORS[index % hues]; + const dash = USAGE_SERIES_DASHES[Math.floor(index / hues) % USAGE_SERIES_DASHES.length]; + return dash ? { color, dash } : { color }; } /** The overlay's range: the last two periods of the metric's cycle, ending now. */ From 346011911ed9544223e323d2fd7a09d6cdc55102 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:48:17 +0000 Subject: [PATCH 7/7] feat(ai): F8 is fixed: an open Analytics page now picks up new usage history by itself, with no navigation and no range change. Before, it read history once when it opened and never again. The full UI unit suite passes (2,957 tests), as does the history Playwright spec (5 of 5), with lint and typecheck clean. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F8 is fixed: an open Analytics page now picks up new usage history by itself, with no navigation and no range change. Before, it read history once when it opened and never again. The full UI unit suite passes (2,957 tests), as does the history Playwright spec (5 of 5), with lint and typecheck clean. **How it works now** (`propr-ui/src/components/Analytics/AgentUsageHistory.tsx`) - The history read now goes through the app's existing `useLiveResource` mechanism, the same one the sidebar's usage section uses. - It reads again whenever the server sends a usage-changed event. The backend writes the new sample before it sends that event, so the re-read always includes it. - Even without an event, it reads again every 5 minutes while connected, or every 30 seconds while disconnected. - Each read asks for the selected range ending at that moment, so the chart and "Latest" values move forward. - While a refresh is in flight the current chart stays on screen. - If a refresh fails, the last good data stays up. An error only shows when nothing has loaded yet. - Changing the range still cancels the old request, and closing the page stops the listening. **Same problem in the sidebar overlay** (`useUsageMetricHistory.ts`): the metric chart overlay read once when opened. Pinned open by a tap, it could stay stale or say "Not enough history yet" forever. It now reads again, skipping its 60-second cache, on each usage-changed event while open, and stops listening when closed. Settings' agent cards show this overlay without a live connection, so there it keeps its single read. **Tests** - **Analytics:** three new tests. An empty history fills in after a usage event, with the same 7-day range ending later. The latest value updates (17% → 44%) while the chart stays up. A failed refresh keeps the last good data. - **Overlay:** one new test. A pinned overlay fills in after a usage event and stops reading once closed. - **Checked against the old code:** all four of these fail on the previous code and pass now. A fifth new test (an error on the first read with nothing to keep, and no listeners left after closing) is a guard, not a regression test. - **Test mocks:** the socket mocks in `AnalyticsPage.test.tsx`, `AgentTankSidebar.test.tsx` and `MobileBottomNavigation.test.tsx` gained the socket functions the new code calls. These are mock-only changes. **What can't be avoided:** a sample recorded between the server's usage checks (for example, during a sidebar refresh) with no value change sends no event. It shows up at the next 5-minute re-read at the latest. I didn't add a preview: the change is the chart updating over time, which a screenshot can't show. PR: #3218 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- .../src/components/AgentTankSidebar.test.tsx | 2 + .../Analytics/AgentUsageHistory.test.tsx | 67 ++++++++++++++++++- .../Analytics/AgentUsageHistory.tsx | 56 +++++++++------- .../MobileBottomNavigation.test.tsx | 2 + .../UsageMetricHistoryOverlay.test.tsx | 39 +++++++++-- .../usageHistory/useUsageMetricHistory.ts | 30 +++++++-- propr-ui/src/pages/AnalyticsPage.test.tsx | 7 +- 7 files changed, 169 insertions(+), 34 deletions(-) diff --git a/propr-ui/src/components/AgentTankSidebar.test.tsx b/propr-ui/src/components/AgentTankSidebar.test.tsx index 4a06a9b70f..7da7bb69e9 100644 --- a/propr-ui/src/components/AgentTankSidebar.test.tsx +++ b/propr-ui/src/components/AgentTankSidebar.test.tsx @@ -24,6 +24,8 @@ vi.mock('../contexts/useSocket', () => ({ return () => socketState.usageCallbacks.delete(callback); }, }), + // The history overlay's optional live refresh is covered by its own suite. + useOptionalSocket: () => null, })); /** Delivers `usage:update` the way the server publishes it when a quota moves. */ diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx index 1a56f1e13b..987af05d88 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAgentTankHistory, type AgentTankHistoryResponse } from '../../api/revertApi'; @@ -12,6 +12,18 @@ import { } from './usageHistoryView'; vi.mock('../../api/revertApi', () => ({ getAgentTankHistory: vi.fn() })); +const usageListeners = vi.hoisted(() => new Set<() => void>()); +vi.mock('../../contexts/useSocket', () => ({ + useSocket: () => ({ + isConnected: true, + onActivityUpdate: () => () => undefined, + onUsageUpdate: (listener: () => void) => { + usageListeners.add(listener); + return () => { usageListeners.delete(listener); }; + }, + }), +})); +const publishUsage = () => act(() => { usageListeners.forEach(listener => listener()); }); // jsdom has no layout, so give the chart a fixed size and it draws its lines. vi.mock('recharts', async importOriginal => { const actual = await importOriginal(); @@ -215,6 +227,59 @@ describe('AgentUsageHistory', () => { renderPane(); expect(await screen.findByText(/No usage history yet/)).toBeInTheDocument(); }); + + it('fills in an empty history once new usage is recorded, without leaving the page', async () => { + mockHistory.mockResolvedValueOnce({ enabled: true, source: 'propr', agents: [] }); + renderPane(); + expect(await screen.findByText(/No usage history yet/)).toBeInTheDocument(); + const first = mockHistory.mock.calls[0][0]; + + publishUsage(); + expect(await screen.findByTestId('agent-usage-history-chart')).toBeInTheDocument(); + expect(screen.queryByText(/No usage history yet/)).not.toBeInTheDocument(); + // The re-read covers the same range, ending now rather than at mount. + const second = mockHistory.mock.calls[1][0]; + expect((second.until ?? 0) - second.since).toBe(7 * DAY); + expect(second.until ?? 0).toBeGreaterThanOrEqual(first.until ?? 0); + }); + + it('moves the latest values forward and keeps the chart up while re-reading', async () => { + renderPane(); + const filters = await screen.findByRole('group', { name: 'Filter usage history' }); + expect(within(filters).getByRole('button', { name: /Claude · Session 17%/ })).toBeInTheDocument(); + + let answer: (value: AgentTankHistoryResponse) => void = () => undefined; + mockHistory.mockReturnValueOnce(new Promise(resolve => { answer = resolve; })); + publishUsage(); + await waitFor(() => expect(mockHistory).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('agent-usage-history-chart')).toBeInTheDocument(); + + const next = response(); + next.agents[1].series[0] = series('session', 'Current session', [10, 30, 17, 44]); + answer(next); + expect(await within(filters).findByRole('button', { name: /Claude · Session 44%/ })).toBeInTheDocument(); + }); + + it('keeps the last good history when a refresh fails', async () => { + renderPane(); + await screen.findByTestId('agent-usage-history-chart'); + mockHistory.mockRejectedValueOnce(new Error('Agent Tank unreachable')); + + publishUsage(); + await waitFor(() => expect(mockHistory).toHaveBeenCalledTimes(2)); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(screen.getByTestId('agent-usage-history-chart')).toBeInTheDocument(); + expect(screen.queryByText('Agent Tank unreachable')).not.toBeInTheDocument(); + }); + + it('shows a read failure with nothing to keep, and stops listening once unmounted', async () => { + mockHistory.mockRejectedValueOnce(new Error('Agent Tank unreachable')); + const { unmount } = renderPane(); + expect(await screen.findByText('Agent Tank unreachable')).toBeInTheDocument(); + + unmount(); + expect(usageListeners.size).toBe(0); + }); }); describe('usage history view', () => { diff --git a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx index af8dc2675b..3221df9fc7 100644 --- a/propr-ui/src/components/Analytics/AgentUsageHistory.tsx +++ b/propr-ui/src/components/Analytics/AgentUsageHistory.tsx @@ -10,13 +10,16 @@ * repeat with a dash pattern, so the chart can draw every selected series. * * History only reaches back about two weeks, so the pane has its own range - * instead of following the page timeframe. It renders nothing when Agent Tank - * is off or the viewer may not read usage, like the sidebar's Usage section. + * instead of following the page timeframe. It re-reads when new usage is + * recorded, so an open page fills in and moves forward on its own. It renders + * nothing when Agent Tank is off or the viewer may not read usage, like the + * sidebar's Usage section. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { getAgentTankHistory, type AgentTankHistoryResponse } from '../../api/revertApi'; +import { useLiveResource } from '../../hooks/useLiveResource'; import { getProviderDisplayName } from '../agentTankMetrics'; import { SectionHeading } from '../Dashboard/sectionPrimitives'; import { SkeletonBlock } from '../ui/Skeleton'; @@ -47,27 +50,34 @@ function isPermissionError(error: unknown): boolean { return error instanceof Error && /instance\.manage_|permission|forbidden/i.test(error.message); } +type SettledRead = Extract; + +/** + * The pane's history, kept current while it is open. A usage push means the + * backend has just recorded a new sample, and the live resource's reconcile + * re-reads now and then regardless; each read asks for the range ending now, + * so the chart and its latest values move forward instead of freezing at mount. + */ function useUsageHistoryRead(range: UsageRange): ReadState { - const [state, setState] = useState({ status: 'loading' }); - useEffect(() => { - const controller = new AbortController(); - const until = Date.now(); - const since = until - USAGE_RANGE_MS[range]; - setState(current => (current.status === 'hidden' ? current : { status: 'loading' })); - getAgentTankHistory({ since, until, signal: controller.signal }) - .then(data => { - if (controller.signal.aborted) return; - setState(data.enabled ? { status: 'ready', data, since, until } : { status: 'hidden' }); - }) - .catch((error: unknown) => { - if (controller.signal.aborted) return; - setState(isPermissionError(error) - ? { status: 'hidden' } - : { status: 'error', message: error instanceof Error ? error.message : 'Failed to load usage history' }); - }); - return () => controller.abort(); - }, [range]); - return state; + const { data, error, loading } = useLiveResource({ + read: async signal => { + const until = Date.now(); + const since = until - USAGE_RANGE_MS[range]; + try { + const response = await getAgentTankHistory({ since, until, signal }); + return response.enabled ? { status: 'ready', data: response, since, until } : { status: 'hidden' }; + } catch (failure) { + if (isPermissionError(failure)) return { status: 'hidden' }; + throw failure; + } + }, + scopeKey: range, + interest: { usage: true }, + }); + // A failed refresh keeps the last good answer on screen. + if (data) return data; + if (loading) return { status: 'loading' }; + return { status: 'error', message: error || 'Failed to load usage history' }; } const chipClass = (on: boolean) => `inline-flex min-h-7 items-center gap-1.5 rounded-full border px-2.5 text-xs transition-colors ${ diff --git a/propr-ui/src/components/MobileBottomNavigation.test.tsx b/propr-ui/src/components/MobileBottomNavigation.test.tsx index 1fdde1ad91..fa2560ae59 100644 --- a/propr-ui/src/components/MobileBottomNavigation.test.tsx +++ b/propr-ui/src/components/MobileBottomNavigation.test.tsx @@ -19,6 +19,8 @@ vi.mock('../contexts/useSocket', () => ({ onNotificationUpdate: () => () => undefined, onUsageUpdate: () => () => undefined, }), + // The history overlay's optional live refresh is covered by its own suite. + useOptionalSocket: () => null, })); const mockGetAgentTankUsage = vi.mocked(getAgentTankUsage); diff --git a/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx index f4a3ebe342..cd085ca294 100644 --- a/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx +++ b/propr-ui/src/components/usageHistory/UsageMetricHistoryOverlay.test.tsx @@ -14,14 +14,19 @@ vi.mock('../../api/revertApi', () => ({ getAgentTankHistory: vi.fn(), })); -vi.mock('../../contexts/useSocket', () => ({ - useSocket: () => ({ +const usageListeners = vi.hoisted(() => new Set<() => void>()); +vi.mock('../../contexts/useSocket', () => { + const socket = () => ({ isConnected: true, onActivityUpdate: () => () => undefined, onNotificationUpdate: () => () => undefined, - onUsageUpdate: () => () => undefined, - }), -})); + onUsageUpdate: (listener: () => void) => { + usageListeners.add(listener); + return () => { usageListeners.delete(listener); }; + }, + }); + return { useSocket: socket, useOptionalSocket: socket }; +}); const HOUR = 3_600_000; const mockUsage = vi.mocked(getAgentTankUsage); @@ -198,6 +203,30 @@ describe('sidebar usage history overlay', () => { expect(await screen.findByText(/Not enough history yet/)).toBeInTheDocument(); }); + it('reads again when new usage is recorded while the overlay stays open', async () => { + mockHistory.mockResolvedValueOnce(history('claude', 'session', [17])); + render(); + await expandProvider(/^Claude/); + + fireEvent.click(metricRow('Session')); + expect(await screen.findByText(/Not enough history yet/)).toBeInTheDocument(); + const before = mockHistory.mock.calls[0][0].until ?? 0; + + // The pinned overlay fills in from the new samples, past the re-hover cache. + act(() => { usageListeners.forEach(listener => listener()); }); + expect(await screen.findByTestId('usage-metric-history-chart')).toBeInTheDocument(); + expect(screen.queryByText(/Not enough history yet/)).not.toBeInTheDocument(); + const calls = mockHistory.mock.calls.filter(([query]) => query.metric === 'session'); + expect(calls).toHaveLength(2); + expect(calls[1][0].until ?? 0).toBeGreaterThanOrEqual(before); + + // Closed, it stops listening. + fireEvent.click(screen.getByRole('button', { name: 'Close usage history' })); + act(() => { usageListeners.forEach(listener => listener()); }); + await new Promise(resolve => setTimeout(resolve, 150)); + expect(mockHistory.mock.calls.filter(([query]) => query.metric === 'session')).toHaveLength(2); + }); + it('stops offering history while the backend reports it disabled', async () => { mockHistory.mockResolvedValue({ enabled: false, agents: [] }); render(); diff --git a/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts b/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts index 8649a54e59..147904c6b2 100644 --- a/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts +++ b/propr-ui/src/components/usageHistory/useUsageMetricHistory.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useSyncExternalStore } from 'react'; import { getAgentTankHistory, type AgentTankHistorySeries } from '../../api/revertApi'; +import { useOptionalSocket } from '../../contexts/useSocket'; import { twoPeriodWindow } from './usageHistoryModel'; /** Re-hovering a row within this long reuses the answer instead of refetching. */ @@ -67,8 +68,9 @@ export type UsageMetricHistoryState = | { status: 'ready'; since: number; until: number; periodMs: number; series: AgentTankHistorySeries | null; enabled: boolean }; /** - * One metric's history over its last two periods, read when `active` turns on. - * The request is aborted if the overlay closes first. + * One metric's history over its last two periods, read when `active` turns on + * and read again, past the cache, whenever new usage is recorded while it + * stays open. The request is aborted if the overlay closes first. */ export function useUsageMetricHistory( agent: string, @@ -77,6 +79,25 @@ export function useUsageMetricHistory( active: boolean, ): UsageMetricHistoryState | null { const [state, setState] = useState(null); + // Bumped by a usage push while open; zero again once the overlay closes. + const [revision, setRevision] = useState(0); + // Rows also render outside the socket provider (Settings' agent cards); + // there the overlay keeps its one read. + const onUsageUpdate = useOptionalSocket()?.onUsageUpdate; + + useEffect(() => { + if (!active) { + setRevision(0); + return undefined; + } + if (!onUsageUpdate) return undefined; + // A push means the backend has just recorded a sample, so the cached + // answer for this metric is behind. + return onUsageUpdate(() => { + cache.delete(`${agent}\u0000${historyKey}`); + setRevision(current => current + 1); + }); + }, [agent, historyKey, active, onUsageUpdate]); useEffect(() => { if (!active) return undefined; @@ -88,7 +109,8 @@ export function useUsageMetricHistory( return undefined; } const controller = new AbortController(); - setState({ status: 'loading', since, until, periodMs }); + // A refresh keeps the chart on screen until the newer answer lands. + setState(current => (revision > 0 && current?.status === 'ready' ? current : { status: 'loading', since, until, periodMs })); getAgentTankHistory({ since, until, agents: [agent], metric: historyKey, signal: controller.signal }) .then(response => { if (controller.signal.aborted) return; @@ -107,7 +129,7 @@ export function useUsageMetricHistory( }); }); return () => controller.abort(); - }, [agent, historyKey, cycle, active]); + }, [agent, historyKey, cycle, active, revision]); return active ? state : null; } diff --git a/propr-ui/src/pages/AnalyticsPage.test.tsx b/propr-ui/src/pages/AnalyticsPage.test.tsx index fbfd81222f..40db5c56e4 100644 --- a/propr-ui/src/pages/AnalyticsPage.test.tsx +++ b/propr-ui/src/pages/AnalyticsPage.test.tsx @@ -17,7 +17,12 @@ vi.mock('../api/revertApi', () => ({ })); vi.mock('../contexts/useSocket', () => ({ - useSocket: () => ({ isConnected: false, onTaskUpdate: () => () => {} }), + useSocket: () => ({ + isConnected: false, + onTaskUpdate: () => () => {}, + onActivityUpdate: () => () => {}, + onUsageUpdate: () => () => {}, + }), })); type TaskStats = Awaited>;