snp-connection-monitoring/frontend/src/pages/statistics/UsageTrendPage.tsx
HYOJIN c2a71c1b77 feat(design): 디자인 시스템 적용 (CSS 토큰, Button/Badge, 차트, 다크모드) (#48)
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/)
- CSS 변수 토큰 시스템 (@theme + :root/.dark 전환)
- cn() 유틸리티 (clsx + tailwind-merge)
- Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응)
- 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일)
- 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX)
- 버튼 다크모드 채도/대비 강화 (primary-600 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:38:00 +09:00

241 lines
9.8 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Area, ComposedChart,
} from 'recharts';
import type { UsageTrendResponse } from '../../types/statistics';
import { getUsageTrend } from '../../services/statisticsService';
import { CHART_COLORS_HEX } from '../../constants/chart';
import { useTheme } from '../../hooks/useTheme';
type Period = 'daily' | 'weekly' | 'monthly';
const PERIOD_OPTIONS: { key: Period; label: string }[] = [
{ key: 'daily', label: '일별' },
{ key: 'weekly', label: '주별' },
{ key: 'monthly', label: '월별' },
];
const formatLabel = (label: string, period: Period): string => {
if (period === 'daily') {
const d = new Date(label);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
if (period === 'weekly') {
const d = new Date(label);
return `${d.getMonth() + 1}/${d.getDate()}~`;
}
return label;
};
const getSuccessRateColor = (rate: number): string => {
if (rate >= 99) return 'text-green-600';
if (rate >= 95) return 'text-yellow-600';
return 'text-red-600';
};
const UsageTrendPage = () => {
const { theme } = useTheme();
const chartColors = CHART_COLORS_HEX[theme];
const [period, setPeriod] = useState<Period>('daily');
const [data, setData] = useState<UsageTrendResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const res = await getUsageTrend(period);
if (res.success && res.data) {
setData(res.data);
}
} finally {
setIsLoading(false);
}
}, [period]);
useEffect(() => {
fetchData();
}, [fetchData]);
const chartData = data?.items.map((item) => ({
...item,
formattedLabel: formatLabel(item.label, period),
})) ?? [];
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"> </h1>
{/* Period Tabs */}
<div className="flex border-b border-[var(--color-border)] mb-6">
{PERIOD_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setPeriod(opt.key)}
className={`px-4 py-2 text-sm font-medium -mb-px ${
period === opt.key
? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
>
{opt.label}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-[var(--color-text-secondary)]"> ...</div>
</div>
) : !data || data.items.length === 0 ? (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
) : (
<>
{/* Chart 1: 요청 수 추이 (full width) */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={chartData}>
<defs>
<linearGradient id="totalRequestsFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors[0]} stopOpacity={0.15} />
<stop offset="95%" stopColor={chartColors[0]} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis />
<Tooltip />
<Legend />
<Area
type="monotone"
dataKey="totalRequests"
stroke="none"
fill="url(#totalRequestsFill)"
name="총 요청"
/>
<Line
type="monotone"
dataKey="totalRequests"
stroke={chartColors[0]}
name="총 요청"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="failureCount"
stroke="#ef4444"
name="실패 수"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Charts 2 & 3: 2 column grid */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 2: 성공률 + 응답시간 추이 */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> + </h3>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis yAxisId="left" unit="%" domain={[0, 100]} />
<YAxis yAxisId="right" orientation="right" unit="ms" />
<Tooltip />
<Legend />
<Line
yAxisId="left"
type="monotone"
dataKey="successRate"
stroke="#10b981"
name="성공률(%)"
strokeWidth={2}
dot={false}
/>
<Bar
yAxisId="right"
dataKey="avgResponseTime"
fill={chartColors[0]}
name="평균 응답시간(ms)"
barSize={20}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Chart 3: 활성 사용자 추이 */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="activeUsers" fill={chartColors[1]} name="활성 사용자" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Table: 상세 데이터 */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]"> </h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">(%)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"> (ms)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"> </th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{data.items.map((item) => (
<tr key={item.label} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{formatLabel(item.label, period)}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{item.totalRequests.toLocaleString()}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{item.successCount.toLocaleString()}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{item.failureCount.toLocaleString()}
</td>
<td className={`px-4 py-3 font-medium ${getSuccessRateColor(item.successRate)}`}>
{item.successRate.toFixed(1)}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{item.avgResponseTime.toFixed(0)}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{item.activeUsers.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
};
export default UsageTrendPage;