Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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>
)
}
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>
)
}
159 changes: 159 additions & 0 deletions admin-web/src/pages/dashboard/quality-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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'

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}%`
}

function short(date: string): string {
const [, month, day] = date.split('-')
return `${Number(month)}/${Number(day)}`
}

function GenerationTooltip(props: {
active?: boolean
payload?: { name?: string; value?: number; color?: string }[]
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>
)
}
Comment thread
theminjunchoi marked this conversation as resolved.
Outdated

export function QualitySection() {
const { data, isLoading } = useCustom<QualityStats>({ url: '/api/admin/dashboard/quality', method: 'get' })
Comment thread
theminjunchoi marked this conversation as resolved.
Outdated

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: short(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={<GenerationTooltip />} 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