From d4a5f20787ce1b45d068e552be4018b0d08733bd Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Mon, 21 Sep 2026 13:39:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=E2=80=94=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=C2=B7=EB=B9=84=EC=9A=A9=C2=B7=EC=9D=91=EB=8B=B5=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=A7=91=EA=B3=84=20API=20+=20=EC=95=B1=20/admin?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- 2_frontend/src/config/routes.ts | 1 + .../src/features/admin/api/admin.api.ts | 20 ++ .../admin/pages/AdminStatsPage.test.tsx | 99 ++++++++++ .../features/admin/pages/AdminStatsPage.tsx | 178 ++++++++++++++++++ .../snap/components/SnapUserControls.tsx | 17 +- 2_frontend/src/routes.tsx | 2 + 2_frontend/src/types/api.ts | 18 ++ 2_frontend/tsconfig.tsbuildinfo | 2 +- 5_django_backend/apps/stats/__init__.py | 0 5_django_backend/apps/stats/apps.py | 6 + 5_django_backend/apps/stats/urls.py | 7 + 5_django_backend/apps/stats/views.py | 106 +++++++++++ 5_django_backend/config/settings.py | 1 + 5_django_backend/config/urls.py | 1 + 5_django_backend/tests/test_stats.py | 60 ++++++ z-my-docs/work-log/2026-09/2026-09-21.md | 1 + 16 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 2_frontend/src/features/admin/api/admin.api.ts create mode 100644 2_frontend/src/features/admin/pages/AdminStatsPage.test.tsx create mode 100644 2_frontend/src/features/admin/pages/AdminStatsPage.tsx create mode 100644 5_django_backend/apps/stats/__init__.py create mode 100644 5_django_backend/apps/stats/apps.py create mode 100644 5_django_backend/apps/stats/urls.py create mode 100644 5_django_backend/apps/stats/views.py create mode 100644 5_django_backend/tests/test_stats.py diff --git a/2_frontend/src/config/routes.ts b/2_frontend/src/config/routes.ts index 8a92719..8162481 100644 --- a/2_frontend/src/config/routes.ts +++ b/2_frontend/src/config/routes.ts @@ -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] diff --git a/2_frontend/src/features/admin/api/admin.api.ts b/2_frontend/src/features/admin/api/admin.api.ts new file mode 100644 index 0000000..ba31c48 --- /dev/null +++ b/2_frontend/src/features/admin/api/admin.api.ts @@ -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("/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, + }) +} diff --git a/2_frontend/src/features/admin/pages/AdminStatsPage.test.tsx b/2_frontend/src/features/admin/pages/AdminStatsPage.test.tsx new file mode 100644 index 0000000..6bbde95 --- /dev/null +++ b/2_frontend/src/features/admin/pages/AdminStatsPage.test.tsx @@ -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()), + 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( + + + + + + ) +} + +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) + }) +}) diff --git a/2_frontend/src/features/admin/pages/AdminStatsPage.tsx b/2_frontend/src/features/admin/pages/AdminStatsPage.tsx new file mode 100644 index 0000000..b0f1d76 --- /dev/null +++ b/2_frontend/src/features/admin/pages/AdminStatsPage.tsx @@ -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 ( +
+ 관리자만 볼 수 있어 +
+ ) + } + + const t = data?.totals + const maxDayTokens = Math.max(1, ...(data?.byDay.map((d) => d.totalTokens) ?? [0])) + + return ( +
+
+ +
+ {PRESETS.map((p) => ( + + ))} +
+
+ +
+

사용량

+

+ {from} ~ {to} · assistant 답변 기준 +

+
+ + {error instanceof ApiError &&
{error.message}
} + {isLoading &&
불러오는 중…
} + + {t && ( + <> + {/* 요약 카드 4개 */} +
+ + + + +
+ + {/* 일별 토큰 막대 — 라이브러리 없이 div 로 */} +
+
+ 일별 토큰 +
+ {data.byDay.length === 0 ? ( +
+ 기간 내 데이터 없음 +
+ ) : ( +
+ {data.byDay.map((d) => ( +
+ ))} +
+ )} +
+ + {/* 사용자별 표 */} +
+ + + + + + + + + + + + + {data.byUser.length === 0 && ( + + + + )} + {data.byUser.map((u) => ( + + + + + + + + + ))} + +
사용자요청세션토큰비용평균 응답
+ 기간 내 사용자 없음 +
+
{u.name || u.email}
+ {u.name &&
{u.email}
} +
{fmtInt(u.requests)}{fmtInt(u.sessions)}{fmtInt(u.totalTokens)}{fmtUsd(u.costUsd)}{fmtSec(u.avgElapsedMs)}
+
+ + )} +
+ ) +} + +function Stat({ label, value, sub }: { label: string; value: string; sub: string }) { + return ( +
+
{label}
+
{value}
+
{sub}
+
+ ) +} diff --git a/2_frontend/src/features/snap/components/SnapUserControls.tsx b/2_frontend/src/features/snap/components/SnapUserControls.tsx index ccec12c..0f53e3c 100644 --- a/2_frontend/src/features/snap/components/SnapUserControls.tsx +++ b/2_frontend/src/features/snap/components/SnapUserControls.tsx @@ -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 (
+ {isAdmin && ( + + )}