feat(me/stats): per-analyst Stats dashboard with 4 tabs (#298)
* feat(me/stats): per-analyst Stats dashboard with 4 tabs
New /me/stats page shows the calling user's own analytics across
four tabs, lazy-loaded per activation:
- **Sessions** — paginated usage_session_summary join with a
filesystem scan of un-processed JSONL (mirrors admin
list_user_sessions shape). v44 token columns aggregated per row.
- **Tokens** — daily series (default 30 days), by-model breakdown
(lifetime), top-10 biggest sessions, lifetime totals. Single
CTE per sub-query against per-user partition (idx_usage_session_user).
- **Data access** — audit_log rows where action LIKE 'query.%' for
the caller. Covers query.local / query.hybrid / query.remote /
query.internal. Cursor-paginated on (timestamp, id).
- **Sync activity** — audit_log rows where action is sync.* or
manifest.* for the caller, plus users.last_pull_at for the
header. Per-pull history now persists thanks to the new
manifest.fetch audit row.
Backend: app/api/me_stats.py — single APIRouter at /api/me/stats/*,
four GET endpoints, all gated by get_current_user (server-side
caller scope; the page route itself only renders the shell).
Frontend: app/web/templates/me_stats.html — tab bar + 4 panels,
plain JS lazy-loads each panel's endpoint on first activation,
caches per-tab so switching back doesn't refetch. Small SVG bar
chart on Tokens tab (no external charting dep). 'Stats' link
added to _app_header.html primary nav between 'Data Packages'
and the Admin dropdown.
Side change in app/api/sync.py: /api/sync/manifest now emits a
manifest.fetch audit_log row alongside the existing
users.last_pull_at bump. The column UPDATE only retains the
most recent timestamp; per-pull history needs an audit row.
client_kind='api' for the manifest endpoint (vs. 'web' which
the audit-read deduper uses for AC reads), so the Sync tab can
distinguish CLI pulls from browser-driven manifest peeks.
7 new tests in tests/test_me_stats.py:
- sessions endpoint caller-scope isolation (user A doesn't see B)
- sessions pagination
- tokens empty-user zero shape
- tokens aggregation across daily window + by_model + top + totals
- queries endpoint filters to action LIKE 'query.%' + caller scope
- sync endpoint surfaces both manifest.fetch and sync.trigger
- manifest endpoint writes the manifest.fetch audit row
* ui(me/stats): widen page to 1400px via main.main escape
Default base.html .container wraps content at max-width 800px. Stats
tables (by-model + top-sessions: 6 columns each) felt cramped at that
width — same constraint dashboard.html escapes via the {% block layout %}
override pattern. Mirror that here: render <main class="main"> and
bump .stats-page max-width to 1400px so the 6-column tables breathe
without going edge-to-edge on wide monitors.
* ui(me/stats): narrow from 1400px to 1280px to match /home
/home isn't actually .container's default 800px — style-custom.css
has a body:has(.home-mock) .container { max-width: 1280px } override
that widens it. 1280px is the shared 'wide content' width across the
codebase (top-nav header + /home + dashboard all use it).
Bumping me_stats from 1400px to 1280px so the Stats page reads as
'same chrome' instead of distinctly wider than its sibling pages.
This commit is contained in:
parent
37ad39c8a3
commit
aa6a6700f4
8 changed files with 1247 additions and 3 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -12,6 +12,29 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
### Added
|
||||
|
||||
- **Per-analyst Stats dashboard at `/me/stats`.** Four-tab page showing
|
||||
the calling user's own data, lazy-loaded per tab:
|
||||
- **Sessions** — paginated `usage_session_summary` rows + filesystem
|
||||
scan of un-processed JSONL (matches the admin `list_user_sessions`
|
||||
shape). Includes the v44 token columns aggregated per row.
|
||||
- **Tokens** — daily series (default last 30 days), by-model
|
||||
breakdown (lifetime), top-10 biggest sessions, lifetime totals.
|
||||
- **Data access** — `audit_log` rows where `action LIKE 'query.%'`
|
||||
for the caller (covers `query.local`, `query.hybrid`, `query.remote`,
|
||||
`query.internal`). Cursor-paginated on `(timestamp, id)`.
|
||||
- **Sync activity** — `audit_log` rows where action is `sync.*` or
|
||||
`manifest.*` for the caller, plus the user's `last_pull_at` for the
|
||||
header. Per-pull history now persists thanks to the new
|
||||
`manifest.fetch` audit row.
|
||||
Backed by `GET /api/me/stats/{sessions,tokens,queries,sync}`,
|
||||
authed-only, server-side caller-scope. New "Stats" link added to the
|
||||
primary nav between "Data Packages" and the Admin dropdown.
|
||||
- **`manifest.fetch` audit_log row** written from
|
||||
`GET /api/sync/manifest` alongside the `users.last_pull_at` bump.
|
||||
Surfaces per-pull history (the column UPDATE only retains the most
|
||||
recent timestamp) so the Sync activity tab and any other
|
||||
audit-log-driven view can render a timeline.
|
||||
|
||||
- **Homepage status frame.** The `/home` page now opens with a 5-card
|
||||
status row above the install-hero / offboard-strip: **Last sync**
|
||||
(your last `agnes pull`), **Sessions**, **Prompts**, **Tokens used**,
|
||||
|
|
|
|||
454
app/api/me_stats.py
Normal file
454
app/api/me_stats.py
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
"""Self-scoped Stats endpoints for /me/stats — the analyst's own
|
||||
analytics dashboard.
|
||||
|
||||
Four tabs, four endpoints, all gated by ``get_current_user`` so a
|
||||
caller can only see their own data:
|
||||
|
||||
- ``GET /api/me/stats/sessions?limit=&offset=`` — paginated session
|
||||
list joined from ``usage_session_summary`` (post-processor) with a
|
||||
filesystem scan of un-processed JSONL (matches the admin
|
||||
``list_user_sessions`` shape).
|
||||
- ``GET /api/me/stats/tokens?days=30`` — daily token series + by-model
|
||||
breakdown + top-10 biggest sessions. Powers the Tokens tab chart.
|
||||
- ``GET /api/me/stats/queries?cursor_ts=&cursor_id=&limit=`` —
|
||||
``audit_log`` rows where ``action LIKE 'query.%'`` (BQ + local
|
||||
DuckDB queries) for this user. Cursor-paginated (keyset on
|
||||
``(timestamp, id)``).
|
||||
- ``GET /api/me/stats/sync?cursor_ts=&cursor_id=&limit=`` —
|
||||
``audit_log`` rows where action is ``sync.*`` or ``manifest.*``,
|
||||
plus the user's ``last_pull_at`` for prominent header rendering.
|
||||
|
||||
Username derivation: ``_username_for_stats(user)`` reuses the
|
||||
email-local-part rule from ``app.api.me`` so the joins on
|
||||
``usage_*`` rows (filesystem-derived OS username) align with what
|
||||
the session collector writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import duckdb
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import _get_db, get_current_user
|
||||
from src.repositories.audit import AuditRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/me/stats", tags=["me"])
|
||||
|
||||
|
||||
def _username_for_stats(user: dict) -> str:
|
||||
"""Email local-part → filesystem username, mirroring the rule in
|
||||
``app.api.me._username_for_stats``. Kept inline so this module
|
||||
has no cross-import dependency on ``me.py``; if the mapping
|
||||
evolves both copies update.
|
||||
"""
|
||||
email: str = user.get("email", "") or ""
|
||||
return email.split("@")[0] if "@" in email else email
|
||||
|
||||
|
||||
def _session_data_dir() -> Path:
|
||||
"""Match ``app.api.admin_user_sessions._session_data_dir``."""
|
||||
return Path(
|
||||
os.environ.get("SESSION_DATA_DIR")
|
||||
or os.environ.get("AGNES_SESSION_DATA_DIR")
|
||||
or "/data/sessions"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
def list_self_sessions(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""Paginated session list for the calling user.
|
||||
|
||||
Joins ``usage_session_summary`` (processed=true) with a filesystem
|
||||
scan of un-processed JSONL so a session appears immediately even
|
||||
before the UsageProcessor runs. Mirrors the admin
|
||||
``list_user_sessions`` projection plus the v44 token columns —
|
||||
the Stats tab renders one row per session with totals on the
|
||||
right.
|
||||
"""
|
||||
username = _username_for_stats(user)
|
||||
user_dir = _session_data_dir() / username
|
||||
|
||||
try:
|
||||
rows_db = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
session_file, session_id, started_at, ended_at,
|
||||
active_seconds, wall_seconds,
|
||||
user_messages, tool_calls, tool_errors,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens,
|
||||
primary_model
|
||||
FROM usage_session_summary
|
||||
WHERE username = ?
|
||||
ORDER BY started_at DESC NULLS LAST
|
||||
""",
|
||||
[username],
|
||||
).fetchall()
|
||||
except Exception:
|
||||
# usage_session_summary may not exist on a partially-migrated DB.
|
||||
# Fall back to filesystem-only listing rather than 500.
|
||||
rows_db = []
|
||||
|
||||
cols = [
|
||||
"session_file", "session_id", "started_at", "ended_at",
|
||||
"active_seconds", "wall_seconds",
|
||||
"user_messages", "tool_calls", "tool_errors",
|
||||
"input_tokens", "output_tokens",
|
||||
"cache_read_tokens", "cache_creation_tokens",
|
||||
"primary_model",
|
||||
]
|
||||
processed: dict[str, dict] = {}
|
||||
for r in rows_db:
|
||||
d = dict(zip(cols, r))
|
||||
for k in ("started_at", "ended_at"):
|
||||
v = d.get(k)
|
||||
if v is not None and hasattr(v, "isoformat"):
|
||||
d[k] = v.isoformat()
|
||||
d["tokens_total"] = (
|
||||
int(d.get("input_tokens") or 0)
|
||||
+ int(d.get("output_tokens") or 0)
|
||||
+ int(d.get("cache_read_tokens") or 0)
|
||||
+ int(d.get("cache_creation_tokens") or 0)
|
||||
)
|
||||
d["processed"] = True
|
||||
processed[d["session_file"]] = d
|
||||
|
||||
all_rows: list[dict] = list(processed.values())
|
||||
if user_dir.is_dir():
|
||||
for p in sorted(
|
||||
user_dir.glob("*.jsonl"),
|
||||
key=lambda x: x.stat().st_mtime,
|
||||
reverse=True,
|
||||
):
|
||||
if p.name in processed:
|
||||
continue
|
||||
mtime = datetime.fromtimestamp(p.stat().st_mtime).isoformat()
|
||||
all_rows.append({
|
||||
"session_file": p.name,
|
||||
"session_id": p.stem,
|
||||
"started_at": mtime,
|
||||
"ended_at": None,
|
||||
"active_seconds": 0,
|
||||
"wall_seconds": 0,
|
||||
"user_messages": 0,
|
||||
"tool_calls": 0,
|
||||
"tool_errors": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_creation_tokens": 0,
|
||||
"tokens_total": 0,
|
||||
"primary_model": None,
|
||||
"processed": False,
|
||||
})
|
||||
|
||||
all_rows.sort(
|
||||
key=lambda r: r.get("started_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
total = len(all_rows)
|
||||
page = all_rows[offset : offset + limit]
|
||||
return {
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"rows": page,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tokens tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
def get_tokens(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""Token breakdown for the Tokens tab.
|
||||
|
||||
Returns a daily series (last *days* days), by-model breakdown
|
||||
(lifetime), top-10 biggest sessions (by total tokens, lifetime),
|
||||
and the lifetime grand total. Single round-trip via three
|
||||
sub-queries — each scans the same per-user partition of
|
||||
``usage_session_summary`` which the
|
||||
``idx_usage_session_user`` index supports.
|
||||
"""
|
||||
username = _username_for_stats(user)
|
||||
|
||||
# Daily series — interval literal interpolated from validated `days`.
|
||||
daily = conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
CAST(started_at AS DATE) AS day,
|
||||
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation,
|
||||
COUNT(*) AS sessions
|
||||
FROM usage_session_summary
|
||||
WHERE username = ?
|
||||
AND started_at >= current_timestamp - INTERVAL {int(days)} DAY
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""",
|
||||
[username],
|
||||
).fetchall()
|
||||
daily_series = [
|
||||
{
|
||||
"day": d.isoformat() if hasattr(d, "isoformat") else str(d),
|
||||
"input": int(i or 0),
|
||||
"output": int(o or 0),
|
||||
"cache_read": int(cr or 0),
|
||||
"cache_creation": int(cc or 0),
|
||||
"sessions": int(s or 0),
|
||||
"total": int((i or 0) + (o or 0) + (cr or 0) + (cc or 0)),
|
||||
}
|
||||
for (d, i, o, cr, cc, s) in daily
|
||||
]
|
||||
|
||||
by_model = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(primary_model, '(unknown)') AS model,
|
||||
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation,
|
||||
COUNT(*) AS sessions
|
||||
FROM usage_session_summary
|
||||
WHERE username = ?
|
||||
GROUP BY 1
|
||||
ORDER BY (
|
||||
COALESCE(SUM(input_tokens), 0)
|
||||
+ COALESCE(SUM(output_tokens), 0)
|
||||
+ COALESCE(SUM(cache_read_tokens), 0)
|
||||
+ COALESCE(SUM(cache_creation_tokens), 0)
|
||||
) DESC
|
||||
""",
|
||||
[username],
|
||||
).fetchall()
|
||||
model_breakdown = [
|
||||
{
|
||||
"model": m, "input": int(i or 0), "output": int(o or 0),
|
||||
"cache_read": int(cr or 0), "cache_creation": int(cc or 0),
|
||||
"sessions": int(s or 0),
|
||||
"total": int((i or 0) + (o or 0) + (cr or 0) + (cc or 0)),
|
||||
}
|
||||
for (m, i, o, cr, cc, s) in by_model
|
||||
]
|
||||
|
||||
top_sessions = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
session_file, session_id, started_at, primary_model,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens,
|
||||
(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)
|
||||
+ COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0))
|
||||
AS tokens_total
|
||||
FROM usage_session_summary
|
||||
WHERE username = ?
|
||||
ORDER BY tokens_total DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
[username],
|
||||
).fetchall()
|
||||
top = [
|
||||
{
|
||||
"session_file": sf,
|
||||
"session_id": sid,
|
||||
"started_at": st.isoformat() if hasattr(st, "isoformat") else st,
|
||||
"primary_model": pm,
|
||||
"input": int(i or 0), "output": int(o or 0),
|
||||
"cache_read": int(cr or 0), "cache_creation": int(cc or 0),
|
||||
"total": int(tt or 0),
|
||||
}
|
||||
for (sf, sid, st, pm, i, o, cr, cc, tt) in top_sessions
|
||||
]
|
||||
|
||||
totals_row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(input_tokens), 0),
|
||||
COALESCE(SUM(output_tokens), 0),
|
||||
COALESCE(SUM(cache_read_tokens), 0),
|
||||
COALESCE(SUM(cache_creation_tokens), 0),
|
||||
COUNT(*)
|
||||
FROM usage_session_summary
|
||||
WHERE username = ?
|
||||
""",
|
||||
[username],
|
||||
).fetchone()
|
||||
ti, to, tcr, tcc, tses = totals_row or (0, 0, 0, 0, 0)
|
||||
totals = {
|
||||
"input": int(ti or 0),
|
||||
"output": int(to or 0),
|
||||
"cache_read": int(tcr or 0),
|
||||
"cache_creation": int(tcc or 0),
|
||||
"total": int((ti or 0) + (to or 0) + (tcr or 0) + (tcc or 0)),
|
||||
"sessions": int(tses or 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"daily": daily_series,
|
||||
"by_model": model_breakdown,
|
||||
"top_sessions": top,
|
||||
"totals": totals,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data access tab (BQ + DuckDB queries)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/queries")
|
||||
def list_self_queries(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
cursor_ts: Optional[datetime] = None,
|
||||
cursor_id: Optional[str] = None,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""Audit-log rows where ``action LIKE 'query.%'`` for the caller.
|
||||
|
||||
Covers query.local (DuckDB on parquet), query.hybrid (BQ + local
|
||||
join), query.remote (BQ direct), and query.internal (admin
|
||||
internal queries that get attributed to the actor). Cursor
|
||||
pagination on (timestamp, id) so streams under concurrent
|
||||
writes don't double-render rows.
|
||||
"""
|
||||
cursor = (cursor_ts, cursor_id) if cursor_ts and cursor_id else None
|
||||
rows, next_cursor = AuditRepository(conn).query(
|
||||
user_id=user["id"],
|
||||
action_prefix="query.",
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
return _audit_response(rows, next_cursor, limit)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync activity tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/sync")
|
||||
def list_self_sync_activity(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
cursor_ts: Optional[datetime] = None,
|
||||
cursor_id: Optional[str] = None,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""Audit-log rows where action is ``sync.*`` or ``manifest.*``
|
||||
for the caller, plus ``users.last_pull_at`` for the header card.
|
||||
|
||||
Two action prefixes are merged with a UNION-ish IN filter via
|
||||
``AuditRepository.query(action_in=[...])`` — but the repo helper
|
||||
doesn't take both prefix and IN, so we call twice and merge.
|
||||
Cheaper alternative is two SELECTs in the repo; for now we fetch
|
||||
two pages and interleave because cursor merging across two
|
||||
independent streams is fiddly without a unified ORDER. To keep
|
||||
the code obvious, we use ``query_actions(...)`` and accept that
|
||||
the cursor is single-stream (start over to page back; first page
|
||||
is what matters for the dashboard).
|
||||
"""
|
||||
actions_seen = AuditRepository(conn).query_actions(
|
||||
actions=_sync_action_list(),
|
||||
limit=limit,
|
||||
)
|
||||
# Filter to this user — query_actions doesn't take user_id.
|
||||
user_rows = [r for r in actions_seen if r.get("user_id") == user["id"]]
|
||||
|
||||
last_pull_row = conn.execute(
|
||||
"SELECT last_pull_at FROM users WHERE id = ?", [user["id"]]
|
||||
).fetchone()
|
||||
last_pull_at = last_pull_row[0] if last_pull_row else None
|
||||
|
||||
return {
|
||||
"last_pull_at": last_pull_at.isoformat()
|
||||
if last_pull_at and hasattr(last_pull_at, "isoformat")
|
||||
else last_pull_at,
|
||||
"rows": [_audit_row_to_payload(r) for r in user_rows[:limit]],
|
||||
# No next_cursor in this branch — fetched a single newest-window
|
||||
# page. Pagination beyond the first page is rarely needed for
|
||||
# personal sync history; the timeline tab in /admin/activity is
|
||||
# the place for deeper dives.
|
||||
"next_cursor": None,
|
||||
}
|
||||
|
||||
|
||||
def _sync_action_list() -> list[str]:
|
||||
"""The set of audit actions that surface on the Sync activity tab.
|
||||
|
||||
Concrete known actions today:
|
||||
- ``sync.trigger`` — admin manually kicks a sync.
|
||||
- ``manifest.fetch`` — added in this PR; bumped on every
|
||||
``GET /api/sync/manifest``.
|
||||
|
||||
Listed explicitly (vs. a prefix LIKE) so accidental future
|
||||
``sync.*`` actions (e.g. an admin-only ``sync.config_change``)
|
||||
don't leak into the analyst-facing view without review.
|
||||
"""
|
||||
return ["sync.trigger", "manifest.fetch"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _audit_row_to_payload(row: dict) -> dict:
|
||||
"""Convert a raw audit_log row dict to the JSON shape the Stats
|
||||
tabs render. Drops `correlation_id` (not useful per-user) and
|
||||
iso-stringifies the timestamp."""
|
||||
ts = row.get("timestamp")
|
||||
return {
|
||||
"id": row.get("id"),
|
||||
"timestamp": ts.isoformat() if ts and hasattr(ts, "isoformat") else ts,
|
||||
"action": row.get("action"),
|
||||
"resource": row.get("resource"),
|
||||
"result": row.get("result"),
|
||||
"duration_ms": row.get("duration_ms"),
|
||||
"params": row.get("params"),
|
||||
"client_kind": row.get("client_kind"),
|
||||
}
|
||||
|
||||
|
||||
def _audit_response(rows: list[dict], next_cursor, limit: int) -> dict:
|
||||
"""Shared shape for the queries and (alternate path) sync endpoints."""
|
||||
if next_cursor is not None:
|
||||
ts, cid = next_cursor
|
||||
nc = {
|
||||
"timestamp": ts.isoformat() if hasattr(ts, "isoformat") else ts,
|
||||
"id": cid,
|
||||
}
|
||||
else:
|
||||
nc = None
|
||||
return {
|
||||
"limit": limit,
|
||||
"rows": [_audit_row_to_payload(r) for r in rows],
|
||||
"next_cursor": nc,
|
||||
}
|
||||
|
|
@ -812,10 +812,22 @@ async def sync_manifest(
|
|||
"UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
|
||||
[user["id"]],
|
||||
)
|
||||
# Also emit an audit_log row so /me/stats Sync activity has a
|
||||
# timeline of pulls (the column UPDATE only retains the most
|
||||
# recent one). Action `manifest.fetch` covers both `agnes pull`
|
||||
# via PAT and browser-driven manifest peeks; clients can
|
||||
# disambiguate via client_kind.
|
||||
AuditRepository(conn).log(
|
||||
user_id=user["id"],
|
||||
action="manifest.fetch",
|
||||
resource="manifest",
|
||||
result="ok",
|
||||
client_kind="api",
|
||||
)
|
||||
except Exception:
|
||||
# Never block a pull because the stamp UPDATE hit a transient
|
||||
# issue (locked WAL, partial migration window). The manifest
|
||||
# itself is the load-bearing payload.
|
||||
# Never block a pull because the stamp UPDATE / audit row hit a
|
||||
# transient issue (locked WAL, partial migration window). The
|
||||
# manifest itself is the load-bearing payload.
|
||||
pass
|
||||
return _build_manifest_for_user(conn, user)
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ from app.api.telegram import router as telegram_router
|
|||
from app.api.access import router as access_router, me_router as me_access_router
|
||||
from app.api.me_debug import router as me_debug_router
|
||||
from app.api.me import router as me_router
|
||||
from app.api.me_stats import router as me_stats_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.admin_bigquery_test import router as admin_bigquery_test_router
|
||||
from app.api.jira_webhooks import router as jira_webhooks_router
|
||||
|
|
@ -612,6 +613,7 @@ def create_app() -> FastAPI:
|
|||
app.include_router(me_access_router)
|
||||
app.include_router(me_debug_router)
|
||||
app.include_router(me_router)
|
||||
app.include_router(me_stats_router)
|
||||
app.include_router(jira_webhooks_router)
|
||||
app.include_router(metrics_router)
|
||||
app.include_router(metadata_router)
|
||||
|
|
|
|||
|
|
@ -726,6 +726,26 @@ async def home_page(
|
|||
return templates.TemplateResponse(request, "home_not_onboarded.html", ctx)
|
||||
|
||||
|
||||
@router.get("/me/stats", response_class=HTMLResponse)
|
||||
async def me_stats_page(
|
||||
request: Request,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Per-analyst stats dashboard. Four tabs (Sessions / Tokens /
|
||||
Data access / Sync activity) backed by /api/me/stats/* endpoints.
|
||||
Authed-only; each endpoint enforces user_id = caller scoping
|
||||
server-side so this route just renders the shell.
|
||||
"""
|
||||
ctx = _build_context(
|
||||
request,
|
||||
user=user,
|
||||
conn=conn,
|
||||
is_admin=is_user_admin(user["id"], conn),
|
||||
)
|
||||
return templates.TemplateResponse(request, "me_stats.html", ctx)
|
||||
|
||||
|
||||
@router.get("/news", response_class=HTMLResponse)
|
||||
async def news_page(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
<a class="app-nav-link {% if _path == _home or _path == '/' or _path == '/dashboard' or _path == '/home' %}is-active{% endif %}" href="{{ _home }}">Home</a>
|
||||
<a class="app-nav-link {% if _path == '/marketplace' or _path.startswith('/marketplace/') %}is-active{% endif %}" href="/marketplace">Marketplace</a>
|
||||
<a class="app-nav-link {% if _path.startswith('/catalog') %}is-active{% endif %}" href="/catalog">Data Packages</a>
|
||||
<a class="app-nav-link {% if _path.startswith('/me/stats') %}is-active{% endif %}" href="/me/stats">Stats</a>
|
||||
{# Memory + Admin menu: both admin-only. Backend gates the routes
|
||||
themselves via require_admin (see app/web/router.py for
|
||||
/corporate-memory + /corporate-memory/admin + /admin/*), so
|
||||
|
|
|
|||
463
app/web/templates/me_stats.html
Normal file
463
app/web/templates/me_stats.html
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Stats — {{ instance_brand }}{% endblock %}
|
||||
|
||||
{# Override block layout (same trick dashboard.html uses) to escape the
|
||||
narrow .container wrap from base.html. Stats tables look cramped
|
||||
inside 800px; 1280px matches the top-nav header width
|
||||
(_app_header.html uses the same value) so the Stats page reads as
|
||||
"same chrome, full content area" rather than a separate visual
|
||||
identity. #}
|
||||
{% block layout %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<main class="main">{{ self.content() }}</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.stats-page { max-width: 1280px; margin: 0 auto; padding: 24px 24px 64px; }
|
||||
.stats-page h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; }
|
||||
.stats-page .lead { color: var(--hp-text-muted, #6b7280); margin: 0 0 20px; font-size: 13px; }
|
||||
|
||||
.stats-tabs {
|
||||
display: flex; gap: 0; border-bottom: 1px solid var(--hp-border-light, rgba(0,0,0,0.12));
|
||||
margin-bottom: 18px; overflow-x: auto;
|
||||
}
|
||||
.stats-tab {
|
||||
border: 0; background: transparent; padding: 10px 16px; font: inherit; font-size: 13.5px;
|
||||
color: var(--hp-text-muted, #6b7280); cursor: pointer; border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stats-tab.is-active { color: var(--hp-text, #111); border-bottom-color: var(--hp-accent, #2563eb); }
|
||||
.stats-panel { display: none; }
|
||||
.stats-panel.is-active { display: block; }
|
||||
|
||||
.stats-loading { color: var(--hp-text-muted, #6b7280); font-style: italic; padding: 24px 0; }
|
||||
.stats-error { color: #b91c1c; padding: 24px 0; }
|
||||
.stats-empty { color: var(--hp-text-muted, #6b7280); padding: 24px 0; }
|
||||
|
||||
table.stats-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
table.stats-table th, table.stats-table td {
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--hp-border-light, rgba(0,0,0,0.07));
|
||||
text-align: left; vertical-align: top;
|
||||
}
|
||||
table.stats-table th { font-weight: 600; color: var(--hp-text-muted, #6b7280); font-size: 11.5px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
table.stats-table td.num { text-align: right; }
|
||||
table.stats-table tbody tr:hover { background: rgba(0,0,0,0.02); }
|
||||
|
||||
.stats-paginate {
|
||||
display: flex; gap: 8px; justify-content: flex-end; padding: 12px 0;
|
||||
}
|
||||
.stats-paginate button {
|
||||
padding: 6px 12px; font: inherit; font-size: 12px; cursor: pointer;
|
||||
border: 1px solid var(--hp-border-light, rgba(0,0,0,0.12)); border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.stats-paginate button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.stats-overview {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 720px) { .stats-overview { grid-template-columns: repeat(2, 1fr); } }
|
||||
.stats-overview .card {
|
||||
padding: 12px; border-radius: 8px;
|
||||
background: var(--hp-stat-bg, rgba(0,0,0,0.025));
|
||||
}
|
||||
.stats-overview .lbl {
|
||||
font-size: 11px; color: var(--hp-text-muted, #6b7280);
|
||||
text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px;
|
||||
}
|
||||
.stats-overview .val { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.tok-chart { width: 100%; height: 160px; margin: 8px 0 16px; }
|
||||
.tok-chart rect { fill: var(--hp-accent, #2563eb); opacity: 0.85; }
|
||||
.tok-chart .axis text { font-size: 10px; fill: var(--hp-text-muted, #6b7280); }
|
||||
</style>
|
||||
|
||||
<div class="stats-page">
|
||||
<h1>Your Stats</h1>
|
||||
<p class="lead">
|
||||
Sessions, tokens, data access, and sync activity for
|
||||
<strong>{{ user.email }}</strong>. Data covers the
|
||||
sessions {{ instance_brand }} has processed from your Claude
|
||||
Code transcripts.
|
||||
</p>
|
||||
|
||||
<nav class="stats-tabs" role="tablist" aria-label="Stats sections">
|
||||
<button type="button" role="tab" class="stats-tab is-active"
|
||||
data-tab="sessions" aria-selected="true">Sessions</button>
|
||||
<button type="button" role="tab" class="stats-tab"
|
||||
data-tab="tokens" aria-selected="false">Tokens</button>
|
||||
<button type="button" role="tab" class="stats-tab"
|
||||
data-tab="queries" aria-selected="false">Data access</button>
|
||||
<button type="button" role="tab" class="stats-tab"
|
||||
data-tab="sync" aria-selected="false">Sync activity</button>
|
||||
</nav>
|
||||
|
||||
<section class="stats-panel is-active" id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions">
|
||||
<div class="stats-loading" data-state="loading">Loading sessions…</div>
|
||||
<div data-state="ready" hidden>
|
||||
<table class="stats-table">
|
||||
<thead><tr>
|
||||
<th>Started</th><th>Model</th>
|
||||
<th class="num">Prompts</th><th class="num">Tools</th>
|
||||
<th class="num">Tokens</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows></tbody>
|
||||
</table>
|
||||
<div class="stats-paginate">
|
||||
<button type="button" data-action="prev">‹ Prev</button>
|
||||
<button type="button" data-action="next">Next ›</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel" id="panel-tokens" role="tabpanel" aria-labelledby="tab-tokens">
|
||||
<div class="stats-loading" data-state="loading">Loading tokens…</div>
|
||||
<div data-state="ready" hidden>
|
||||
<div class="stats-overview">
|
||||
<div class="card"><div class="lbl">Input</div><div class="val" data-tok-total="input">—</div></div>
|
||||
<div class="card"><div class="lbl">Output</div><div class="val" data-tok-total="output">—</div></div>
|
||||
<div class="card"><div class="lbl">Cache read</div><div class="val" data-tok-total="cache_read">—</div></div>
|
||||
<div class="card"><div class="lbl">Cache creation</div><div class="val" data-tok-total="cache_creation">—</div></div>
|
||||
</div>
|
||||
<svg class="tok-chart" data-tok-chart viewBox="0 0 600 160" preserveAspectRatio="none"></svg>
|
||||
|
||||
<h3 style="font-size: 13px; margin: 16px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--hp-text-muted, #6b7280);">By model</h3>
|
||||
<table class="stats-table">
|
||||
<thead><tr>
|
||||
<th>Model</th><th class="num">Sessions</th>
|
||||
<th class="num">Input</th><th class="num">Output</th>
|
||||
<th class="num">Cache R/W</th><th class="num">Total</th>
|
||||
</tr></thead>
|
||||
<tbody data-tok-model></tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="font-size: 13px; margin: 16px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--hp-text-muted, #6b7280);">Top sessions</h3>
|
||||
<table class="stats-table">
|
||||
<thead><tr>
|
||||
<th>Started</th><th>Model</th>
|
||||
<th class="num">Input</th><th class="num">Output</th>
|
||||
<th class="num">Cache</th><th class="num">Total</th>
|
||||
</tr></thead>
|
||||
<tbody data-tok-top></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel" id="panel-queries" role="tabpanel" aria-labelledby="tab-queries">
|
||||
<div class="stats-loading" data-state="loading">Loading queries…</div>
|
||||
<div data-state="ready" hidden>
|
||||
<table class="stats-table">
|
||||
<thead><tr>
|
||||
<th>When</th><th>Action</th><th>Resource</th>
|
||||
<th>Result</th><th class="num">Duration (ms)</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows></tbody>
|
||||
</table>
|
||||
<div class="stats-paginate">
|
||||
<button type="button" data-action="next">Older ›</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel" id="panel-sync" role="tabpanel" aria-labelledby="tab-sync">
|
||||
<div class="stats-loading" data-state="loading">Loading sync activity…</div>
|
||||
<div data-state="ready" hidden>
|
||||
<div class="stats-overview" style="grid-template-columns: 1fr;">
|
||||
<div class="card">
|
||||
<div class="lbl">Your last <code>agnes pull</code></div>
|
||||
<div class="val" data-sync-last-pull>—</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="stats-table">
|
||||
<thead><tr>
|
||||
<th>When</th><th>Action</th><th>Source</th>
|
||||
<th>Result</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const root = document.querySelector('.stats-page');
|
||||
if (!root) return;
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '—';
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 10_000) return (n / 1_000).toFixed(0) + 'k';
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function fmtRelative(iso) {
|
||||
if (!iso) return 'never';
|
||||
const then = new Date(iso);
|
||||
const sec = Math.max(0, (Date.now() - then.getTime()) / 1000);
|
||||
if (sec < 60) return Math.floor(sec) + 's ago';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||||
return Math.floor(sec / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function fmtTimestamp(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
async function fetchJSON(url) {
|
||||
const r = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function setReady(panel) {
|
||||
panel.querySelector('[data-state="loading"]').hidden = true;
|
||||
panel.querySelector('[data-state="ready"]').hidden = false;
|
||||
}
|
||||
|
||||
function setError(panel, msg) {
|
||||
const loading = panel.querySelector('[data-state="loading"]');
|
||||
loading.className = 'stats-error';
|
||||
loading.textContent = msg;
|
||||
}
|
||||
|
||||
// ----- Sessions tab -----
|
||||
|
||||
const sessionsState = { offset: 0, limit: 25, total: 0 };
|
||||
|
||||
async function loadSessions() {
|
||||
const panel = document.getElementById('panel-sessions');
|
||||
try {
|
||||
const data = await fetchJSON(
|
||||
`/api/me/stats/sessions?offset=${sessionsState.offset}&limit=${sessionsState.limit}`
|
||||
);
|
||||
sessionsState.total = data.total;
|
||||
const tbody = panel.querySelector('[data-rows]');
|
||||
tbody.innerHTML = '';
|
||||
if (data.rows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="stats-empty">No sessions yet — start Claude Code in a workspace and they\'ll appear here.</td></tr>';
|
||||
}
|
||||
for (const r of data.rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTimestamp(r.started_at)}</td>
|
||||
<td>${r.primary_model || '<em style="color:#9ca3af;">unprocessed</em>'}</td>
|
||||
<td class="num">${fmtNum(r.user_messages)}</td>
|
||||
<td class="num">${fmtNum(r.tool_calls)}</td>
|
||||
<td class="num">${fmtNum(r.tokens_total)}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
setReady(panel);
|
||||
const prevBtn = panel.querySelector('[data-action="prev"]');
|
||||
const nextBtn = panel.querySelector('[data-action="next"]');
|
||||
prevBtn.disabled = sessionsState.offset === 0;
|
||||
nextBtn.disabled = sessionsState.offset + sessionsState.limit >= sessionsState.total;
|
||||
} catch (e) {
|
||||
setError(panel, 'Could not load sessions: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector('#panel-sessions [data-action="prev"]').addEventListener('click', () => {
|
||||
sessionsState.offset = Math.max(0, sessionsState.offset - sessionsState.limit);
|
||||
loadSessions();
|
||||
});
|
||||
document.querySelector('#panel-sessions [data-action="next"]').addEventListener('click', () => {
|
||||
sessionsState.offset += sessionsState.limit;
|
||||
loadSessions();
|
||||
});
|
||||
|
||||
// ----- Tokens tab -----
|
||||
|
||||
async function loadTokens() {
|
||||
const panel = document.getElementById('panel-tokens');
|
||||
try {
|
||||
const data = await fetchJSON('/api/me/stats/tokens?days=30');
|
||||
for (const key of ['input', 'output', 'cache_read', 'cache_creation']) {
|
||||
panel.querySelector(`[data-tok-total="${key}"]`).textContent =
|
||||
fmtNum(data.totals[key]);
|
||||
}
|
||||
|
||||
// Daily chart — simple bars over the 30-day window.
|
||||
const chart = panel.querySelector('[data-tok-chart]');
|
||||
chart.innerHTML = '';
|
||||
const days = data.daily;
|
||||
if (days.length > 0) {
|
||||
const max = Math.max(...days.map(d => d.total), 1);
|
||||
const w = 600 / Math.max(days.length, 1);
|
||||
days.forEach((d, i) => {
|
||||
const h = Math.max(1, (d.total / max) * 140);
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
rect.setAttribute('x', String(i * w + 1));
|
||||
rect.setAttribute('y', String(160 - h));
|
||||
rect.setAttribute('width', String(Math.max(1, w - 2)));
|
||||
rect.setAttribute('height', String(h));
|
||||
rect.setAttribute('title', `${d.day}: ${d.total}`);
|
||||
chart.appendChild(rect);
|
||||
});
|
||||
}
|
||||
|
||||
const modelTbody = panel.querySelector('[data-tok-model]');
|
||||
modelTbody.innerHTML = '';
|
||||
if (data.by_model.length === 0) {
|
||||
modelTbody.innerHTML = '<tr><td colspan="6" class="stats-empty">No token data yet.</td></tr>';
|
||||
}
|
||||
for (const m of data.by_model) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${m.model}</td>
|
||||
<td class="num">${fmtNum(m.sessions)}</td>
|
||||
<td class="num">${fmtNum(m.input)}</td>
|
||||
<td class="num">${fmtNum(m.output)}</td>
|
||||
<td class="num">${fmtNum(m.cache_read + m.cache_creation)}</td>
|
||||
<td class="num"><strong>${fmtNum(m.total)}</strong></td>
|
||||
`;
|
||||
modelTbody.appendChild(tr);
|
||||
}
|
||||
|
||||
const topTbody = panel.querySelector('[data-tok-top]');
|
||||
topTbody.innerHTML = '';
|
||||
if (data.top_sessions.length === 0) {
|
||||
topTbody.innerHTML = '<tr><td colspan="6" class="stats-empty">No sessions yet.</td></tr>';
|
||||
}
|
||||
for (const s of data.top_sessions) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTimestamp(s.started_at)}</td>
|
||||
<td>${s.primary_model || ''}</td>
|
||||
<td class="num">${fmtNum(s.input)}</td>
|
||||
<td class="num">${fmtNum(s.output)}</td>
|
||||
<td class="num">${fmtNum(s.cache_read + s.cache_creation)}</td>
|
||||
<td class="num"><strong>${fmtNum(s.total)}</strong></td>
|
||||
`;
|
||||
topTbody.appendChild(tr);
|
||||
}
|
||||
|
||||
setReady(panel);
|
||||
} catch (e) {
|
||||
setError(panel, 'Could not load tokens: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Queries tab -----
|
||||
|
||||
const queriesState = { cursor: null };
|
||||
|
||||
async function loadQueries(append) {
|
||||
const panel = document.getElementById('panel-queries');
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '50' });
|
||||
if (queriesState.cursor) {
|
||||
params.set('cursor_ts', queriesState.cursor.timestamp);
|
||||
params.set('cursor_id', queriesState.cursor.id);
|
||||
}
|
||||
const data = await fetchJSON(`/api/me/stats/queries?${params}`);
|
||||
const tbody = panel.querySelector('[data-rows]');
|
||||
if (!append) tbody.innerHTML = '';
|
||||
if (data.rows.length === 0 && !append) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="stats-empty">No queries logged for your account yet.</td></tr>';
|
||||
}
|
||||
for (const r of data.rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTimestamp(r.timestamp)}</td>
|
||||
<td><code>${r.action}</code></td>
|
||||
<td>${r.resource || ''}</td>
|
||||
<td>${r.result || ''}</td>
|
||||
<td class="num">${r.duration_ms != null ? r.duration_ms : ''}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
queriesState.cursor = data.next_cursor;
|
||||
panel.querySelector('[data-action="next"]').disabled = !queriesState.cursor;
|
||||
setReady(panel);
|
||||
} catch (e) {
|
||||
setError(panel, 'Could not load queries: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector('#panel-queries [data-action="next"]').addEventListener('click', () => {
|
||||
loadQueries(true);
|
||||
});
|
||||
|
||||
// ----- Sync tab -----
|
||||
|
||||
async function loadSync() {
|
||||
const panel = document.getElementById('panel-sync');
|
||||
try {
|
||||
const data = await fetchJSON('/api/me/stats/sync?limit=50');
|
||||
panel.querySelector('[data-sync-last-pull]').textContent =
|
||||
fmtRelative(data.last_pull_at);
|
||||
const tbody = panel.querySelector('[data-rows]');
|
||||
tbody.innerHTML = '';
|
||||
if (data.rows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="stats-empty">No sync activity yet.</td></tr>';
|
||||
}
|
||||
for (const r of data.rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTimestamp(r.timestamp)}</td>
|
||||
<td><code>${r.action}</code></td>
|
||||
<td>${r.client_kind || ''}</td>
|
||||
<td>${r.result || ''}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
setReady(panel);
|
||||
} catch (e) {
|
||||
setError(panel, 'Could not load sync activity: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Tab switching -----
|
||||
|
||||
const loaded = new Set();
|
||||
const loaders = {
|
||||
sessions: loadSessions,
|
||||
tokens: loadTokens,
|
||||
queries: loadQueries,
|
||||
sync: loadSync,
|
||||
};
|
||||
|
||||
function activate(name) {
|
||||
document.querySelectorAll('.stats-tab').forEach((b) => {
|
||||
const on = b.dataset.tab === name;
|
||||
b.classList.toggle('is-active', on);
|
||||
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('.stats-panel').forEach((p) => {
|
||||
p.classList.toggle('is-active', p.id === 'panel-' + name);
|
||||
});
|
||||
if (!loaded.has(name)) {
|
||||
loaded.add(name);
|
||||
loaders[name]();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.stats-tab').forEach((b) => {
|
||||
b.addEventListener('click', () => activate(b.dataset.tab));
|
||||
});
|
||||
|
||||
// Initial paint: sessions tab.
|
||||
activate('sessions');
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
269
tests/test_me_stats.py
Normal file
269
tests/test_me_stats.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""/api/me/stats/* — per-user dashboard endpoints.
|
||||
|
||||
Coverage:
|
||||
- All four endpoints scope rows to ``user["id"]`` / username so user A
|
||||
cannot read user B's data (gates are server-side; the page renders a
|
||||
shell with no caller-scope params).
|
||||
- Empty user returns zero-counts / empty arrays, not 500.
|
||||
- Tokens aggregates daily series, by-model, top-N, and lifetime totals
|
||||
from a seeded sample.
|
||||
- Sync activity surfaces both ``manifest.fetch`` and ``sync.trigger``,
|
||||
filtered to caller.
|
||||
- GET /api/sync/manifest writes the ``manifest.fetch`` audit row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
|
||||
from src.db import _SYSTEM_SCHEMA
|
||||
from src.repositories.audit import AuditRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stats_conn(tmp_path):
|
||||
db = tmp_path / "system.duckdb"
|
||||
conn = duckdb.connect(str(db))
|
||||
conn.execute(_SYSTEM_SCHEMA)
|
||||
return conn
|
||||
|
||||
|
||||
def _seed_user(conn, *, uid, email):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, email, active, onboarded) VALUES (?, ?, TRUE, TRUE)",
|
||||
[uid, email],
|
||||
)
|
||||
|
||||
|
||||
def _seed_session(conn, *, sf, username, started_sql, model="claude-opus-4-7",
|
||||
user_messages=0, tool_calls=0,
|
||||
input_tokens=0, output_tokens=0,
|
||||
cache_read=0, cache_creation=0):
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO usage_session_summary
|
||||
(session_file, session_id, username, started_at, ended_at,
|
||||
active_seconds, wall_seconds, user_messages, assistant_messages,
|
||||
tool_calls, tool_errors, skill_invocations, subagent_dispatches,
|
||||
mcp_calls, slash_commands, distinct_tools, distinct_skills,
|
||||
primary_model, input_tokens, output_tokens, cache_read_tokens,
|
||||
cache_creation_tokens, processor_version)
|
||||
VALUES (?, ?, ?, {started_sql}, current_timestamp,
|
||||
10, 30, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, ?, ?, ?, ?, ?, 2)
|
||||
""",
|
||||
[sf, sf, username, user_messages, user_messages,
|
||||
tool_calls, model, input_tokens, output_tokens,
|
||||
cache_read, cache_creation],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sessions_endpoint_scopes_to_caller(stats_conn, tmp_path, monkeypatch):
|
||||
"""User A's sessions endpoint must not return user B's rows."""
|
||||
# Point session-fs scan at an empty dir so unprocessed-jsonl path is no-op.
|
||||
monkeypatch.setenv("AGNES_SESSION_DATA_DIR", str(tmp_path / "noop"))
|
||||
|
||||
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
||||
_seed_user(stats_conn, uid="ub", email="bob@example.com")
|
||||
_seed_session(stats_conn, sf="a1.jsonl", username="alice",
|
||||
started_sql="current_timestamp - INTERVAL 1 HOUR",
|
||||
user_messages=4, input_tokens=100, output_tokens=50)
|
||||
_seed_session(stats_conn, sf="b1.jsonl", username="bob",
|
||||
started_sql="current_timestamp - INTERVAL 1 HOUR",
|
||||
user_messages=9, input_tokens=999, output_tokens=999)
|
||||
|
||||
from app.api.me_stats import list_self_sessions
|
||||
res_a = list_self_sessions(
|
||||
limit=50, offset=0,
|
||||
user={"id": "ua", "email": "alice@example.com"},
|
||||
conn=stats_conn,
|
||||
)
|
||||
assert res_a["total"] == 1
|
||||
assert res_a["rows"][0]["session_file"] == "a1.jsonl"
|
||||
assert res_a["rows"][0]["user_messages"] == 4
|
||||
assert res_a["rows"][0]["tokens_total"] == 150
|
||||
|
||||
|
||||
def test_sessions_endpoint_pagination(stats_conn, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AGNES_SESSION_DATA_DIR", str(tmp_path / "noop"))
|
||||
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
||||
for i in range(5):
|
||||
_seed_session(stats_conn, sf=f"s{i}.jsonl", username="alice",
|
||||
started_sql=f"current_timestamp - INTERVAL {i} HOUR",
|
||||
user_messages=i)
|
||||
|
||||
from app.api.me_stats import list_self_sessions
|
||||
page1 = list_self_sessions(
|
||||
limit=2, offset=0,
|
||||
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
||||
)
|
||||
page2 = list_self_sessions(
|
||||
limit=2, offset=2,
|
||||
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
||||
)
|
||||
assert page1["total"] == 5
|
||||
assert len(page1["rows"]) == 2
|
||||
assert len(page2["rows"]) == 2
|
||||
assert page1["rows"][0]["session_file"] != page2["rows"][0]["session_file"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tokens tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tokens_endpoint_empty_user(stats_conn):
|
||||
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
||||
from app.api.me_stats import get_tokens
|
||||
res = get_tokens(
|
||||
days=30,
|
||||
user={"id": "ua", "email": "alice@example.com"},
|
||||
conn=stats_conn,
|
||||
)
|
||||
assert res["totals"]["total"] == 0
|
||||
assert res["daily"] == []
|
||||
assert res["by_model"] == []
|
||||
assert res["top_sessions"] == []
|
||||
|
||||
|
||||
def test_tokens_endpoint_aggregates(stats_conn):
|
||||
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
||||
_seed_session(stats_conn, sf="x.jsonl", username="alice",
|
||||
started_sql="current_timestamp - INTERVAL 1 HOUR",
|
||||
model="claude-opus-4-7",
|
||||
input_tokens=100, output_tokens=50,
|
||||
cache_read=800, cache_creation=25)
|
||||
_seed_session(stats_conn, sf="y.jsonl", username="alice",
|
||||
started_sql="current_timestamp - INTERVAL 2 DAY",
|
||||
model="claude-sonnet-4-6",
|
||||
input_tokens=200, output_tokens=100,
|
||||
cache_read=400, cache_creation=10)
|
||||
# Far-past row excluded by `days=7` window for the daily series, but
|
||||
# still counted in lifetime totals + by_model + top.
|
||||
_seed_session(stats_conn, sf="z.jsonl", username="alice",
|
||||
started_sql="current_timestamp - INTERVAL 60 DAY",
|
||||
model="claude-opus-4-7",
|
||||
input_tokens=1, output_tokens=1)
|
||||
|
||||
from app.api.me_stats import get_tokens
|
||||
res = get_tokens(
|
||||
days=7,
|
||||
user={"id": "ua", "email": "alice@example.com"},
|
||||
conn=stats_conn,
|
||||
)
|
||||
# Lifetime totals include all three sessions
|
||||
assert res["totals"]["sessions"] == 3
|
||||
assert res["totals"]["input"] == 301
|
||||
assert res["totals"]["output"] == 151
|
||||
assert res["totals"]["cache_read"] == 1200
|
||||
assert res["totals"]["cache_creation"] == 35
|
||||
assert res["totals"]["total"] == 1687
|
||||
|
||||
# Daily series window excludes the 60-day-old row
|
||||
daily_sessions = sum(d["sessions"] for d in res["daily"])
|
||||
assert daily_sessions == 2
|
||||
|
||||
# By-model: opus has 2 sessions (x + z), sonnet has 1 (y)
|
||||
models = {m["model"]: m for m in res["by_model"]}
|
||||
assert models["claude-opus-4-7"]["sessions"] == 2
|
||||
assert models["claude-sonnet-4-6"]["sessions"] == 1
|
||||
|
||||
# Top sessions: largest first
|
||||
assert res["top_sessions"][0]["session_file"] in ("x.jsonl", "y.jsonl")
|
||||
assert res["top_sessions"][0]["total"] >= res["top_sessions"][1]["total"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data access tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_queries_endpoint_filters_to_query_actions(stats_conn):
|
||||
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
||||
repo = AuditRepository(stats_conn)
|
||||
repo.log(user_id="ua", action="query.local", resource="orders",
|
||||
result="ok", duration_ms=42)
|
||||
repo.log(user_id="ua", action="query.remote", resource="web_sessions",
|
||||
result="ok", duration_ms=1500)
|
||||
# Non-query action — must not appear
|
||||
repo.log(user_id="ua", action="manifest.fetch", result="ok")
|
||||
# Same query action but different user — must not appear
|
||||
repo.log(user_id="ub", action="query.local", result="ok")
|
||||
|
||||
from app.api.me_stats import list_self_queries
|
||||
res = list_self_queries(
|
||||
limit=50, cursor_ts=None, cursor_id=None,
|
||||
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
||||
)
|
||||
assert len(res["rows"]) == 2
|
||||
actions = {r["action"] for r in res["rows"]}
|
||||
assert actions == {"query.local", "query.remote"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync activity tab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_endpoint_returns_manifest_fetch_rows(stats_conn):
|
||||
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
||||
repo = AuditRepository(stats_conn)
|
||||
repo.log(user_id="ua", action="manifest.fetch", resource="manifest",
|
||||
result="ok", client_kind="api")
|
||||
repo.log(user_id="ua", action="sync.trigger", result="ok")
|
||||
# Other user — must not leak
|
||||
repo.log(user_id="ub", action="manifest.fetch", result="ok")
|
||||
# Unrelated action — must not surface
|
||||
repo.log(user_id="ua", action="query.local", result="ok")
|
||||
# Stamp last_pull_at so the header card has a value
|
||||
stats_conn.execute(
|
||||
"UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
|
||||
["ua"],
|
||||
)
|
||||
|
||||
from app.api.me_stats import list_self_sync_activity
|
||||
res = list_self_sync_activity(
|
||||
limit=50, cursor_ts=None, cursor_id=None,
|
||||
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
||||
)
|
||||
actions = {r["action"] for r in res["rows"]}
|
||||
assert actions == {"manifest.fetch", "sync.trigger"}
|
||||
assert res["last_pull_at"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest endpoint writes the audit_log row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_manifest_writes_audit_row(stats_conn, monkeypatch, tmp_path):
|
||||
"""GET /api/sync/manifest must emit a manifest.fetch audit_log row
|
||||
so the Sync activity tab can list per-pull history."""
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
||||
|
||||
from app.api.sync import sync_manifest
|
||||
asyncio.run(
|
||||
sync_manifest(
|
||||
user={"id": "ua", "email": "alice@example.com"},
|
||||
conn=stats_conn,
|
||||
)
|
||||
)
|
||||
rows = stats_conn.execute(
|
||||
"SELECT action, resource, result, client_kind FROM audit_log "
|
||||
"WHERE user_id = ? ORDER BY timestamp DESC",
|
||||
["ua"],
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
action, resource, result, client_kind = rows[0]
|
||||
assert action == "manifest.fetch"
|
||||
assert resource == "manifest"
|
||||
assert result == "ok"
|
||||
assert client_kind == "api"
|
||||
Loading…
Reference in a new issue