generated from gc/template-java-maven
통계 메뉴 (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>
194 lines
8.2 KiB
TypeScript
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;
|