From e0b5a03fe122d9bfec2748b43034e19873e78f9b Mon Sep 17 00:00:00 2001 From: hickoxd <233332566+hickoxd@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:14:39 +0000 Subject: [PATCH] feat: add a stacked spend-over-time chart to the usage tab Add GET /api/usage/series, a credits-only stacked time series over the per-turn usage shards layered by channel, agent, model, or session start week (top-N plus "other", with an explicit "unattributed" layer for turns that did not record the field), and a "Spend over time" card on the Overview Usage tab that renders it as a d3 stacked area with per-day and cumulative views. The channel comes from the session key, never the row's own surface field, for the reason cost_breakdown documents. The parsed-row cache is bounded on rows and on every retained string; a window past the cap keeps its newest rows and the payload says how many of the oldest were left out. App callers are refused with an audited 404. The plot is a keyboard slider over the day axis, the tooltip lists every layer, and the axis top is rounded so the ticks read cleanly. --- docs/feature-map/README.md | 1 + .../modules/learn-cron-dashboard.md | 68 ++ src/kiro_crew/dashboard/handlers/__init__.py | 1 + .../dashboard/handlers/usage_series.py | 481 ++++++++++++++ src/kiro_crew/dashboard/routes/system.py | 1 + test/test_usage_series.py | 603 ++++++++++++++++++ website/src/api/acpBackend.test.ts | 20 +- website/src/api/acpBackend.ts | 15 + website/src/api/client.ts | 5 + website/src/api/client/telemetry.ts | 34 + website/src/i18n/en.context.json | 27 +- website/src/i18n/locales/bn.json | 29 +- website/src/i18n/locales/de.json | 29 +- website/src/i18n/locales/en-XA.json | 29 +- website/src/i18n/locales/en.manual.json | 29 +- website/src/i18n/locales/es.json | 29 +- website/src/i18n/locales/fr.json | 29 +- website/src/i18n/locales/hi.json | 29 +- website/src/i18n/locales/it.json | 29 +- website/src/i18n/locales/ja.json | 29 +- website/src/i18n/locales/ko.json | 29 +- website/src/i18n/locales/pt.json | 29 +- website/src/i18n/locales/ru.json | 29 +- website/src/i18n/locales/zh-CN.json | 29 +- .../pages/overview/TokenStackedAreaChart.tsx | 444 +++++++++++++ website/src/pages/overview/UsageTab.tsx | 46 +- .../test/ApiClient.refactor.surface.test.ts | 2 +- .../src/test/TokenStackedAreaChart.test.tsx | 449 +++++++++++++ website/src/test/UsageTab.cache.test.tsx | 7 + .../src/test/UsageTab.dailyCredits.test.tsx | 12 + .../test/UsageTab.estimatedTokens.test.tsx | 13 + .../test/UsageTab.refusedTranscripts.test.tsx | 12 + .../test/UsageTab.spendChartHarness.test.tsx | 145 +++++ 33 files changed, 2745 insertions(+), 18 deletions(-) create mode 100644 src/kiro_crew/dashboard/handlers/usage_series.py create mode 100644 test/test_usage_series.py create mode 100644 website/src/pages/overview/TokenStackedAreaChart.tsx create mode 100644 website/src/test/TokenStackedAreaChart.test.tsx create mode 100644 website/src/test/UsageTab.spendChartHarness.test.tsx diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index 1d4815647451..ec392a6fc9f2 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -201,6 +201,7 @@ and Forget. Legacy schedule rows state that the run uses Global Memory V1. | Embeddings | Enable the vector store, pick its model, and view localized setup errors, warnings, and deferred repair status | Memory browser → vector card | `pages/overview/VectorMemoryCard.tsx`, `pages/overview/EmbeddingModelCard.tsx`, `pages/overview/embeddingStatusText.ts` (shared status-text helper; no new entry point) | `handlers/memory.py` | `GET /api/memory/embedding-status`, `POST /api/memory/enable-embeddings`, `POST /api/memory/embedding-model` | | Memory graph | Entity/relation visualizer over the memory store | `/developer?tab=memory` | `pages/overview/MemoryGraphTab.tsx` | `handlers/memory.py` | `GET /api/memory/graph`, `GET /api/memory/observability` | | Usage | Token and turn usage over time, including estimated Kiro CLI tokens for this month, last month, and each day | `/settings/overview?view=usage` | `pages/overview/UsageTab.tsx` | `handlers/usage.py`, `handlers/telemetry.py` | `GET /api/usage`, `GET /api/usage/kiro`, `GET /api/usage/turns` | +| Spend over time (credits) | Stacked area chart of the last 30 days' credit spend, layered by channel (where each session came from), agent, model, or the week a session was first seen in the window (the oldest layer, **Started by** a date, also holds sessions that began before it); cumulative by default or per day; hover or the arrow keys read out a day's total and each layer's value, the top layers are kept with the rest folded into **Other**, and a window the server had to shorten says how many of its oldest turns were left out and from which day every turn is counted; shown only when the default harness bills in credits (kiro-cli, KAS), with a notice in its place when that setting could not be read | `/settings/overview?view=usage` → **Spend over time (credits)** card, above Session activity (30 days) | `pages/overview/TokenStackedAreaChart.tsx` | `handlers/usage_series.py` | `GET /api/usage/series` | | Kiro credit balance | The top-bar credit pill and its account modal. The gateway refreshes the reading on a timer: the free `GetUsageLimits` API first, then the `kiro-cli /usage` scrape (a local slash command over the same free call) whenever the API returns no plan, parked for 6 h after three consecutive scrape attempts that failed (timed out, errored) or produced no parseable plan. The modal's **Refresh** button runs that same refresh now — under the state's notice when there is no reading ("Could not read your balance." for a failed read, "No balance reading is available for this account yet." for `none` — a dash the pill shows only while the selected harness is kiro-cli (`agent.acp_backend` names the kiro id, read positively by `api/acpBackend.ts`); on any other harness that payload hides the pill, as it always did), compact beside the meter when there is one — and writes the result into the pill's query. Single-flight (409 while a refresh runs, shown as an `ErrorNotice` with the hand-off like every other refresh outcome, Refresh held until the modal's ~3 s re-read lands and the notice clears); one notice at a time (a failed refresh replaces the standing failure notice with the same one sentence, the stale line yields to the stale-refresh notice); Refresh is held while the parked notice says when to try again; a parked scrape is reported after the API attempt (`skipped: scrape_parked`): no new reading is fetched, and the cache then holds a same-identity prior reading dimmed `stale` or an unavailable marker. A refresh that comes back `stale` is reported as such — an `ErrorNotice` with the hand-off — rather than stamped as checked now. Every credential read — the API call and the `/usage` scrape alike — is bracketed by a whoami immediately before and one immediately after, and its reading is published only when the pair PROVES one account (`_whoami_agreement` = `same`: at least one matching identity field, none disagreeing); `different` and `unproven` publish nothing (two identity-less whoamis around the API fall through to the scrape; around the scrape they end the refresh with no reading), and every failure path keeps a prior reading only for the same account (`_same_identity`), so an account switch never shows the previous account's balance. Under the meter, one muted line projects whether the plan lasts to the reset (`api/creditForecast.ts`): the pace is the account's own used credits over the days the cycle has run (cycle start = one calendar month before `resets`), shaped by weekday from the Usage tab's Daily History credits (the shared `providerUsageQuery` over `GET /api/usage/kiro`, last 28 days, flat until 14 days of history); the line is omitted when no reset date is known, the plan is used up, bonus credits still hold a balance, less than a day of the cycle has run, or the report is still loading or refreshing, a failed read shows an `ErrorNotice` with the hand-off, and the run-out case carries the warn tone. No notification yet (#17112). No config key, no Settings control, no cost copy | Top bar → credit pill → **Kiro Account** (desktop); on the phone the pill is not shown and the same modal opens from the nav drawer → bottom group → **Kiro Account** row, or on the chat route from the chat drawer → nav rail → **Kiro Account** tile | `shell/topbar/kiroUsageReadout.tsx`, `App.tsx` (mounts the modal), `components/KiroAccountModal.tsx`, `api/kiroUsage.ts`, `api/creditForecast.ts` | `handlers/sessions.py`, `handlers/usage.py` | `GET /api/sessions/usage`, `POST /api/sessions/usage/refresh`, `GET /api/usage/kiro` | | WakaTime coding activity and export | Coding stats for a named range plus project-grouped hours over a date range as a CSV or JSON download, for the productivity view and billable-hours invoicing | `/settings/overview?view=wakatime` | `pages/overview/WakaTimeTab.tsx` | `handlers/wakatime.py` | `GET /api/wakatime/stats`, `GET /api/wakatime/export` | | Portability | Export and import the whole memory/config bundle, optionally with persistent chat history (add-only on import); both ends warn about crew agent templates the bundle does not carry. A memory-only export (`?components=memory`, never chats) asks first with a dialog listing what the file holds; a memory-only import (`?components=memory`, or any archive whose manifest declares memory only) merges just the memory and refuses Replace | `/settings/imports` | `pages/overview/PortabilityTab.tsx` | `handlers/portability.py` | `GET /api/portability/export`, `POST /api/portability/import`, `POST /api/portability/preview` | diff --git a/docs/system-specs/modules/learn-cron-dashboard.md b/docs/system-specs/modules/learn-cron-dashboard.md index a3d3a5c05662..3c7840f96cf8 100644 --- a/docs/system-specs/modules/learn-cron-dashboard.md +++ b/docs/system-specs/modules/learn-cron-dashboard.md @@ -137,6 +137,74 @@ refresh can recover without clearing the report. A provider without usage support shows neutral status text, not an error. No report is persisted to disk, and the top-bar credit readout keeps its separate billing refresh policy. +The Usage tab's "Spend over time (credits)" card is a stacked area chart with one layer +per bucket of the chosen dimension, backed by `GET /api/usage/series` +(`dashboard/handlers/usage_series.py`). The route reads the same per-turn +shards as the Daily History credits, admits rows by the same guard (a `tokens` +row with a parseable local day), and answers a dense local-day axis -- every +day of the shard retention window, zero-filled -- plus one series per bucket +of the requested dimension: `by=channel` (the default), `agent`, `model`, or +`cohort`, the ISO week in which the row's session was first seen inside the +window, keyed by its Monday and assigned in a second pass once every session is +known. The channel is derived from the row's session key with +`telemetry_channel_of`, never read from the row's own `surface` field, for the +reason `cost_breakdown` documents: historical rows can carry a wrong but +non-empty surface and the row schema has no writer marker, so the key is the +authority and this chart buckets spend by the same closed channel vocabulary as +the Spend panel. The model is canonicalised across provider id migrations like +the daily chart does. The value stacked is the row's `credits`, the unit the +kiro-cli backend and its KAS relay bill in; a harness that bills in tokens or +dollars writes rows with no credits, so the Usage tab mounts the card only once +a loaded config names a credits-billing default harness (`agent.acp_backend` +via `reportsCredits` in `api/acpBackend.ts`, stated positively like +`isKiroBackend`), never on a guess and never as "not claude"; a config read +that failed with nothing cached (`failedWithNoData`) is said in an +`ErrorNotice` where the card would stand, carrying the failed request's report +for the agent hand-off and a Try again button that re-reads, since on a +kiro-cli install a silent absence would read as "this harness has no credits"; turns a crew +member ran on another harness (`agent.member_acp_backend`) land in the same +store and count whatever credits they recorded. The top seven buckets by +window total (the session palette's hue count) are kept and the rest fold into +one `other` series carrying its member count; a row whose dimension value is +empty or absent lands in an explicit `unattributed` series rather than being +guessed at or dropped, so the stack's top edge always equals the day's total +spend. Series arrive in stack order, bottom first: value dimensions +largest-first, cohorts oldest-first, then `other`, then `unattributed`. An +unknown `by` is a 400 (`invalid_dimension`) rather than a silent substitution, +and an app token is answered 404 with a +SEL `app_isolation` audit record -- the payload is the whole install's spend +with no slot filter, unlike the row-isolated `/api/usage/turns`. The parsed rows +are cached on the shard fingerprint the other readers use, so switching +dimension re-aggregates in memory; the cache is bounded on both axes +(`MAX_ROWS` rows, `MAX_FIELD_CHARS` per retained string), newest shards are read +first so a window past the cap keeps its newest rows, and the payload discloses +the shortening (`truncated`, `dropped_rows`, and `complete_from`, the oldest +day every row of which was kept, never reaching past an unreadable shard -- +`null` when the cap tripped inside the newest shard) instead of serving a +partial window as the whole one. The chart stacks +and, in its default cumulative view, +prefix-sums the series client-side (`pages/overview/TokenStackedAreaChart.tsx`, +`d3`'s `stack`/`area`, axis top rounded to a nice number); its view choices +persist in browser storage; the top-N buckets take the session palette's hues +and `other` / `unattributed` the muted tokens; channel ids are shown capitalised +with the unrecognised-key channel labelled apart from the `other` fold; the plot +is a keyboard-operable slider over the day axis whose value text reads the +focused day out; an `unattributed` layer earns a one-line caption under the +controls and a truncated window a warning-toned one that leads with the +consequence (the totals undercount) and, when the server could name it, from +which day every turn is counted. Cohort layers are labelled "Week of" their +Monday except the oldest one drawn, labelled "Started by" its Sunday (or the +axis's last day while that week is still running): a cohort is the week a +session was first seen inside the window, so the oldest layer also holds +sessions that began before the window, and the cohort caption says so. A failed first load shows a titled `ErrorNotice`, the +server's text on its own line under the title, with a Try again button that +refetches; a failed refresh over a drawn chart shows the same shape above the +stack it keeps showing. Changing the dimension +keeps the previous stack on screen, dimmed and `aria-busy` with a "Loading…" +cue beside the select, until the new answer arrives (`keepPreviousData`); each +answer carries the dimension it was split by, so the stand-in keeps its own +labels and caption rather than the new selection's. + ## Gateway restart The dashboard restart endpoint and successful update applies share diff --git a/src/kiro_crew/dashboard/handlers/__init__.py b/src/kiro_crew/dashboard/handlers/__init__.py index d27442c043e9..efd12831b7fc 100644 --- a/src/kiro_crew/dashboard/handlers/__init__.py +++ b/src/kiro_crew/dashboard/handlers/__init__.py @@ -650,6 +650,7 @@ async def api_tasks_summary(request): api_kiro_usage, api_usage, ) +from kiro_crew.dashboard.handlers.usage_series import api_usage_series # noqa: E402, F401 from kiro_crew.dashboard.handlers.wakatime import ( # noqa: E402, F401 api_wakatime_export, api_wakatime_stats, diff --git a/src/kiro_crew/dashboard/handlers/usage_series.py b/src/kiro_crew/dashboard/handlers/usage_series.py new file mode 100644 index 000000000000..5563d7188c5c --- /dev/null +++ b/src/kiro_crew/dashboard/handlers/usage_series.py @@ -0,0 +1,481 @@ +"""Stacked spend-over-time series for the Usage tab's area chart. + +``GET /api/usage/series`` answers one question the per-day totals cannot: +*what is today's spend made of, and how did that composition drift?* The +reply is a dense day axis plus one zero-filled series per bucket of a chosen +dimension, shaped so the browser can stack the series directly (and, for the +cumulative view, run a prefix sum over each one). Rows come from the same +per-turn shards every other usage reader scans +(``/usage/tokens/YYYY-MM-DD.jsonl``), admitted by the same row +guard: a ``tokens`` row with a parseable local day. + +Dimensions (``by``): ``channel`` (where the session came from: ``dashboard``, +``cron``, ``background``, ``telegram`` …), ``agent``, ``model``, and +``cohort`` — the ISO week in which the row's session was first seen inside the +window, keyed by that week's Monday. Cohort is the one dimension that is a +property of the session rather than of the row, so it is assigned in a second +pass once every row's session is known. A session that started before the +window counts from its first row inside it; the shards it started in have been +retired, so there is nothing to read a truer answer from, and the chart labels +its oldest cohort layer as sessions "started by" a date rather than a week of +starts. + +The channel is derived from the row's session key with +:func:`~kiro_crew.messaging.link.telemetry_channel_of`, never read from the +row's own ``surface`` field — the same rule, for the same reason, as the Spend +panel's ``cost_breakdown``: historical rows can carry a wrong but non-empty +``surface`` (``dashboard`` for a Telegram turn), and the row schema has no +writer marker that would tell such a row from a trustworthy one. The key is +authoritative, and it also keeps the layer set closed (one label per channel, +not one per background job), so this chart and that table bucket spend by the +same vocabulary. + +The value stacked is the row's ``credits``, the unit the kiro-cli backend +(and its KAS relay) bills in. A harness that bills in tokens or dollars writes +rows with no credits, so the Usage tab mounts the chart only when the +configured default harness (``agent.acp_backend``) reports credits +(``api/acpBackend.ts``); turns a crew member ran on a different harness +(``agent.member_acp_backend``) still land in the same store and count +whatever credits they recorded. + +Bucketing is top-N by total over the window, with the remainder folded into +one ``other`` series so a dimension with hundreds of distinct values (agents, +session cohorts on a busy install) still renders as a legible stack. A row +whose dimension value is empty or absent — a row with no session key, a row +that predates ``agent``, a turn whose surface never set a model — lands in an +explicit ``unattributed`` series rather than being guessed at or dropped: +dropping it would make the stack's top edge disagree with the Daily History +credits for the same day. Series are returned in stack order, bottom first: +value dimensions largest-first, cohorts oldest-first, then ``other``, then +``unattributed``. + +The parsed rows are cached on the shard fingerprint the other readers use +(``(path, mtime, size)`` per shard in the window, plus a TTL), so a dashboard +switching dimension re-aggregates a few thousand small tuples +instead of re-reading the shards. The cache is bounded on both axes the rule +``a-bound-bounds-every-field-it-retains`` names: :data:`MAX_ROWS` caps how +many rows the window may retain and :data:`MAX_FIELD_CHARS` caps every string +a row keeps. The count bound is applied as each shard is read -- a shard +holds at most the rows the window still has room for while it is being parsed, +never the whole file -- so when the cap trips the NEWEST rows are kept, the +oldest are counted rather than retained, and the payload says so +(``truncated``, ``dropped_rows``, and ``complete_from``: the first day every +row of which was kept) instead of presenting a shortened window as the whole +one. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import math +import time +from collections import deque +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any + +from aiohttp import web + +from kiro_crew import model_registry +from kiro_crew import sel as _sel_mod +from kiro_crew.dashboard.handlers import usage as _usage +from kiro_crew.jsonl_util import bounded_records +from kiro_crew.messaging.link import telemetry_channel_of + +logger = logging.getLogger(__name__) + +#: Dimensions a caller may stack by. Everything but ``cohort`` is a row field. +SERIES_DIMENSIONS: tuple[str, ...] = ("channel", "agent", "model", "cohort") +DEFAULT_DIMENSION = "channel" +#: Buckets kept apart before the rest folds into ``other``. Matches the number +#: of distinguishable hues the dashboard's session palette generates, so the +#: chart never needs more colours than the theme can give it. +TOP_BUCKETS = 7 +#: The series window is the shard retention window: a longer axis would only +#: add empty days. +WINDOW_DAYS = _usage._TOKEN_HISTORY_DAYS + +#: Rows the window may retain. ``bounded_records`` bounds each record's size; +#: this bounds their count, so the parsed-row cache (and with it the handler's +#: memory) stays finite however busy a day's shard got. Sized at roughly two +#: turns a minute around the clock for the whole window — far above a single +#: operator's install, which the chart exists for. A store past it keeps its +#: newest rows and the payload counts the oldest ones left out. +MAX_ROWS = 100_000 +#: Longest string a retained row keeps for any field (session key, agent, +#: model). No writer produces one this long; the bound exists so a hand-edited +#: or foreign shard cannot grow the cache through its fields. +MAX_FIELD_CHARS = 256 + +#: Reserved series keys. Chosen so no real channel, agent, model or week date +#: can collide with them (dimension values are free text but a user-facing +#: name wrapped in double underscores is not one anyone writes). +OTHER_KEY = "__other__" +UNATTRIBUTED_KEY = "__unattributed__" + +_ROW_FIELDS = {"channel": "channel", "agent": "agent", "model": "model"} + +# Parsed-row cache: keyed on the shard fingerprint plus a TTL safety net for +# clock skew and in-place edits, the same posture as usage._parse_token_history. +_SCAN_CACHE: _Scan | None = None +_SCAN_CACHE_KEY: tuple[tuple[str, float, int], ...] | None = None +_SCAN_CACHE_TS: float = 0.0 +_SCAN_CACHE_TTL = 120.0 + + +@dataclass(frozen=True, slots=True) +class _Row: + """One admitted per-turn row, reduced to what the series needs.""" + + day: str + ts: float + slot: str + channel: str + agent: str + model: str + credits: float + + +@dataclass(frozen=True, slots=True) +class _Scan: + """The window's retained rows plus how the retention cap shortened it. + + ``dropped`` is how many admitted rows the cap refused. ``complete_from`` is + the oldest day every row of which was kept — the first day the chart shows + whole — or ``None`` when no day is (the cap tripped inside the newest shard) + or nothing was refused. + """ + + rows: list[_Row] + dropped: int + complete_from: str | None = None + + +def _str_field(obj: dict[str, Any], key: str) -> str: + """The row's string field, clipped to :data:`MAX_FIELD_CHARS` at retention.""" + value = obj.get(key) + return value[:MAX_FIELD_CHARS] if isinstance(value, str) else "" + + +def _credits_of(obj: dict[str, Any]) -> float: + """The row's credits as a finite float; anything else counts 0. + + ``float`` of an int wider than a double raises rather than answers, and a + non-finite value would reach the client as ``Infinity``, which is not JSON. + """ + number = _usage._usage_number(obj.get("credits")) + if number is None: + return 0.0 + try: + value = float(number) + except OverflowError: + return 0.0 + return value if math.isfinite(value) else 0.0 + + +def _row_from(obj: dict[str, Any]) -> _Row | None: + if obj.get("_type") != "tokens": + return None + ts_raw = obj.get("ts") + ts_epoch = _usage._parse_row_ts(str(ts_raw or "")) + day = _usage._parse_row_day(ts_raw) + if ts_epoch is None or day is None: + return None + slot = _str_field(obj, "slot") + return _Row( + day=day, + ts=ts_epoch, + slot=slot, + # From the session key, not the row's ``surface`` field — see the + # module doc. A row with no key has no channel to derive and stays + # unattributed rather than taking the classifier's ``unknown``. + channel=telemetry_channel_of(slot) if slot else "", + agent=_str_field(obj, "agent"), + # Same canonicalisation the daily chart applies, so a model that + # crossed a provider id migration never splits into two buckets. + model=model_registry.canonicalize_for_provider( + _str_field(obj, "model"), _str_field(obj, "provider") + ), + credits=_credits_of(obj), + ) + + +def _shard_rows(path: Path, budget: int) -> tuple[list[_Row], int]: + """The newest ``budget`` admitted rows of one shard, plus how many it refused. + + Within a day the file is chronological, so a deque bounded by the budget + holds the tail as the file streams past: an admitted row beyond the bound + is counted and let go at the point of retention, and the shard never sits + in memory whole however large it grew. + """ + kept: deque[_Row] = deque(maxlen=budget) + refused = 0 + with path.open("rb") as fh: + for line in bounded_records(fh, path, label="usage"): + try: + obj = json.loads(line) + except ValueError: + continue + if not isinstance(obj, dict): + continue + row = _row_from(obj) + if row is None: + continue + if len(kept) == budget: + refused += 1 + kept.append(row) + return list(kept), refused + + +def _scan_rows(days: int) -> _Scan: + rows: list[_Row] = [] + dropped = 0 + complete_from: str | None = None + unbroken = True + # The shard window's cutoff is inclusive, so it also yields the shard for the + # day before the axis opens; its rows never reach the chart, so they must not + # spend the row budget or be counted as refused. + first_day = _day_axis(days, datetime.now().astimezone().date())[0] + shards = [p for p in _usage._shards_in_window(days) if p.stem >= first_day] + # Newest shard first, so the cap — when it trips — shortens the window from + # its OLD end and the recent days a spend chart is read for stay whole. + for path in sorted(shards, key=lambda p: p.name, reverse=True): + try: + shard, refused = _shard_rows(path, MAX_ROWS - len(rows)) + except (OSError, UnicodeDecodeError): + # A corrupt or unreadable shard costs its own rows, not the window; + # but a day whose rows were lost is not complete, so "complete from" + # cannot reach past it either. + unbroken = False + continue + rows.extend(shard) + # Shards are read newest first and the cap only ever refuses from the + # old end, so every shard retained whole before the first refusal is a + # day the chart shows complete; the oldest of them is where "complete" + # starts. A day with no shard at all counts as complete too. + if unbroken and refused == 0 and not dropped: + complete_from = path.stem + dropped += refused + if dropped: + logger.warning( + "usage series: the window held %d more rows than the %d retained; " + "the oldest were left out", + dropped, + MAX_ROWS, + ) + return _Scan(rows, dropped, complete_from if dropped else None) + + +def load_rows() -> _Scan: + """Every retained row in the window, cached on the shard fingerprint. + + Returns the rows together with the count the cap refused, so the caller can + disclose a shortened window instead of serving it as the whole one. + """ + global _SCAN_CACHE, _SCAN_CACHE_KEY, _SCAN_CACHE_TS + + shard_paths = _usage._shards_in_window(WINDOW_DAYS) + cache_key: tuple[tuple[str, float, int], ...] | None + try: + cache_key = tuple( + sorted((str(p), p.stat().st_mtime, p.stat().st_size) for p in shard_paths) + ) + except OSError: + cache_key = None + now = time.time() + if ( + _SCAN_CACHE is not None + and cache_key is not None + and _SCAN_CACHE_KEY == cache_key + and (now - _SCAN_CACHE_TS) < _SCAN_CACHE_TTL + ): + return _SCAN_CACHE + scan = _scan_rows(WINDOW_DAYS) + _SCAN_CACHE = scan + _SCAN_CACHE_KEY = cache_key + _SCAN_CACHE_TS = now + return scan + + +def _week_monday(ts: float) -> str: + """The Monday of the ISO week containing the LOCAL day of ``ts``.""" + local = datetime.fromtimestamp(ts).astimezone() + monday = local.date() - timedelta(days=local.isoweekday() - 1) + return monday.isoformat() + + +def _cohort_of_slot(rows: list[_Row]) -> dict[str, str]: + """Each session's cohort: the ISO week of its first row inside the window.""" + first_seen: dict[str, float] = {} + for row in rows: + if not row.slot: + continue + seen = first_seen.get(row.slot) + if seen is None or row.ts < seen: + first_seen[row.slot] = row.ts + return {slot: _week_monday(ts) for slot, ts in first_seen.items()} + + +def _bucket_key(row: _Row, by: str, cohorts: dict[str, str]) -> str: + if by == "cohort": + return cohorts.get(row.slot, "") if row.slot else "" + return getattr(row, _ROW_FIELDS[by]) + + +def _day_axis(days: int, today: date) -> list[str]: + start = today - timedelta(days=days - 1) + return [(start + timedelta(days=i)).isoformat() for i in range(days)] + + +def _round(value: float) -> float: + return round(value, 6) + + +def _add(total: float, value: float) -> float: + """``total + value``, or ``total`` unchanged when the sum leaves the finite range. + + Every operand is finite, but a sum of finite floats can still overflow to + ``Infinity``, which is not JSON. Same rule as the Daily History credits: the + contribution that would push a figure past the finite range is dropped. + """ + candidate = total + value + return candidate if math.isfinite(candidate) else total + + +def _sum(values: Iterable[float]) -> float: + total = 0.0 + for value in values: + total = _add(total, value) + return total + + +def build_series( + rows: list[_Row], + *, + by: str = DEFAULT_DIMENSION, + days: int = WINDOW_DAYS, + top: int = TOP_BUCKETS, + dropped: int = 0, + complete_from: str | None = None, + today: date | None = None, +) -> dict[str, Any]: + """Aggregate ``rows`` into the stacked-series payload. Pure; see the module doc. + + ``dropped`` is how many admitted rows the retention cap refused and + ``complete_from`` the first day it left whole; both pass through as the + payload's truncation disclosure. + """ + today = today or datetime.now().astimezone().date() + dates = _day_axis(days, today) + index = {d: i for i, d in enumerate(dates)} + window_rows = [r for r in rows if r.day in index] + cohorts = _cohort_of_slot(window_rows) if by == "cohort" else {} + + per_bucket: dict[str, list[float]] = {} + unattributed = [0.0] * days + for row in window_rows: + value = row.credits + if value == 0.0: + continue + key = _bucket_key(row, by, cohorts) + target = unattributed if not key else per_bucket.setdefault(key, [0.0] * days) + target[index[row.day]] = _add(target[index[row.day]], value) + + ranked = sorted(per_bucket.items(), key=lambda kv: (-_sum(kv[1]), kv[0])) + kept, folded = ranked[:top], ranked[top:] + if by == "cohort": + kept.sort(key=lambda kv: kv[0]) + + series: list[dict[str, Any]] = [ + { + "key": key, + "kind": "bucket", + "values": [_round(v) for v in values], + "total": _round(_sum(values)), + } + for key, values in kept + ] + if folded: + other = [0.0] * days + for _key, values in folded: + for i, v in enumerate(values): + other[i] = _add(other[i], v) + series.append( + { + "key": OTHER_KEY, + "kind": "other", + "values": [_round(v) for v in other], + "total": _round(_sum(other)), + "members": len(folded), + } + ) + if any(unattributed): + series.append( + { + "key": UNATTRIBUTED_KEY, + "kind": "unattributed", + "values": [_round(v) for v in unattributed], + "total": _round(_sum(unattributed)), + } + ) + return { + "dates": dates, + "series": series, + "total": _round(_sum(s["total"] for s in series)), + "truncated": dropped > 0, + "dropped_rows": dropped, + "complete_from": complete_from if dropped else None, + } + + +def _query_choice( + request: web.Request, name: str, allowed: tuple[str, ...], default: str +) -> str | None: + """The query value when it is one of ``allowed``; the default when absent; ``None`` otherwise.""" + raw = (request.query.get(name) or "").strip() + if not raw: + return default + return raw if raw in allowed else None + + +async def api_usage_series(request: web.Request) -> web.Response: + """GET /api/usage/series?by=channel|agent|model|cohort. + + Dashboard-only: the payload is the whole install's spend with no slot + filter, so an app token — scoped to its own rows on ``/api/usage/turns`` — + is answered 404 here, indistinguishable from a route it was never granted, + and the refusal is SEL-audited like every app-caller decision. An unknown + ``by`` is a 400, because silently substituting the default would render a + chart of something other than what was asked for. + """ + request_app = str(request.get("app", "") or "") + if request_app: + + def _audit_denied() -> None: + _sel_mod.sel().log_api_access( + caller=request_app, + operation="usage_series", + outcome="denied", + source="app_isolation", + error="dashboard-only endpoint", + ) + + await asyncio.to_thread(_audit_denied) + return web.json_response({"error": "not found", "code": "not_found"}, status=404) + by = _query_choice(request, "by", SERIES_DIMENSIONS, DEFAULT_DIMENSION) + if by is None: + return web.json_response( + { + "error": f"by must be one of {', '.join(SERIES_DIMENSIONS)}", + "code": "invalid_dimension", + }, + status=400, + ) + scan = await asyncio.to_thread(load_rows) + return web.json_response( + build_series(scan.rows, by=by, dropped=scan.dropped, complete_from=scan.complete_from) + ) diff --git a/src/kiro_crew/dashboard/routes/system.py b/src/kiro_crew/dashboard/routes/system.py index 65cc004544ea..f535d4949ed8 100644 --- a/src/kiro_crew/dashboard/routes/system.py +++ b/src/kiro_crew/dashboard/routes/system.py @@ -93,6 +93,7 @@ def register(app: web.Application) -> None: app.router.add_post("/api/tasks/{task_id}/cancel", handlers.api_task_cancel) app.router.add_get("/api/usage/kiro", handlers.api_kiro_usage) app.router.add_get("/api/usage", handlers.api_usage) + app.router.add_get("/api/usage/series", handlers.api_usage_series) app.router.add_get("/api/telemetry/startup", handlers.api_telemetry_startup) app.router.add_get("/api/telemetry/context-trace", handlers.api_context_trace) app.router.add_get("/api/usage/turns", handlers.api_usage_turns) diff --git a/test/test_usage_series.py b/test/test_usage_series.py new file mode 100644 index 000000000000..7e5be2bdc793 --- /dev/null +++ b/test/test_usage_series.py @@ -0,0 +1,603 @@ +"""Tests for kiro_crew.dashboard.handlers.usage_series (the Usage tab's stacked series).""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +import kiro_crew.dashboard.handlers.usage as usage_mod +import kiro_crew.dashboard.handlers.usage_series as series_mod +import kiro_crew.sel as sel_mod +from kiro_crew.dashboard.handlers.usage_series import ( + OTHER_KEY, + UNATTRIBUTED_KEY, + _Row, + api_usage_series, + build_series, + load_rows, +) +from kiro_crew.testing.clock import ManualClock + +#: The one instant the shard-reading tests run at, as a LOCAL wall time (naive). +#: The product keys everything on local days -- shard names, the day axis, the +#: cohort week -- so pinning the wall time rather than an epoch makes the frozen +#: clock read Friday noon in every zone a runner can be in, and the expectations +#: below derive from this same constant instead of a second clock read (D5). +FROZEN_LOCAL = datetime(2026, 10, 2, 12, 0) +TODAY = FROZEN_LOCAL.date() # a Friday, so its ISO week opens on the Monday four days earlier +MONDAY = (TODAY - timedelta(days=TODAY.isoweekday() - 1)).isoformat() + + +def _row( + day: str, + credits: float = 1.0, + *, + slot: str = "chat-1-1700000000", + channel: str = "dashboard", + agent: str = "kirocrew", + model: str = "m", + hour: int = 12, +) -> _Row: + ts = datetime.fromisoformat(f"{day}T{hour:02d}:00:00").astimezone().timestamp() + return _Row( + day=day, + ts=ts, + slot=slot, + channel=channel, + agent=agent, + model=model, + credits=credits, + ) + + +def _by_key(payload: dict) -> dict[str, dict]: + return {s["key"]: s for s in payload["series"]} + + +class TestBuildSeries: + def test_dense_day_axis_is_zero_filled(self): + rows = [_row("2026-10-01", 2.5), _row("2026-10-02", 1.5)] + + out = build_series(rows, by="channel", days=5, today=TODAY) + + assert out["dates"] == [ + "2026-09-28", + "2026-09-29", + "2026-09-30", + "2026-10-01", + "2026-10-02", + ] + assert out["series"] == [ + {"key": "dashboard", "kind": "bucket", "values": [0, 0, 0, 2.5, 1.5], "total": 4.0} + ] + assert out["total"] == 4.0 + assert out["truncated"] is False + assert out["dropped_rows"] == 0 + assert out["complete_from"] is None + + def test_rows_outside_the_window_are_ignored(self): + rows = [_row("2026-09-01", 99.0), _row("2026-10-02", 1.0)] + + out = build_series(rows, by="channel", days=7, today=TODAY) + + assert out["total"] == 1.0 + + def test_value_dimensions_stack_largest_first(self): + rows = [ + _row("2026-10-02", 1.0, channel="background"), + _row("2026-10-02", 5.0, channel="cron"), + _row("2026-10-01", 3.0, channel="dashboard"), + ] + + out = build_series(rows, by="channel", days=2, today=TODAY) + + assert [s["key"] for s in out["series"]] == ["cron", "dashboard", "background"] + + def test_top_n_folds_the_remainder_into_other(self): + rows = [_row("2026-10-02", float(n), agent=f"agent-{n}") for n in range(1, 6)] + + out = build_series(rows, by="agent", days=1, top=2, today=TODAY) + + keys = [s["key"] for s in out["series"]] + assert keys == ["agent-5", "agent-4", OTHER_KEY] + other = _by_key(out)[OTHER_KEY] + assert other == { + "key": OTHER_KEY, + "kind": "other", + "values": [6.0], + "total": 6.0, + "members": 3, + } + # Folding loses nothing: the stack's top edge is still the day's spend. + assert out["total"] == 15.0 + + def test_empty_dimension_value_is_an_explicit_unattributed_series(self): + rows = [_row("2026-10-02", 2.0, model=""), _row("2026-10-02", 3.0, model="opus")] + + out = build_series(rows, by="model", days=1, today=TODAY) + + assert [s["key"] for s in out["series"]] == ["opus", UNATTRIBUTED_KEY] + assert _by_key(out)[UNATTRIBUTED_KEY]["total"] == 2.0 + assert out["total"] == 5.0 + + def test_unattributed_and_other_are_omitted_when_empty(self): + out = build_series([_row("2026-10-02", 1.0)], by="channel", days=1, today=TODAY) + + assert [s["kind"] for s in out["series"]] == ["bucket"] + + def test_zero_valued_rows_do_not_create_buckets(self): + rows = [ + _row("2026-10-02", 0.0, channel="cron"), + _row("2026-10-02", 1.0, channel="dashboard"), + ] + + out = build_series(rows, by="channel", days=1, today=TODAY) + + assert [s["key"] for s in out["series"]] == ["dashboard"] + + def test_a_row_that_would_overflow_a_day_is_dropped_not_served_as_infinity(self): + rows = [_row("2026-10-02", 1.0), _row("2026-10-02", 1.7e308), _row("2026-10-02", 1.7e308)] + + out = build_series(rows, by="channel", days=1, today=TODAY) + + assert out["total"] == 1.0 + 1.7e308 + assert json.dumps(out, allow_nan=False) + + def test_folded_and_total_sums_cannot_overflow_to_infinity_either(self): + rows = [ + _row("2026-10-01", 1.7e308, channel="kept"), + _row("2026-10-02", 1.0e308, channel="folded-a"), + _row("2026-10-02", 1.0e308, channel="folded-b"), + ] + + out = build_series(rows, by="channel", days=2, top=1, today=TODAY) + + assert _by_key(out)["kept"]["total"] == 1.7e308 + assert _by_key(out)["__other__"]["values"] == [0.0, 1.0e308] + assert out["total"] == 1.7e308 + assert json.dumps(out, allow_nan=False) + + def test_cohort_is_the_sessions_first_seen_iso_week_in_stack_order_oldest_first(self): + rows = [ + # s-old starts Tue 09-15 (week of Mon 09-14) and keeps spending into October: + # every one of its rows belongs to the 09-14 cohort, not the week it was spent. + _row("2026-09-15", 1.0, slot="s-old"), + _row("2026-10-02", 4.0, slot="s-old"), + # s-new starts Thu 10-01 (week of Mon 09-28). + _row("2026-10-01", 2.0, slot="s-new"), + _row("2026-10-02", 8.0, slot="s-new"), + ] + + out = build_series(rows, by="cohort", days=30, today=TODAY) + + assert [s["key"] for s in out["series"]] == ["2026-09-14", "2026-09-28"] + cohorts = _by_key(out) + assert cohorts["2026-09-14"]["total"] == 5.0 + assert cohorts["2026-09-28"]["total"] == 10.0 + # Oldest first even though the newer cohort is the bigger one. + assert cohorts["2026-09-14"]["total"] < cohorts["2026-09-28"]["total"] + + def test_cohort_first_seen_is_judged_inside_the_window_only(self): + rows = [_row("2026-09-01", 1.0, slot="s"), _row("2026-10-02", 1.0, slot="s")] + + out = build_series(rows, by="cohort", days=7, today=TODAY) + + # The September row is outside a 7-day window, so the session's first + # appearance inside the window is the October row: week of Mon 09-28. + assert [s["key"] for s in out["series"]] == ["2026-09-28"] + + def test_cohort_row_without_a_slot_is_unattributed(self): + rows = [_row("2026-10-02", 1.0, slot=""), _row("2026-10-02", 2.0, slot="s")] + + out = build_series(rows, by="cohort", days=1, today=TODAY) + + assert [s["key"] for s in out["series"]] == ["2026-09-28", UNATTRIBUTED_KEY] + + def test_dropped_count_is_disclosed_as_truncation(self): + out = build_series( + [_row("2026-10-02", 1.0)], + by="channel", + days=1, + dropped=3, + complete_from="2026-10-02", + today=TODAY, + ) + + assert out["truncated"] is True + assert out["dropped_rows"] == 3 + assert out["complete_from"] == "2026-10-02" + + def test_complete_from_is_withheld_when_nothing_was_dropped(self): + out = build_series( + [_row("2026-10-02", 1.0)], by="channel", days=1, complete_from="2026-10-02", today=TODAY + ) + + assert (out["truncated"], out["complete_from"]) == (False, None) + + +def _freeze(monkeypatch) -> ManualClock: + """Freeze the clocks the shard window, the day axis and the scan cache read. + + One clock on both modules: ``usage`` picks the shards by their date and + ``usage_series`` builds the axis and stamps the cache, and the two must agree + on what day it is. ``FROZEN_LOCAL.timestamp()`` is read in the process's zone, + so the installed ``datetime.now()`` answers that wall time whatever the zone. + """ + clock = ManualClock(start=FROZEN_LOCAL.timestamp()) + clock.install(monkeypatch, usage_mod, time=False, datetime=True) + clock.install(monkeypatch, series_mod, time=True, datetime=True) + return clock + + +def _patch_shards(monkeypatch, tmp_path): + _freeze(monkeypatch) + shard_dir = tmp_path / "tokens" + shard_dir.mkdir() + monkeypatch.setattr(usage_mod, "_TOKEN_USAGE_DIR", shard_dir) + monkeypatch.setattr(series_mod, "_SCAN_CACHE", None) + monkeypatch.setattr(series_mod, "_SCAN_CACHE_KEY", None) + monkeypatch.setattr(series_mod, "_SCAN_CACHE_TS", 0.0) + return shard_dir + + +def _write_shard(shard_dir, day: str, records: list[dict]) -> None: + (shard_dir / f"{day}.jsonl").write_text( + "\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8" + ) + + +def _stamp(offset: timedelta = timedelta(0)) -> str: + """A row ``ts`` at ``FROZEN_LOCAL + offset``, in the aware form the writer stores.""" + return (FROZEN_LOCAL + offset).astimezone().isoformat() + + +def _day(offset: timedelta = timedelta(0)) -> str: + """The local day of ``FROZEN_LOCAL + offset``: a shard name and an axis entry.""" + return (TODAY + offset).isoformat() + + +def _record(ts: str, **fields) -> dict: + base = { + "_type": "tokens", + "ts": ts, + "slot": "chat-1-1700000000", + "provider": "acp", + "model": "m", + "surface": "dashboard", + "agent": "kirocrew", + "input": 0, + "output": 0, + "cache_create": 0, + "cache_read": 0, + "credits": 1.0, + } + base.update(fields) + return base + + +class TestLoadRows: + def test_reads_tokens_rows_and_skips_the_rest(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + _write_shard( + shard_dir, + _day(), + [ + _record(_stamp(), credits=2.5, input=10, output=5, cache_create=1, cache_read=4), + {"_type": "context", "ts": _stamp(), "slot": "chat-1"}, + {"_type": "tokens", "ts": "not a timestamp", "credits": 99}, + {"_type": "tokens", "ts": _stamp(), "credits": "nan", "slot": "chat-2"}, + ], + ) + (shard_dir / f"{_day()}.jsonl").open("a", encoding="utf-8").write("{not json\n") + + scan = load_rows() + + assert [(r.slot, r.credits) for r in scan.rows] == [ + ("chat-1-1700000000", 2.5), + ("chat-2", 0.0), + ] + assert scan.dropped == 0 + + def test_channel_comes_from_the_session_key_not_the_surface_field(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + _write_shard( + shard_dir, + _day(), + [ + # A historical row can carry a wrong but non-empty surface; the key wins. + _record(_stamp(), slot="telegram:1234", surface="dashboard"), + _record(_stamp(), slot="cron:nightly", surface="dashboard"), + _record(_stamp(), slot="chat-7-1700000000", surface=""), + # No key: nothing to derive from, so the row stays unattributed + # rather than taking the classifier's "unknown". + _record(_stamp(), slot="", surface="dashboard"), + ], + ) + + scan = load_rows() + + assert [r.channel for r in scan.rows] == ["telegram", "cron", "dashboard", ""] + + def test_retained_strings_are_clipped_at_the_field_bound(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + long = "a" * (series_mod.MAX_FIELD_CHARS + 50) + _write_shard(shard_dir, _day(), [_record(_stamp(), slot=long, agent=long, model=long)]) + + (row,) = load_rows().rows + + assert len(row.slot) == len(row.agent) == len(row.model) == series_mod.MAX_FIELD_CHARS + + def test_row_cap_keeps_the_newest_rows_and_counts_the_rest(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + monkeypatch.setattr(series_mod, "MAX_ROWS", 3) + yesterday = timedelta(days=-1) + _write_shard( + shard_dir, + _day(yesterday), + [_record(_stamp(yesterday), credits=float(c)) for c in (1, 2, 3)], + ) + _write_shard(shard_dir, _day(), [_record(_stamp(), credits=float(c)) for c in (4, 5)]) + + scan = load_rows() + + # Today's rows whole, then the newest of yesterday's; the two oldest counted. + assert sorted(r.credits for r in scan.rows) == [3.0, 4.0, 5.0] + assert scan.dropped == 2 + # Yesterday lost rows, so today is the first day the chart shows whole. + assert scan.complete_from == _day() + out = build_series( + scan.rows, by="channel", dropped=scan.dropped, complete_from=scan.complete_from + ) + assert (out["truncated"], out["dropped_rows"], out["complete_from"]) == (True, 2, _day()) + + def test_complete_from_is_the_oldest_whole_day_even_across_a_day_with_no_shard( + self, tmp_path, monkeypatch + ): + shard_dir = _patch_shards(monkeypatch, tmp_path) + monkeypatch.setattr(series_mod, "MAX_ROWS", 3) + three_back, two_back = timedelta(days=-3), timedelta(days=-2) + _write_shard(shard_dir, _day(three_back), [_record(_stamp(three_back))] * 2) + _write_shard(shard_dir, _day(two_back), [_record(_stamp(two_back))] * 2) + _write_shard(shard_dir, _day(), [_record(_stamp())]) + + scan = load_rows() + + # Today (1) and two days back (2) fill the cap; three days back is refused + # entirely. Yesterday had no shard, which leaves it complete, so the whole + # stretch from two days back is intact. + assert (len(scan.rows), scan.dropped) == (3, 2) + assert scan.complete_from == _day(two_back) + + def test_complete_from_stops_at_an_unreadable_shard(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + monkeypatch.setattr(series_mod, "MAX_ROWS", 2) + three_back, two_back, yesterday = timedelta(days=-3), timedelta(days=-2), timedelta(days=-1) + _write_shard(shard_dir, _day(three_back), [_record(_stamp(three_back))] * 2) + _write_shard(shard_dir, _day(two_back), [_record(_stamp(two_back))]) + _write_shard(shard_dir, _day(yesterday), [_record(_stamp(yesterday))]) + _write_shard(shard_dir, _day(), [_record(_stamp())]) + real_shard_rows = series_mod._shard_rows + + def unreadable_yesterday(path, budget): + if path.stem == _day(yesterday): + raise OSError("simulated unreadable shard") + return real_shard_rows(path, budget) + + monkeypatch.setattr(series_mod, "_shard_rows", unreadable_yesterday) + + scan = load_rows() + + # Yesterday's rows are lost, so "complete" cannot reach past it even + # though the day before it was read whole; three days back is refused. + assert (len(scan.rows), scan.dropped) == (2, 2) + assert scan.complete_from == _day() + + def test_complete_from_is_none_when_the_cap_trips_inside_the_newest_shard( + self, tmp_path, monkeypatch + ): + shard_dir = _patch_shards(monkeypatch, tmp_path) + monkeypatch.setattr(series_mod, "MAX_ROWS", 1) + _write_shard(shard_dir, _day(), [_record(_stamp())] * 2) + + scan = load_rows() + out = build_series( + scan.rows, by="channel", dropped=scan.dropped, complete_from=scan.complete_from + ) + + assert (len(scan.rows), scan.dropped, scan.complete_from) == (1, 1, None) + assert (out["truncated"], out["complete_from"]) == (True, None) + + def test_a_shard_wider_than_the_budget_is_never_held_whole(self, tmp_path, monkeypatch): + """The count bound applies while a shard streams past, not once it is parsed. + + Rows are counted as they are built and released, so the peak is the budget + plus the one row in hand when the oldest is let go; a reader that parsed + the file first would peak at the file's whole row count. + """ + shard_dir = _patch_shards(monkeypatch, tmp_path) + live = peak = 0 + + class _CountedRow(_Row): + def __init__(self, *args, **kwargs): + nonlocal live, peak + super().__init__(*args, **kwargs) + live += 1 + peak = max(peak, live) + + def __del__(self): + nonlocal live + live -= 1 + + monkeypatch.setattr(series_mod, "_Row", _CountedRow) + _write_shard(shard_dir, _day(), [_record(_stamp(), credits=float(c)) for c in range(1, 11)]) + + kept, refused = series_mod._shard_rows(shard_dir / f"{_day()}.jsonl", 3) + + assert ([r.credits for r in kept], refused) == ([8.0, 9.0, 10.0], 7) + assert peak == 4, f"{peak} rows were live at once for a budget of 3" + + def test_row_cap_is_silent_when_the_window_fits(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + monkeypatch.setattr(series_mod, "MAX_ROWS", 2) + _write_shard(shard_dir, _day(), [_record(_stamp()) for _ in range(2)]) + + scan = load_rows() + + assert (len(scan.rows), scan.dropped) == (2, 0) + + def test_the_shard_before_the_axis_opens_neither_spends_the_budget_nor_counts_as_refused( + self, tmp_path, monkeypatch + ): + """The shard window's inclusive cutoff yields one shard older than the chart. + + Its rows are never drawn, so a cap that trips on them would disclose a + shortened window while every charted day is whole. + """ + shard_dir = _patch_shards(monkeypatch, tmp_path) + monkeypatch.setattr(series_mod, "MAX_ROWS", 2) + before_axis = timedelta(days=-series_mod.WINDOW_DAYS) + _write_shard(shard_dir, _day(before_axis), [_record(_stamp(before_axis)) for _ in range(3)]) + _write_shard(shard_dir, _day(), [_record(_stamp()) for _ in range(2)]) + + scan = load_rows() + out = build_series(scan.rows, by="channel", dropped=scan.dropped, today=TODAY) + + assert (len(scan.rows), scan.dropped) == (2, 0) + assert all(r.day >= out["dates"][0] for r in scan.rows) + assert (out["truncated"], out["dropped_rows"]) == (False, 0) + + def test_a_credits_figure_wider_than_a_double_counts_zero_instead_of_failing_the_read( + self, tmp_path, monkeypatch + ): + shard_dir = _patch_shards(monkeypatch, tmp_path) + _write_shard(shard_dir, _day(), [_record(_stamp(), credits=10**400)]) + + scan = load_rows() + out = build_series(scan.rows, by="channel", days=1) + + assert [r.credits for r in scan.rows] == [0.0] + assert out["total"] == 0.0 + assert json.dumps(out, allow_nan=False) + + def test_rows_are_cached_until_a_shard_changes(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + _write_shard(shard_dir, _day(), [_record(_stamp(), credits=1.0)]) + + first = load_rows() + assert len(first.rows) == 1 + # An append changes the shard's size, so the fingerprint misses. + with (shard_dir / f"{_day()}.jsonl").open("a", encoding="utf-8") as fh: + fh.write(json.dumps(_record(_stamp(), credits=2.0)) + "\n") + second = load_rows() + + assert len(second.rows) == 2 + assert load_rows() is second + + def test_missing_directory_yields_no_rows(self, tmp_path, monkeypatch): + _patch_shards(monkeypatch, tmp_path) + monkeypatch.setattr(usage_mod, "_TOKEN_USAGE_DIR", tmp_path / "absent") + + assert load_rows().rows == [] + + +class TestApiUsageSeries: + async def _get(self, query: str, app_name: str = ""): + app = web.Application() + + @web.middleware + async def stamp_app(request, handler): + request["app"] = app_name + return await handler(request) + + app.middlewares.append(stamp_app) + app.router.add_get("/api/usage/series", api_usage_series) + async with TestClient(TestServer(app)) as client: + resp = await client.get("/api/usage/series" + query) + return resp.status, await resp.json() + + @pytest.mark.asyncio + async def test_defaults_to_channel_and_credits_over_the_whole_window( + self, tmp_path, monkeypatch + ): + shard_dir = _patch_shards(monkeypatch, tmp_path) + _write_shard(shard_dir, _day(), [_record(_stamp(), credits=3.0, slot="cron:tick")]) + + status, body = await self._get("") + + assert status == 200 + assert len(body["dates"]) == series_mod.WINDOW_DAYS + assert body["dates"][-1] == _day() + assert [s["key"] for s in body["series"]] == ["cron"] + assert body["total"] == 3.0 + assert (body["truncated"], body["dropped_rows"], body["complete_from"]) == (False, 0, None) + + @pytest.mark.asyncio + async def test_unknown_dimension_is_a_400(self, tmp_path, monkeypatch): + _patch_shards(monkeypatch, tmp_path) + + status, body = await self._get("?by=slot") + + assert (status, body["code"]) == (400, "invalid_dimension") + + @pytest.mark.asyncio + async def test_cohort_dimension_over_the_wire(self, tmp_path, monkeypatch): + shard_dir = _patch_shards(monkeypatch, tmp_path) + _write_shard(shard_dir, _day(), [_record(_stamp(), credits=1.0)]) + + status, body = await self._get("?by=cohort") + + assert status == 200 + assert [s["key"] for s in body["series"]] == [MONDAY] + + @pytest.mark.asyncio + @pytest.mark.parametrize("zone", ["UTC", "Pacific/Kiritimati", "America/St_Johns"]) + async def test_every_reader_agrees_on_the_local_day_whatever_the_zone( + self, tmp_path, monkeypatch, local_tz, zone + ): + """The shard window, the day axis and the cohort week read one LOCAL day. + + Each reader derives its day from a stamp carrying the zone's offset, so a + reader that fell back to the UTC day would put a Kiritimati noon on the + day before. Pinned under UTC, a zone a calendar day ahead of it for most + of every day, and a half-hour offset (D5). + """ + local_tz(zone) + shard_dir = _patch_shards(monkeypatch, tmp_path) + _write_shard(shard_dir, _day(), [_record(_stamp(), credits=1.0)]) + + status, body = await self._get("?by=cohort") + + assert status == 200 + assert body["dates"][-1] == _day() + assert [s["key"] for s in body["series"]] == [MONDAY] + assert body["total"] == 1.0 + + @pytest.mark.asyncio + async def test_app_token_is_refused_as_not_found_and_the_refusal_is_audited( + self, tmp_path, monkeypatch + ): + _patch_shards(monkeypatch, tmp_path) + audits: list[dict] = [] + + class _Sel: + def log_api_access(self, **kw): + audits.append(kw) + + monkeypatch.setattr(sel_mod, "sel", lambda: _Sel()) + + status, body = await self._get("", app_name="some-app") + + assert (status, body["code"]) == (404, "not_found") + assert audits == [ + { + "caller": "some-app", + "operation": "usage_series", + "outcome": "denied", + "source": "app_isolation", + "error": "dashboard-only endpoint", + } + ] diff --git a/website/src/api/acpBackend.test.ts b/website/src/api/acpBackend.test.ts index 22fb64544c67..e86a1a129aa4 100644 --- a/website/src/api/acpBackend.test.ts +++ b/website/src/api/acpBackend.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { ACP_BACKEND_KIRO, isKiroBackend } from './acpBackend' +import { ACP_BACKEND_KAS, ACP_BACKEND_KIRO, isKiroBackend, reportsCredits } from './acpBackend' describe('isKiroBackend', () => { it('is false while the config has not loaded: nothing Kiro-only renders on a guess', () => { @@ -27,3 +27,21 @@ describe('isKiroBackend', () => { } }) }) + +describe('reportsCredits', () => { + it('is false while the config has not loaded: a credits chart does not render on a guess', () => { + expect(reportsCredits(undefined)).toBe(false) + }) + + it('is true for kiro-cli (also what an unset key means) and for its KAS relay', () => { + expect(reportsCredits({ agent: { acp_backend: ACP_BACKEND_KIRO } })).toBe(true) + expect(reportsCredits({})).toBe(true) + expect(reportsCredits({ agent: { acp_backend: ACP_BACKEND_KAS } })).toBe(true) + }) + + it('is false for a harness that bills in tokens or dollars', () => { + for (const backend of ['claude', 'codex', 'opencode', 'pi', 'goose', 'deepseek', 'some-future-harness']) { + expect(reportsCredits({ agent: { acp_backend: backend } }), backend).toBe(false) + } + }) +}) diff --git a/website/src/api/acpBackend.ts b/website/src/api/acpBackend.ts index ad8f28a5a46f..686ab92ea1af 100644 --- a/website/src/api/acpBackend.ts +++ b/website/src/api/acpBackend.ts @@ -35,6 +35,21 @@ export function isKiroBackend(cfg: AcpBackendConfig | undefined): boolean { return (cfg.agent?.acp_backend ?? ACP_BACKEND_KIRO) === ACP_BACKEND_KIRO } +/** + * True only when a LOADED config names a harness that bills in credits — the + * unit the Usage tab's spend chart stacks. kiro-cli and its KAS relay both + * report a turn's credits (`TurnUsage.credits` on the gateway); every other + * harness fills token counts and a dollar cost and leaves credits at zero, so a + * credits chart over its rows would show "no spend" for turns that did run. + * Named positively, like `isKiroBackend`, so a harness added later is not + * assumed to bill in credits. + */ +export function reportsCredits(cfg: AcpBackendConfig | undefined): boolean { + if (cfg === undefined) return false + const backend = cfg.agent?.acp_backend ?? ACP_BACKEND_KIRO + return backend === ACP_BACKEND_KIRO || backend === ACP_BACKEND_KAS +} + /** Shared translated harness labels; unknown harnesses keep the server's policy name. */ export function acpBackendName(backend: { id: string; policy_id?: string }): string { switch (backend.id) { diff --git a/website/src/api/client.ts b/website/src/api/client.ts index 236cb1623b13..7027b26abe27 100644 --- a/website/src/api/client.ts +++ b/website/src/api/client.ts @@ -75,6 +75,11 @@ import type { DecisionsConsentData } from './client/decisions' export type { TunnelStatus } from './client/system' export type { WakaTimeStatsEntry, WakaTimeStats } from './client/telemetry' +export type { + UsageSeriesDimension, + UsageSeriesLayer, + UsageSeriesPayload, +} from './client/telemetry' export type { KiroPrerequisiteStatus, KasLoginStatus, diff --git a/website/src/api/client/telemetry.ts b/website/src/api/client/telemetry.ts index 67d2f3456e67..7b1d86dc08a3 100644 --- a/website/src/api/client/telemetry.ts +++ b/website/src/api/client/telemetry.ts @@ -29,6 +29,35 @@ export type WakaTimeStats = { } } +/** Stacking dimensions `GET /api/usage/series` accepts. */ +export type UsageSeriesDimension = 'channel' | 'agent' | 'model' | 'cohort' + +/** One stack layer: a per-day value for every entry of the payload's `dates`. + * `kind` separates a real bucket from the two reserved layers (`other`, the + * fold of everything past the top-N, which carries how many buckets it holds; + * `unattributed`, rows whose dimension value was never recorded). */ +export type UsageSeriesLayer = { + key: string + kind: 'bucket' | 'other' | 'unattributed' + values: number[] + total: number + members?: number +} + +/** Dense local-day axis plus layers in stack order, bottom first. `truncated` + * says the window held more turns than the server retains; `dropped_rows` is + * how many of the oldest it left out and `complete_from` the first day it kept + * whole (null when none is), so the chart can say so rather than present a + * shortened window as the whole one. */ +export type UsageSeriesPayload = { + dates: string[] + series: UsageSeriesLayer[] + total: number + truncated: boolean + dropped_rows: number + complete_from: string | null +} + export function createTelemetryEndpoints({ get, post, j, jfetch: fetch }: ClientTransport) { const usageReadouts = { /** The five session folds of a crew log, keyed by name, in ONE request. @@ -93,6 +122,11 @@ export function createTelemetryEndpoints({ get, post, j, jfetch: fetch }: Client * every row (the endpoint's app-ownership filter applies to app callers). */ usageTurns: (slot: string) => fetch('/api/usage/turns?slot=' + encodeURIComponent(slot)).then(j), + /** The Usage tab's stacked spend-over-time series: whole-install credits by + * one dimension; the same always-written row store as `usageTurns`, with no + * slot filter, which is why the route refuses app tokens. */ + usageSeries: (by: UsageSeriesDimension) => + get(`/api/usage/series?by=${encodeURIComponent(by)}`).then(j) as Promise, /** WakaTime coding stats for a named range. Returns { configured: false } * when the integration is off; a 502 body carries { code: 'upstream_unavailable' }. */ wakatimeStats: (range: string) => diff --git a/website/src/i18n/en.context.json b/website/src/i18n/en.context.json index d04872be6e51..1d13583673f5 100644 --- a/website/src/i18n/en.context.json +++ b/website/src/i18n/en.context.json @@ -676,6 +676,31 @@ "pages.chatSidebar.bulk_switch_keeps_conversation": "Description under the Switch All Sessions heading, where one model is picked for every live session. The conversation itself (the transcript and the agent's memory of it) is kept and resumes on the new model, so do not word it as a reset, a deletion or a fresh start.", "pages.chatSidebar.bulk_switch_stops_running_reply": "Red caveat in the Switch All Sessions panel, shown only after the user unticks the 'Skip N running sessions' checkbox while some sessions are mid-reply: those sessions are switched as well, and the reply each is producing is cut off.", "components.markdownRenderer.closed_session_open_failed": "Short one-line inline error shown right beside a chip or link in chat prose that names an earlier (closed) chat session, after the reader clicked it and the app could not check whether that session still exists. {{name}} is the session identifier the chip or link names (for example chat-7-1791000000), shown verbatim; keep it short so it fits on one line. 'Session' is a chat/agent conversation in a developer tool, never a login session.", - "components.markdownRenderer.closed_session_try_again": "Button label beside that inline error; clicking it checks again and opens the earlier chat session if it is there." + "components.markdownRenderer.closed_session_try_again": "Button label beside that inline error; clicking it checks again and opens the earlier chat session if it is there.", + "pages.overview.usageTab.spend_over_time": "Title of the Usage tab card holding the stacked area chart of credits spent per day over the last month. \"Spend\" is the noun (money/credits consumed), the same word the Billing card uses for its \"Spend\" row in this locale. The bracketed unit names what the chart's numbers are, so a reader does not take them for money; reuse the Billing card's word for credits and your locale's bracketing.", + "pages.overview.usageTab.harness_unread": "Error notice shown in place of the Spend over time card when the dashboard could not read the agent backend setting (the request failed, nothing cached); a Try again button sits under it. The chart stacks credits, which only some backends bill in, so without that setting it cannot tell whether to show at all; the notice says that and no more. \"Agent backend\" is the Developer tab's term for the harness (kiro-cli, KAS, Claude Code); reuse that tab's word for it, and the Usage tab's word for credits.", + "pages.overview.usageSeriesChart.layer_by": "Label before the dropdown that chooses which dimension the stacked chart is split into (channel, agent, model, session start week). \"Layer\" is the verb: divide the stack into layers by this.", + "pages.overview.usageSeriesChart.by_channel": "Dropdown option: split the chart by the channel each session came from -- dashboard chat, cron, background jobs, a messaging transport such as Telegram or Slack. The same sense as the Spend panel's channel column; reuse that word.", + "pages.overview.usageSeriesChart.by_agent": "Dropdown option: split the chart by the agent that ran each turn. The noun as in the Sessions table's Agent column.", + "pages.overview.usageSeriesChart.by_model": "Dropdown option: split the chart by the language model that served each turn.", + "pages.overview.usageSeriesChart.by_cohort": "Dropdown option: split the chart by the week in which each session was first seen, so long-lived sessions and new ones are separate layers. Plain words, not the statistical term \"cohort\"; the week is when the session STARTED (first appeared), not the week the spend happened.", + "pages.overview.usageSeriesChart.cumulative": "Label beside the switch that turns the chart into a running total, so each layer's height is everything that bucket has spent so far rather than that day alone. The adjective describing the view.", + "pages.overview.usageSeriesChart.other": "Legend label for the layer that folds every small bucket past the top few into one. {{n}} is how many buckets were folded, already locale-formatted; \"more\" tells the reader it is a count of further groups, not a value. Keep the parentheses or your locale's equivalent bracketing.", + "pages.overview.usageSeriesChart.unattributed": "Legend label for the layer of turns whose session key, agent or model was never recorded. It is not an error and not a bucket named Unknown by a user; the adjective meaning \"not assigned to any bucket\".", + "pages.overview.usageSeriesChart.unattributed_hint": "One-line caption shown under the controls whenever the chart has an Unattributed layer, so the reader learns what it holds without hovering. Starts with the same word as the Unattributed legend label, then a short explanation: these are turns that did not record the detail the chart is split by (agent, model, or session). Must fit any of the three dimensions.", + "pages.overview.usageSeriesChart.channel_unrecognized": "Legend label, in the channel view, for sessions whose key is not a recognised channel shape (not dashboard, cron, a messaging transport, ...). Keep it distinct from the \"Other (n more)\" fold label, which is a different layer.", + "pages.overview.usageSeriesChart.cohort_week": "Legend label for one layer when splitting by session start week: the ISO week starting on the Monday {{date}}, which arrives already locale-formatted as a short month-and-day (e.g. Sep 7). The caption above the chart explains that a layer is the sessions first seen that week, so this stays short: it sits in a legend next to a number.", + "pages.overview.usageSeriesChart.cohort_started_by": "Legend label for the OLDEST layer drawn when splitting by session start week. A cohort is the week a session was first seen inside the 30-day window, so the oldest layer also holds sessions that began before the window; it is named for the last day those sessions could have started by, not as a week of starts: {{date}} is the Sunday closing that week (or the window's last day when the week is still running), formatted as a short month and day. Reads as \"started on or before {{date}}\"; keep it short, it sits in a legend beside the \"Week of\" labels.", + "pages.overview.usageSeriesChart.cohort_caption": "One-sentence caption shown under the controls while the chart is split by session start week. States the rule (a session's week is the week it was FIRST SEEN inside the 30-day window, not when it truly began) and its consequence: a session that began before the window counts from its first turn inside it, so the oldest layer -- the one carrying the cohort_started_by label, quoted here without its date -- also holds older sessions. Quote the same words your cohort_started_by translation uses, in your locale's quotation marks; where the date comes first in your label, an ellipsis may stand for it.", + "pages.overview.usageSeriesChart.total_to_date": "First line of the chart's hover tooltip in the cumulative view: everything spent from the start of the window up to and including the hovered day. {{value}} is the already-formatted number; place it where your language wants it.", + "pages.overview.usageSeriesChart.total_that_day": "First line of the chart's hover tooltip in the per-day view: what was spent on the hovered day alone. {{value}} is the already-formatted number; place it where your language wants it.", + "pages.overview.usageSeriesChart.empty": "Shown in place of the chart when no spend has been recorded in the window. A plain state, not an error.", + "pages.overview.usageSeriesChart.truncated_notice": "Warning caption shown under the controls when the server kept only the newest turns of the window because there were too many to retain; {{n}} is how many of the oldest were left out, already locale-formatted. Leads with the consequence (the totals on screen are lower than the true spend), then says why. A warning about the numbers, not an error.", + "pages.overview.usageSeriesChart.truncated_from": "The same warning caption as truncated_notice, used when the server can also name the first day it kept whole: {{n}} is how many of the oldest turns were left out and {{date}} the first day whose turns are all counted, both already locale-formatted. Same lead (the totals on screen are lower than the true spend), then the dropped count, then that every day from {{date}} on counts all of its turns -- say it per day, not \"the chart is complete\", because a running total from that day still misses the dropped turns. Days before {{date}} may be partly or wholly missing.", + "pages.overview.usageSeriesChart.load_failed": "Bold title of the error box shown in place of the chart when the first request for the series fails (the raw error text follows it). Says which thing could not be loaded -- the spend-over-time chart -- in the past tense of an attempt that failed, like the other Couldn't load titles in this locale.", + "pages.overview.usageSeriesChart.retry": "Button under that error box that asks for the series again. Short imperative; reuse your locale's usual Try again / Retry button label.", + "pages.overview.usageSeriesChart.loading": "Short status word shown beside the Layer by dropdown while a newly chosen dimension is still loading and the previous dimension's chart stays on screen dimmed. Your locale's usual Loading… label with its ellipsis.", + "pages.overview.usageSeriesChart.aria_label": "Accessible name of the chart image for screen readers: a stacked area chart (layers of area stacked on top of each other) of spend over time.", + "pages.overview.usageSeriesChart.keyboard_hint": "Appended to the chart's accessible name for screen-reader and keyboard users: the plot can be focused and the left/right arrow keys move the highlighted day (whose values the tooltip then reads out). One short imperative sentence." } } diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 9d3815b5c818..bc344478440a 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -14273,7 +14273,9 @@ "total_sessions_30d": "মোট সেশন (30d)", "total_tokens": "মোট টোকেন", "total_turns": "মোট টার্ন", - "usage_tracking_is_not_available_for": "{{provider}}-এর জন্য ব্যবহার ট্র্যাকিং উপলব্ধ নয়।" + "usage_tracking_is_not_available_for": "{{provider}}-এর জন্য ব্যবহার ট্র্যাকিং উপলব্ধ নয়।", + "spend_over_time": "সময়ের সাথে খরচ (ক্রেডিট)", + "harness_unread": "এজেন্ট ব্যাকএন্ড সেটিং পড়া যায়নি, তাই খরচের চার্ট বুঝতে পারছে না এই ইনস্টলটি ক্রেডিটে বিল করে কি না।" }, "vectorMemoryCard": { "legacy_vectors_warning": "এই ভেক্টরগুলো মডেল ফাইলের তথ্য রেকর্ড করার আগে তৈরি হয়েছিল। ফাইল বদলে থাকলে ভেক্টর পুনরায় তৈরি করতে এমবেডিং মডেল সেটিংসে সেটি আবার প্রয়োগ করুন।", @@ -14538,6 +14540,31 @@ "err_name_reserved": "“{{name}}” একটি বিল্ট-ইন এজেন্টের জন্য সংরক্ষিত। অন্য একটি নাম বেছে নাও।", "err_generic": "অনুরোধ ব্যর্থ হয়েছে। আবার চেষ্টা করো।", "chat_with_caption": "চ্যাট শেয়ার করা ডিফল্ট মেমোরিতে চলে — কোনো ক্রুমেটের নিজের মেমোরিতে নয়।" + }, + "usageSeriesChart": { + "layer_by": "স্তর অনুযায়ী", + "by_channel": "চ্যানেল", + "by_agent": "এজেন্ট", + "by_model": "মডেল", + "by_cohort": "সেশন শুরুর সপ্তাহ", + "cumulative": "সঞ্চিত", + "other": "অন্যান্য (আরও {{n}})", + "unattributed": "অনির্ধারিত", + "unattributed_hint": "অনির্ধারিত: যে টার্নগুলোতে এই তথ্য রেকর্ড হয়নি।", + "channel_unrecognized": "অজ্ঞাত সেশন", + "cohort_week": "{{date}}-এর সপ্তাহ", + "cohort_started_by": "{{date}}-এর মধ্যে শুরু", + "cohort_caption": "স্তরগুলো খরচকে সেই সপ্তাহ অনুসারে ভাগ করে যেখানে প্রতিটি সেশন এই সময়সীমায় প্রথম দেখা গেছে; আগে শুরু হওয়া সেশন এখানে তার প্রথম টার্ন থেকে গণনা হয়, তাই “…-এর মধ্যে শুরু” লেখা সবচেয়ে পুরোনো স্তরে পুরোনো সেশনও থাকে।", + "total_to_date": "সেই দিন পর্যন্ত মোট: {{value}}", + "total_that_day": "সেই দিন: {{value}}", + "empty": "এখনও কোনো খরচ রেকর্ড হয়নি।", + "truncated_notice": "মোট কম দেখানো হচ্ছে: সবচেয়ে পুরোনো {{n}}টি টার্ন গণনা করা হয়নি।", + "truncated_from": "মোট কম দেখানো হচ্ছে: সবচেয়ে পুরোনো {{n}}টি টার্ন গণনা করা হয়নি; {{date}} থেকে প্রতিদিনের সব টার্ন গণনা করা হয়েছে।", + "load_failed": "সময়ের সাথে খরচ লোড করা যায়নি", + "retry": "আবার চেষ্টা করো", + "loading": "লোড হচ্ছে…", + "aria_label": "সময়ের সাথে খরচের স্ট্যাকড এরিয়া চার্ট", + "keyboard_hint": "দিন পরিবর্তন করতে বাম ও ডান তীর কী ব্যবহার করো।" } }, "overviewPage": { diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 2d5cc813980f..59cc281c7c4b 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -14273,7 +14273,9 @@ "total_sessions_30d": "Sitzungen insgesamt (30 T)", "total_tokens": "Tokens insgesamt", "total_turns": "Züge insgesamt", - "usage_tracking_is_not_available_for": "Nutzungserfassung ist für {{provider}} nicht verfügbar." + "usage_tracking_is_not_available_for": "Nutzungserfassung ist für {{provider}} nicht verfügbar.", + "spend_over_time": "Ausgaben im Zeitverlauf (Credits)", + "harness_unread": "Die Einstellung des Agent-Backends konnte nicht gelesen werden, daher kann das Ausgabendiagramm nicht erkennen, ob diese Installation in Credits abrechnet." }, "vectorMemoryCard": { "legacy_vectors_warning": "Diese Vektoren entstanden vor der Erfassung ihrer Modelldatei. Wenn Sie diese Datei geändert haben, wenden Sie sie in den Einstellungen des Einbettungsmodells erneut an, um die Vektoren neu zu erstellen.", @@ -14538,6 +14540,31 @@ "err_name_reserved": "„{{name}}“ ist für einen integrierten Agenten reserviert. Wähle einen anderen Namen.", "err_generic": "Die Anfrage ist fehlgeschlagen. Bitte erneut versuchen.", "chat_with_caption": "Der Chat läuft mit dem geteilten Standard-Gedächtnis – nicht mit dem eines Crew-Kameraden." + }, + "usageSeriesChart": { + "layer_by": "Aufteilen nach", + "by_channel": "Kanal", + "by_agent": "Agent", + "by_model": "Modell", + "by_cohort": "Startwoche der Sitzung", + "cumulative": "Kumuliert", + "other": "Sonstige ({{n}} weitere)", + "unattributed": "Nicht zugeordnet", + "unattributed_hint": "Nicht zugeordnet: Turns, die dieses Detail nicht aufgezeichnet haben.", + "channel_unrecognized": "Nicht erkannte Sitzungen", + "cohort_week": "Woche vom {{date}}", + "cohort_started_by": "Begonnen bis {{date}}", + "cohort_caption": "Die Schichten gruppieren die Ausgaben nach der Woche, in der jede Sitzung in diesem Zeitfenster zuerst gesehen wurde; eine früher begonnene Sitzung zählt ab ihrem ersten Turn hier, daher enthält die älteste Schicht, „Begonnen bis“ ein Datum, auch ältere Sitzungen.", + "total_to_date": "Summe bis dahin: {{value}}", + "total_that_day": "An diesem Tag: {{value}}", + "empty": "Noch keine Ausgaben erfasst.", + "truncated_notice": "Die Summen fallen zu niedrig aus: Die ältesten {{n}} Turns wurden nicht gezählt.", + "truncated_from": "Die Summen fallen zu niedrig aus: Die ältesten {{n}} Turns wurden nicht gezählt; ab {{date}} zählt jeder Tag alle seine Turns.", + "load_failed": "Ausgaben im Zeitverlauf konnten nicht geladen werden", + "retry": "Erneut versuchen", + "loading": "Wird geladen…", + "aria_label": "Gestapeltes Flächendiagramm der Ausgaben im Zeitverlauf", + "keyboard_hint": "Mit den Pfeiltasten links und rechts durch die Tage blättern." } }, "overviewPage": { diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 368ca65c7377..277c3d18a0fe 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -13923,7 +13923,9 @@ "credits": "[Çŕèðìţş ···········]", "spend": "[Şþèñð ········]", "tokens": "[Ţøķèñş ·········]", - "usage_tracking_is_not_available_for": "[Ùşàğè ţŕàçķìñğ ìş ñøţ àṽàìĺàƀĺè ƒøŕ {{provider}}. ···················]" + "usage_tracking_is_not_available_for": "[Ùşàğè ţŕàçķìñğ ìş ñøţ àṽàìĺàƀĺè ƒøŕ {{provider}}. ···················]", + "spend_over_time": "[Şþèñð øṽèŕ ţìɱè (çŕèðìţş) ··················]", + "harness_unread": "[Çøùĺðñ’ţ ŕèàð ţĥè àğèñţ ƀàçķèñð şèţţìñğ, şø ţĥè şþèñð çĥàŕţ çàñ’ţ ţèĺĺ ẁĥèţĥèŕ ţĥìş ìñşţàĺĺ ƀìĺĺş ìñ çŕèðìţş. ·································]" }, "vectorMemoryCard": { "active": "[àçţìṽè ·········]", @@ -14213,6 +14215,31 @@ "err_name_reserved": "[“{{name}}” ìş ŕèşèŕṽèð ƒøŕ à ƀùìĺţ-ìñ àğèñţ. Þìçķ àñøţĥèŕ ñàɱè. ···················]", "err_generic": "[Ţĥè ŕèǫùèşţ ƒàìĺèð. Ţŕý àğàìñ. ·····················]", "chat_with_caption": "[Çĥàţ ŕùñş øñ ţĥè şĥàŕèð ðèƒàùĺţ ɱèɱøŕý — ñøţ à çŕèẁɱàţè'ş øẁñ. ······················]" + }, + "usageSeriesChart": { + "layer_by": "[Ĺàýèŕ ƀý ············]", + "by_channel": "[Çĥàññèĺ ···········]", + "by_agent": "[Àğèñţ ········]", + "by_model": "[Ṁøðèĺ ········]", + "by_cohort": "[Şèşşìøñ şţàŕţ ẁèèķ ················]", + "cumulative": "[Çùɱùĺàţìṽè ···············]", + "other": "[Øţĥèŕ ({{n}} ɱøŕè) ············]", + "unattributed": "[Ùñàţţŕìƀùţèð ···········]", + "unattributed_hint": "[Ùñàţţŕìƀùţèð: ţùŕñş ţĥàţ ðìðñ'ţ ŕèçøŕð ţĥìş ðèţàìĺ. ··················]", + "channel_unrecognized": "[Ùñŕèçøğñìžèð şèşşìøñş ···············]", + "cohort_week": "[Ẁèèķ øƒ {{date}} ············]", + "cohort_started_by": "[Şţàŕţèð ƀý {{date}} ··········]", + "cohort_caption": "[Ĺàýèŕş ğŕøùþ şþèñð ƀý ţĥè ẁèèķ èàçĥ şèşşìøñ ẁàş ƒìŕşţ şèèñ ìñ ţĥìş ẁìñðøẁ; à şèşşìøñ ţĥàţ ƀèğàñ èàŕĺìèŕ çøùñţş ƒŕøɱ ìţş ƒìŕşţ ţùŕñ ĥèŕè, şø ţĥè øĺðèşţ ĺàýèŕ, “Şţàŕţèð ƀý” à ðàţè, àĺşø ĥøĺðş øĺðèŕ şèşşìøñş. ······························································]", + "total_to_date": "[Ţøţàĺ ţø ðàţè: {{value}} ··············]", + "total_that_day": "[Ţĥàţ ðàý: {{value}} ···············]", + "empty": "[Ñø şþèñð ŕèçøŕðèð ýèţ. ···············]", + "truncated_notice": "[Ţøţàĺş ùñðèŕçøùñţ: ţĥè øĺðèşţ {{n}} ţùŕñş ẁèŕèñ'ţ çøùñţèð. ···················]", + "truncated_from": "[Ţøţàĺş ùñðèŕçøùñţ: ţĥè øĺðèşţ {{n}} ţùŕñş ẁèŕèñ'ţ çøùñţèð; èàçĥ ðàý ƒŕøɱ {{date}} çøùñţş àĺĺ øƒ ìţş ţùŕñş. ····························]", + "load_failed": "[Çøùĺðñ'ţ ĺøàð şþèñð øṽèŕ ţìɱè ····················]", + "retry": "[Ţŕý àğàìñ ··············]", + "loading": "[Ĺøàðìñğ… ············]", + "aria_label": "[Şţàçķèð àŕèà çĥàŕţ øƒ şþèñð øṽèŕ ţìɱè ···················]", + "keyboard_hint": "[Ùşè ţĥè ĺèƒţ àñð ŕìğĥţ àŕŕøẁ ķèýş ţø şţèþ ţĥŕøùğĥ ðàýş. ···················]" } }, "overviewPage": { diff --git a/website/src/i18n/locales/en.manual.json b/website/src/i18n/locales/en.manual.json index 1c7a3c098b49..e7c1daf4ce6d 100644 --- a/website/src/i18n/locales/en.manual.json +++ b/website/src/i18n/locales/en.manual.json @@ -6628,7 +6628,9 @@ "credits": "Credits", "spend": "Spend", "tokens": "Tokens", - "usage_tracking_is_not_available_for": "Usage tracking is not available for {{provider}}." + "usage_tracking_is_not_available_for": "Usage tracking is not available for {{provider}}.", + "spend_over_time": "Spend over time (credits)", + "harness_unread": "Couldn’t read the agent backend setting, so the spend chart can’t tell whether this install bills in credits." }, "vectorMemoryCard": { "embedding_status": "Embedding status", @@ -6798,6 +6800,31 @@ "err_name_reserved": "“{{name}}” is reserved for a built-in agent. Pick another name.", "err_generic": "The request failed. Try again.", "chat_with_caption": "Chat runs on the shared default memory — not a crewmate's own." + }, + "usageSeriesChart": { + "layer_by": "Layer by", + "by_channel": "Channel", + "by_agent": "Agent", + "by_model": "Model", + "by_cohort": "Session start week", + "cumulative": "Cumulative", + "other": "Other ({{n}} more)", + "unattributed": "Unattributed", + "unattributed_hint": "Unattributed: turns that didn't record this detail.", + "channel_unrecognized": "Unrecognized sessions", + "cohort_week": "Week of {{date}}", + "cohort_started_by": "Started by {{date}}", + "cohort_caption": "Layers group spend by the week each session was first seen in this window; a session that began earlier counts from its first turn here, so the oldest layer, “Started by” a date, also holds older sessions.", + "total_to_date": "Total to date: {{value}}", + "total_that_day": "That day: {{value}}", + "empty": "No spend recorded yet.", + "truncated_notice": "Totals undercount: the oldest {{n}} turns weren't counted.", + "truncated_from": "Totals undercount: the oldest {{n}} turns weren't counted; each day from {{date}} counts all of its turns.", + "load_failed": "Couldn't load spend over time", + "retry": "Try again", + "loading": "Loading…", + "aria_label": "Stacked area chart of spend over time", + "keyboard_hint": "Use the left and right arrow keys to step through days." } }, "overviewPage": { diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index d151d1fd4cde..eafb0d2aa141 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -14488,7 +14488,9 @@ "total_sessions_30d": "Sesiones totales (30d)", "total_tokens": "Tokens totales", "total_turns": "Turnos totales", - "usage_tracking_is_not_available_for": "El seguimiento de uso no está disponible para {{provider}}." + "usage_tracking_is_not_available_for": "El seguimiento de uso no está disponible para {{provider}}.", + "spend_over_time": "Gasto a lo largo del tiempo (créditos)", + "harness_unread": "No se pudo leer el ajuste del backend del agente, así que el gráfico de gasto no puede saber si esta instalación factura en créditos." }, "vectorMemoryCard": { "legacy_vectors_warning": "Estos vectores son anteriores al registro de su archivo de modelo. Si cambió ese archivo, vuelva a aplicarlo en los ajustes del modelo de embeddings para reconstruirlos.", @@ -14764,6 +14766,31 @@ "err_name_reserved": "“{{name}}” está reservado para un agente integrado. Elige otro nombre.", "err_generic": "La solicitud falló. Inténtalo de nuevo.", "chat_with_caption": "El chat usa la memoria predeterminada compartida, no la propia de un compañero." + }, + "usageSeriesChart": { + "layer_by": "Dividir por", + "by_channel": "Canal", + "by_agent": "Agente", + "by_model": "Modelo", + "by_cohort": "Semana de inicio de sesión", + "cumulative": "Acumulado", + "other": "Otros ({{n}} más)", + "unattributed": "Sin atribuir", + "unattributed_hint": "Sin atribuir: turnos que no registraron este dato.", + "channel_unrecognized": "Sesiones no reconocidas", + "cohort_week": "Semana del {{date}}", + "cohort_started_by": "Iniciadas hasta el {{date}}", + "cohort_caption": "Las capas agrupan el gasto por la semana en que cada sesión se vio por primera vez en esta ventana; una sesión que empezó antes cuenta desde su primer turno aquí, así que la capa más antigua, “Iniciadas hasta el” una fecha, también contiene sesiones más antiguas.", + "total_to_date": "Total hasta la fecha: {{value}}", + "total_that_day": "Ese día: {{value}}", + "empty": "Todavía no hay gasto registrado.", + "truncated_notice": "Los totales están incompletos: los {{n}} turnos más antiguos no se contaron.", + "truncated_from": "Los totales están incompletos: los {{n}} turnos más antiguos no se contaron; desde {{date}}, cada día cuenta todos sus turnos.", + "load_failed": "No se pudo cargar el gasto a lo largo del tiempo", + "retry": "Reintentar", + "loading": "Cargando…", + "aria_label": "Gráfico de áreas apiladas del gasto a lo largo del tiempo", + "keyboard_hint": "Usa las flechas izquierda y derecha para recorrer los días." } }, "overviewPage": { diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index d9f5ab3ce6f0..7b4cbc52f1d1 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -14488,7 +14488,9 @@ "total_sessions_30d": "Sessions totales (30 j)", "total_tokens": "Total des tokens", "total_turns": "Total des tours", - "usage_tracking_is_not_available_for": "Le suivi de l’utilisation n’est pas disponible pour {{provider}}." + "usage_tracking_is_not_available_for": "Le suivi de l’utilisation n’est pas disponible pour {{provider}}.", + "spend_over_time": "Dépenses dans le temps (crédits)", + "harness_unread": "Impossible de lire le réglage du backend de l’agent, donc le graphique des dépenses ne peut pas savoir si cette installation facture en crédits." }, "vectorMemoryCard": { "legacy_vectors_warning": "Ces vecteurs datent d’avant l’enregistrement de leur fichier de modèle. Si vous avez changé ce fichier, réappliquez-le dans les paramètres du modèle de plongement pour les reconstruire.", @@ -14764,6 +14766,31 @@ "err_name_reserved": "« {{name}} » est réservé à un agent intégré. Choisis un autre nom.", "err_generic": "La requête a échoué. Réessaie.", "chat_with_caption": "La discussion utilise la mémoire partagée par défaut, pas celle d’un coéquipier." + }, + "usageSeriesChart": { + "layer_by": "Répartir par", + "by_channel": "Canal", + "by_agent": "Agent", + "by_model": "Modèle", + "by_cohort": "Semaine de début de session", + "cumulative": "Cumulé", + "other": "Autres ({{n}} de plus)", + "unattributed": "Non attribué", + "unattributed_hint": "Non attribué : tours qui n'ont pas enregistré cette information.", + "channel_unrecognized": "Sessions non reconnues", + "cohort_week": "Semaine du {{date}}", + "cohort_started_by": "Commencées au plus tard le {{date}}", + "cohort_caption": "Les couches regroupent les dépenses par semaine de première apparition de chaque session dans cette fenêtre ; une session commencée plus tôt compte à partir de son premier tour ici, donc la couche la plus ancienne, « Commencées au plus tard le » une date, contient aussi des sessions plus anciennes.", + "total_to_date": "Total à date : {{value}}", + "total_that_day": "Ce jour-là : {{value}}", + "empty": "Aucune dépense enregistrée pour l'instant.", + "truncated_notice": "Les totaux sont sous-estimés : les {{n}} tours les plus anciens n’ont pas été comptés.", + "truncated_from": "Les totaux sont sous-estimés : les {{n}} tours les plus anciens n’ont pas été comptés ; à partir du {{date}}, chaque jour compte tous ses tours.", + "load_failed": "Impossible de charger les dépenses dans le temps", + "retry": "Réessayer", + "loading": "Chargement…", + "aria_label": "Graphique en aires empilées des dépenses dans le temps", + "keyboard_hint": "Utilise les flèches gauche et droite pour parcourir les jours." } }, "overviewPage": { diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index 457a98fa8616..f3fda5d9974c 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -14273,7 +14273,9 @@ "total_sessions_30d": "कुल सत्र (30d)", "total_tokens": "कुल टोकन", "total_turns": "कुल टर्न", - "usage_tracking_is_not_available_for": "{{provider}} के लिए उपयोग ट्रैकिंग उपलब्ध नहीं है।" + "usage_tracking_is_not_available_for": "{{provider}} के लिए उपयोग ट्रैकिंग उपलब्ध नहीं है।", + "spend_over_time": "समय के साथ खर्च (क्रेडिट)", + "harness_unread": "एजेंट बैकएंड सेटिंग नहीं पढ़ी जा सकी, इसलिए खर्च चार्ट यह नहीं बता सकता कि यह इंस्टॉल क्रेडिट में बिल करता है या नहीं।" }, "vectorMemoryCard": { "legacy_vectors_warning": "ये वेक्टर मॉडल फ़ाइल का रिकॉर्ड बनने से पहले बनाए गए थे। यदि फ़ाइल बदली गई है, तो वेक्टर दोबारा बनाने के लिए एम्बेडिंग मॉडल सेटिंग में उसे फिर लागू करें।", @@ -14538,6 +14540,31 @@ "err_name_reserved": "“{{name}}” एक बिल्ट-इन एजेंट के लिए आरक्षित है। कोई और नाम चुनो।", "err_generic": "अनुरोध विफल रहा। फिर कोशिश करें।", "chat_with_caption": "चैट साझा डिफ़ॉल्ट मेमोरी पर चलती है — किसी क्रूमेट की अपनी मेमोरी पर नहीं।" + }, + "usageSeriesChart": { + "layer_by": "इसके अनुसार परतें", + "by_channel": "चैनल", + "by_agent": "एजेंट", + "by_model": "मॉडल", + "by_cohort": "सेशन शुरू होने का सप्ताह", + "cumulative": "संचयी", + "other": "अन्य ({{n}} और)", + "unattributed": "अनिर्धारित", + "unattributed_hint": "अनिर्धारित: ऐसे टर्न जिनमें यह विवरण दर्ज नहीं हुआ।", + "channel_unrecognized": "अज्ञात सत्र", + "cohort_week": "{{date}} का सप्ताह", + "cohort_started_by": "{{date}} तक शुरू", + "cohort_caption": "परतें खर्च को उस सप्ताह के अनुसार समूहित करती हैं जिसमें हर सेशन इस विंडो में पहली बार दिखा; पहले शुरू हुआ सेशन यहाँ अपने पहले टर्न से गिना जाता है, इसलिए “… तक शुरू” वाली सबसे पुरानी परत में पुराने सेशन भी होते हैं।", + "total_to_date": "उस दिन तक कुल: {{value}}", + "total_that_day": "उस दिन: {{value}}", + "empty": "अभी तक कोई खर्च दर्ज नहीं हुआ।", + "truncated_notice": "कुल कम दिखाया गया है: सबसे पुराने {{n}} टर्न नहीं गिने गए।", + "truncated_from": "कुल कम दिखाया गया है: सबसे पुराने {{n}} टर्न नहीं गिने गए; {{date}} से हर दिन के सभी टर्न गिने गए हैं।", + "load_failed": "समय के साथ खर्च लोड नहीं हो सका", + "retry": "फिर कोशिश करो", + "loading": "लोड हो रहा है…", + "aria_label": "समय के साथ खर्च का स्टैक्ड एरिया चार्ट", + "keyboard_hint": "दिनों के बीच जाने के लिए बाएँ और दाएँ तीर कुंजियों का उपयोग करें।" } }, "overviewPage": { diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index b1a8157b876c..fb4ce806640d 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -14488,7 +14488,9 @@ "total_sessions_30d": "Totale sessioni (30 gg)", "total_tokens": "Totale token", "total_turns": "Totale turni", - "usage_tracking_is_not_available_for": "Il monitoraggio dell’utilizzo non è disponibile per {{provider}}." + "usage_tracking_is_not_available_for": "Il monitoraggio dell’utilizzo non è disponibile per {{provider}}.", + "spend_over_time": "Spesa nel tempo (crediti)", + "harness_unread": "Impossibile leggere l’impostazione del backend dell’agente, quindi il grafico della spesa non può sapere se questa installazione addebita in crediti." }, "vectorMemoryCard": { "legacy_vectors_warning": "Questi vettori precedono la registrazione del file del modello. Se hai modificato il file, riapplicalo nelle impostazioni del modello di embedding per ricostruirli.", @@ -14764,6 +14766,31 @@ "err_name_reserved": "“{{name}}” è riservato a un agente integrato. Scegli un altro nome.", "err_generic": "La richiesta non è riuscita. Riprova.", "chat_with_caption": "La chat usa la memoria predefinita condivisa, non quella di un compagno." + }, + "usageSeriesChart": { + "layer_by": "Suddividi per", + "by_channel": "Canale", + "by_agent": "Agente", + "by_model": "Modello", + "by_cohort": "Settimana di inizio sessione", + "cumulative": "Cumulativo", + "other": "Altro ({{n}} in più)", + "unattributed": "Non attribuito", + "unattributed_hint": "Non attribuito: turni che non hanno registrato questo dettaglio.", + "channel_unrecognized": "Sessioni non riconosciute", + "cohort_week": "Settimana del {{date}}", + "cohort_started_by": "Iniziate entro il {{date}}", + "cohort_caption": "Gli strati raggruppano la spesa per la settimana in cui ogni sessione è stata vista per la prima volta in questa finestra; una sessione iniziata prima conta dal suo primo turno qui, quindi lo strato più vecchio, “Iniziate entro il” una data, contiene anche sessioni più vecchie.", + "total_to_date": "Totale a oggi: {{value}}", + "total_that_day": "Quel giorno: {{value}}", + "empty": "Nessuna spesa registrata finora.", + "truncated_notice": "I totali sono sottostimati: i {{n}} turni più vecchi non sono stati contati.", + "truncated_from": "I totali sono sottostimati: i {{n}} turni più vecchi non sono stati contati; dal {{date}} ogni giorno conta tutti i suoi turni.", + "load_failed": "Impossibile caricare la spesa nel tempo", + "retry": "Riprova", + "loading": "Caricamento…", + "aria_label": "Grafico ad aree impilate della spesa nel tempo", + "keyboard_hint": "Usa le frecce sinistra e destra per scorrere i giorni." } }, "overviewPage": { diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 7835bc3887e6..fec13e0435ff 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -14058,7 +14058,9 @@ "total_sessions_30d": "合計セッション(30日)", "total_tokens": "合計トークン", "total_turns": "合計ターン", - "usage_tracking_is_not_available_for": "{{provider}} では使用量のトラッキングを利用できません。" + "usage_tracking_is_not_available_for": "{{provider}} では使用量のトラッキングを利用できません。", + "spend_over_time": "支出の推移(クレジット)", + "harness_unread": "エージェントバックエンドの設定を読み込めなかったため、支出グラフはこのインストールがクレジット課金かどうかを判断できません。" }, "vectorMemoryCard": { "legacy_vectors_warning": "これらのベクトルはモデルファイルの記録前に生成されました。ファイルを変更した場合は、埋め込みモデル設定で再適用して再構築してください。", @@ -14312,6 +14314,31 @@ "err_name_reserved": "「{{name}}」は組み込みエージェント用に予約されています。別の名前を選んでください。", "err_generic": "リクエストに失敗しました。もう一度お試しください。", "chat_with_caption": "チャットは共有のデフォルトメモリで動きます。クルーメイト固有のメモリではありません。" + }, + "usageSeriesChart": { + "layer_by": "内訳", + "by_channel": "チャネル", + "by_agent": "エージェント", + "by_model": "モデル", + "by_cohort": "セッション開始週", + "cumulative": "累積", + "other": "その他(他{{n}}件)", + "unattributed": "未分類", + "unattributed_hint": "未分類: この項目が記録されていないターンです。", + "channel_unrecognized": "認識できないセッション", + "cohort_week": "{{date}}の週", + "cohort_started_by": "{{date}}までに開始", + "cohort_caption": "各レイヤーはこの期間内でセッションが最初に現れた週ごとの支出で、それより前に始まったセッションはここでの最初のターンから数えるため、「…までに開始」と表示される最も古いレイヤーには古いセッションも含まれます。", + "total_to_date": "その日までの合計: {{value}}", + "total_that_day": "その日: {{value}}", + "empty": "まだ支出が記録されていません。", + "truncated_notice": "合計は実際より少なくなっています。最も古い{{n}}件のターンは集計されていません。", + "truncated_from": "合計は実際より少なくなっています。最も古い{{n}}件のターンは集計されていません。{{date}}以降は各日のすべてのターンが集計されています。", + "load_failed": "支出の推移を読み込めませんでした", + "retry": "再試行", + "loading": "読み込み中…", + "aria_label": "支出の推移を示す積み上げ面グラフ", + "keyboard_hint": "左右の矢印キーで日を移動できます。" } }, "overviewPage": { diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index 472b6fc9e664..da11508c73c6 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -14058,7 +14058,9 @@ "total_sessions_30d": "총 세션 (30일)", "total_tokens": "총 토큰", "total_turns": "총 턴", - "usage_tracking_is_not_available_for": "{{provider}}에서는 사용량 추적을 사용할 수 없습니다." + "usage_tracking_is_not_available_for": "{{provider}}에서는 사용량 추적을 사용할 수 없습니다.", + "spend_over_time": "기간별 지출 (크레딧)", + "harness_unread": "에이전트 백엔드 설정을 읽을 수 없어 지출 차트에서 이 설치의 크레딧 과금 여부를 판단할 수 없습니다." }, "vectorMemoryCard": { "legacy_vectors_warning": "이 벡터는 모델 파일이 기록되기 전에 생성되었습니다. 파일을 변경했다면 임베딩 모델 설정에서 다시 적용하여 벡터를 다시 만드세요.", @@ -14312,6 +14314,31 @@ "err_name_reserved": "‘{{name}}’은(는) 기본 제공 에이전트용으로 예약된 이름입니다. 다른 이름을 선택하세요.", "err_generic": "요청이 실패했습니다. 다시 시도하세요.", "chat_with_caption": "채팅은 크루메이트 고유 메모리가 아닌 공유 기본 메모리로 실행됩니다." + }, + "usageSeriesChart": { + "layer_by": "구분 기준", + "by_channel": "채널", + "by_agent": "에이전트", + "by_model": "모델", + "by_cohort": "세션 시작 주", + "cumulative": "누적", + "other": "기타 ({{n}}개 더)", + "unattributed": "미분류", + "unattributed_hint": "미분류: 이 항목을 기록하지 않은 턴입니다.", + "channel_unrecognized": "인식되지 않은 세션", + "cohort_week": "{{date}} 주", + "cohort_started_by": "{{date}}까지 시작", + "cohort_caption": "각 층은 이 기간에 세션이 처음 나타난 주별로 묶은 지출이며, 그보다 먼저 시작된 세션은 여기서의 첫 턴부터 계산되므로 ‘…까지 시작’으로 표시된 가장 오래된 층에는 더 오래된 세션도 포함됩니다.", + "total_to_date": "해당일까지 합계: {{value}}", + "total_that_day": "해당일: {{value}}", + "empty": "아직 기록된 지출이 없습니다.", + "truncated_notice": "합계가 실제보다 적습니다. 가장 오래된 턴 {{n}}개는 집계되지 않았습니다.", + "truncated_from": "합계가 실제보다 적습니다. 가장 오래된 턴 {{n}}개는 집계되지 않았습니다. {{date}}부터는 매일 모든 턴이 집계됩니다.", + "load_failed": "시간별 지출을 불러올 수 없습니다", + "retry": "다시 시도", + "loading": "불러오는 중…", + "aria_label": "기간별 지출 누적 영역 차트", + "keyboard_hint": "왼쪽과 오른쪽 화살표 키로 날짜를 이동할 수 있습니다." } }, "overviewPage": { diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 83398e64d903..dfaa731d6610 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -14488,7 +14488,9 @@ "total_sessions_30d": "Total de sessões (30d)", "total_tokens": "Total de tokens", "total_turns": "Total de turnos", - "usage_tracking_is_not_available_for": "O acompanhamento de uso não está disponível para {{provider}}." + "usage_tracking_is_not_available_for": "O acompanhamento de uso não está disponível para {{provider}}.", + "spend_over_time": "Gasto ao longo do tempo (créditos)", + "harness_unread": "Não foi possível ler a configuração do backend do agente, então o gráfico de gasto não consegue saber se esta instalação cobra em créditos." }, "vectorMemoryCard": { "legacy_vectors_warning": "Estes vetores foram criados antes do registro do arquivo do modelo. Se você alterou esse arquivo, aplique-o novamente nas configurações do modelo de embeddings para reconstruí-los.", @@ -14764,6 +14766,31 @@ "err_name_reserved": "“{{name}}” está reservado para um agente integrado. Escolha outro nome.", "err_generic": "A solicitação falhou. Tente novamente.", "chat_with_caption": "O chat usa a memória padrão compartilhada, não a de um colega." + }, + "usageSeriesChart": { + "layer_by": "Dividir por", + "by_channel": "Canal", + "by_agent": "Agente", + "by_model": "Modelo", + "by_cohort": "Semana de início da sessão", + "cumulative": "Acumulado", + "other": "Outros ({{n}} a mais)", + "unattributed": "Sem atribuição", + "unattributed_hint": "Sem atribuição: turnos que não registraram este detalhe.", + "channel_unrecognized": "Sessões não reconhecidas", + "cohort_week": "Semana de {{date}}", + "cohort_started_by": "Iniciadas até {{date}}", + "cohort_caption": "As camadas agrupam o gasto pela semana em que cada sessão apareceu pela primeira vez nesta janela; uma sessão que começou antes conta a partir do primeiro turno aqui, então a camada mais antiga, “Iniciadas até” uma data, também contém sessões mais antigas.", + "total_to_date": "Total até a data: {{value}}", + "total_that_day": "Nesse dia: {{value}}", + "empty": "Nenhum gasto registrado ainda.", + "truncated_notice": "Os totais estão subestimados: os {{n}} turnos mais antigos não foram contados.", + "truncated_from": "Os totais estão subestimados: os {{n}} turnos mais antigos não foram contados; a partir de {{date}}, cada dia conta todos os seus turnos.", + "load_failed": "Não foi possível carregar o gasto ao longo do tempo", + "retry": "Tentar novamente", + "loading": "Carregando…", + "aria_label": "Gráfico de áreas empilhadas do gasto ao longo do tempo", + "keyboard_hint": "Use as setas para a esquerda e para a direita para percorrer os dias." } }, "overviewPage": { diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index 3afed19ad173..303d10128240 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -14703,7 +14703,9 @@ "total_sessions_30d": "Всего сессий (30 дн.)", "total_tokens": "Всего токенов", "total_turns": "Всего ходов", - "usage_tracking_is_not_available_for": "Отслеживание использования недоступно для {{provider}}." + "usage_tracking_is_not_available_for": "Отслеживание использования недоступно для {{provider}}.", + "spend_over_time": "Расход во времени (кредиты)", + "harness_unread": "Не удалось прочитать настройку бэкенда агента, поэтому график расходов не может определить, выставляет ли эта установка счёт в кредитах." }, "vectorMemoryCard": { "legacy_vectors_warning": "Эти векторы созданы до записи сведений о файле модели. Если вы меняли файл, примените его снова в настройках модели эмбеддингов, чтобы перестроить векторы.", @@ -14990,6 +14992,31 @@ "err_name_reserved": "«{{name}}» зарезервировано для встроенного агента. Выбери другое имя.", "err_generic": "Запрос не выполнен. Попробуйте ещё раз.", "chat_with_caption": "Чат работает на общей памяти по умолчанию, а не на памяти товарища." + }, + "usageSeriesChart": { + "layer_by": "Разбить по", + "by_channel": "Канал", + "by_agent": "Агент", + "by_model": "Модель", + "by_cohort": "Неделя начала сессии", + "cumulative": "Накопительно", + "other": "Другое (ещё {{n}})", + "unattributed": "Без атрибуции", + "unattributed_hint": "Без атрибуции: ходы, в которых эта информация не была записана.", + "channel_unrecognized": "Нераспознанные сессии", + "cohort_week": "Неделя с {{date}}", + "cohort_started_by": "Начаты не позднее {{date}}", + "cohort_caption": "Слои группируют расход по неделе, когда каждая сессия впервые появилась в этом окне; сессия, начатая раньше, считается с её первого хода здесь, поэтому самый старый слой, «Начаты не позднее» даты, содержит и более старые сессии.", + "total_to_date": "Итого на дату: {{value}}", + "total_that_day": "За этот день: {{value}}", + "empty": "Расход пока не зафиксирован.", + "truncated_notice": "Итоги занижены: {{n}} самых старых ходов не учтены.", + "truncated_from": "Итоги занижены: {{n}} самых старых ходов не учтены; начиная с {{date}} каждый день учитывает все свои ходы.", + "load_failed": "Не удалось загрузить расход по времени", + "retry": "Попробовать ещё раз", + "loading": "Загрузка…", + "aria_label": "Диаграмма с накоплением областей: расход во времени", + "keyboard_hint": "Используй стрелки влево и вправо для перехода между днями." } }, "overviewPage": { diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index eeb8c46bf209..efbf2b421d39 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -14058,7 +14058,9 @@ "total_sessions_30d": "会话总数(30 天)", "total_tokens": "令牌总数", "total_turns": "总轮次", - "usage_tracking_is_not_available_for": "{{provider}} 不支持用量跟踪。" + "usage_tracking_is_not_available_for": "{{provider}} 不支持用量跟踪。", + "spend_over_time": "支出走势(额度)", + "harness_unread": "无法读取代理后端设置,因此支出图表无法判断此安装是否按额度计费。" }, "vectorMemoryCard": { "legacy_vectors_warning": "这些向量生成时尚未记录模型文件。如果你更换过该文件,请在嵌入模型设置中重新应用,以重建向量。", @@ -14312,6 +14314,31 @@ "err_name_reserved": "“{{name}}”是内置智能体的保留名称。请换一个名字。", "err_generic": "请求失败,请重试。", "chat_with_caption": "聊天使用共享的默认记忆,而不是某个队友自己的记忆。" + }, + "usageSeriesChart": { + "layer_by": "分层依据", + "by_channel": "渠道", + "by_agent": "代理", + "by_model": "模型", + "by_cohort": "会话开始周", + "cumulative": "累计", + "other": "其他(另 {{n}} 项)", + "unattributed": "未归类", + "unattributed_hint": "未归类:未记录此信息的轮次。", + "channel_unrecognized": "无法识别的会话", + "cohort_week": "{{date}}当周", + "cohort_started_by": "{{date}}或之前开始", + "cohort_caption": "各层按每个会话在此时间窗内首次出现的那一周归类支出;更早开始的会话从它在此处首轮算起,因此“…或之前开始”这一最早层也包含更早会话。", + "total_to_date": "截至当日合计:{{value}}", + "total_that_day": "当日:{{value}}", + "empty": "尚未记录任何支出。", + "truncated_notice": "总计偏低:最早的 {{n}} 条轮次未计入。", + "truncated_from": "总计偏低:最早的 {{n}} 条轮次未计入;自 {{date}} 起,每一天都计入了全部轮次。", + "load_failed": "无法加载支出趋势", + "retry": "重试", + "loading": "加载中…", + "aria_label": "支出走势堆叠面积图", + "keyboard_hint": "使用左右方向键在日期间切换。" } }, "overviewPage": { diff --git a/website/src/pages/overview/TokenStackedAreaChart.tsx b/website/src/pages/overview/TokenStackedAreaChart.tsx new file mode 100644 index 000000000000..4739b2ac099b --- /dev/null +++ b/website/src/pages/overview/TokenStackedAreaChart.tsx @@ -0,0 +1,444 @@ +import { useMemo, useRef, useState } from 'react' +import type { KeyboardEvent } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import { area, curveMonotoneX, scaleLinear, stack } from 'd3' +import type { Series, SeriesPoint } from 'd3' +import { api } from '../../api/client' +import type { + UsageSeriesDimension, + UsageSeriesLayer, + UsageSeriesPayload, +} from '../../api/client' +import ErrorNotice from '../../components/ErrorNotice' +import SimpleSelect from '../../components/SimpleSelect' +import { Btn, Toggle } from '../../components/ui' +import { useSessionPalette } from '../../hooks/useSessionPalette' +import { fmtCompact, fmtCredits, fmtDate, fmtDateFields, fmtNumber } from '../../i18n/format' +import { i18nT } from '../../i18n/t' +import { safeGetItem, safeSetItem } from '../../utils/safeStorage' + +export const DIMENSIONS: UsageSeriesDimension[] = ['channel', 'agent', 'model', 'cohort'] + +// Flat tables indexed inline at the call, the one shape the static key checker +// can verify (see MemoryGraphTab's GROUP_LABEL_KEY). +const DIMENSION_LABEL_KEY: Record = { + channel: 'pages.overview.usageSeriesChart.by_channel', + agent: 'pages.overview.usageSeriesChart.by_agent', + model: 'pages.overview.usageSeriesChart.by_model', + cohort: 'pages.overview.usageSeriesChart.by_cohort', +} + +/** Persisted view choices. Versioned so a shape change invalidates. */ +const PREFS_KEY = 'kc.usageSeries.v1' +type Prefs = { by: UsageSeriesDimension; cumulative: boolean } +const DEFAULT_PREFS: Prefs = { by: 'channel', cumulative: true } + +function readPrefs(): Prefs { + try { + const raw = safeGetItem(PREFS_KEY) + if (!raw) return DEFAULT_PREFS + const parsed = JSON.parse(raw) as Partial + return { + by: DIMENSIONS.includes(parsed.by as UsageSeriesDimension) ? (parsed.by as UsageSeriesDimension) : DEFAULT_PREFS.by, + cumulative: typeof parsed.cumulative === 'boolean' ? parsed.cumulative : DEFAULT_PREFS.cumulative, + } + } catch { + return DEFAULT_PREFS + } +} + +/** Running total per layer, so a layer that stopped spending stays level + * rather than vanishing: credits are never un-spent, which is what makes the + * cumulative stack the faithful reading of "what today's total is made of". */ +export function toCumulative(layers: UsageSeriesLayer[]): UsageSeriesLayer[] { + return layers.map(layer => { + let running = 0 + return { ...layer, values: layer.values.map(v => (running += v)) } + }) +} + +/** The day index under a pointer at `fraction` (0..1) of the chart width. */ +export function nearestIndex(fraction: number, count: number): number { + if (count <= 1) return 0 + const clamped = Math.min(1, Math.max(0, fraction)) + return Math.round(clamped * (count - 1)) +} + +/** `YYYY-MM-DD` as a LOCAL calendar day. `new Date('2026-09-14')` would read + * the string as UTC midnight and render the previous evening west of it. */ +function localDay(iso: string): Date { + const [y, m, d] = iso.split('-').map(Number) + return new Date(y, m - 1, d) +} + +/** `YYYY-MM-DD` of a local calendar day, the form the server keys weeks by. */ +function isoDay(day: Date): string { + return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}` +} + +/** The Monday of the ISO week holding `iso`, as the server keys a cohort. */ +export function weekMondayOf(iso: string): string { + const day = localDay(iso) + day.setDate(day.getDate() - ((day.getDay() + 6) % 7)) + return isoDay(day) +} + +/** The last day a session first seen in the cohort week keyed `monday` could + * have started by: that week's Sunday, or the axis's last day when the week + * is still running. */ +export function startedByDay(monday: string, lastAxisDay: string): string { + const sunday = localDay(monday) + sunday.setDate(sunday.getDate() + 6) + const bound = isoDay(sunday) + return bound < lastAxisDay ? bound : lastAxisDay +} + +type Row = Record + +/** Normalised chart space: paths are drawn in a 0..1000 box the SVG stretches. */ +const BOX = 1000 + +/** The axis top for a stack whose highest point is `max`: the next round number + * up, so the labels read `2K · 1K · 0` rather than `1.6K · 801.1 · 0`. */ +export function niceTop(max: number): number { + const [, top] = scaleLinear().domain([0, Math.max(1e-9, max)]).nice(4).domain() + return top +} + +export function stackPaths(layers: UsageSeriesLayer[], days: number): { paths: string[]; top: number } { + const rows: Row[] = Array.from({ length: days }, (_, i) => + Object.fromEntries(layers.map(layer => [layer.key, layer.values[i] ?? 0])), + ) + const stacked: Series[] = stack().keys(layers.map(layer => layer.key))(rows) + const top = niceTop(Math.max(...stacked.flatMap(series => series.map(point => point[1])))) + const x = (i: number) => (days <= 1 ? BOX / 2 : (i / (days - 1)) * BOX) + const y = (v: number) => BOX - (v / top) * BOX + const shape = area>() + .x((_, i) => x(i)) + .y0(point => y(point[0])) + .y1(point => y(point[1])) + .curve(curveMonotoneX) + return { paths: stacked.map(series => shape(series) ?? ''), top } +} + +/** Channel ids are the closed lowercase vocabulary `telemetry_channel_of` + * returns (`dashboard`, `cron`, `background`, `telegram` …), shown capitalised + * so the legend reads as places spend went rather than as code. `other` is the + * classifier's "key shape not recognised" and gets its own label, since shown + * as-is it would be mistaken for the fold layer's "Other (n more)". */ +export function channelLabel(id: string): string { + if (id === 'other') return i18nT('pages.overview.usageSeriesChart.channel_unrecognized') + const words = id.replace(/_/g, ' ') + return words.charAt(0).toUpperCase() + words.slice(1) +} + +/** A series answer plus the dimension it was requested for. */ +type SeriesView = UsageSeriesPayload & { by: UsageSeriesDimension } + +/** `oldest` is the key of the oldest cohort layer drawn, `lastDay` the axis's + * last day. A cohort is the week a session was first seen INSIDE the window, + * so the oldest layer also holds sessions that began before the window; it is + * named for the last day they could have started by, not as a week of starts. */ +function layerLabel(layer: UsageSeriesLayer, by: UsageSeriesDimension, oldest: string | undefined, lastDay: string): string { + if (layer.kind === 'other') return i18nT('pages.overview.usageSeriesChart.other', { n: fmtNumber(layer.members ?? 0) }) + if (layer.kind === 'unattributed') return i18nT('pages.overview.usageSeriesChart.unattributed') + if (by === 'cohort' && layer.key === oldest) return i18nT('pages.overview.usageSeriesChart.cohort_started_by', { date: fmtDateFields(localDay(startedByDay(layer.key, lastDay)), { month: 'short', day: 'numeric' }) }) + if (by === 'cohort') return i18nT('pages.overview.usageSeriesChart.cohort_week', { date: fmtDateFields(localDay(layer.key), { month: 'short', day: 'numeric' }) }) + if (by === 'channel') return channelLabel(layer.key) + return layer.key +} + +/** + * Stacked area chart of spend over the shard retention window: one layer per + * bucket of the chosen dimension, cumulative by default so the stack's top + * edge is the window's running total and each band's height is that bucket's + * share of it. The per-day view is the same stack without the prefix sum. + * Layers come from the backend already in stack order and already folded to + * top-N + other + unattributed; the browser only sums, stacks and draws. + */ +export function TokenStackedAreaChart() { + const [prefs, setPrefs] = useState(readPrefs) + const [hover, setHover] = useState(null) + const plotRef = useRef(null) + const reducedMotion = useReducedMotion() + const { paletteColors } = useSessionPalette() + + const update = (patch: Partial) => { + setPrefs(prev => { + const next = { ...prev, ...patch } + safeSetItem(PREFS_KEY, JSON.stringify(next)) + return next + }) + } + + // Switching Layer by keeps the previous dimension's stack on screen, dimmed, + // until the new one answers; without `keepPreviousData` every first switch + // collapses the card to the skeleton. The answer carries the dimension it was + // split by, so a stand-in stack keeps its own labels and caption. + const { data, error: queryErr, isPlaceholderData, isFetching, refetch } = useQuery({ + queryKey: ['usage-series', prefs.by], + queryFn: async () => ({ ...(await api.usageSeries(prefs.by)), by: prefs.by }), + staleTime: 60_000, + placeholderData: keepPreviousData, + }) + const shownBy = data?.by ?? prefs.by + const err = queryErr ? (queryErr instanceof Error ? queryErr.message : String(queryErr)) : '' + + const layers = useMemo(() => { + if (!data) return [] + return prefs.cumulative ? toCumulative(data.series) : data.series + }, [data, prefs.cumulative]) + const days = data?.dates.length ?? 0 + const { paths, top } = useMemo(() => stackPaths(layers, days), [layers, days]) + + const colorOf = (layer: UsageSeriesLayer, index: number): string => { + if (layer.kind === 'other') return 'var(--muted)' + if (layer.kind === 'unattributed') return 'var(--muted-strong)' + return paletteColors[index % paletteColors.length] || 'var(--accent)' + } + + const pointTo = (clientX: number) => { + const el = plotRef.current + if (!el || days === 0) return + const rect = el.getBoundingClientRect() + if (rect.width <= 0) return + setHover(nearestIndex((clientX - rect.left) / rect.width, days)) + } + + // The keyboard path to per-day values: the plot takes focus, arrows step the + // day, Home/End jump, and the slider's value text reads each day out. + const stepTo = (e: KeyboardEvent) => { + if (days === 0) return + const current = hover ?? days - 1 + const next = + e.key === 'ArrowLeft' ? Math.max(0, current - 1) + : e.key === 'ArrowRight' ? Math.min(days - 1, current + 1) + : e.key === 'Home' ? 0 + : e.key === 'End' ? days - 1 + : e.key === 'Escape' ? null + : undefined + if (next === undefined) return + e.preventDefault() + setHover(next) + } + + const hasUnattributed = layers.some(layer => layer.kind === 'unattributed') + const controls = ( + <> +
+
+ {i18nT('pages.overview.usageSeriesChart.layer_by')} + i18nT(DIMENSION_LABEL_KEY[d]))} + value={prefs.by} + onChange={v => update({ by: v as UsageSeriesDimension })} + /> +
+
+ {i18nT('pages.overview.usageSeriesChart.cumulative')} + update({ cumulative: v })} + label={i18nT('pages.overview.usageSeriesChart.cumulative')} + /> +
+ {/* The dimmed stand-in stack below still carries the previous dimension's + legend, so the controls say in words that the new one is on its way. + Always mounted as a live region: a status that appears from nothing is + not reliably announced, one whose text changes is. */} + + {isPlaceholderData ? i18nT('pages.overview.usageSeriesChart.loading') : ''} + +
+ {shownBy === 'cohort' && ( +

+ {i18nT('pages.overview.usageSeriesChart.cohort_caption')} +

+ )} + {hasUnattributed && ( +

+ {i18nT('pages.overview.usageSeriesChart.unattributed_hint')} +

+ )} + {data?.truncated && ( +

+ {data.complete_from + ? i18nT('pages.overview.usageSeriesChart.truncated_from', { + n: fmtNumber(data.dropped_rows), + date: fmtDate(localDay(data.complete_from)), + }) + : i18nT('pages.overview.usageSeriesChart.truncated_notice', { n: fmtNumber(data.dropped_rows) })} +

+ )} + + ) + + // With nothing drawn there are no "last values" to keep showing, so a failed + // refresh over an empty window reads like a failed first load. + const nothingDrawn = !data || data.total <= 0 || days === 0 + if (err && nothingDrawn) { + return ( +
+ {controls} + + void refetch()}> + {i18nT('pages.overview.usageSeriesChart.retry')} + +
+ ) + } + if (!data) return
{controls}
+ if (nothingDrawn) { + return ( +
+ {controls} +
+ {i18nT('pages.overview.usageSeriesChart.empty')} +
+
+ ) + } + + const hoverX = hover == null ? null : days <= 1 ? 50 : (hover / (days - 1)) * 100 + // Bottom of the stack first, matching the legend: the largest bucket (or the + // oldest cohort) leads, and the reserved layers close the list. + const rowsAt = (day: number) => + layers.map((layer, i) => ({ layer, i, value: layer.values[day] ?? 0 })).filter(r => r.value > 0) + const hoverRows = hover == null ? [] : rowsAt(hover) + const hoverTotal = hoverRows.reduce((sum, r) => sum + r.value, 0) + const totalLine = (total: number) => + i18nT(prefs.cumulative ? 'pages.overview.usageSeriesChart.total_to_date' : 'pages.overview.usageSeriesChart.total_that_day', { value: fmtCredits(total) }) + // What a screen reader hears for the focused day: the slider's value text, + // the same facts the pointer tooltip shows. + const focusedDay = hover ?? days - 1 + const focusedRows = rowsAt(focusedDay) + // Cohorts arrive oldest-first and a 30-day window holds at most six of them + // against the seven kept, so the oldest cohort drawn is never in the fold. + const oldestCohort = shownBy === 'cohort' ? layers.find(layer => layer.kind === 'bucket')?.key : undefined + const lastDay = data.dates[data.dates.length - 1] + const valueText = [ + fmtDate(localDay(data.dates[focusedDay])), + totalLine(focusedRows.reduce((sum, r) => sum + r.value, 0)), + ...focusedRows.map(({ layer, value }) => `${layerLabel(layer, shownBy, oldestCohort, lastDay)} ${fmtCredits(value)}`), + ].join('. ') + const transition = reducedMotion ? { duration: 0 } : { duration: 0.35, ease: 'easeOut' as const } + + return ( +
+ {err && } + {controls} +
+
+ {/* Y axis: HTML labels so the stretched SVG never distorts text. */} +
+ {fmtCompact(top)} + {fmtCompact(top / 2)} + {fmtNumber(0)} +
+
pointTo(e.clientX)} + onPointerDown={e => pointTo(e.clientX)} + onPointerLeave={() => setHover(null)} + onKeyDown={stepTo} + onFocus={() => setHover(h => h ?? days - 1)} + onBlur={() => setHover(null)} + > + + {[0.25, 0.5, 0.75].map(f => ( + + ))} + + {layers.map((layer, i) => ( + + ))} + + {hoverX != null && ( + + )} + + {/* Tooltip sits INSIDE the plot (same choice as TokenDailyChart): hung + above it, it would overflow the card for the top of the window. It + slides with the day line -- left-aligned at the first day, centred + mid-window, right-aligned at the last -- and is capped at the plot's + width, so it always covers the line and never crosses the card's + edge, where `overflow: hidden` would cut its amounts off. */} + {hover != null && hoverX != null && ( + + )} +
+
+
+ {[...new Set([0, Math.floor((days - 1) / 2), days - 1])].map(i => ( + {fmtDateFields(localDay(data.dates[i]), { month: 'short', day: 'numeric' })} + ))} +
+
+ {layers.map((layer, i) => ( + + + {layerLabel(layer, shownBy, oldestCohort, lastDay)} + {fmtCredits(layer.total)} + + ))} +
+
+
+ ) +} diff --git a/website/src/pages/overview/UsageTab.tsx b/website/src/pages/overview/UsageTab.tsx index 2ef01dac6ee6..93e66698f429 100644 --- a/website/src/pages/overview/UsageTab.tsx +++ b/website/src/pages/overview/UsageTab.tsx @@ -1,12 +1,18 @@ import { Fragment } from 'react' -import { BarChart3 } from 'lucide-react' +import { BarChart3, ChartArea } from 'lucide-react' import { useQuery } from '@tanstack/react-query' -import { Card, CardTitle, Badge } from '../../components/ui' +import { Btn, Card, CardTitle, Badge } from '../../components/ui' import ErrorNotice from '../../components/ErrorNotice' import { useProvider } from '../../providers' import type { NormalizedUsage, TokenEstimate } from '../../providers' import { providerUsageQuery } from '../../api/providerUsageQuery' +import { api } from '../../api/client' +import { reportsCredits } from '../../api/acpBackend' +import { failedWithNoData } from '../../api/queryState' +import { reportForError } from '../../utils/errorReport' +import type { AcpBackendConfig } from '../../api/acpBackend' import { TokenDailyChart } from './TokenDailyChart' +import { TokenStackedAreaChart } from './TokenStackedAreaChart' import { formatCost } from '../../utils/formatCost' import { fmtCompact, fmtNumber, fmtPercent } from '../../i18n/format' @@ -101,6 +107,24 @@ export default function UsageTab() { const provider = useProvider() const { data, error: queryErr } = useQuery(providerUsageQuery(provider)) const err = queryErr ? (queryErr instanceof Error ? queryErr.message : String(queryErr)) : '' + // The spend chart stacks credits, so it shows only once a LOADED config names + // a default harness that bills in them (kiro-cli, KAS); on a harness that + // bills in tokens or dollars its rows carry no credits, and the chart would + // call turns that did run "no spend". Read positively, never as "not claude"; + // crew members on another harness (member_acp_backend) write to the same + // store and count whatever credits they recorded. + const cfgQuery = useQuery({ + queryKey: ['kirocrewConfig'], + queryFn: () => api.kirocrewConfig(), + }) + const creditsHarness = reportsCredits(cfgQuery.data) + // A config read that failed with nothing cached is said where the card would + // stand: on a kiro-cli install a silent absence would read as "this harness + // has no credits". `failedWithNoData`, not `isError`, so the notice holds + // through the retry instead of blinking out; the failed request's report + // rides along for the agent hand-off, since the message is a translated + // sentence no journal entry matches. + const harnessUnread = failedWithNoData(cfgQuery) if (!provider.capabilities.usageBilling) return ( @@ -182,6 +206,24 @@ export default function UsageTab() { )} + {/* Whole-install spend by channel / agent / model / session start week, + from the per-turn shards -- the Daily History credits below, decomposed. + It fetches its own series, so it renders beside the usage report + regardless of what the provider's report carries. */} + {creditsHarness ? ( + + {i18nT('pages.overview.usageTab.spend_over_time')} + + + ) : harnessUnread ? ( + + + void cfgQuery.refetch()}> + {i18nT('pages.overview.usageSeriesChart.retry')} + + + ) : null} + {i18nT('pages.overview.usageTab.session_activity_30_days')} {data.refreshing ? ( diff --git a/website/src/test/ApiClient.refactor.surface.test.ts b/website/src/test/ApiClient.refactor.surface.test.ts index d4faa412ae24..70cf95ece175 100644 --- a/website/src/test/ApiClient.refactor.surface.test.ts +++ b/website/src/test/ApiClient.refactor.surface.test.ts @@ -25,7 +25,7 @@ const API_KEY_ORDER = [ 'status', 'tunnelStatus', 'system', 'leakedRuntimes', 'reclaimLeakedRuntimes', 'sessionStorage', 'sessionStorageCleanup', 'sessionStorageRestore', 'sessionStorageEmpty', 'sessionStorageEmptyStatus', 'sessionInventory', 'sessionInventoryDetail', 'sessionInventoryTrash', 'sessionCrewLogProjections', 'sessionWorkProjection', - 'telemetryStartup', 'telemetryContextTrace', 'usageTurns', 'wakatimeStats', + 'telemetryStartup', 'telemetryContextTrace', 'usageTurns', 'usageSeries', 'wakatimeStats', 'wakatimeExportUrl', 'crewBoard', 'crewBoardAction', 'wakatimeExportDownload', 'sessionSummary', 'dashboardCard', 'generateSessionSummary', 'beaconStatus', 'collectionStatus', 'kiroPrerequisite', 'repairKiroPrerequisiteSpecs', diff --git a/website/src/test/TokenStackedAreaChart.test.tsx b/website/src/test/TokenStackedAreaChart.test.tsx new file mode 100644 index 000000000000..70db6cf2faad --- /dev/null +++ b/website/src/test/TokenStackedAreaChart.test.tsx @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Provider } from 'react-redux' +import { createTestStore } from './helpers' +import type { UsageSeriesPayload } from '../api/client' +import { + TokenStackedAreaChart, + channelLabel, + nearestIndex, + niceTop, + stackPaths, + startedByDay, + toCumulative, + weekMondayOf, +} from '../pages/overview/TokenStackedAreaChart' + +const { usageSeries } = vi.hoisted(() => ({ usageSeries: vi.fn() })) + +vi.mock('../api/client', () => ({ api: { usageSeries } })) +// The chart colours its layers from the session palette, which reads the theme +// context; a fixed palette keeps the test on the chart's own behaviour. +vi.mock('../hooks/useSessionPalette', () => ({ + useSessionPalette: () => ({ paletteColors: ['#ff0000', '#00ff00', '#0000ff'] }), +})) +// The Layer by control is a SimpleSelect, which renders a native