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
@@ -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"