diff --git a/src/attune/ops/routes/dashboard.py b/src/attune/ops/routes/dashboard.py index 925db390..4ae3fce9 100644 --- a/src/attune/ops/routes/dashboard.py +++ b/src/attune/ops/routes/dashboard.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -from attune.ops import data +from attune.ops import anthropic_cost, data logger = logging.getLogger(__name__) @@ -60,6 +60,20 @@ async def home(request: Request) -> HTMLResponse: runner = getattr(request.app.state, "runner", None) recent_runs = [r.to_dict() for r in runner.recent(limit=5)] if runner else [] attune_ai = next((v for v in versions if v.package == "attune-ai"), None) + # Anthropic account-spend tiles (Phase 2 of anthropic-cost-integration). + # Defensive try/except — a fetch crash must not block the dashboard + # render, so we degrade silently (no tile cluster) on unexpected + # errors. Categorized failures (no_key, auth_failed, rate_limited, + # network) come back through cost_error and the template renders + # the right CTA / notice / fallback per-kind. + refresh = request.query_params.get("refresh") == "1" + try: + cost_summary, cost_error = anthropic_cost.fetch_summary(refresh=refresh) + except Exception: # noqa: BLE001 + # INTENTIONAL: defensive degradation; surface in DEBUG logs but + # never block the home page render on a billing-fetch surprise. + logger.debug("anthropic_cost.fetch_summary raised", exc_info=True) + cost_summary, cost_error = None, None return _render( request, "home.html", @@ -71,6 +85,8 @@ async def home(request: Request) -> HTMLResponse: sparkline=sparkline, recent_runs=recent_runs, attune_ai=attune_ai, + cost_summary=cost_summary, + cost_error=cost_error, ) diff --git a/src/attune/ops/static/css/main.css b/src/attune/ops/static/css/main.css index 2109cdd9..10e49e3d 100644 --- a/src/attune/ops/static/css/main.css +++ b/src/attune/ops/static/css/main.css @@ -268,6 +268,29 @@ a.kpi:hover { color: var(--fg-subtle); } +/* Account-spend cluster (Phase 2 anthropic-cost-integration). + Visually distinguishes Anthropic-billing tiles from workflow- + telemetry tiles above so users can tell at a glance which numbers + come from which data source. Thin left-edge accent on each tile + in the cluster + tighter top margin so the two clusters read as + related-but-distinct rather than as a single grid. */ +.kpi-cluster-account { + margin-top: -8px; +} +.kpi-cluster-account .kpi { + border-left: 3px solid var(--accent); +} +.kpi-cluster-account .kpi-value-small { + font-size: 18px; + font-weight: 500; +} +.kpi-cluster-account .kpi-cta { + border-left-color: var(--fg-muted); +} +.kpi-cluster-account .kpi-notice { + border-left-color: var(--warn, #c87a00); +} + /* ---------- Panels ---------- */ .panel { diff --git a/src/attune/ops/templates/home.html b/src/attune/ops/templates/home.html index 35cd00d9..3319c0db 100644 --- a/src/attune/ops/templates/home.html +++ b/src/attune/ops/templates/home.html @@ -40,6 +40,49 @@

attune ops

+{# Account-spend cluster (Phase 2 of anthropic-cost-integration). + Visually distinguished from the workflow-telemetry cluster above + via the .kpi-cluster-account class — these numbers come from the + Anthropic admin API, not local usage.jsonl. Gated on cost_summary + availability; CTA when no admin key; friendly notice on auth fail; + silent fall-through on transient errors so the dashboard stays + functional. #} +{% if cost_summary is not none %} +
+ +
Today (account)
+
${{ '%.2f'|format(cost_summary.today_usd) }}
+
Anthropic billing · {{ cost_summary.source }}
+
+ +
7d (account)
+
${{ '%.2f'|format(cost_summary.seven_day_usd) }}
+
last 7 days, all workspaces
+
+ +
MTD (account)
+
${{ '%.2f'|format(cost_summary.month_to_date_usd) }}
+
month-to-date
+
+
+{% elif cost_error is not none and cost_error.kind == "no_key" %} +
+ +
Set up cost reporting
+
Connect billing
+
Add an Anthropic admin API key to see account-wide spend here.
+
+
+{% elif cost_error is not none and cost_error.kind == "auth_failed" %} +
+ +
Account billing
+
Key rejected
+
Anthropic admin key was rejected. Refresh the key to see account-wide spend.
+
+
+{% endif %} +

Recent runs

{% if recent_runs %} diff --git a/tests/unit/ops/test_anthropic_cost_home_integration.py b/tests/unit/ops/test_anthropic_cost_home_integration.py new file mode 100644 index 00000000..92186e1a --- /dev/null +++ b/tests/unit/ops/test_anthropic_cost_home_integration.py @@ -0,0 +1,172 @@ +"""Phase 2 of anthropic-cost-integration — home page wiring. + +The spec (docs/specs/anthropic-cost-integration/tasks.md, task 2.4) +specifies four scenarios this file covers: + +1. No admin key → CTA tile, no traceback, dashboard otherwise functional +2. Valid CostSummary → all three account KPI tiles populated +3. Auth-failed error → friendly notice, dashboard otherwise functional +4. ?refresh=1 query param → fetch called with refresh=True + +The fetch_summary call is mocked at its import site in dashboard.py +(not at its definition site) so the dispatch in the home route picks +up the mock. See CLAUDE.md lesson "Real project files on disk override +test mocks" for the rationale. +""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from unittest.mock import patch + +import pytest + +pytest.importorskip("fastapi") +pytest.importorskip("jinja2") + +from fastapi.testclient import TestClient # noqa: E402 + +from attune.ops.anthropic_cost import CostFetchError, CostSummary # noqa: E402 +from attune.ops.config import build_config # noqa: E402 +from attune.ops.server import create_app # noqa: E402 + + +def _client(tmp_path, monkeypatch) -> TestClient: + monkeypatch.setenv("ATTUNE_HOME", str(tmp_path / "attune-home")) + config = build_config( + project_root=tmp_path, + trusted_hosts=("testserver", "test"), + ) + app = create_app(config) + return TestClient(app) + + +def _valid_summary() -> CostSummary: + return CostSummary( + today_usd=4.72, + seven_day_usd=18.31, + month_to_date_usd=42.50, + thirty_day_usd=42.50, + by_day=[(date(2026, 5, 19), 4.72)], + by_model=[("claude-sonnet-4-5", 4.72)], + by_cost_type=[("input_tokens", 1.10), ("output_tokens", 3.62)], + fetched_at=datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc), + source="live", + ) + + +def test_home_renders_cta_when_no_admin_key(tmp_path, monkeypatch): + """Home page shows the no-key CTA, no traceback, dashboard renders.""" + err = CostFetchError(kind="no_key", message="no admin key configured") + with patch( + "attune.ops.anthropic_cost.fetch_summary", + return_value=(None, err), + ): + with _client(tmp_path, monkeypatch) as client: + resp = client.get("/") + + assert resp.status_code == 200 + # CTA copy and class are present. + assert "Set up cost reporting" in resp.text + assert "Connect billing" in resp.text + assert "kpi-cluster-account" in resp.text + # Telemetry tiles still render — dashboard is otherwise functional. + assert "Today's events" in resp.text + assert "7-day spend" in resp.text + + +def test_home_renders_three_tiles_with_valid_summary(tmp_path, monkeypatch): + """All three account-spend KPI tiles populated with formatted USD.""" + summary = _valid_summary() + with patch( + "attune.ops.anthropic_cost.fetch_summary", + return_value=(summary, None), + ): + with _client(tmp_path, monkeypatch) as client: + resp = client.get("/") + + assert resp.status_code == 200 + # Three tile labels. + assert "Today (account)" in resp.text + assert "7d (account)" in resp.text + assert "MTD (account)" in resp.text + # Formatted dollar values. + assert "$4.72" in resp.text + assert "$18.31" in resp.text + assert "$42.50" in resp.text + # Source indicator visible on the today tile. + assert "live" in resp.text + + +def test_home_renders_notice_on_auth_failed(tmp_path, monkeypatch): + """auth_failed surfaces a friendly notice; dashboard stays functional.""" + err = CostFetchError(kind="auth_failed", message="key rejected (HTTP 401)") + with patch( + "attune.ops.anthropic_cost.fetch_summary", + return_value=(None, err), + ): + with _client(tmp_path, monkeypatch) as client: + resp = client.get("/") + + assert resp.status_code == 200 + assert "Key rejected" in resp.text + assert "kpi-notice" in resp.text + # Friendly notice — no raw HTTP status or stack trace surfaced. + assert "HTTP 401" not in resp.text + # Workflow and version tiles still render. + assert "Workflows" in resp.text + + +def test_home_passes_refresh_true_when_query_param_set(tmp_path, monkeypatch): + """?refresh=1 → fetch_summary called with refresh=True.""" + summary = _valid_summary() + with patch( + "attune.ops.anthropic_cost.fetch_summary", + return_value=(summary, None), + ) as mock_fetch: + with _client(tmp_path, monkeypatch) as client: + resp = client.get("/?refresh=1") + + assert resp.status_code == 200 + mock_fetch.assert_called_once() + # Keyword arg, per the helper's keyword-only signature. + assert mock_fetch.call_args.kwargs.get("refresh") is True + + +def test_home_default_call_passes_refresh_false(tmp_path, monkeypatch): + """Without ?refresh=1, fetch_summary is called with refresh=False.""" + summary = _valid_summary() + with patch( + "attune.ops.anthropic_cost.fetch_summary", + return_value=(summary, None), + ) as mock_fetch: + with _client(tmp_path, monkeypatch) as client: + resp = client.get("/") + + assert resp.status_code == 200 + mock_fetch.assert_called_once() + assert mock_fetch.call_args.kwargs.get("refresh") is False + + +def test_home_degrades_silently_on_fetch_exception(tmp_path, monkeypatch): + """Unexpected fetch exception → no tile cluster, dashboard renders. + + Defensive try/except in the home route should swallow surprises and + let the rest of the page render. The cost-cluster simply doesn't + appear; no traceback bleeds into the HTML. + """ + with patch( + "attune.ops.anthropic_cost.fetch_summary", + side_effect=RuntimeError("anthropic edge melted"), + ): + with _client(tmp_path, monkeypatch) as client: + resp = client.get("/") + + assert resp.status_code == 200 + # No tile cluster rendered. + assert "kpi-cluster-account" not in resp.text + # No traceback / exception text leaked. + assert "anthropic edge melted" not in resp.text + assert "Traceback" not in resp.text + # Dashboard otherwise functional. + assert "attune ops" in resp.text # hero title