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