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
|
### 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
|
- **Homepage status frame.** The `/home` page now opens with a 5-card
|
||||||
status row above the install-hero / offboard-strip: **Last sync**
|
status row above the install-hero / offboard-strip: **Last sync**
|
||||||
(your last `agnes pull`), **Sessions**, **Prompts**, **Tokens used**,
|
(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 = ?",
|
"UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
|
||||||
[user["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:
|
except Exception:
|
||||||
# Never block a pull because the stamp UPDATE hit a transient
|
# Never block a pull because the stamp UPDATE / audit row hit a
|
||||||
# issue (locked WAL, partial migration window). The manifest
|
# transient issue (locked WAL, partial migration window). The
|
||||||
# itself is the load-bearing payload.
|
# manifest itself is the load-bearing payload.
|
||||||
pass
|
pass
|
||||||
return _build_manifest_for_user(conn, user)
|
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.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_debug import router as me_debug_router
|
||||||
from app.api.me import router as me_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 import router as admin_router
|
||||||
from app.api.admin_bigquery_test import router as admin_bigquery_test_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
|
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_access_router)
|
||||||
app.include_router(me_debug_router)
|
app.include_router(me_debug_router)
|
||||||
app.include_router(me_router)
|
app.include_router(me_router)
|
||||||
|
app.include_router(me_stats_router)
|
||||||
app.include_router(jira_webhooks_router)
|
app.include_router(jira_webhooks_router)
|
||||||
app.include_router(metrics_router)
|
app.include_router(metrics_router)
|
||||||
app.include_router(metadata_router)
|
app.include_router(metadata_router)
|
||||||
|
|
|
||||||
|
|
@ -726,6 +726,26 @@ async def home_page(
|
||||||
return templates.TemplateResponse(request, "home_not_onboarded.html", ctx)
|
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)
|
@router.get("/news", response_class=HTMLResponse)
|
||||||
async def news_page(
|
async def news_page(
|
||||||
request: Request,
|
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 == _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 == '/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('/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
|
{# Memory + Admin menu: both admin-only. Backend gates the routes
|
||||||
themselves via require_admin (see app/web/router.py for
|
themselves via require_admin (see app/web/router.py for
|
||||||
/corporate-memory + /corporate-memory/admin + /admin/*), so
|
/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