snp-connection-monitoring/frontend/src/pages/statistics/UserStatsPage.tsx
HYOJIN 8ebac1fa54 feat(stats): 통계 메뉴 + 대시보드 피드백 반영
통계 메뉴 (5개 서브페이지):
- 서비스 통계 (요약카드+에러율비교+응답시간분포+시간별추이)
- 사용자 통계 (전체/API Key보유/API요청 사용자+역할분포+Top10)
- API 통계 (호출순위+에러순위+메서드분포+상태코드분포)
- 테넌트 통계 (요약카드+일별추이+API Key현황)
- 사용량 추이 (일별/주별/월별 탭, 요청수+성공률+응답시간+활성사용자)

대시보드 피드백:
- 요약카드 전일대비 소숫점 2자리
- 하트비트 카드형 (프로그레스바 제거, flex 균등분할)
- 테넌트 차트 제거
- 상위 API URL 쿼리파라미터 정규화 (SPLIT_PART)
- Gateway request_url 저장 시 쿼리스트링 제외
- "활성 사용자" → "API 요청 사용자" 라벨 변경

서비스 통계: 요약카드 flex 유동너비, 에러율+응답시간 차트 교체
사용자 통계: API Key 보유 사용자 카드 추가, flex 균등분할
API 통계: 타이틀 변경, 쿼리파라미터 제외 쿼리, 프로그레스바 분리
테넌트 통계: flex 균등분할, 빈 테넌트명 Unknown 처리

Closes #23

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

194 lines
8.2 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
PieChart, Pie, Cell,
} from 'recharts';
import type { UserStatsResponse } from '../../types/statistics';
import { getUserStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const ROLE_BADGE: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-blue-100 text-blue-800',
USER: 'bg-green-100 text-green-800',
};
const getToday = () => new Date().toISOString().slice(0, 10);
const UserStatsPage = () => {
const [startDate, setStartDate] = useState(getToday());
const [endDate, setEndDate] = useState(getToday());
const [data, setData] = useState<UserStatsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const res = await getUserStats(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);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400"> ...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6"> </h1>
<DateRangeFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
{!data ? (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
) : (
<>
{/* Summary Cards */}
<div className="flex gap-4 mb-6">
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500 dark:text-gray-400"> </p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalUsers}</p>
</div>
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500 dark:text-gray-400">API Key </p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.usersWithActiveKey}</p>
</div>
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500 dark:text-gray-400">API </p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalActiveUsers}</p>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Daily Active Users */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> API </h3>
{data.dailyActiveUsers.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data.dailyActiveUsers}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Area
type="monotone"
dataKey="activeUsers"
stroke="#3b82f6"
fill="#3b82f6"
fillOpacity={0.3}
name="API 요청 사용자"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
{/* Chart 2: Role Distribution Donut */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{data.roleDistribution.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.roleDistribution}
dataKey="requestCount"
nameKey="role"
innerRadius={60}
outerRadius={100}
>
{data.roleDistribution.map((_, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
</div>
{/* Table: Top Users */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> Top 10</h3>
</div>
{data.topUsers.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{data.topUsers.slice(0, 10).map((user, idx) => (
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ROLE_BADGE[user.role] ?? 'bg-gray-100 text-gray-800'}`}>
{user.role}
</span>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.requestCount.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.successRate.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-8"> </p>
)}
</div>
</>
)}
</div>
);
};
export default UserStatsPage;