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>
213 lines
8.6 KiB
TypeScript
213 lines
8.6 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
|
LineChart, Line, Cell,
|
|
} from 'recharts';
|
|
import type { ServiceStatsResponse } from '../../types/statistics';
|
|
import { getServiceStats } from '../../services/statisticsService';
|
|
import DateRangeFilter from '../../components/DateRangeFilter';
|
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
|
import { useTheme } from '../../hooks/useTheme';
|
|
|
|
const getToday = () => new Date().toISOString().slice(0, 10);
|
|
|
|
const ServiceStatsPage = () => {
|
|
const { theme } = useTheme();
|
|
const chartColors = CHART_COLORS_HEX[theme];
|
|
const [startDate, setStartDate] = useState(getToday());
|
|
const [endDate, setEndDate] = useState(getToday());
|
|
const [data, setData] = useState<ServiceStatsResponse | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await getServiceStats(startDate, endDate);
|
|
if (res.success && res.data) {
|
|
setData(res.data);
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [startDate, endDate]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handlePreset = (days: number) => {
|
|
const today = getToday();
|
|
if (days === 0) {
|
|
setStartDate(today);
|
|
} else {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - days);
|
|
setStartDate(d.toISOString().slice(0, 10));
|
|
}
|
|
setEndDate(today);
|
|
};
|
|
|
|
const hourlyTrendPivoted = useMemo(() => {
|
|
if (!data) return { data: [], serviceNames: [] };
|
|
const serviceNames = [...new Set(data.hourlyTrend.map((e) => e.serviceName))];
|
|
const byHour: Record<number, Record<string, number | string>> = {};
|
|
for (const item of data.hourlyTrend) {
|
|
if (!byHour[item.hour]) {
|
|
byHour[item.hour] = { hour: item.hour };
|
|
}
|
|
byHour[item.hour][item.serviceName] = item.count;
|
|
}
|
|
return { data: Object.values(byHour), serviceNames };
|
|
}, [data]);
|
|
|
|
const barChartData = useMemo(() => {
|
|
if (!data) return [];
|
|
return data.serviceStats.map((s) => ({
|
|
serviceName: s.serviceName,
|
|
totalRequests: s.totalRequests,
|
|
}));
|
|
}, [data]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">서비스 통계</h1>
|
|
|
|
<DateRangeFilter
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onStartDateChange={setStartDate}
|
|
onEndDateChange={setEndDate}
|
|
onPreset={handlePreset}
|
|
/>
|
|
|
|
{!data || data.serviceStats.length === 0 ? (
|
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
|
) : (
|
|
<>
|
|
{/* Summary Cards */}
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
{data.serviceStats.map((svc) => (
|
|
<div key={svc.serviceId} className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 flex-1 min-w-[200px]">
|
|
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{svc.serviceName}</p>
|
|
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
|
|
{svc.totalRequests.toLocaleString()}
|
|
</p>
|
|
<div className="flex items-center gap-3 mt-2 text-sm">
|
|
<span className="text-green-600">성공 {svc.successRate.toFixed(1)}%</span>
|
|
<span className="text-[var(--color-text-secondary)]">{svc.avgResponseTime.toFixed(0)}ms</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Charts */}
|
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
|
{/* Chart 1: Service Request Count Bar */}
|
|
<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={barChartData} layout="vertical">
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis type="number" />
|
|
<YAxis dataKey="serviceName" type="category" width={120} />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey="totalRequests" fill={chartColors[0]} name="요청 수" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Chart 2: Hourly Service Trend */}
|
|
<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>
|
|
{hourlyTrendPivoted.data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<LineChart data={hourlyTrendPivoted.data}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} />
|
|
<YAxis />
|
|
<Tooltip labelFormatter={(h) => `${h}시`} />
|
|
<Legend />
|
|
{hourlyTrendPivoted.serviceNames.map((name, idx) => (
|
|
<Line
|
|
key={name}
|
|
type="monotone"
|
|
dataKey={name}
|
|
stroke={chartColors[idx % chartColors.length]}
|
|
/>
|
|
))}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Charts Row 2: Error Rate + Response Time */}
|
|
<div className="grid grid-cols-2 gap-6">
|
|
{/* Chart: Error Rate Comparison */}
|
|
<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={data.serviceStats.map((s) => ({
|
|
serviceName: s.serviceName,
|
|
successRate: Number(s.successRate.toFixed(1)),
|
|
errorRate: Number((100 - s.successRate).toFixed(1)),
|
|
}))}
|
|
layout="vertical"
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis type="number" domain={[0, 100]} unit="%" />
|
|
<YAxis dataKey="serviceName" type="category" width={120} />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey="successRate" stackId="a" fill="#10b981" name="성공률" />
|
|
<Bar dataKey="errorRate" stackId="a" fill="#ef4444" name="에러율" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Chart: Avg Response Time Comparison */}
|
|
<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={data.serviceStats.map((s) => ({
|
|
serviceName: s.serviceName,
|
|
avgResponseTime: Number(s.avgResponseTime.toFixed(0)),
|
|
}))}
|
|
layout="vertical"
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis type="number" unit="ms" />
|
|
<YAxis dataKey="serviceName" type="category" width={120} />
|
|
<Tooltip />
|
|
<Bar dataKey="avgResponseTime" name="평균 응답시간 (ms)">
|
|
{data.serviceStats.map((s, idx) => {
|
|
const rt = s.avgResponseTime;
|
|
const color = rt < 100 ? '#10b981' : rt < 300 ? '#f59e0b' : '#ef4444';
|
|
return <Cell key={idx} fill={color} />;
|
|
})}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ServiceStatsPage;
|