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 && ( + + )}