snp-connection-monitoring/frontend/src/pages/statistics/UserStatsPage.tsx
HYOJIN 88e25abe14 feat(frontend): 디자인 시스템 적용 및 전체 UI 개선 (#42)
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info)
- PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용
- SERVICE_BADGE_VARIANTS 공통 상수 추출
- 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영
- 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs)
- 타이틀 아이콘 전체 페이지 통일
- 카드 테두리 디자인 통일 (border + rounded-xl)
- FHD 1920x1080 최적화
2026-04-17 14:45:27 +09:00

397 lines
17 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import type { UserStatsResponse, UserRoleDistribution } from '../../types/statistics';
import { getUserStats } from '../../services/statisticsService';
import PeriodFilter from '../../components/PeriodFilter';
import Badge from '../../components/ui/Badge';
import { CHART_COLORS_HEX } from '../../constants/chart';
import { useTheme } from '../../hooks/useTheme';
// 역할별 Badge variant 매핑
const ROLE_BADGE_VARIANT: Record<string, 'rose' | 'blue' | 'teal' | 'lavender' | 'coral' | 'gold'> = {
ADMIN: 'rose',
MANAGER: 'blue',
USER: 'teal',
};
const ROLE_DONUT_COLORS_HEX = {
light: ['#507FB9', '#B59854', '#B5607D', '#3D998A', '#8B6DB5', '#C07850'],
dark: ['#6D94C5', '#C4B48C', '#D4839B', '#5BB5A6', '#B79FD4', '#D4956B'],
} as const;
const successRateClass = (rate: number) => {
if (rate >= 90) return 'text-[var(--color-success)]';
if (rate >= 70) return 'text-[var(--color-warning)]';
return 'text-[var(--color-danger)]';
};
// SVG 도넛 차트 (역할별 분포)
interface DonutChartProps {
data: UserRoleDistribution[];
colors: readonly string[];
}
const DonutChart = ({ data, colors }: DonutChartProps) => {
const total = data.reduce((sum, d) => sum + d.requestCount, 0);
const size = 100;
const cx = size / 2;
const cy = size / 2;
const r = 36;
const strokeWidth = 18;
const circumference = 2 * Math.PI * r;
let cumulativePct = 0;
return (
<div className="flex items-center gap-4">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0">
{/* 배경 트랙 */}
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke="var(--color-border)"
strokeWidth={strokeWidth}
/>
{total === 0 ? null : data.map((segment, idx) => {
const pct = segment.requestCount / total;
const dashArray = circumference * pct;
const dashOffset = circumference * (1 - cumulativePct);
cumulativePct += pct;
return (
<circle
key={segment.role}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={colors[idx % colors.length]}
strokeWidth={strokeWidth}
strokeDasharray={`${dashArray} ${circumference - dashArray}`}
strokeDashoffset={dashOffset}
transform={`rotate(-90 ${cx} ${cy})`}
/>
);
})}
<text x={cx} y={cy - 5} textAnchor="middle" fontSize={10} fill="var(--color-text-tertiary)"></text>
<text x={cx} y={cy + 9} textAnchor="middle" fontSize={11} fontWeight={700} fill="var(--color-text-primary)">
{total.toLocaleString()}
</text>
</svg>
{/* 범례 */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
{data.map((segment, idx) => {
const pct = total > 0 ? ((segment.requestCount / total) * 100).toFixed(1) : '0.0';
return (
<div key={segment.role} className="flex items-center gap-2">
<span
className="shrink-0 w-2 h-2 rounded-full"
style={{ backgroundColor: colors[idx % colors.length] }}
/>
<span className="text-xs text-[var(--color-text-secondary)] min-w-[52px]">{segment.role}</span>
<span className="text-xs font-medium text-[var(--color-text-primary)] ml-auto">
{segment.requestCount.toLocaleString()}
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] w-10 text-right shrink-0">
{pct}%
</span>
</div>
);
})}
</div>
</div>
);
};
// KPI 카드
interface KpiCardProps {
icon: 'users' | 'key' | 'activity';
label: string;
value: number;
total?: number;
}
const KpiCard = ({ icon, label, value, total }: KpiCardProps) => {
const pct = total && total > 0 ? Math.min(100, (value / total) * 100) : null;
return (
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-8 h-8 rounded-md bg-[var(--color-primary-subtle)]">
{icon === 'users' && (
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)}
{icon === 'key' && (
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
)}
{icon === 'activity' && (
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
)}
</span>
<span className="text-xs text-[var(--color-text-secondary)]">{label}</span>
</div>
<p className="text-3xl font-extrabold text-[var(--color-text-primary)] leading-none">
{value.toLocaleString()}
</p>
{pct !== null && (
<div className="flex items-center gap-2 mt-auto">
<div className="flex-1 h-1.5 bg-[var(--color-bg-base)] rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: 'var(--color-primary)' }}
/>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] shrink-0 w-10 text-right">
{pct.toFixed(1)}%
</span>
</div>
)}
</div>
);
};
// 메인 페이지
const UserStatsPage = () => {
const { theme } = useTheme();
const chartColors = CHART_COLORS_HEX[theme];
const donutColors = ROLE_DONUT_COLORS_HEX[theme];
const [startDate, setStartDate] = useState(() => {
const d = new Date(); d.setDate(d.getDate() - 7); return d.toISOString().slice(0, 10);
});
const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0, 10));
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 maxRequests = useMemo(() => {
if (!data || data.topUsers.length === 0) return 1;
return Math.max(...data.topUsers.map((u) => u.requestCount), 1);
}, [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 flex flex-col gap-4">
{/* 헤더: 제목 + PeriodFilter */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-[var(--color-text-primary)]"> </h1>
<p className="text-sm text-[var(--color-text-secondary)]"> API </p>
</div>
</div>
<PeriodFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onRefresh={fetchData}
/>
</div>
{!data ? (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
) : (
<>
{/* 1행: KPI 카드 3개 */}
<div className="grid grid-cols-3 gap-3">
<KpiCard
icon="users"
label="전체 사용자"
value={data.totalUsers}
/>
<KpiCard
icon="key"
label="API Key 보유 사용자"
value={data.usersWithActiveKey}
total={data.totalUsers}
/>
<KpiCard
icon="activity"
label="API 요청 사용자"
value={data.totalActiveUsers}
total={data.totalUsers}
/>
</div>
{/* 2행: 일별 추이(좌 60%) + 역할별 분포(우 40%) */}
<div className="grid grid-cols-5 gap-3">
{/* 좌: 일별 활성 사용자 추이 */}
<div className="col-span-3 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3"> API </h3>
{data.dailyActiveUsers.length > 0 ? (
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={data.dailyActiveUsers}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} width={35} />
<Tooltip contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area
type="monotone"
dataKey="activeUsers"
stroke={chartColors[0]}
fill={chartColors[0]}
fillOpacity={0.2}
name="API 요청 사용자"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center flex-1">
<p className="text-[var(--color-text-tertiary)] text-sm"> </p>
</div>
)}
</div>
{/* 우: 역할별 요청 분포 (CSS 도넛) */}
<div className="col-span-2 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3"> </h3>
{data.roleDistribution.length > 0 ? (
<div className="flex items-center justify-center flex-1">
<DonutChart data={data.roleDistribution} colors={donutColors} />
</div>
) : (
<div className="flex items-center justify-center flex-1">
<p className="text-[var(--color-text-tertiary)] text-sm"> </p>
</div>
)}
</div>
</div>
{/* 3행: 상위 사용자 Top 5 테이블 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<div className="px-4 py-3 border-b border-[var(--color-border)]">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]"> Top 5</h3>
</div>
{data.topUsers.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr className="h-8">
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-16"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-28"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider"> </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-28"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{data.topUsers.slice(0, 5).map((user, idx) => {
const rank = idx + 1;
const rankBg = rank <= 3
? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary-text)]'
: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]';
const reqPct = maxRequests > 0 ? (user.requestCount / maxRequests) * 100 : 0;
return (
<tr key={user.userId} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
{/* 순위 뱃지 */}
<td className="px-3 py-1">
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-md text-xs font-bold ${rankBg}`}>
{rank}
</span>
</td>
{/* 사용자명 (마스킹) */}
<td className="px-3 py-1">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />
</svg>
<span className="font-medium text-[var(--color-text-primary)]">
{user.userName.length > 0 ? `${user.userName.charAt(0)}***` : '-'}
</span>
</div>
</td>
{/* 역할 Badge pill */}
<td className="px-3 py-1">
<Badge
variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'}
size="sm"
>
{user.role}
</Badge>
</td>
{/* 요청 수 + 프로그레스 바 */}
<td className="px-3 py-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-[var(--color-bg-base)] rounded-full overflow-hidden min-w-[80px]">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${reqPct}%`, backgroundColor: 'var(--color-primary)' }}
/>
</div>
<span className="text-xs font-medium text-[var(--color-text-primary)] w-16 text-right shrink-0">
{user.requestCount.toLocaleString()}
</span>
</div>
</td>
{/* 성공률 색상 코딩 */}
<td className="px-3 py-1">
<span className={`text-xs font-semibold ${successRateClass(user.successRate)}`}>
{user.successRate.toFixed(1)}%
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-8 text-sm"> </p>
)}
</div>
</>
)}
</div>
);
};
export default UserStatsPage;