Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/attune/ops/routes/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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",
Expand All @@ -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,
)


Expand Down
23 changes: 23 additions & 0 deletions src/attune/ops/static/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions src/attune/ops/templates/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,49 @@ <h1>attune ops</h1>
</a>
</section>

{# 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 %}
<section class="kpi-grid kpi-cluster-account">
<a class="kpi" href="/telemetry">
<div class="kpi-label">Today (account)</div>
<div class="kpi-value">${{ '%.2f'|format(cost_summary.today_usd) }}</div>
<div class="kpi-foot">Anthropic billing &middot; {{ cost_summary.source }}</div>
</a>
<a class="kpi" href="/telemetry">
<div class="kpi-label">7d (account)</div>
<div class="kpi-value">${{ '%.2f'|format(cost_summary.seven_day_usd) }}</div>
<div class="kpi-foot">last 7 days, all workspaces</div>
</a>
<a class="kpi" href="/telemetry">
<div class="kpi-label">MTD (account)</div>
<div class="kpi-value">${{ '%.2f'|format(cost_summary.month_to_date_usd) }}</div>
<div class="kpi-foot">month-to-date</div>
</a>
</section>
{% elif cost_error is not none and cost_error.kind == "no_key" %}
<section class="kpi-grid kpi-cluster-account">
<a class="kpi kpi-cta" href="https://github.com/Smart-AI-Memory/attune-ai/blob/main/docs/reference/" rel="noopener">
<div class="kpi-label">Set up cost reporting</div>
<div class="kpi-value kpi-value-small">Connect billing</div>
<div class="kpi-foot">Add an Anthropic admin API key to see account-wide spend here.</div>
</a>
</section>
{% elif cost_error is not none and cost_error.kind == "auth_failed" %}
<section class="kpi-grid kpi-cluster-account">
<a class="kpi kpi-notice" href="/telemetry">
<div class="kpi-label">Account billing</div>
<div class="kpi-value kpi-value-small">Key rejected</div>
<div class="kpi-foot">Anthropic admin key was rejected. Refresh the key to see account-wide spend.</div>
</a>
</section>
{% endif %}

<section class="panel">
<h2>Recent runs</h2>
{% if recent_runs %}
Expand Down
172 changes: 172 additions & 0 deletions tests/unit/ops/test_anthropic_cost_home_integration.py
Original file line number Diff line number Diff line change
@@ -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
Loading