generated from gc/template-java-maven
- 디자인 시스템 가이드 문서 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>
241 lines
9.8 KiB
TypeScript
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;
|