mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 13:42:16 +00:00
feat: cache usage stats and add reviewer metrics (#1432)
* feat: cache usage dashboard stats Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * chore: adjust usage nav placement Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: paginate reviewer usage stats Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
f3512841fc
commit
e128f2d6dd
6 changed files with 728 additions and 38 deletions
|
|
@ -5,22 +5,28 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from langgraph_sdk import get_client
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
|
from ..reviewer_findings import REVIEWER_THREAD_KIND
|
||||||
from ..utils.github_app import get_github_app_installation_token
|
from ..utils.github_app import get_github_app_installation_token
|
||||||
|
|
||||||
USAGE_THREAD_NAMESPACE: list[str] = ["agent_usage", "threads"]
|
USAGE_THREAD_NAMESPACE: list[str] = ["agent_usage", "threads"]
|
||||||
USAGE_PR_NAMESPACE: list[str] = ["agent_usage", "prs"]
|
USAGE_PR_NAMESPACE: list[str] = ["agent_usage", "prs"]
|
||||||
|
USAGE_LEADERBOARD_CACHE_NAMESPACE: list[str] = ["agent_usage", "leaderboard_cache"]
|
||||||
|
REVIEWER_STATS_CACHE_NAMESPACE: list[str] = ["agent_usage", "reviewer_stats_cache"]
|
||||||
|
|
||||||
Period = Literal["7d", "30d", "all"]
|
Period = Literal["7d", "30d", "all"]
|
||||||
_AGENT_SOURCES = frozenset({"dashboard", "github", "slack", "linear"})
|
_AGENT_SOURCES = frozenset({"dashboard", "github", "slack", "linear"})
|
||||||
_PR_REFRESH_INTERVAL_MS = 10 * 60 * 1000
|
_PR_REFRESH_INTERVAL_MS = 10 * 60 * 1000
|
||||||
_MAX_PR_REFRESH_PER_REQUEST = 25
|
_MAX_PR_REFRESH_PER_REQUEST = 25
|
||||||
_PR_REFRESH_CONCURRENCY = 5
|
_PR_REFRESH_CONCURRENCY = 5
|
||||||
|
_CACHE_TTL_MS = 10 * 60 * 1000
|
||||||
|
_CACHE_SEARCH_LIMIT = 1000
|
||||||
_GITHUB_API = "https://api.github.com"
|
_GITHUB_API = "https://api.github.com"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -64,7 +70,9 @@ async def _get_value(namespace: list[str], key: str) -> dict[str, Any] | None:
|
||||||
return _record_from_item(item)
|
return _record_from_item(item)
|
||||||
|
|
||||||
|
|
||||||
async def _search_values(namespace: list[str], *, limit: int = 1000) -> list[dict[str, Any]]:
|
async def _search_values(
|
||||||
|
namespace: list[str], *, limit: int = _CACHE_SEARCH_LIMIT
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
result = await _client().store.search_items(namespace, limit=limit)
|
result = await _client().store.search_items(namespace, limit=limit)
|
||||||
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
|
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
|
||||||
values: list[dict[str, Any]] = []
|
values: list[dict[str, Any]] = []
|
||||||
|
|
@ -95,6 +103,26 @@ def _coerce_int(value: object) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_ms(value: object) -> int:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int | float):
|
||||||
|
raw = int(value)
|
||||||
|
return raw if raw > 10_000_000_000 else raw * 1000
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
raw = value.strip()
|
||||||
|
if raw.isdigit():
|
||||||
|
return _timestamp_ms(int(raw))
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
|
return int(parsed.timestamp() * 1000)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _in_period(record: dict[str, Any], cutoff_ms: int | None) -> bool:
|
def _in_period(record: dict[str, Any], cutoff_ms: int | None) -> bool:
|
||||||
if cutoff_ms is None:
|
if cutoff_ms is None:
|
||||||
return True
|
return True
|
||||||
|
|
@ -309,17 +337,27 @@ async def _refresh_pr_records(records: list[dict[str, Any]]) -> list[dict[str, A
|
||||||
return refreshed
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
async def list_agent_usage_leaderboard(
|
def _serialize_usage_user(index: int, user: dict[str, Any]) -> dict[str, Any]:
|
||||||
*,
|
model_counts: Counter[str] = user.get("model_counts", Counter())
|
||||||
period: str | None,
|
favorite_model = model_counts.most_common(1)[0][0] if model_counts else "default"
|
||||||
limit: int,
|
return {
|
||||||
current_login: str | None,
|
"rank": index,
|
||||||
current_email: str | None,
|
"key": user["key"],
|
||||||
) -> dict[str, Any]:
|
"name": user.get("name") or "Unknown user",
|
||||||
"""Build the Open SWE Agent usage leaderboard from recorded telemetry."""
|
"github_login": user.get("github_login") or None,
|
||||||
normalized_period = _normalize_period(period)
|
"email": user.get("email") or None,
|
||||||
cutoff_ms = _period_cutoff_ms(normalized_period)
|
"favorite_model": favorite_model,
|
||||||
safe_limit = min(max(limit, 1), 100)
|
"agent_runs": user["agent_runs"],
|
||||||
|
"prs_opened": user["prs_opened"],
|
||||||
|
"merged_prs": user["merged_prs"],
|
||||||
|
"agent_loc": user["agent_loc"],
|
||||||
|
"additions": user["additions"],
|
||||||
|
"deletions": user["deletions"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_usage_leaderboard_snapshot(period: Period) -> dict[str, Any]:
|
||||||
|
cutoff_ms = _period_cutoff_ms(period)
|
||||||
users: dict[str, dict[str, Any]] = {}
|
users: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
for thread in await _search_values(USAGE_THREAD_NAMESPACE):
|
for thread in await _search_values(USAGE_THREAD_NAMESPACE):
|
||||||
|
|
@ -366,6 +404,236 @@ async def list_agent_usage_leaderboard(
|
||||||
item.get("name") or "",
|
item.get("name") or "",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"users": [_serialize_usage_user(index, user) for index, user in enumerate(sorted_users, 1)],
|
||||||
|
"total_members": len(sorted_users),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_usage_leaderboard_cache(period: str | None = "30d") -> dict[str, Any]:
|
||||||
|
normalized_period = _normalize_period(period)
|
||||||
|
snapshot = await _build_usage_leaderboard_snapshot(normalized_period)
|
||||||
|
await _client().store.put_item(
|
||||||
|
USAGE_LEADERBOARD_CACHE_NAMESPACE,
|
||||||
|
normalized_period,
|
||||||
|
{"generated_at_ms": _now_ms(), "snapshot": snapshot},
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _is_finding_surfaced(finding: dict[str, Any]) -> bool:
|
||||||
|
surface = finding.get("surface") if isinstance(finding.get("surface"), dict) else {}
|
||||||
|
state = surface.get("state")
|
||||||
|
if state in {"surfaced", "resolve_pending", "resolved"}:
|
||||||
|
return True
|
||||||
|
if isinstance(finding.get("github_review_id"), int):
|
||||||
|
return True
|
||||||
|
if isinstance(finding.get("github_review_comment_id"), int):
|
||||||
|
return True
|
||||||
|
comment_ids = finding.get("github_review_comment_ids")
|
||||||
|
thread_ids = finding.get("github_review_thread_ids")
|
||||||
|
return bool(comment_ids or thread_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_finding_resolved_by_us(finding: dict[str, Any]) -> bool:
|
||||||
|
if finding.get("status") != "resolved" or not _is_finding_surfaced(finding):
|
||||||
|
return False
|
||||||
|
surface = finding.get("surface") if isinstance(finding.get("surface"), dict) else {}
|
||||||
|
return bool(
|
||||||
|
surface.get("state") == "resolved"
|
||||||
|
or finding.get("github_thread_resolved")
|
||||||
|
or finding.get("github_resolved_thread_ids")
|
||||||
|
or finding.get("resolution_note")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_created_at_ms(thread: dict[str, Any], metadata: dict[str, Any]) -> int:
|
||||||
|
timestamp = _thread_explicit_created_at_ms(thread, metadata)
|
||||||
|
if timestamp:
|
||||||
|
return timestamp
|
||||||
|
for source in (
|
||||||
|
metadata.get("updated_at_ms"),
|
||||||
|
metadata.get("updated_at"),
|
||||||
|
thread.get("updated_at"),
|
||||||
|
thread.get("updatedAt"),
|
||||||
|
):
|
||||||
|
timestamp = _timestamp_ms(source)
|
||||||
|
if timestamp:
|
||||||
|
return timestamp
|
||||||
|
return _now_ms()
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_explicit_created_at_ms(thread: dict[str, Any], metadata: dict[str, Any]) -> int:
|
||||||
|
for source in (
|
||||||
|
metadata.get("created_at_ms"),
|
||||||
|
metadata.get("created_at"),
|
||||||
|
thread.get("created_at"),
|
||||||
|
thread.get("createdAt"),
|
||||||
|
):
|
||||||
|
timestamp = _timestamp_ms(source)
|
||||||
|
if timestamp:
|
||||||
|
return timestamp
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _counter_rows(counter: Counter[str], *, limit: int = 5) -> list[dict[str, Any]]:
|
||||||
|
return [{"name": name, "count": count} for name, count in counter.most_common(limit)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _iter_reviewer_thread_pages(cutoff_ms: int | None):
|
||||||
|
client = _client()
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
page = await client.threads.search(
|
||||||
|
metadata={"kind": REVIEWER_THREAD_KIND},
|
||||||
|
limit=_CACHE_SEARCH_LIMIT,
|
||||||
|
offset=offset,
|
||||||
|
sort_by="created_at",
|
||||||
|
sort_order="desc",
|
||||||
|
)
|
||||||
|
if not page:
|
||||||
|
return
|
||||||
|
yield page
|
||||||
|
if len(page) < _CACHE_SEARCH_LIMIT:
|
||||||
|
return
|
||||||
|
if cutoff_ms is not None:
|
||||||
|
last_thread = next(
|
||||||
|
(thread for thread in reversed(page) if isinstance(thread, dict)), None
|
||||||
|
)
|
||||||
|
metadata = last_thread.get("metadata") if isinstance(last_thread, dict) else None
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
last_created_at_ms = _thread_explicit_created_at_ms(last_thread, metadata)
|
||||||
|
if last_created_at_ms and last_created_at_ms < cutoff_ms:
|
||||||
|
return
|
||||||
|
offset += len(page)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_reviewer_stats_snapshot(period: Period) -> dict[str, Any]:
|
||||||
|
cutoff_ms = _period_cutoff_ms(period)
|
||||||
|
|
||||||
|
reviewed_prs = 0
|
||||||
|
prs_with_findings = 0
|
||||||
|
findings_recorded = 0
|
||||||
|
surfaced_findings = 0
|
||||||
|
addressed_findings = 0
|
||||||
|
dismissed_findings = 0
|
||||||
|
human_replies = 0
|
||||||
|
resolved_after_update = 0
|
||||||
|
severity_counts: Counter[str] = Counter()
|
||||||
|
category_counts: Counter[str] = Counter()
|
||||||
|
|
||||||
|
async for page in _iter_reviewer_thread_pages(cutoff_ms):
|
||||||
|
for thread in page:
|
||||||
|
if not isinstance(thread, dict):
|
||||||
|
continue
|
||||||
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
|
if cutoff_ms is not None and _thread_created_at_ms(thread, metadata) < cutoff_ms:
|
||||||
|
continue
|
||||||
|
|
||||||
|
reviewed_prs += 1
|
||||||
|
findings = metadata.get("findings")
|
||||||
|
if not isinstance(findings, list):
|
||||||
|
continue
|
||||||
|
valid_findings = [finding for finding in findings if isinstance(finding, dict)]
|
||||||
|
if valid_findings:
|
||||||
|
prs_with_findings += 1
|
||||||
|
for finding in valid_findings:
|
||||||
|
findings_recorded += 1
|
||||||
|
severity = finding.get("severity")
|
||||||
|
if isinstance(severity, str) and severity:
|
||||||
|
severity_counts[severity] += 1
|
||||||
|
category = finding.get("category")
|
||||||
|
if isinstance(category, str) and category:
|
||||||
|
category_counts[category] += 1
|
||||||
|
interactions = finding.get("interactions")
|
||||||
|
if isinstance(interactions, list):
|
||||||
|
human_replies += sum(
|
||||||
|
1
|
||||||
|
for interaction in interactions
|
||||||
|
if isinstance(interaction, dict)
|
||||||
|
and interaction.get("kind") == "human_reply"
|
||||||
|
)
|
||||||
|
elif finding.get("last_human_reply_at"):
|
||||||
|
human_replies += 1
|
||||||
|
|
||||||
|
surfaced = _is_finding_surfaced(finding)
|
||||||
|
if surfaced:
|
||||||
|
surfaced_findings += 1
|
||||||
|
if _is_finding_resolved_by_us(finding):
|
||||||
|
addressed_findings += 1
|
||||||
|
first_seen_sha = finding.get("first_seen_sha")
|
||||||
|
head_sha = metadata.get("head_sha") or finding.get("last_confirmed_sha")
|
||||||
|
if (
|
||||||
|
isinstance(first_seen_sha, str)
|
||||||
|
and isinstance(head_sha, str)
|
||||||
|
and first_seen_sha != head_sha
|
||||||
|
):
|
||||||
|
resolved_after_update += 1
|
||||||
|
if finding.get("status") == "dismissed":
|
||||||
|
dismissed_findings += 1
|
||||||
|
|
||||||
|
unresolved_surfaced_findings = max(
|
||||||
|
0, surfaced_findings - addressed_findings - dismissed_findings
|
||||||
|
)
|
||||||
|
resolution_rate = addressed_findings / surfaced_findings if surfaced_findings else 0.0
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"reviewed_prs": reviewed_prs,
|
||||||
|
"prs_with_findings": prs_with_findings,
|
||||||
|
"findings_recorded": findings_recorded,
|
||||||
|
"surfaced_findings": surfaced_findings,
|
||||||
|
"addressed_findings": addressed_findings,
|
||||||
|
"resolved_after_update": resolved_after_update,
|
||||||
|
"dismissed_findings": dismissed_findings,
|
||||||
|
"unresolved_surfaced_findings": unresolved_surfaced_findings,
|
||||||
|
"resolution_rate": resolution_rate,
|
||||||
|
"human_replies": human_replies,
|
||||||
|
"severity_counts": dict(severity_counts),
|
||||||
|
"top_categories": _counter_rows(category_counts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_reviewer_stats_cache(period: str | None = "30d") -> dict[str, Any]:
|
||||||
|
normalized_period = _normalize_period(period)
|
||||||
|
snapshot = await _build_reviewer_stats_snapshot(normalized_period)
|
||||||
|
await _client().store.put_item(
|
||||||
|
REVIEWER_STATS_CACHE_NAMESPACE,
|
||||||
|
normalized_period,
|
||||||
|
{"generated_at_ms": _now_ms(), "snapshot": snapshot},
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def _cached_snapshot(
|
||||||
|
namespace: list[str],
|
||||||
|
period: Period,
|
||||||
|
refresh: Callable[[str | None], Awaitable[dict[str, Any]]],
|
||||||
|
*,
|
||||||
|
schedule_refresh: Callable[[Period], None] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], int | None]:
|
||||||
|
cached = await _get_value(namespace, period)
|
||||||
|
if cached:
|
||||||
|
snapshot = cached.get("snapshot")
|
||||||
|
generated_at_ms = _coerce_int(cached.get("generated_at_ms"))
|
||||||
|
if isinstance(snapshot, dict):
|
||||||
|
if not generated_at_ms or _now_ms() - generated_at_ms <= _CACHE_TTL_MS:
|
||||||
|
return snapshot, generated_at_ms or None
|
||||||
|
if schedule_refresh is not None:
|
||||||
|
schedule_refresh(period)
|
||||||
|
return snapshot, generated_at_ms
|
||||||
|
return await refresh(period), _now_ms()
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_payload_from_snapshot(
|
||||||
|
snapshot: dict[str, Any],
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
current_login: str | None,
|
||||||
|
current_email: str | None,
|
||||||
|
generated_at_ms: int | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
safe_limit = min(max(limit, 1), 100)
|
||||||
current_keys = {
|
current_keys = {
|
||||||
_user_key(current_login, current_email),
|
_user_key(current_login, current_email),
|
||||||
_user_key(current_login, None),
|
_user_key(current_login, None),
|
||||||
|
|
@ -373,25 +641,27 @@ async def list_agent_usage_leaderboard(
|
||||||
}
|
}
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
current_user_row: dict[str, Any] | None = None
|
current_user_row: dict[str, Any] | None = None
|
||||||
for index, user in enumerate(sorted_users, start=1):
|
for user in snapshot.get("users", []):
|
||||||
model_counts: Counter[str] = user.pop("model_counts")
|
if not isinstance(user, dict):
|
||||||
favorite_model = model_counts.most_common(1)[0][0] if model_counts else "default"
|
continue
|
||||||
github_login = user.get("github_login") or None
|
github_login = (
|
||||||
is_current_user = user["key"] in current_keys
|
user.get("github_login") if isinstance(user.get("github_login"), str) else None
|
||||||
|
)
|
||||||
|
is_current_user = user.get("key") in current_keys
|
||||||
row = {
|
row = {
|
||||||
"rank": index,
|
"rank": _coerce_int(user.get("rank")),
|
||||||
"user": {
|
"user": {
|
||||||
"name": user.get("name") if is_current_user or github_login else "Open SWE user",
|
"name": user.get("name") if is_current_user or github_login else "Open SWE user",
|
||||||
"github_login": github_login,
|
"github_login": github_login,
|
||||||
"email": (user.get("email") or None) if is_current_user else None,
|
"email": (user.get("email") or None) if is_current_user else None,
|
||||||
},
|
},
|
||||||
"favorite_model": favorite_model,
|
"favorite_model": user.get("favorite_model") or "default",
|
||||||
"agent_runs": user["agent_runs"],
|
"agent_runs": _coerce_int(user.get("agent_runs")),
|
||||||
"prs_opened": user["prs_opened"],
|
"prs_opened": _coerce_int(user.get("prs_opened")),
|
||||||
"merged_prs": user["merged_prs"],
|
"merged_prs": _coerce_int(user.get("merged_prs")),
|
||||||
"agent_loc": user["agent_loc"],
|
"agent_loc": _coerce_int(user.get("agent_loc")),
|
||||||
"additions": user["additions"],
|
"additions": _coerce_int(user.get("additions")),
|
||||||
"deletions": user["deletions"],
|
"deletions": _coerce_int(user.get("deletions")),
|
||||||
}
|
}
|
||||||
if is_current_user:
|
if is_current_user:
|
||||||
current_user_row = row
|
current_user_row = row
|
||||||
|
|
@ -402,8 +672,43 @@ async def list_agent_usage_leaderboard(
|
||||||
rows.append(current_user_row)
|
rows.append(current_user_row)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"period": normalized_period,
|
"period": snapshot.get("period") or "30d",
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"total_members": len(sorted_users),
|
"total_members": _coerce_int(snapshot.get("total_members")),
|
||||||
"current_user_rank": current_user_row["rank"] if current_user_row else None,
|
"current_user_rank": current_user_row["rank"] if current_user_row else None,
|
||||||
|
"generated_at_ms": generated_at_ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_agent_usage_leaderboard(
|
||||||
|
*,
|
||||||
|
period: str | None,
|
||||||
|
limit: int,
|
||||||
|
current_login: str | None,
|
||||||
|
current_email: str | None,
|
||||||
|
schedule_usage_refresh: Callable[[Period], None] | None = None,
|
||||||
|
schedule_reviewer_refresh: Callable[[Period], None] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return cached Open SWE Agent and reviewer usage stats."""
|
||||||
|
normalized_period = _normalize_period(period)
|
||||||
|
usage_snapshot, generated_at_ms = await _cached_snapshot(
|
||||||
|
USAGE_LEADERBOARD_CACHE_NAMESPACE,
|
||||||
|
normalized_period,
|
||||||
|
refresh_usage_leaderboard_cache,
|
||||||
|
schedule_refresh=schedule_usage_refresh,
|
||||||
|
)
|
||||||
|
reviewer_stats, reviewer_generated_at_ms = await _cached_snapshot(
|
||||||
|
REVIEWER_STATS_CACHE_NAMESPACE,
|
||||||
|
normalized_period,
|
||||||
|
refresh_reviewer_stats_cache,
|
||||||
|
schedule_refresh=schedule_reviewer_refresh,
|
||||||
|
)
|
||||||
|
payload = _usage_payload_from_snapshot(
|
||||||
|
usage_snapshot,
|
||||||
|
limit=limit,
|
||||||
|
current_login=current_login,
|
||||||
|
current_email=current_email,
|
||||||
|
generated_at_ms=generated_at_ms,
|
||||||
|
)
|
||||||
|
payload["reviewer_stats"] = {**reviewer_stats, "generated_at_ms": reviewer_generated_at_ms}
|
||||||
|
return payload
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@ import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .admin import is_admin
|
from .admin import is_admin
|
||||||
from .agent_usage import list_agent_usage_leaderboard
|
from .agent_usage import (
|
||||||
|
list_agent_usage_leaderboard,
|
||||||
|
refresh_reviewer_stats_cache,
|
||||||
|
refresh_usage_leaderboard_cache,
|
||||||
|
)
|
||||||
from .analyzer_cron import remove_continual_cron
|
from .analyzer_cron import remove_continual_cron
|
||||||
from .enabled_repos import (
|
from .enabled_repos import (
|
||||||
list_enabled_review_repos,
|
list_enabled_review_repos,
|
||||||
|
|
@ -706,6 +710,7 @@ async def api_delete_review_style(
|
||||||
|
|
||||||
@router.get("/agent-usage-leaderboard")
|
@router.get("/agent-usage-leaderboard")
|
||||||
async def api_agent_usage_leaderboard(
|
async def api_agent_usage_leaderboard(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
period: str | None = "30d",
|
period: str | None = "30d",
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
|
@ -715,6 +720,12 @@ async def api_agent_usage_leaderboard(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
current_login=session["sub"],
|
current_login=session["sub"],
|
||||||
current_email=session.get("email"),
|
current_email=session.get("email"),
|
||||||
|
schedule_usage_refresh=lambda cache_period: background_tasks.add_task(
|
||||||
|
refresh_usage_leaderboard_cache, cache_period
|
||||||
|
),
|
||||||
|
schedule_reviewer_refresh=lambda cache_period: background_tasks.add_task(
|
||||||
|
refresh_reviewer_stats_cache, cache_period
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
218
tests/test_agent_usage.py
Normal file
218
tests/test_agent_usage.py
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.dashboard import agent_usage
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self, values: dict[tuple[tuple[str, ...], str], dict] | None = None):
|
||||||
|
self.values = values or {}
|
||||||
|
self.puts: list[tuple[list[str], str, dict]] = []
|
||||||
|
|
||||||
|
async def get_item(self, namespace: list[str], key: str) -> dict | None:
|
||||||
|
value = self.values.get((tuple(namespace), key))
|
||||||
|
return {"value": value} if value is not None else None
|
||||||
|
|
||||||
|
async def put_item(self, namespace: list[str], key: str, value: dict) -> None:
|
||||||
|
self.puts.append((namespace, key, value))
|
||||||
|
self.values[(tuple(namespace), key)] = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeThreads:
|
||||||
|
def __init__(self, threads: list[dict]):
|
||||||
|
self.threads = threads
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
async def search(self, **kwargs) -> list[dict]:
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
offset = kwargs.get("offset") or 0
|
||||||
|
limit = kwargs.get("limit") or len(self.threads)
|
||||||
|
return self.threads[offset : offset + limit]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *, store: FakeStore | None = None, threads: FakeThreads | None = None):
|
||||||
|
self.store = store or FakeStore()
|
||||||
|
self.threads = threads or FakeThreads([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cached_usage_payload_returns_stale_snapshot_and_schedules_refresh(monkeypatch):
|
||||||
|
usage_snapshot = {
|
||||||
|
"period": "30d",
|
||||||
|
"total_members": 2,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"key": "github:octo",
|
||||||
|
"name": "octo",
|
||||||
|
"github_login": "octo",
|
||||||
|
"email": "octo@example.com",
|
||||||
|
"favorite_model": "claude",
|
||||||
|
"agent_runs": 3,
|
||||||
|
"prs_opened": 2,
|
||||||
|
"merged_prs": 1,
|
||||||
|
"agent_loc": 10,
|
||||||
|
"additions": 8,
|
||||||
|
"deletions": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 2,
|
||||||
|
"key": "email:private@example.com",
|
||||||
|
"name": "private",
|
||||||
|
"github_login": None,
|
||||||
|
"email": "private@example.com",
|
||||||
|
"favorite_model": "default",
|
||||||
|
"agent_runs": 1,
|
||||||
|
"prs_opened": 0,
|
||||||
|
"merged_prs": 0,
|
||||||
|
"agent_loc": 0,
|
||||||
|
"additions": 0,
|
||||||
|
"deletions": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
reviewer_snapshot = {
|
||||||
|
"period": "30d",
|
||||||
|
"reviewed_prs": 1,
|
||||||
|
"prs_with_findings": 1,
|
||||||
|
"findings_recorded": 1,
|
||||||
|
"surfaced_findings": 1,
|
||||||
|
"addressed_findings": 0,
|
||||||
|
"resolved_after_update": 0,
|
||||||
|
"dismissed_findings": 0,
|
||||||
|
"unresolved_surfaced_findings": 1,
|
||||||
|
"resolution_rate": 0.0,
|
||||||
|
"human_replies": 0,
|
||||||
|
"severity_counts": {"medium": 1},
|
||||||
|
"top_categories": [{"name": "correctness", "count": 1}],
|
||||||
|
}
|
||||||
|
store = FakeStore(
|
||||||
|
{
|
||||||
|
(tuple(agent_usage.USAGE_LEADERBOARD_CACHE_NAMESPACE), "30d"): {
|
||||||
|
"generated_at_ms": 1,
|
||||||
|
"snapshot": usage_snapshot,
|
||||||
|
},
|
||||||
|
(tuple(agent_usage.REVIEWER_STATS_CACHE_NAMESPACE), "30d"): {
|
||||||
|
"generated_at_ms": 1,
|
||||||
|
"snapshot": reviewer_snapshot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(agent_usage, "_client", lambda: FakeClient(store=store))
|
||||||
|
monkeypatch.setattr(agent_usage, "_now_ms", lambda: agent_usage._CACHE_TTL_MS + 2)
|
||||||
|
|
||||||
|
usage_refreshes: list[str] = []
|
||||||
|
reviewer_refreshes: list[str] = []
|
||||||
|
payload = await agent_usage.list_agent_usage_leaderboard(
|
||||||
|
period="30d",
|
||||||
|
limit=1,
|
||||||
|
current_login="octo",
|
||||||
|
current_email="octo@example.com",
|
||||||
|
schedule_usage_refresh=usage_refreshes.append,
|
||||||
|
schedule_reviewer_refresh=reviewer_refreshes.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert usage_refreshes == ["30d"]
|
||||||
|
assert reviewer_refreshes == ["30d"]
|
||||||
|
assert payload["rows"][0]["user"]["email"] == "octo@example.com"
|
||||||
|
assert payload["total_members"] == 2
|
||||||
|
assert payload["reviewer_stats"]["surfaced_findings"] == 1
|
||||||
|
assert store.puts == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reviewer_stats_snapshot_counts_surfaced_and_resolved_findings(monkeypatch):
|
||||||
|
threads = [
|
||||||
|
{
|
||||||
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
|
"metadata": {
|
||||||
|
"kind": "reviewer",
|
||||||
|
"head_sha": "fixed-sha",
|
||||||
|
"pr": {"owner": "langchain-ai", "name": "open-swe", "number": 1},
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"id": "f_1",
|
||||||
|
"status": "resolved",
|
||||||
|
"severity": "high",
|
||||||
|
"category": "correctness",
|
||||||
|
"first_seen_sha": "buggy-sha",
|
||||||
|
"github_thread_resolved": True,
|
||||||
|
"github_review_comment_id": 10,
|
||||||
|
"resolution_note": "Fixed in a follow-up commit.",
|
||||||
|
"interactions": [{"kind": "human_reply"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f_2",
|
||||||
|
"status": "open",
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "performance",
|
||||||
|
"github_review_comment_id": 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f_3",
|
||||||
|
"status": "dismissed",
|
||||||
|
"severity": "low",
|
||||||
|
"category": "style",
|
||||||
|
"github_review_id": 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(agent_usage, "_client", lambda: FakeClient(threads=FakeThreads(threads)))
|
||||||
|
|
||||||
|
snapshot = await agent_usage._build_reviewer_stats_snapshot("all")
|
||||||
|
|
||||||
|
assert snapshot["reviewed_prs"] == 1
|
||||||
|
assert snapshot["prs_with_findings"] == 1
|
||||||
|
assert snapshot["findings_recorded"] == 3
|
||||||
|
assert snapshot["surfaced_findings"] == 3
|
||||||
|
assert snapshot["addressed_findings"] == 1
|
||||||
|
assert snapshot["resolved_after_update"] == 1
|
||||||
|
assert snapshot["dismissed_findings"] == 1
|
||||||
|
assert snapshot["unresolved_surfaced_findings"] == 1
|
||||||
|
assert snapshot["human_replies"] == 1
|
||||||
|
assert snapshot["severity_counts"] == {"high": 1, "medium": 1, "low": 1}
|
||||||
|
assert snapshot["top_categories"] == [
|
||||||
|
{"name": "correctness", "count": 1},
|
||||||
|
{"name": "performance", "count": 1},
|
||||||
|
{"name": "style", "count": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reviewer_stats_paginates_reviewer_threads(monkeypatch):
|
||||||
|
threads = [
|
||||||
|
{"created_at": "2025-01-03T00:00:00Z", "metadata": {"kind": "reviewer", "findings": []}},
|
||||||
|
{"created_at": "2025-01-02T00:00:00Z", "metadata": {"kind": "reviewer", "findings": []}},
|
||||||
|
{"created_at": "2025-01-01T00:00:00Z", "metadata": {"kind": "reviewer", "findings": []}},
|
||||||
|
]
|
||||||
|
fake_threads = FakeThreads(threads)
|
||||||
|
monkeypatch.setattr(agent_usage, "_CACHE_SEARCH_LIMIT", 2)
|
||||||
|
monkeypatch.setattr(agent_usage, "_client", lambda: FakeClient(threads=fake_threads))
|
||||||
|
|
||||||
|
snapshot = await agent_usage._build_reviewer_stats_snapshot("all")
|
||||||
|
|
||||||
|
assert snapshot["reviewed_prs"] == 3
|
||||||
|
assert [call["offset"] for call in fake_threads.calls] == [0, 2]
|
||||||
|
assert all(call["sort_by"] == "created_at" for call in fake_threads.calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reviewer_stats_stops_after_page_older_than_cutoff(monkeypatch):
|
||||||
|
threads = [
|
||||||
|
{"created_at": "2025-01-03T00:00:00Z", "metadata": {"kind": "reviewer", "findings": []}},
|
||||||
|
{"created_at": "2025-01-01T00:00:00Z", "metadata": {"kind": "reviewer", "findings": []}},
|
||||||
|
{"created_at": "2024-12-31T00:00:00Z", "metadata": {"kind": "reviewer", "findings": []}},
|
||||||
|
]
|
||||||
|
fake_threads = FakeThreads(threads)
|
||||||
|
monkeypatch.setattr(agent_usage, "_CACHE_SEARCH_LIMIT", 2)
|
||||||
|
monkeypatch.setattr(agent_usage, "_client", lambda: FakeClient(threads=fake_threads))
|
||||||
|
cutoff_ms = agent_usage._timestamp_ms("2025-01-02T00:00:00Z")
|
||||||
|
|
||||||
|
pages = [page async for page in agent_usage._iter_reviewer_thread_pages(cutoff_ms)]
|
||||||
|
|
||||||
|
assert len(pages) == 1
|
||||||
|
assert [call["offset"] for call in fake_threads.calls] == [0]
|
||||||
|
|
@ -30,8 +30,8 @@ interface NavItem {
|
||||||
const NAV: Array<NavItem> = [
|
const NAV: Array<NavItem> = [
|
||||||
{ to: "/my-settings", label: "Profile Settings", icon: IoOptionsOutline },
|
{ to: "/my-settings", label: "Profile Settings", icon: IoOptionsOutline },
|
||||||
{ to: "/cloud-agents", label: "Open SWE Agent", icon: IoCloudOutline },
|
{ to: "/cloud-agents", label: "Open SWE Agent", icon: IoCloudOutline },
|
||||||
{ to: "/usage", label: "Usage Leaderboard", icon: IoStatsChartOutline },
|
|
||||||
{ to: "/review", label: "Open SWE Review", icon: IoGitPullRequestOutline },
|
{ to: "/review", label: "Open SWE Review", icon: IoGitPullRequestOutline },
|
||||||
|
{ to: "/usage", label: "Usage", icon: IoStatsChartOutline },
|
||||||
{ to: "/admin", label: "Admin", icon: IoSettingsOutline, adminOnly: true },
|
{ to: "/admin", label: "Admin", icon: IoSettingsOutline, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,11 +157,35 @@ export interface UsageLeaderboardRow {
|
||||||
deletions: number;
|
deletions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewerStatsCounterRow {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewerStatsPayload {
|
||||||
|
period: UsageLeaderboardPeriod;
|
||||||
|
reviewed_prs: number;
|
||||||
|
prs_with_findings: number;
|
||||||
|
findings_recorded: number;
|
||||||
|
surfaced_findings: number;
|
||||||
|
addressed_findings: number;
|
||||||
|
resolved_after_update: number;
|
||||||
|
dismissed_findings: number;
|
||||||
|
unresolved_surfaced_findings: number;
|
||||||
|
resolution_rate: number;
|
||||||
|
human_replies: number;
|
||||||
|
severity_counts: Record<string, number>;
|
||||||
|
top_categories: Array<ReviewerStatsCounterRow>;
|
||||||
|
generated_at_ms: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageLeaderboardPayload {
|
export interface UsageLeaderboardPayload {
|
||||||
period: UsageLeaderboardPeriod;
|
period: UsageLeaderboardPeriod;
|
||||||
rows: Array<UsageLeaderboardRow>;
|
rows: Array<UsageLeaderboardRow>;
|
||||||
total_members: number;
|
total_members: number;
|
||||||
current_user_rank: number | null;
|
current_user_rank: number | null;
|
||||||
|
generated_at_ms: number | null;
|
||||||
|
reviewer_stats: ReviewerStatsPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Repository {
|
export interface Repository {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { Navigate, createFileRoute } from "@tanstack/react-router"
|
import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
|
||||||
import type { UsageLeaderboardPeriod, UsageLeaderboardRow } from "@/lib/api"
|
import type {
|
||||||
|
ReviewerStatsPayload,
|
||||||
|
UsageLeaderboardPeriod,
|
||||||
|
UsageLeaderboardRow,
|
||||||
|
} from "@/lib/api"
|
||||||
import { AppShell, SettingsSection } from "@/components/AppShell"
|
import { AppShell, SettingsSection } from "@/components/AppShell"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import {
|
import {
|
||||||
|
|
@ -43,6 +47,7 @@ function UsagePage() {
|
||||||
queryKey: ["usageLeaderboard", activePeriod],
|
queryKey: ["usageLeaderboard", activePeriod],
|
||||||
queryFn: () => api.usageLeaderboard(activePeriod, 10),
|
queryFn: () => api.usageLeaderboard(activePeriod, 10),
|
||||||
enabled: !!session.data,
|
enabled: !!session.data,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (session.isLoading) {
|
if (session.isLoading) {
|
||||||
|
|
@ -55,13 +60,9 @@ function UsagePage() {
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <Navigate to="/login" />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell user={session.data} title="Usage">
|
||||||
user={session.data}
|
|
||||||
title="Usage Leaderboard"
|
|
||||||
description="Open SWE Agent usage recorded from this release onward. Reviewer-agent runs are excluded."
|
|
||||||
>
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Leaderboard"
|
title="Agent leaderboard"
|
||||||
description="Ranked by agent lines of code, then PRs opened and agent runs."
|
description="Ranked by agent lines of code, then PRs opened and agent runs."
|
||||||
action={
|
action={
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -94,7 +95,7 @@ function UsagePage() {
|
||||||
</div>
|
</div>
|
||||||
) : leaderboard.isError ? (
|
) : leaderboard.isError ? (
|
||||||
<p className="p-4 text-xs text-destructive">
|
<p className="p-4 text-xs text-destructive">
|
||||||
Failed to load usage leaderboard: {leaderboard.error.message}
|
Failed to load usage data: {leaderboard.error.message}
|
||||||
</p>
|
</p>
|
||||||
) : !leaderboard.data?.rows.length ? (
|
) : !leaderboard.data?.rows.length ? (
|
||||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
|
@ -108,6 +109,31 @@ function UsagePage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
title="Reviewer stats"
|
||||||
|
description="Issues surfaced by Open SWE Review and how often users addressed them."
|
||||||
|
>
|
||||||
|
{leaderboard.isLoading ? (
|
||||||
|
<div className="grid gap-3 p-4 sm:grid-cols-2">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
) : leaderboard.isError ? (
|
||||||
|
<p className="p-4 text-xs text-destructive">
|
||||||
|
Failed to load reviewer stats: {leaderboard.error.message}
|
||||||
|
</p>
|
||||||
|
) : leaderboard.data?.reviewer_stats ? (
|
||||||
|
<ReviewerStats stats={leaderboard.data.reviewer_stats} />
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No reviewer stats have been recorded for{" "}
|
||||||
|
{PERIOD_LABELS[activePeriod].toLowerCase()} yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +198,105 @@ function UsageTable({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReviewerStats({ stats }: { stats: ReviewerStatsPayload }) {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: "Reviewed PRs",
|
||||||
|
value: stats.reviewed_prs,
|
||||||
|
helper: `${formatNumber(stats.prs_with_findings)} with findings`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Issues surfaced",
|
||||||
|
value: stats.surfaced_findings,
|
||||||
|
helper: `${formatNumber(stats.findings_recorded)} recorded`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Addressed & resolved",
|
||||||
|
value: stats.addressed_findings,
|
||||||
|
helper: `${formatPercent(stats.resolution_rate)} of surfaced`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Resolved after update",
|
||||||
|
value: stats.resolved_after_update,
|
||||||
|
helper: "Resolved on a later PR head",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Awaiting follow-up",
|
||||||
|
value: stats.unresolved_surfaced_findings,
|
||||||
|
helper: "Surfaced but not resolved/dismissed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dismissed",
|
||||||
|
value: stats.dismissed_findings,
|
||||||
|
helper: `${formatNumber(stats.human_replies)} human replies tracked`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<div key={card.label} className="rounded-md border border-border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{card.label}</div>
|
||||||
|
<div className="mt-1 text-xl font-medium tabular-nums">
|
||||||
|
{formatNumber(card.value)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{card.helper}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 border-t border-border pt-4 sm:grid-cols-2">
|
||||||
|
<CounterList title="Top categories" rows={stats.top_categories} />
|
||||||
|
<CounterList title="Severity mix" rows={severityRows(stats)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityRows(
|
||||||
|
stats: ReviewerStatsPayload
|
||||||
|
): Array<{ name: string; count: number }> {
|
||||||
|
return ["critical", "high", "medium", "low"]
|
||||||
|
.map((severity) => ({
|
||||||
|
name: severity,
|
||||||
|
count: stats.severity_counts[severity] ?? 0,
|
||||||
|
}))
|
||||||
|
.filter((row) => row.count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CounterList({
|
||||||
|
title,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
rows: Array<{ name: string; count: number }>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
|
||||||
|
{rows.length ? (
|
||||||
|
<ul className="mt-2 space-y-2 text-sm">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.name}
|
||||||
|
className="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span className="truncate text-foreground">{row.name}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{formatNumber(row.count)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">No data yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function UserCell({ row }: { row: UsageLeaderboardRow }) {
|
function UserCell({ row }: { row: UsageLeaderboardRow }) {
|
||||||
const initials = initialsFor(row.user.name)
|
const initials = initialsFor(row.user.name)
|
||||||
return (
|
return (
|
||||||
|
|
@ -203,3 +328,10 @@ function initialsFor(name: string): string {
|
||||||
function formatNumber(value: number): string {
|
function formatNumber(value: number): string {
|
||||||
return new Intl.NumberFormat().format(value)
|
return new Intl.NumberFormat().format(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
style: "percent",
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue