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:
lee-hyeon-cheol
2026-09-21 13:39:06 +09:00
co-authored by Claude Fable 5.1
parent aa37cef13d
commit d4a5f20787
16 changed files with 517 additions and 2 deletions
+1
View File
@@ -5,6 +5,7 @@ export const PATHS = {
SNAP_NEW: "/snap/new",
SNAP_SESSION: "/snap/s/:id",
SNIPPET: "/snippet",
ADMIN: "/admin",
} as const
export type Path = (typeof PATHS)[keyof typeof PATHS]
@@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query"
import { apiGet } from "@/lib/api/client"
import type { AdminStatsResponse } from "@/types/api"
export const ADMIN_STATS_KEY = ["admin", "stats"] as const
export const adminApi = {
stats: (from: string, to: string) =>
apiGet<AdminStatsResponse>("/admin/stats", { params: { from, to } }),
}
/** 관리자 집계 — 기간(YYYY-MM-DD) 바뀌면 재조회. 관리자 아니면 403 이라 enabled 로 막음. */
export function useAdminStats(from: string, to: string, enabled = true) {
return useQuery({
queryKey: [...ADMIN_STATS_KEY, from, to],
queryFn: () => adminApi.stats(from, to),
enabled,
staleTime: 60_000,
})
}
@@ -0,0 +1,99 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import AdminStatsPage from "./AdminStatsPage"
import { useAuthStore } from "@/features/auth/store/authStore"
import * as client from "@/lib/api/client"
import type { AdminStatsResponse } from "@/types/api"
// 훅이 모듈 안 adminApi 를 직접 잡고 있어서 그 아래 apiGet 을 막음
vi.mock("@/lib/api/client", async (orig) => ({
...(await orig<typeof client>()),
apiGet: vi.fn(),
}))
const SAMPLE: AdminStatsResponse = {
from: "2026-09-01",
to: "2026-09-21",
totals: {
requests: 12,
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
costUsd: 0.1234,
avgElapsedMs: 2500,
users: 2,
sessions: 3,
},
byUser: [
{
userId: "u1",
email: "a@x.com",
name: "김개발",
sessions: 2,
requests: 10,
inputTokens: 900,
outputTokens: 450,
totalTokens: 1350,
costUsd: 0.12,
avgElapsedMs: 2000,
},
],
byDay: [
{
day: "2026-09-20",
requests: 5,
inputTokens: 500,
outputTokens: 250,
totalTokens: 750,
costUsd: 0.05,
avgElapsedMs: 2000,
},
],
}
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AdminStatsPage />
</MemoryRouter>
</QueryClientProvider>
)
}
const asUser = (role: "ADMIN" | "USER") =>
useAuthStore.setState({
user: {
id: "u",
email: "a@x.com",
userName: null,
role,
employeeId: null,
department: null,
authProvider: "local",
},
})
describe("AdminStatsPage", () => {
beforeEach(() => vi.mocked(client.apiGet).mockReset().mockResolvedValue(SAMPLE))
it("관리자 아니면 안내만 보이고 API 안 부름", () => {
asUser("USER")
mount()
expect(screen.getByText("관리자만 볼 수 있어")).toBeTruthy()
expect(client.apiGet).not.toHaveBeenCalled()
})
it("관리자면 요약 카드·사용자 표를 그린다", async () => {
asUser("ADMIN")
mount()
expect(await screen.findByText("1,500")).toBeTruthy() // 총 토큰
expect(screen.getByText("$0.1234")).toBeTruthy()
expect(screen.getByText("2.5s")).toBeTruthy()
expect(screen.getByText("김개발")).toBeTruthy()
expect(client.apiGet).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,178 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft } from "lucide-react"
import { PATHS } from "@/config/routes"
import { useAuthStore } from "@/features/auth/store/authStore"
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
import { Kbd } from "@/shared/components/Kbd"
import { ApiError } from "@/lib/api/errors"
import { useAdminStats } from "../api/admin.api"
const fmtInt = (n: number) => n.toLocaleString("ko-KR")
const fmtUsd = (n: number) => `$${n.toFixed(n < 1 ? 4 : 2)}`
const fmtSec = (ms: number | null) => (ms === null ? "" : `${(ms / 1000).toFixed(1)}s`)
const isoDay = (d: Date) => d.toISOString().slice(0, 10)
const PRESETS: { label: string; days: number }[] = [
{ label: "오늘", days: 1 },
{ label: "7일", days: 7 },
{ label: "30일", days: 30 },
{ label: "90일", days: 90 },
]
/** 관리자 대시보드 — 총 토큰·비용·응답시간 + 사용자별·일별. 서버는 ChatMessage 집계만(새로 모으는 것 없음). */
export default function AdminStatsPage() {
const navigate = useNavigate()
const isAdmin = useAuthStore((s) => s.user?.role === "ADMIN")
const [days, setDays] = useState(30)
useEscapeKey(() => navigate(PATHS.SNAP))
const to = isoDay(new Date())
const from = isoDay(new Date(Date.now() - (days - 1) * 86_400_000))
const { data, isLoading, error } = useAdminStats(from, to, isAdmin)
if (!isAdmin) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
</div>
)
}
const t = data?.totals
const maxDayTokens = Math.max(1, ...(data?.byDay.map((d) => d.totalTokens) ?? [0]))
return (
<div className="mx-auto flex h-full w-full max-w-[880px] flex-col gap-4 overflow-y-auto px-5 py-3">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => navigate(PATHS.SNAP)}
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring -ml-2 inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11.5px] transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<ChevronLeft className="size-3.5" aria-hidden="true" />
<Kbd>Esc</Kbd>
</button>
<div className="flex items-center gap-1" role="group" aria-label="기간">
{PRESETS.map((p) => (
<button
key={p.days}
type="button"
onClick={() => setDays(p.days)}
aria-pressed={days === p.days}
className={`h-7 rounded-md px-2.5 text-[11.5px] font-medium transition-colors ${
days === p.days
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{p.label}
</button>
))}
</div>
</div>
<div>
<h1 className="text-base font-semibold"></h1>
<p className="text-muted-foreground text-[11.5px]">
{from} ~ {to} · assistant
</p>
</div>
{error instanceof ApiError && <div className="text-destructive text-sm">{error.message}</div>}
{isLoading && <div className="text-muted-foreground text-xs"> </div>}
{t && (
<>
{/* 요약 카드 4개 */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Stat
label="요청"
value={fmtInt(t.requests)}
sub={`${t.users}명 · ${t.sessions}세션`}
/>
<Stat
label="토큰"
value={fmtInt(t.totalTokens)}
sub={`입력 ${fmtInt(t.inputTokens)} · 출력 ${fmtInt(t.outputTokens)}`}
/>
<Stat label="비용" value={fmtUsd(t.costUsd)} sub="OpenCode 집계 기준" />
<Stat label="평균 응답" value={fmtSec(t.avgElapsedMs)} sub="첫 요청→완료" />
</div>
{/* 일별 토큰 막대 — 라이브러리 없이 div 로 */}
<section className="border-border bg-card rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-[11px] font-semibold tracking-wide">
</div>
{data.byDay.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-xs">
</div>
) : (
<div className="flex h-28 items-end gap-[3px]" role="img" aria-label="일별 토큰 막대">
{data.byDay.map((d) => (
<div
key={d.day}
title={`${d.day}: ${fmtInt(d.totalTokens)} 토큰 · ${d.requests}건 · ${fmtUsd(d.costUsd)}`}
className="bg-primary/80 hover:bg-primary min-w-[4px] flex-1 rounded-sm transition-colors"
style={{ height: `${Math.max(2, (d.totalTokens / maxDayTokens) * 100)}%` }}
/>
))}
</div>
)}
</section>
{/* 사용자별 표 */}
<section className="border-border bg-card overflow-hidden rounded-lg border">
<table className="w-full text-[12px]">
<thead className="text-muted-foreground bg-muted/50 text-left text-[11px]">
<tr>
<th className="px-3 py-2 font-semibold"></th>
<th className="px-3 py-2 text-right font-semibold"></th>
<th className="px-3 py-2 text-right font-semibold"></th>
<th className="px-3 py-2 text-right font-semibold"></th>
<th className="px-3 py-2 text-right font-semibold"></th>
<th className="px-3 py-2 text-right font-semibold"> </th>
</tr>
</thead>
<tbody className="divide-border/70 divide-y">
{data.byUser.length === 0 && (
<tr>
<td colSpan={6} className="text-muted-foreground py-6 text-center text-xs">
</td>
</tr>
)}
{data.byUser.map((u) => (
<tr key={u.userId} className="hover:bg-accent/40">
<td className="px-3 py-2">
<div className="font-medium">{u.name || u.email}</div>
{u.name && <div className="text-muted-foreground text-[11px]">{u.email}</div>}
</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtInt(u.requests)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtInt(u.sessions)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtInt(u.totalTokens)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtUsd(u.costUsd)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtSec(u.avgElapsedMs)}</td>
</tr>
))}
</tbody>
</table>
</section>
</>
)}
</div>
)
}
function Stat({ label, value, sub }: { label: string; value: string; sub: string }) {
return (
<div className="border-border bg-card rounded-lg border px-3 py-2.5">
<div className="text-muted-foreground text-[11px] font-semibold tracking-wide">{label}</div>
<div className="mt-0.5 text-lg font-semibold tabular-nums">{value}</div>
<div className="text-muted-foreground text-[11px]">{sub}</div>
</div>
)
}
@@ -1,7 +1,8 @@
import { LogOut } from "lucide-react"
import { BarChart3, LogOut } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { useLogout } from "@/features/auth/hooks/useLogout"
import { useAuthStore } from "@/features/auth/store/authStore"
import { ThemeToggle } from "@/shared/components/ThemeToggle"
import { Button } from "@/shared/ui/button"
@@ -9,6 +10,7 @@ import { Button } from "@/shared/ui/button"
export function SnapUserControls() {
const navigate = useNavigate()
const logout = useLogout()
const isAdmin = useAuthStore((s) => s.user?.role === "ADMIN")
const handleLogout = () => {
logout.mutate(undefined, {
@@ -18,6 +20,19 @@ export function SnapUserControls() {
return (
<div className="flex items-center gap-0.5">
{isAdmin && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-7"
aria-label="관리자 대시보드"
title="관리자 대시보드"
onClick={() => navigate(PATHS.ADMIN)}
>
<BarChart3 className="size-3.5" aria-hidden="true" />
</Button>
)}
<ThemeToggle />
<Button
type="button"
+2
View File
@@ -12,6 +12,7 @@ const SessionListPage = lazy(() => import("@/features/snap/pages/SessionListPage
const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage"))
const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage"))
const AdminStatsPage = lazy(() => import("@/features/admin/pages/AdminStatsPage"))
const fallback = <CenterSpinner />
@@ -35,6 +36,7 @@ export const routes: RouteObject[] = [
{ path: PATHS.SNAP.slice(1), element: wrap(<SessionListPage />) },
{ path: PATHS.SNAP_NEW.slice(1), element: wrap(<NewChatPage />) },
{ path: PATHS.SNAP_SESSION.slice(1), element: wrap(<SessionChatPage />) },
{ path: PATHS.ADMIN.slice(1), element: wrap(<AdminStatsPage />) },
],
},
{
+18
View File
@@ -64,3 +64,21 @@ export interface EntraConfigResponse {
authority: string
tenantId: string
}
// 관리자 대시보드 — GET /admin/stats
export interface AdminStatRow {
requests: number
inputTokens: number
outputTokens: number
totalTokens: number
costUsd: number
avgElapsedMs: number | null
}
export interface AdminStatsResponse {
from: string
to: string
totals: AdminStatRow & { users: number; sessions: number }
byUser: (AdminStatRow & { userId: string; email: string; name: string; sessions: number })[]
byDay: (AdminStatRow & { day: string })[]
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/config/env.ts","./src/config/routes.ts","./src/features/auth/index.ts","./src/features/auth/api/auth.api.ts","./src/features/auth/components/entraloginbutton.tsx","./src/features/auth/components/entraloginsection.tsx","./src/features/auth/components/loginform.tsx","./src/features/auth/components/sessionexpirydialog.tsx","./src/features/auth/hooks/useentraenabled.ts","./src/features/auth/hooks/useentralogin.ts","./src/features/auth/hooks/uselogin.ts","./src/features/auth/hooks/uselogout.ts","./src/features/auth/hooks/useme.ts","./src/features/auth/hooks/useslidingrefresh.ts","./src/features/auth/pages/loginpage.tsx","./src/features/auth/schemas/index.ts","./src/features/auth/store/authstore.ts","./src/features/auth/store/sessionexpirystore.ts","./src/features/auth/utils/saferedirectpath.ts","./src/features/snap/api/snap.api.ts","./src/features/snap/api/snap.stream.ts","./src/features/snap/components/chatheader.tsx","./src/features/snap/components/clipboardhistory.tsx","./src/features/snap/components/codeblock.tsx","./src/features/snap/components/composer.tsx","./src/features/snap/components/hero.tsx","./src/features/snap/components/message.tsx","./src/features/snap/components/navrail.tsx","./src/features/snap/components/searchhitcard.tsx","./src/features/snap/components/sessioncard.tsx","./src/features/snap/components/sessionsearch.tsx","./src/features/snap/components/snaplayout.tsx","./src/features/snap/components/snapusercontrols.tsx","./src/features/snap/components/abaphljs.ts","./src/features/snap/contract/types.ts","./src/features/snap/hooks/useescapekey.ts","./src/features/snap/hooks/usesnapchat.ts","./src/features/snap/lib/format.ts","./src/features/snap/pages/newchatpage.tsx","./src/features/snap/pages/sessionchatpage.tsx","./src/features/snap/pages/sessionlistpage.tsx","./src/features/snap/store/snapchatstore.ts","./src/features/snippets/types.ts","./src/features/snippets/api/snippets.api.ts","./src/features/snippets/components/categorychips.tsx","./src/features/snippets/components/editdialog.tsx","./src/features/snippets/components/previewpane.tsx","./src/features/snippets/components/snippetrow.tsx","./src/features/snippets/core/ranking.ts","./src/features/snippets/core/search.ts","./src/features/snippets/hooks/usesnippets.ts","./src/features/snippets/pages/snippetpalettepage.tsx","./src/lib/api/client.ts","./src/lib/api/errors.ts","./src/lib/auth/msal.ts","./src/lib/auth/tokenprovider.ts","./src/lib/bridge/bridgenavigate.ts","./src/lib/bridge/snippetbridge.ts","./src/lib/bridge/transport.ts","./src/lib/bridge/webviewbridge.ts","./src/lib/hooks/usedebounce.ts","./src/lib/query/queryclient.ts","./src/lib/streaming/stoppednotice.tsx","./src/lib/streaming/streamingtext.tsx","./src/lib/streaming/abort.ts","./src/lib/streaming/index.ts","./src/lib/streaming/sse.ts","./src/lib/streaming/streamllm.ts","./src/lib/streaming/usesmoothedtext.ts","./src/lib/streaming/usestreamsession.ts","./src/lib/utils/cn.ts","./src/lib/utils/randomid.ts","./src/lib/utils/relativetime.ts","./src/lib/utils/sleep.ts","./src/shared/components/centerspinner.tsx","./src/shared/components/desktopwindowframe.tsx","./src/shared/components/errorboundary.tsx","./src/shared/components/kbd.tsx","./src/shared/components/layout.tsx","./src/shared/components/logomark.tsx","./src/shared/components/paletteshell.tsx","./src/shared/components/pastetargetbadge.tsx","./src/shared/components/protectedroute.tsx","./src/shared/components/themetoggle.tsx","./src/shared/hooks/usepastetarget.ts","./src/shared/store/themestore.ts","./src/shared/theme/derive.ts","./src/shared/theme/palettes.ts","./src/shared/theme/usetheme.ts","./src/shared/ui/alert-dialog.tsx","./src/shared/ui/alert.tsx","./src/shared/ui/button.tsx","./src/shared/ui/dialog.tsx","./src/shared/ui/dropdown-menu.tsx","./src/shared/ui/input.tsx","./src/shared/ui/label.tsx","./src/shared/ui/separator.tsx","./src/shared/ui/skeleton.tsx","./src/shared/ui/textarea.tsx","./src/shared/ui/tooltip.tsx","./src/types/api.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/config/env.ts","./src/config/routes.ts","./src/features/admin/api/admin.api.ts","./src/features/admin/pages/adminstatspage.tsx","./src/features/auth/index.ts","./src/features/auth/api/auth.api.ts","./src/features/auth/components/entraloginbutton.tsx","./src/features/auth/components/entraloginsection.tsx","./src/features/auth/components/loginform.tsx","./src/features/auth/components/sessionexpirydialog.tsx","./src/features/auth/hooks/useentraenabled.ts","./src/features/auth/hooks/useentralogin.ts","./src/features/auth/hooks/uselogin.ts","./src/features/auth/hooks/uselogout.ts","./src/features/auth/hooks/useme.ts","./src/features/auth/hooks/useslidingrefresh.ts","./src/features/auth/pages/loginpage.tsx","./src/features/auth/schemas/index.ts","./src/features/auth/store/authstore.ts","./src/features/auth/store/sessionexpirystore.ts","./src/features/auth/utils/saferedirectpath.ts","./src/features/snap/api/snap.api.ts","./src/features/snap/api/snap.stream.ts","./src/features/snap/components/chatheader.tsx","./src/features/snap/components/clipboardhistory.tsx","./src/features/snap/components/codeblock.tsx","./src/features/snap/components/composer.tsx","./src/features/snap/components/hero.tsx","./src/features/snap/components/message.tsx","./src/features/snap/components/navrail.tsx","./src/features/snap/components/searchhitcard.tsx","./src/features/snap/components/sessioncard.tsx","./src/features/snap/components/sessionsearch.tsx","./src/features/snap/components/snaplayout.tsx","./src/features/snap/components/snapusercontrols.tsx","./src/features/snap/components/abaphljs.ts","./src/features/snap/contract/types.ts","./src/features/snap/hooks/useescapekey.ts","./src/features/snap/hooks/usesnapchat.ts","./src/features/snap/lib/format.ts","./src/features/snap/pages/newchatpage.tsx","./src/features/snap/pages/sessionchatpage.tsx","./src/features/snap/pages/sessionlistpage.tsx","./src/features/snap/store/snapchatstore.ts","./src/features/snippets/types.ts","./src/features/snippets/api/snippets.api.ts","./src/features/snippets/components/categorychips.tsx","./src/features/snippets/components/editdialog.tsx","./src/features/snippets/components/previewpane.tsx","./src/features/snippets/components/snippetrow.tsx","./src/features/snippets/core/ranking.ts","./src/features/snippets/core/search.ts","./src/features/snippets/hooks/usesnippets.ts","./src/features/snippets/pages/snippetpalettepage.tsx","./src/lib/api/client.ts","./src/lib/api/errors.ts","./src/lib/auth/msal.ts","./src/lib/auth/tokenprovider.ts","./src/lib/bridge/bridgenavigate.ts","./src/lib/bridge/snippetbridge.ts","./src/lib/bridge/transport.ts","./src/lib/bridge/webviewbridge.ts","./src/lib/hooks/usedebounce.ts","./src/lib/query/queryclient.ts","./src/lib/streaming/stoppednotice.tsx","./src/lib/streaming/streamingtext.tsx","./src/lib/streaming/abort.ts","./src/lib/streaming/index.ts","./src/lib/streaming/sse.ts","./src/lib/streaming/streamllm.ts","./src/lib/streaming/usesmoothedtext.ts","./src/lib/streaming/usestreamsession.ts","./src/lib/utils/cn.ts","./src/lib/utils/randomid.ts","./src/lib/utils/relativetime.ts","./src/lib/utils/sleep.ts","./src/shared/components/centerspinner.tsx","./src/shared/components/desktopwindowframe.tsx","./src/shared/components/errorboundary.tsx","./src/shared/components/kbd.tsx","./src/shared/components/layout.tsx","./src/shared/components/logomark.tsx","./src/shared/components/paletteshell.tsx","./src/shared/components/pastetargetbadge.tsx","./src/shared/components/protectedroute.tsx","./src/shared/components/themetoggle.tsx","./src/shared/hooks/usepastetarget.ts","./src/shared/store/themestore.ts","./src/shared/theme/derive.ts","./src/shared/theme/palettes.ts","./src/shared/theme/usetheme.ts","./src/shared/ui/alert-dialog.tsx","./src/shared/ui/alert.tsx","./src/shared/ui/button.tsx","./src/shared/ui/dialog.tsx","./src/shared/ui/dropdown-menu.tsx","./src/shared/ui/input.tsx","./src/shared/ui/label.tsx","./src/shared/ui/separator.tsx","./src/shared/ui/skeleton.tsx","./src/shared/ui/textarea.tsx","./src/shared/ui/tooltip.tsx","./src/types/api.ts"],"version":"5.9.3"}
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class StatsConfig(AppConfig):
name = "apps.stats"
verbose_name = "관리자 통계(사용량·비용·시간)"
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import AdminStatsView
urlpatterns = [
path("stats", AdminStatsView.as_view()),
]
+106
View File
@@ -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],
}
)
+1
View File
@@ -100,6 +100,7 @@ INSTALLED_APPS = [
"apps.accounts",
"apps.chat",
"apps.gateway",
"apps.stats",
]
MIDDLEWARE = [
+1
View File
@@ -21,5 +21,6 @@ urlpatterns = [
path("api/v1/auth/", include("apps.accounts.urls")),
path("api/v1/users/me", MeView.as_view()),
path("api/v1/chat/", include("apps.chat.urls")),
path("api/v1/admin/", include("apps.stats.urls")), # 관리자 대시보드 집계
path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계
]
+60
View File
@@ -0,0 +1,60 @@
"""GET /api/v1/admin/stats — 관리자만, 토큰·비용·시간 집계."""
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework.test import APIClient
from apps.chat.models import ChatMessage, ChatSession
def _msg(session, role="assistant", days_ago=0, **kw):
m = ChatMessage.objects.create(session=session, role=role, content="x", **kw)
if days_ago:
ChatMessage.objects.filter(id=m.id).update(created_at=timezone.now() - timedelta(days=days_ago))
return m
@pytest.fixture
def admin_api(db, django_user_model):
admin = django_user_model.objects.create_superuser(username="a@x.com", email="a@x.com", password="x")
c = APIClient()
c.force_authenticate(admin)
return c, admin
def test_non_admin_forbidden(auth_api):
res = auth_api.get("/api/v1/admin/stats")
assert res.status_code == 403 and res.json()["code"] == "FORBIDDEN"
def test_totals_by_user_by_day(admin_api, user):
api, admin = admin_api
s1 = ChatSession.objects.create(id="s1", user=user)
s2 = ChatSession.objects.create(id="s2", user=admin)
_msg(s1, role="user") # user 행은 집계 제외
_msg(s1, input_tokens=100, output_tokens=50, cost_usd=0.01, elapsed_ms=1000)
_msg(s1, input_tokens=200, output_tokens=100, cost_usd=0.02, elapsed_ms=3000, days_ago=1)
_msg(s2, input_tokens=10, output_tokens=5, cost_usd=0.001, elapsed_ms=500)
_msg(s2, input_tokens=999, output_tokens=999, cost_usd=9.0, elapsed_ms=9, days_ago=40) # 기간 밖
data = api.get("/api/v1/admin/stats").json()["data"]
t = data["totals"]
assert t["requests"] == 3 and t["inputTokens"] == 310 and t["outputTokens"] == 155 and t["totalTokens"] == 465
assert t["costUsd"] == pytest.approx(0.031) and t["avgElapsedMs"] == 1500
assert t["users"] == 2 and t["sessions"] == 2
by_user = {r["email"]: r for r in data["byUser"]}
assert by_user[user.email]["requests"] == 2 and by_user[user.email]["totalTokens"] == 450
assert by_user[user.email]["sessions"] == 1 and by_user[user.email]["avgElapsedMs"] == 2000
assert data["byUser"][0]["email"] == user.email # 비용 큰 순
assert len(data["byDay"]) == 2 and sum(r["requests"] for r in data["byDay"]) == 3
def test_range_validation(admin_api):
api, _ = admin_api
assert api.get("/api/v1/admin/stats?from=2026-13-01").status_code == 400
assert api.get("/api/v1/admin/stats?from=2026-09-10&to=2026-09-01").status_code == 400
res = api.get("/api/v1/admin/stats?from=2026-09-01&to=2026-09-02")
assert res.status_code == 200 and res.json()["data"]["from"] == "2026-09-01"
+1
View File
@@ -8,3 +8,4 @@
| 10:43 | SAP MCP 를 -12 에 따로 띄우는 준비 — OpenCode 템플릿 sap-icf 를 MCP_API_KEY 유무로 on/off 렌더, .env.example SAP 블록, README 절차(-13 번들 재활용, 컨테이너 간 통신 없이) |
| 13:24 | Esc 로 답변 중단 — 생성 중이면 Esc 가 중단(클로드처럼), 아니면 목록으로. 재진입 상태에서도 백엔드 cancel. 페이지 테스트 2개 |
| 13:30 | 스트림 타임아웃 env 화 — 첫 이벤트 60→300s, FabriX 조각 간 120→300s (Gemma4 '안녕' 200초 대비). 한 턴 600 유지 |
| 13:39 | 관리자 대시보드 — 백엔드 /admin/stats 집계(총 토큰·비용·평균 응답, 사용자별·일별, superuser 만) + 앱 /admin 페이지(카드 4·일별 막대·사용자 표·기간 프리셋). 헤더에 관리자만 아이콘 |