Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions admin-web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Authenticated, Refine } from '@refinedev/core'
import routerBindings, { CatchAllNavigate, NavigateToResource } from '@refinedev/react-router-v6'
import { BrowserRouter, Outlet, Route, Routes } from 'react-router-dom'
import routerBindings, { CatchAllNavigate } from '@refinedev/react-router-v6'
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom'
import { firebaseAuthProvider } from '@/providers/authProvider'
import { devAuthProvider } from '@/providers/devAuthProvider'
import { dataProvider } from '@/providers/dataProvider'
import { AdminLayout } from '@/components/layout/AdminLayout'
import { DashboardPage } from '@/pages/dashboard'
import { LoginPage } from '@/pages/login'
import { MemberList } from '@/pages/members/list'
import { MemberShow } from '@/pages/members/show'
Expand Down Expand Up @@ -35,7 +36,8 @@ export default function App() {
</Authenticated>
}
>
<Route index element={<NavigateToResource resource="members" />} />
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/members" element={<MemberList />} />
<Route path="/members/:id" element={<MemberShow />} />
<Route path="/admin-accounts" element={<AdminAccountList />} />
Expand All @@ -45,7 +47,7 @@ export default function App() {
<Route
element={
<Authenticated key="public" fallback={<Outlet />}>
<NavigateToResource resource="members" />
<Navigate to="/dashboard" replace />
</Authenticated>
}
>
Expand Down
2 changes: 1 addition & 1 deletion admin-web/src/components/layout/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface NavItem {
}

const NAV: NavItem[] = [
{ label: '대시보드', to: '/dashboard', icon: LayoutDashboard, disabled: true },
{ label: '대시보드', to: '/dashboard', icon: LayoutDashboard },
{ label: '회원 관리', to: '/members', icon: Users },
{ label: '관리자 관리', to: '/admin-accounts', icon: ShieldCheck },
{ label: 'LLM 설정', to: '/llm-settings', icon: Sparkles },
Expand Down
39 changes: 39 additions & 0 deletions admin-web/src/components/metric-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'

type Tone = 'default' | 'danger' | 'success'

const TONE_VALUE: Record<Tone, string> = {
default: 'text-foreground',
danger: 'text-red-600',
success: 'text-emerald-600',
}

/**
* 대시보드 KPI 한 칸. 라벨 + 큰 수치 + (선택) 보조 설명. tone 으로 위험/정상 강조를 표현한다.
*/
export function MetricCard({
label,
value,
hint,
tone = 'default',
icon,
}: {
label: string
value: string
hint?: ReactNode
tone?: Tone
icon?: ReactNode
}) {
return (
<Card className="p-5">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{label}</p>
{icon && <span className="text-muted-foreground/60">{icon}</span>}
</div>
<p className={cn('mt-1.5 text-2xl font-semibold tracking-tight tabular-nums', TONE_VALUE[tone])}>{value}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</Card>
)
}
46 changes: 46 additions & 0 deletions admin-web/src/pages/dashboard/chart-shared.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'

/** "2026-07-26" → "7/26" (차트 X축 라벨용). */
export function shortDate(date: string): string {
const [, month, day] = date.split('-')
return `${Number(month)}/${Number(day)}`
}

interface SeriesPayload {
name?: string
value?: number
color?: string
}

/** 여러 시리즈(대화·카드·메시지, 성공·실패 등)를 색점과 함께 보여주는 공통 차트 툴팁. */
export function SeriesTooltip(props: { active?: boolean; payload?: SeriesPayload[]; label?: string }) {
if (!props.active || !props.payload?.length) {
return null
}
return (
<div className="rounded-md border bg-background px-2.5 py-1.5 text-xs shadow-sm">
<p className="mb-1 font-medium">{props.label}</p>
{props.payload.map((p) => (
<p key={p.name} className="flex items-center gap-1.5 text-muted-foreground">
<span className="size-2 rounded-full" style={{ background: p.color }} />
{p.name} {p.value ?? 0}
</p>
))}
</div>
)
}

/** 대시보드 섹션의 지표 로드 실패 상태. "데이터 없음"과 구분해 재시도를 제공한다. */
export function SectionError({ onRetry }: { onRetry: () => void }) {
return (
<Card className="flex flex-col items-start gap-3 p-6">
<p className="text-sm text-muted-foreground">지표를 불러오지 못했습니다.</p>
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCcw className="size-4" />
다시 시도
</Button>
</Card>
)
}
38 changes: 38 additions & 0 deletions admin-web/src/pages/dashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Suspense, lazy } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '@/components/page-header'

// 차트(recharts)는 무거워서 지연 로드해 초기·로그인 번들에서 제외한다.
const UsageSection = lazy(() => import('./usage-section').then((m) => ({ default: m.UsageSection })))
const QualitySection = lazy(() => import('./quality-section').then((m) => ({ default: m.QualitySection })))

function SectionTitle({ title, description }: { title: string; description: string }) {
return (
<div className="space-y-0.5">
<h2 className="text-sm font-semibold tracking-tight text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
)
}

export function DashboardPage() {
return (
<div className="space-y-8">
<PageHeader title="대시보드" description="서비스 사용량과 LLM 생성 품질을 한눈에 확인합니다. (최근 14일 기준)" />

<section className="space-y-4">
<SectionTitle title="사용량 / 도입" description="오늘의 활동량과 회원·감정 지표" />
<Suspense fallback={<Skeleton className="h-[380px]" />}>
<UsageSection />
</Suspense>
</section>

<section className="space-y-4">
<SectionTitle title="LLM 품질 / 안정성" description="생성 성공률·지연·토큰과 즉시 대응이 필요한 신호" />
<Suspense fallback={<Skeleton className="h-[380px]" />}>
<QualitySection />
</Suspense>
</section>
</div>
)
}
138 changes: 138 additions & 0 deletions admin-web/src/pages/dashboard/quality-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useCustom } from '@refinedev/core'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { AlertTriangle, Coins, Gauge, RefreshCw } from 'lucide-react'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { MetricCard } from '@/components/metric-card'
import { SectionError, SeriesTooltip, shortDate } from './chart-shared'

interface DailyGeneration {
date: string
success: number
failed: number
}

interface QualityStats {
totalGenerations: number
successGenerations: number
failedGenerations: number
successRate: number | null
totalLlmCalls: number
retryRate: number | null
avgLatencyMs: number
p95LatencyMs: number
totalTokens: number
stuckPending: number
dailyGeneration: DailyGeneration[]
}

const SUCCESS_COLOR = '#10b981'
const FAILED_COLOR = '#ef4444'

function latency(ms: number): string {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(1)}s`
}
return `${ms}ms`
}

function percent(value: number | null): string {
if (value === null) {
return '—'
}
return `${value}%`
}

export function QualitySection() {
const { data, isLoading, isError, refetch } = useCustom<QualityStats>({ url: '/api/admin/dashboard/quality', method: 'get' })

if (isError && !data?.data) {
return <SectionError onRetry={() => refetch()} />
}

if (isLoading) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-[92px]" />
))}
</div>
<Skeleton className="h-[248px]" />
</div>
)
}

const stats = data?.data
if (!stats) {
return null
}

const successTone = stats.successRate === null ? 'default' : stats.successRate < 90 ? 'danger' : 'success'
const pendingTone = stats.stuckPending > 0 ? 'danger' : 'default'
const trend = stats.dailyGeneration.map((d) => ({ label: shortDate(d.date), success: d.success, failed: d.failed }))

return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="생성 성공률"
value={percent(stats.successRate)}
tone={successTone}
hint={`성공 ${stats.successGenerations.toLocaleString()} / 총 ${stats.totalGenerations.toLocaleString()}`}
/>
<MetricCard
label="실패한 생성"
value={stats.failedGenerations.toLocaleString()}
tone={stats.failedGenerations > 0 ? 'danger' : 'default'}
/>
<MetricCard
label="막힌 PENDING"
value={stats.stuckPending.toLocaleString()}
tone={pendingTone}
hint={pendingTone === 'danger' ? '고아 생성 — 확인 필요' : '고아 생성 없음'}
icon={<AlertTriangle className="size-4" />}
/>
<MetricCard
label="재시도율"
value={percent(stats.retryRate)}
hint={`LLM 호출 ${stats.totalLlmCalls.toLocaleString()}회`}
icon={<RefreshCw className="size-4" />}
/>
<MetricCard label="평균 지연" value={latency(stats.avgLatencyMs)} icon={<Gauge className="size-4" />} />
<MetricCard label="p95 지연" value={latency(stats.p95LatencyMs)} hint="상위 5% 대기시간" />
<MetricCard label="토큰 사용량" value={stats.totalTokens.toLocaleString()} hint="최근 기간 누적" icon={<Coins className="size-4" />} />
<MetricCard label="총 생성 요청" value={stats.totalGenerations.toLocaleString()} hint="최근 기간" />
</div>

<Card className="p-5">
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium">생성 성공·실패 추이</p>
<p className="text-xs text-muted-foreground">최근 {trend.length}일</p>
</div>
<div className="mt-2 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={trend} margin={{ top: 8, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
<XAxis dataKey="label" tickLine={false} axisLine={false} tick={{ fontSize: 11, fill: '#a1a1aa' }} minTickGap={16} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} width={28} tick={{ fontSize: 11, fill: '#a1a1aa' }} />
<Tooltip content={<SeriesTooltip />} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.4 }} />
<Bar dataKey="success" name="성공" stackId="gen" fill={SUCCESS_COLOR} radius={[0, 0, 0, 0]} />
<Bar dataKey="failed" name="실패" stackId="gen" fill={FAILED_COLOR} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex items-center justify-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ background: SUCCESS_COLOR }} />
성공
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ background: FAILED_COLOR }} />
실패
</span>
</div>
</Card>
</div>
)
}
Loading