feat: 관리자 대시보드 — 토큰·비용·응답시간 집계 API + 앱 /admin 페이지
- backend apps/stats: GET /api/v1/admin/stats?from&to → totals / byUser / byDay. ChatMessage(assistant) 집계만, is_superuser 아니면 403. 테스트 3개 - frontend features/admin: 요약 카드 4개(요청·토큰·비용·평균 응답), 일별 토큰 막대(div), 사용자별 표, 기간 프리셋(오늘/7/30/90일). 헤더에 ADMIN 만 아이콘. 테스트 2개 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
aa37cef13d
commit
d4a5f20787
@@ -0,0 +1,106 @@
|
||||
"""관리자 대시보드 집계 — ChatMessage(assistant 행)에 이미 쌓인 토큰·비용·시간을 모아서 줌.
|
||||
|
||||
GET /api/v1/admin/stats?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
→ { totals, byUser[], byDay[] } (is_superuser 만. 아니면 403 FORBIDDEN)
|
||||
|
||||
새로 모으는 건 없음. 기간 기본은 최근 30일(to 포함).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
|
||||
from django.db.models import Avg, Count, F, Sum
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.utils import timezone
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.chat.models import ChatMessage
|
||||
from common.envelope import CodedError
|
||||
|
||||
MAX_DAYS = 366
|
||||
|
||||
|
||||
def _parse_range(request) -> tuple[date, date]:
|
||||
today = timezone.localdate()
|
||||
raw_from, raw_to = request.query_params.get("from"), request.query_params.get("to")
|
||||
try:
|
||||
d_to = date.fromisoformat(raw_to) if raw_to else today
|
||||
d_from = date.fromisoformat(raw_from) if raw_from else d_to - timedelta(days=29)
|
||||
except ValueError:
|
||||
raise CodedError(400, "VALIDATION_ERROR", "from/to 는 YYYY-MM-DD 여야 해")
|
||||
if d_from > d_to:
|
||||
raise CodedError(400, "VALIDATION_ERROR", "from 이 to 보다 늦어")
|
||||
if (d_to - d_from).days >= MAX_DAYS:
|
||||
raise CodedError(400, "VALIDATION_ERROR", f"기간은 최대 {MAX_DAYS}일")
|
||||
return d_from, d_to
|
||||
|
||||
|
||||
def _agg(qs):
|
||||
"""공통 집계 — 요청 수·토큰·비용·평균 응답시간."""
|
||||
return qs.aggregate(
|
||||
requests=Count("id"),
|
||||
input_tokens=Sum("input_tokens"),
|
||||
output_tokens=Sum("output_tokens"),
|
||||
cost_usd=Sum("cost_usd"),
|
||||
avg_elapsed_ms=Avg("elapsed_ms"),
|
||||
)
|
||||
|
||||
|
||||
def _row(d: dict) -> dict:
|
||||
return {
|
||||
"requests": d["requests"] or 0,
|
||||
"inputTokens": d["input_tokens"] or 0,
|
||||
"outputTokens": d["output_tokens"] or 0,
|
||||
"totalTokens": (d["input_tokens"] or 0) + (d["output_tokens"] or 0),
|
||||
"costUsd": round(d["cost_usd"] or 0.0, 6),
|
||||
"avgElapsedMs": round(d["avg_elapsed_ms"]) if d["avg_elapsed_ms"] is not None else None,
|
||||
}
|
||||
|
||||
|
||||
class AdminStatsView(APIView):
|
||||
def get(self, request):
|
||||
if not request.user.is_superuser:
|
||||
raise CodedError(403, "FORBIDDEN", "관리자만 볼 수 있어")
|
||||
d_from, d_to = _parse_range(request)
|
||||
tz = timezone.get_current_timezone()
|
||||
start = datetime.combine(d_from, time.min, tzinfo=tz)
|
||||
end = datetime.combine(d_to + timedelta(days=1), time.min, tzinfo=tz)
|
||||
base = ChatMessage.objects.filter(role="assistant", created_at__gte=start, created_at__lt=end)
|
||||
|
||||
by_user = (
|
||||
base.values(user_id=F("session__user_id"), email=F("session__user__email"), name=F("session__user__user_name"))
|
||||
.annotate(
|
||||
requests=Count("id"),
|
||||
input_tokens=Sum("input_tokens"),
|
||||
output_tokens=Sum("output_tokens"),
|
||||
cost_usd=Sum("cost_usd"),
|
||||
avg_elapsed_ms=Avg("elapsed_ms"),
|
||||
sessions=Count("session_id", distinct=True),
|
||||
)
|
||||
.order_by("-cost_usd", "-requests")
|
||||
)
|
||||
by_day = (
|
||||
base.annotate(day=TruncDate("created_at", tzinfo=tz))
|
||||
.values("day")
|
||||
.annotate(
|
||||
requests=Count("id"),
|
||||
input_tokens=Sum("input_tokens"),
|
||||
output_tokens=Sum("output_tokens"),
|
||||
cost_usd=Sum("cost_usd"),
|
||||
avg_elapsed_ms=Avg("elapsed_ms"),
|
||||
)
|
||||
.order_by("day")
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"from": d_from.isoformat(),
|
||||
"to": d_to.isoformat(),
|
||||
"totals": {**_row(_agg(base)), "users": base.values("session__user_id").distinct().count(),
|
||||
"sessions": base.values("session_id").distinct().count()},
|
||||
"byUser": [
|
||||
{"userId": r["user_id"], "email": r["email"], "name": r["name"] or "", "sessions": r["sessions"], **_row(r)}
|
||||
for r in by_user
|
||||
],
|
||||
"byDay": [{"day": r["day"].isoformat(), **_row(r)} for r in by_day],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user