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:
co-authored by
Claude Fable 5.1
parent
aa37cef13d
commit
d4a5f20787
@@ -5,6 +5,7 @@ export const PATHS = {
|
|||||||
SNAP_NEW: "/snap/new",
|
SNAP_NEW: "/snap/new",
|
||||||
SNAP_SESSION: "/snap/s/:id",
|
SNAP_SESSION: "/snap/s/:id",
|
||||||
SNIPPET: "/snippet",
|
SNIPPET: "/snippet",
|
||||||
|
ADMIN: "/admin",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Path = (typeof PATHS)[keyof typeof PATHS]
|
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 { useNavigate } from "react-router-dom"
|
||||||
import { PATHS } from "@/config/routes"
|
import { PATHS } from "@/config/routes"
|
||||||
import { useLogout } from "@/features/auth/hooks/useLogout"
|
import { useLogout } from "@/features/auth/hooks/useLogout"
|
||||||
|
import { useAuthStore } from "@/features/auth/store/authStore"
|
||||||
import { ThemeToggle } from "@/shared/components/ThemeToggle"
|
import { ThemeToggle } from "@/shared/components/ThemeToggle"
|
||||||
import { Button } from "@/shared/ui/button"
|
import { Button } from "@/shared/ui/button"
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { Button } from "@/shared/ui/button"
|
|||||||
export function SnapUserControls() {
|
export function SnapUserControls() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const logout = useLogout()
|
const logout = useLogout()
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === "ADMIN")
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout.mutate(undefined, {
|
logout.mutate(undefined, {
|
||||||
@@ -18,6 +20,19 @@ export function SnapUserControls() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5">
|
<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 />
|
<ThemeToggle />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const SessionListPage = lazy(() => import("@/features/snap/pages/SessionListPage
|
|||||||
const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage"))
|
const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage"))
|
||||||
const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
|
const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
|
||||||
const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage"))
|
const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage"))
|
||||||
|
const AdminStatsPage = lazy(() => import("@/features/admin/pages/AdminStatsPage"))
|
||||||
|
|
||||||
const fallback = <CenterSpinner />
|
const fallback = <CenterSpinner />
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: PATHS.SNAP.slice(1), element: wrap(<SessionListPage />) },
|
{ path: PATHS.SNAP.slice(1), element: wrap(<SessionListPage />) },
|
||||||
{ path: PATHS.SNAP_NEW.slice(1), element: wrap(<NewChatPage />) },
|
{ path: PATHS.SNAP_NEW.slice(1), element: wrap(<NewChatPage />) },
|
||||||
{ path: PATHS.SNAP_SESSION.slice(1), element: wrap(<SessionChatPage />) },
|
{ path: PATHS.SNAP_SESSION.slice(1), element: wrap(<SessionChatPage />) },
|
||||||
|
{ path: PATHS.ADMIN.slice(1), element: wrap(<AdminStatsPage />) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,3 +64,21 @@ export interface EntraConfigResponse {
|
|||||||
authority: string
|
authority: string
|
||||||
tenantId: 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 @@
|
|||||||
{"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"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StatsConfig(AppConfig):
|
||||||
|
name = "apps.stats"
|
||||||
|
verbose_name = "관리자 통계(사용량·비용·시간)"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import AdminStatsView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("stats", AdminStatsView.as_view()),
|
||||||
|
]
|
||||||
@@ -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],
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -100,6 +100,7 @@ INSTALLED_APPS = [
|
|||||||
"apps.accounts",
|
"apps.accounts",
|
||||||
"apps.chat",
|
"apps.chat",
|
||||||
"apps.gateway",
|
"apps.gateway",
|
||||||
|
"apps.stats",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ urlpatterns = [
|
|||||||
path("api/v1/auth/", include("apps.accounts.urls")),
|
path("api/v1/auth/", include("apps.accounts.urls")),
|
||||||
path("api/v1/users/me", MeView.as_view()),
|
path("api/v1/users/me", MeView.as_view()),
|
||||||
path("api/v1/chat/", include("apps.chat.urls")),
|
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) 중계
|
path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -8,3 +8,4 @@
|
|||||||
| 10:43 | SAP MCP 를 -12 에 따로 띄우는 준비 — OpenCode 템플릿 sap-icf 를 MCP_API_KEY 유무로 on/off 렌더, .env.example SAP 블록, README 절차(-13 번들 재활용, 컨테이너 간 통신 없이) |
|
| 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:24 | Esc 로 답변 중단 — 생성 중이면 Esc 가 중단(클로드처럼), 아니면 목록으로. 재진입 상태에서도 백엔드 cancel. 페이지 테스트 2개 |
|
||||||
| 13:30 | 스트림 타임아웃 env 화 — 첫 이벤트 60→300s, FabriX 조각 간 120→300s (Gemma4 '안녕' 200초 대비). 한 턴 600 유지 |
|
| 13:30 | 스트림 타임아웃 env 화 — 첫 이벤트 60→300s, FabriX 조각 간 120→300s (Gemma4 '안녕' 200초 대비). 한 턴 600 유지 |
|
||||||
|
| 13:39 | 관리자 대시보드 — 백엔드 /admin/stats 집계(총 토큰·비용·평균 응답, 사용자별·일별, superuser 만) + 앱 /admin 페이지(카드 4·일별 막대·사용자 표·기간 프리셋). 헤더에 관리자만 아이콘 |
|
||||||
|
|||||||
Reference in New Issue
Block a user