Merge branch 'master' into feat/issue-636-inventory-api
This commit is contained in:
+118
@@ -52,6 +52,12 @@ from webui.inventory import (
|
||||
snapshot_to_dict as inventory_snapshot_to_dict,
|
||||
)
|
||||
from webui.timeline import load_timeline, snapshot_to_dict as timeline_snapshot_to_dict
|
||||
from webui.analytics_loader import (
|
||||
load_analytics,
|
||||
record_usage,
|
||||
snapshot_to_dict as analytics_snapshot_to_dict,
|
||||
)
|
||||
from webui.analytics_views import render_analytics_page
|
||||
from webui.system_health import (
|
||||
API_PATH as SYSTEM_HEALTH_API_PATH,
|
||||
load_system_health,
|
||||
@@ -596,6 +602,114 @@ async def api_v1_timeline(request: Request) -> JSONResponse:
|
||||
return JSONResponse(timeline_snapshot_to_dict(snapshot), status_code=status_code)
|
||||
|
||||
|
||||
async def analytics(request: Request) -> HTMLResponse:
|
||||
"""Read-only model usage, token cost, latency, and performance analytics HTML view (#651)."""
|
||||
snapshot = load_analytics(
|
||||
remote=request.query_params.get("remote"),
|
||||
org=request.query_params.get("org"),
|
||||
repo=request.query_params.get("repo"),
|
||||
role=request.query_params.get("role"),
|
||||
model=request.query_params.get("model"),
|
||||
stage=request.query_params.get("stage"),
|
||||
issue_number=_query_int(request, "issue"),
|
||||
pr_number=_query_int(request, "pr"),
|
||||
limit=_query_int(request, "limit") or 200,
|
||||
)
|
||||
return HTMLResponse(render_analytics_page(snapshot))
|
||||
|
||||
|
||||
async def api_v1_analytics(request: Request) -> JSONResponse:
|
||||
"""Read-only model usage, token cost, latency, and performance analytics API (#651)."""
|
||||
snapshot = load_analytics(
|
||||
remote=request.query_params.get("remote"),
|
||||
org=request.query_params.get("org"),
|
||||
repo=request.query_params.get("repo"),
|
||||
role=request.query_params.get("role"),
|
||||
model=request.query_params.get("model"),
|
||||
stage=request.query_params.get("stage"),
|
||||
issue_number=_query_int(request, "issue"),
|
||||
pr_number=_query_int(request, "pr"),
|
||||
limit=_query_int(request, "limit") or 500,
|
||||
)
|
||||
status_code = 200 if snapshot.ok else 500
|
||||
return JSONResponse(analytics_snapshot_to_dict(snapshot), status_code=status_code)
|
||||
|
||||
|
||||
async def api_v1_analytics_ingest(request: Request) -> JSONResponse:
|
||||
"""Optional session instrumentation ingestion endpoint (#651).
|
||||
|
||||
Fail-closed write: every request is authorized through console_authz
|
||||
(``record_analytics_usage``) before any control-plane DB mutation. Phase 1
|
||||
keeps ``execution_enabled=False`` and denies unauthenticated callers, so
|
||||
this route cannot be used as an unauthenticated write or XSS injection
|
||||
vector (PR #876 F2).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
|
||||
principal = resolve_principal(headers=dict(request.headers))
|
||||
decision = authorize(
|
||||
"record_analytics_usage", principal, for_execution=True
|
||||
)
|
||||
allowed = bool(decision.allowed and decision.execution_enabled)
|
||||
console_audit.record_event(
|
||||
action_id="record_analytics_usage",
|
||||
result=(
|
||||
console_audit.RESULT_ALLOWED
|
||||
if allowed
|
||||
else console_audit.RESULT_DENIED
|
||||
),
|
||||
decision=decision,
|
||||
principal=principal,
|
||||
target=_audit_target("record_analytics_usage", body),
|
||||
request_id=_request_id(),
|
||||
detail=decision.detail,
|
||||
)
|
||||
authorization = decision.to_dict()
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "unauthorized",
|
||||
"detail": (
|
||||
"POST /api/v1/analytics/usage requires an authenticated "
|
||||
"principal with record_analytics_usage execution enabled"
|
||||
),
|
||||
"authorization": authorization,
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
usage_id = record_usage(
|
||||
session_id=body.get("session_id"),
|
||||
remote=body.get("remote", "dadeschools"),
|
||||
org=body.get("org", ""),
|
||||
repo=body.get("repo", ""),
|
||||
project_id=body.get("project_id"),
|
||||
role=body.get("role", "unknown"),
|
||||
model=body.get("model", "unknown"),
|
||||
issue_number=body.get("issue_number") or body.get("issue"),
|
||||
pr_number=body.get("pr_number") or body.get("pr"),
|
||||
stage=body.get("stage", "unknown"),
|
||||
input_tokens=body.get("input_tokens"),
|
||||
output_tokens=body.get("output_tokens"),
|
||||
total_tokens=body.get("total_tokens"),
|
||||
estimated_cost_usd=body.get("estimated_cost_usd"),
|
||||
latency_ms=body.get("latency_ms"),
|
||||
duration_ms=body.get("duration_ms"),
|
||||
status=body.get("status", "success"),
|
||||
metadata=body.get("metadata"),
|
||||
)
|
||||
return JSONResponse(
|
||||
{"ok": True, "usage_id": usage_id, "authorization": authorization},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
path = request.url.path
|
||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||
@@ -637,6 +751,10 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
Route("/runtime", runtime, methods=["GET"]),
|
||||
Route("/api/runtime", api_runtime, methods=["GET"]),
|
||||
Route("/api/v1/timeline", api_v1_timeline, methods=["GET"]),
|
||||
Route("/analytics", analytics, methods=["GET"]),
|
||||
Route("/api/analytics", api_v1_analytics, methods=["GET"]),
|
||||
Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]),
|
||||
Route("/api/v1/analytics/usage", api_v1_analytics_ingest, methods=["POST"]),
|
||||
Route("/audit", audit, methods=["GET", "POST"]),
|
||||
Route("/api/audit", api_audit, methods=["GET", "POST"]),
|
||||
Route("/worktrees", worktrees, methods=["GET"]),
|
||||
|
||||
Reference in New Issue
Block a user