Merge pull request 'feat(stats): 통계 메뉴 + 대시보드 피드백 반영' (#24) from feature/ISSUE-23-stats-menu into develop

This commit is contained in:
HYOJIN 2026-04-09 11:05:26 +09:00
커밋 126e632f5b
25개의 변경된 파일2198개의 추가작업 그리고 102개의 파일을 삭제

파일 보기

@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased]
### 추가
- 통계 메뉴 5개 (서비스/사용자/API/테넌트/사용량 추이) (#23)
- DateRangeFilter 재사용 컴포넌트 (오늘/7일/30일/커스텀) (#23)
- 사용자 통계: API Key 보유 사용자 카드 (#23)
- 사용량 추이: 일별/주별/월별 탭, 요청수+성공률+응답시간+사용자 차트 (#23)
### 변경
- 대시보드: 하트비트 카드형 + flex 균등분할, 테넌트 차트 제거 (#23)
- 대시보드: 상위 API URL 쿼리파라미터 정규화 (SPLIT_PART) (#23)
- Gateway: request_url 저장 시 쿼리스트링 제외 (#23)
- 라벨 변경: "활성 사용자" → "API 요청 사용자" (#23)
- 서비스 통계: 느린 서비스 → 에러율 비교 + 응답시간 분포 교체 (#23)
- API 통계: 타이틀 변경 (호출 순위/에러 순위), 쿼리파라미터 제외 (#23)
- 테넌트/서비스/사용자 요약카드: flex 균등분할 (#23)
## [2026-04-08.2]
### 추가

파일 보기

@ -0,0 +1,88 @@
# 대시보드 + 통계 메뉴 가이드
## Dashboard (`/dashboard`)
실시간 모니터링 (30초 자동 갱신)
| 섹션 | 내용 |
|------|------|
| 요약 카드 4개 | 오늘 총 요청(전일 대비%), 성공률(실패건수), 평균 응답시간(ms), 활성 사용자 수 |
| 하트비트 상태 바 | 서비스별 UP/DOWN 상태, 응답시간, 마지막 체크 시간 |
| 시간별 요청 추이 | LineChart (성공/실패, 0~23시) |
| 서비스별 요청 비율 | PieChart 도넛 |
| 에러율 추이 | AreaChart (시간별, 서비스별) |
| 상위 호출 API | 서비스 태그 + API명 + 프로그레스바 Top 10 |
| 테넌트별 요청 비율 | PieChart 도넛 |
| 테넌트별 사용자 비율 | PieChart 도넛 |
| 최근 요청 로그 | 최근 5건 + 더보기 링크 |
---
## Statistics > 서비스 통계 (`/statistics/services`)
기간 선택: 오늘 / 7일 / 30일 / 커스텀
| 섹션 | 내용 | 데이터 소스 |
|------|------|------------|
| 요약 카드 | 서비스별 요청수, 성공률%, 평균 응답시간 | request_log + service |
| 서비스별 요청 수 | BarChart 수평 | request_log GROUP BY service |
| 시간별 서비스 요청 추이 | LineChart (서비스별 라인) | request_log GROUP BY hour, service |
| 느린 서비스 Top 5 | 테이블 (서비스명, 평균응답시간, 요청수) | request_log AVG(response_time) |
---
## Statistics > 사용자 통계 (`/statistics/users`)
기간 선택: 오늘 / 7일 / 30일 / 커스텀
| 섹션 | 내용 | 데이터 소스 |
|------|------|------------|
| 활성 사용자 수 | 카드 | request_log DISTINCT user_id |
| 일별 활성 사용자 추이 | AreaChart | request_log GROUP BY date |
| 역할별 요청 분포 | PieChart 도넛 (ADMIN/MANAGER/USER/VIEWER) | request_log + user GROUP BY role |
| 상위 사용자 Top 10 | 테이블 (이름, 역할 배지, 요청수, 성공률%) | request_log GROUP BY user |
---
## Statistics > API 통계 (`/statistics/apis`)
기간 선택: 오늘 / 7일 / 30일 / 커스텀
| 섹션 | 내용 | 데이터 소스 |
|------|------|------------|
| HTTP 메서드 분포 | PieChart 도넛 (GET/POST/PUT/DELETE) | request_log GROUP BY method |
| HTTP 상태 코드 분포 | BarChart (200/400/401/403/500...) | request_log GROUP BY response_status |
| 상위 호출 API Top 20 | 테이블 (서비스 태그, API명, 메서드 배지, 호출수, 응답시간, 성공률) | request_log + service + service_api |
| 에러 많은 API Top 10 | 테이블 (서비스 태그, API명, 에러수, 전체수, 에러율%) | request_log WHERE status != SUCCESS |
---
## Statistics > 테넌트 통계 (`/statistics/tenants`)
기간 선택: 오늘 / 7일 / 30일 / 커스텀
| 섹션 | 내용 | 데이터 소스 |
|------|------|------------|
| 요약 카드 | 테넌트별 요청수, 활성 사용자, 성공률 | request_log + tenant |
| 일별 테넌트 요청 추이 | LineChart (테넌트별 라인) | request_log GROUP BY date, tenant |
| 테넌트별 API Key 현황 | BarChart (전체 키 vs 활성 키) | api_key + user + tenant |
| 테넌트 상세 | 테이블 (테넌트명, 요청수, 활성사용자, 성공률%, 평균응답시간) | request_log GROUP BY tenant |
---
## Statistics > 사용량 추이 (`/statistics/usage-trend`)
기간 탭: [일별] [주별] [월별]
| 섹션 | 내용 | 데이터 소스 |
|------|------|------------|
| 요청 수 추이 | LineChart + Area (총 요청 + 실패 분리) | request_log GROUP BY date/week/month |
| 성공률 + 응답시간 | ComposedChart 이중 Y축 (Line 성공률% + Bar 평균응답시간ms) | request_log |
| 활성 사용자 추이 | BarChart (기간별 사용자 수) | request_log DISTINCT user_id |
| 상세 테이블 | 기간, 총 요청, 성공, 실패, 성공률%, 평균응답시간, 활성 사용자 | request_log |
| 기간 | 범위 |
|------|------|
| 일별 | 최근 30일 |
| 주별 | 최근 12주 |
| 월별 | 최근 12개월 |

파일 보기

@ -16,6 +16,11 @@ import KeyAdminPage from './pages/apikeys/KeyAdminPage';
import ServicesPage from './pages/admin/ServicesPage';
import UsersPage from './pages/admin/UsersPage';
import TenantsPage from './pages/admin/TenantsPage';
import ServiceStatsPage from './pages/statistics/ServiceStatsPage';
import UserStatsPage from './pages/statistics/UserStatsPage';
import ApiStatsPage from './pages/statistics/ApiStatsPage';
import TenantStatsPage from './pages/statistics/TenantStatsPage';
import UsageTrendPage from './pages/statistics/UsageTrendPage';
import NotFoundPage from './pages/NotFoundPage';
const BASE_PATH = '/snp-connection';
@ -38,6 +43,11 @@ const App = () => {
<Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
<Route path="/monitoring/service-status" element={<ServiceStatusPage />} />
<Route path="/monitoring/service-status/:serviceId" element={<ServiceStatusDetailPage />} />
<Route path="/statistics/services" element={<ServiceStatsPage />} />
<Route path="/statistics/users" element={<UserStatsPage />} />
<Route path="/statistics/apis" element={<ApiStatsPage />} />
<Route path="/statistics/tenants" element={<TenantStatsPage />} />
<Route path="/statistics/usage-trend" element={<UsageTrendPage />} />
<Route path="/apikeys/my-keys" element={<MyKeysPage />} />
<Route path="/apikeys/request" element={<KeyRequestPage />} />
<Route path="/apikeys/admin" element={<KeyAdminPage />} />

파일 보기

@ -0,0 +1,73 @@
interface DateRangeFilterProps {
startDate: string;
endDate: string;
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
onPreset: (days: number) => void;
}
const getToday = () => {
const d = new Date();
return d.toISOString().slice(0, 10);
};
const getDaysAgo = (days: number) => {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().slice(0, 10);
};
const isPresetActive = (startDate: string, endDate: string, days: number): boolean => {
const today = getToday();
if (endDate !== today) return false;
if (days === 0) return startDate === today;
return startDate === getDaysAgo(days);
};
const DateRangeFilter = ({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
onPreset,
}: DateRangeFilterProps) => {
const presets = [
{ label: '오늘', days: 0 },
{ label: '7일', days: 7 },
{ label: '30일', days: 30 },
];
return (
<div className="flex items-center gap-3 mb-6">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">:</span>
{presets.map((preset) => (
<button
key={preset.days}
onClick={() => onPreset(preset.days)}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
isPresetActive(startDate, endDate, preset.days)
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
{preset.label}
</button>
))}
<input
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
<span className="text-gray-500 dark:text-gray-400">~</span>
<input
type="date"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</div>
);
};
export default DateRangeFilter;

파일 보기

@ -17,6 +17,16 @@ const navGroups: NavGroup[] = [
{ label: 'Service Status', path: '/monitoring/service-status' },
],
},
{
label: 'Statistics',
items: [
{ label: '서비스 통계', path: '/statistics/services' },
{ label: '사용자 통계', path: '/statistics/users' },
{ label: 'API 통계', path: '/statistics/apis' },
{ label: '테넌트 통계', path: '/statistics/tenants' },
{ label: '사용량 추이', path: '/statistics/usage-trend' },
],
},
{
label: 'API Keys',
items: [
@ -41,6 +51,7 @@ const MainLayout = () => {
const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true,
Statistics: true,
'API Keys': true,
Admin: true,
});

파일 보기

@ -4,13 +4,12 @@ import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
PieChart, Pie, Cell, AreaChart, Area,
} from 'recharts';
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi, TenantRatio } from '../types/dashboard';
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi } from '../types/dashboard';
import type { RequestLog } from '../types/monitoring';
import type { HeartbeatStatus } from '../types/service';
import {
getSummary, getHourlyTrend, getServiceRatio,
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
getTenantRequestRatio, getTenantUserRatio,
} from '../services/dashboardService';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
@ -56,15 +55,13 @@ const DashboardPage = () => {
const [serviceRatio, setServiceRatio] = useState<ServiceRatio[]>([]);
const [errorTrend, setErrorTrend] = useState<ErrorTrend[]>([]);
const [topApis, setTopApis] = useState<TopApi[]>([]);
const [tenantRequestRatio, setTenantRequestRatio] = useState<TenantRatio[]>([]);
const [tenantUserRatio, setTenantUserRatio] = useState<TenantRatio[]>([]);
const [recentLogs, setRecentLogs] = useState<RequestLog[]>([]);
const [lastUpdated, setLastUpdated] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const fetchAll = useCallback(async () => {
try {
const [summaryRes, heartbeatRes, hourlyRes, serviceRes, errorRes, topRes, tenantReqRes, tenantUserRes, logsRes] =
const [summaryRes, heartbeatRes, hourlyRes, serviceRes, errorRes, topRes, logsRes] =
await Promise.allSettled([
getSummary(),
getHeartbeat(),
@ -72,8 +69,6 @@ const DashboardPage = () => {
getServiceRatio(),
getErrorTrend(),
getTopApis(),
getTenantRequestRatio(),
getTenantUserRatio(),
getRecentLogs(),
]);
@ -83,8 +78,6 @@ const DashboardPage = () => {
setServiceRatio(extractSettled<ServiceRatio[]>(serviceRes, []));
setErrorTrend(extractSettled<ErrorTrend[]>(errorRes, []));
setTopApis(extractSettled<TopApi[]>(topRes, []));
setTenantRequestRatio(extractSettled<TenantRatio[]>(tenantReqRes, []));
setTenantUserRatio(extractSettled<TenantRatio[]>(tenantUserRes, []));
setRecentLogs(extractSettled<RequestLog[]>(logsRes, []));
setLastUpdated(new Date().toLocaleTimeString('ko-KR'));
} finally {
@ -150,7 +143,7 @@ const DashboardPage = () => {
<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">{stats.totalRequests.toLocaleString()}</p>
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-gray-500 dark:text-gray-400'}`}>
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} {stats.changePercent}%
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} {stats.changePercent.toFixed(2)}%
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
@ -163,44 +156,52 @@ const DashboardPage = () => {
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.avgResponseTime.toFixed(0)}ms</p>
</div>
<div className="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-sm text-gray-500 dark:text-gray-400">API </p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.activeUserCount}</p>
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
</div>
)}
{/* Row 2: Heartbeat Status Bar */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
{/* Row 2: Heartbeat Status Cards */}
<div className="mb-6">
{heartbeat.length > 0 ? (
<div className="flex flex-row gap-6">
{heartbeat.map((svc) => (
<div
key={svc.serviceId}
className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg px-2 py-1 transition-colors"
onClick={() => navigate('/monitoring/service-status')}
>
<div className="flex gap-4">
{heartbeat.map((svc) => {
const isUp = svc.healthStatus === 'UP';
const isDown = svc.healthStatus === 'DOWN';
const borderColor = isUp ? 'border-green-500' : isDown ? 'border-red-500' : 'border-gray-400';
return (
<div
className={`w-3 h-3 rounded-full ${
svc.healthStatus === 'UP'
? 'bg-green-500'
: svc.healthStatus === 'DOWN'
? 'bg-red-500'
: 'bg-gray-400'
}`}
/>
<span className="font-medium text-gray-900 dark:text-gray-100">{svc.serviceName}</span>
{svc.healthResponseTime !== null && (
<span className="text-gray-500 dark:text-gray-400 text-sm">{svc.healthResponseTime}ms</span>
)}
{svc.healthCheckedAt && (
<span className="text-gray-400 dark:text-gray-500 text-xs">{svc.healthCheckedAt}</span>
)}
</div>
))}
key={svc.serviceId}
className={`flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 ${borderColor} cursor-pointer hover:shadow-md transition-shadow`}
onClick={() => navigate('/monitoring/service-status')}
>
<div className="flex items-center gap-2 mb-2">
<div
className={`w-3 h-3 rounded-full ${
isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400'
}`}
/>
<span className="font-medium text-gray-900 dark:text-gray-100">{svc.serviceName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className={`font-medium ${isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-gray-500 dark:text-gray-400'}`}>
{isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'}
</span>
{svc.healthCheckedAt && (
<span className="text-gray-400 dark:text-gray-500 text-xs">{svc.healthCheckedAt}</span>
)}
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
</div>
)}
</div>
@ -316,60 +317,7 @@ const DashboardPage = () => {
</div>
</div>
{/* Row 4: Tenant Stats */}
<div className="grid grid-cols-2 gap-6 mb-6">
<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>
{tenantRequestRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={tenantRequestRatio}
dataKey="count"
nameKey="tenantName"
innerRadius={60}
outerRadius={100}
>
{tenantRequestRatio.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 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>
{tenantUserRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={tenantUserRatio}
dataKey="count"
nameKey="tenantName"
innerRadius={60}
outerRadius={100}
>
{tenantUserRatio.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>
{/* Row 5: Recent Logs */}
{/* Row 4: Recent Logs */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
<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"> </h3>

파일 보기

@ -0,0 +1,268 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
PieChart, Pie, Cell,
} from 'recharts';
import type { ApiStatsResponse } from '../../types/statistics';
import { getApiStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const SERVICE_TAG_STYLES = [
'bg-blue-100 text-blue-700',
'bg-emerald-100 text-emerald-700',
'bg-amber-100 text-amber-700',
'bg-red-100 text-red-700',
'bg-violet-100 text-violet-700',
'bg-cyan-100 text-cyan-700',
];
const METHOD_BADGE: Record<string, string> = {
GET: 'bg-blue-100 text-blue-800',
POST: 'bg-green-100 text-green-800',
PUT: 'bg-amber-100 text-amber-800',
DELETE: 'bg-red-100 text-red-800',
PATCH: 'bg-purple-100 text-purple-800',
};
const getToday = () => new Date().toISOString().slice(0, 10);
const ApiStatsPage = () => {
const [startDate, setStartDate] = useState(getToday());
const [endDate, setEndDate] = useState(getToday());
const [data, setData] = useState<ApiStatsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const res = await getApiStats(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 serviceColorMap = useMemo(() => {
if (!data) return {};
const serviceNames = [...new Set(data.topApis.map((a) => a.serviceName))];
const map: Record<string, { tag: string; bar: string }> = {};
serviceNames.forEach((name, i) => {
map[name] = {
tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length],
bar: PIE_COLORS[i % PIE_COLORS.length],
};
});
return map;
}, [data]);
const statusChartData = useMemo(() => {
if (!data) return [];
return data.statusCodeDistribution.map((s) => ({
statusCode: String(s.statusCode),
count: s.count,
}));
}, [data]);
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">API </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>
) : (
<>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: HTTP Method Distribution */}
<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">HTTP </h3>
{data.methodDistribution.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.methodDistribution}
dataKey="count"
nameKey="method"
innerRadius={60}
outerRadius={100}
>
{data.methodDistribution.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>
{/* Chart 2: HTTP Status Code Distribution */}
<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">HTTP </h3>
{statusChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statusChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="statusCode" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#3b82f6" name="건수" />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
</div>
{/* Table 1: Top APIs */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
<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">API </h3>
</div>
{data.topApis.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">API</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.topApis.slice(0, 20).map((api, idx) => {
const maxCount = data.topApis[0]?.callCount || 1;
const pct = (api.callCount / maxCount) * 100;
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] };
return (
<tr key={idx} 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">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
{api.serviceName}
</span>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 truncate max-w-[250px]" title={api.apiName}>
{api.apiName}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${METHOD_BADGE[api.requestMethod] ?? 'bg-gray-100 text-gray-800'}`}>
{api.requestMethod}
</span>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
<div className="flex items-center gap-2">
<div className="w-24 bg-gray-100 dark:bg-gray-700 rounded-full h-4">
<div className="h-4 rounded-full" style={{ width: `${pct}%`, backgroundColor: colors.bar }} />
</div>
<span>{api.callCount.toLocaleString()}</span>
</div>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.avgResponseTime.toFixed(0)}ms</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.successRate.toFixed(1)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-8"> </p>
)}
</div>
{/* Table 2: Top Error APIs */}
<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">API </h3>
</div>
{data.topErrorApis.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">API</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.topErrorApis.slice(0, 10).map((api, idx) => {
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] };
return (
<tr key={idx} 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">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
{api.serviceName}
</span>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100" title={api.apiName}>{api.apiName}</td>
<td className="px-4 py-3 text-red-600">{api.errorCount.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.totalCount.toLocaleString()}</td>
<td className="px-4 py-3 text-red-600">{api.errorRate.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 ApiStatsPage;

파일 보기

@ -0,0 +1,210 @@
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';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const getToday = () => new Date().toISOString().slice(0, 10);
const ServiceStatsPage = () => {
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-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 || data.serviceStats.length === 0 ? (
<p className="text-gray-400 dark:text-gray-500 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-white dark:bg-gray-800 rounded-lg shadow p-6 flex-1 min-w-[200px]">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{svc.serviceName}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 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-gray-500 dark:text-gray-400">{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-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>
<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="#3b82f6" name="요청 수" />
</BarChart>
</ResponsiveContainer>
</div>
{/* Chart 2: Hourly Service Trend */}
<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>
{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={PIE_COLORS[idx % PIE_COLORS.length]}
/>
))}
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 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-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>
<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-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>
<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;

파일 보기

@ -0,0 +1,186 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
LineChart, Line,
} from 'recharts';
import type { TenantStatsResponse } from '../../types/statistics';
import { getTenantStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const getToday = () => new Date().toISOString().slice(0, 10);
const TenantStatsPage = () => {
const [startDate, setStartDate] = useState(getToday());
const [endDate, setEndDate] = useState(getToday());
const [data, setData] = useState<TenantStatsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const res = await getTenantStats(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 dailyTrendPivoted = useMemo(() => {
if (!data) return { data: [], tenantNames: [] };
const tenantNames = [...new Set(data.dailyTrend.map((e) => e.tenantName))];
const byDate: Record<string, Record<string, number | string>> = {};
for (const item of data.dailyTrend) {
if (!byDate[item.date]) {
byDate[item.date] = { date: item.date };
}
byDate[item.date][item.tenantName] = item.count;
}
return { data: Object.values(byDate), tenantNames };
}, [data]);
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 || data.tenantStats.length === 0 ? (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
) : (
<>
{/* Summary Cards */}
<div className="flex gap-4 mb-6">
{data.tenantStats.map((tenant) => (
<div key={tenant.tenantId} className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{tenant.tenantName || 'Unknown'}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{tenant.totalRequests.toLocaleString()}
</p>
<div className="flex items-center gap-3 mt-2 text-sm">
<span className="text-gray-500 dark:text-gray-400"> {tenant.activeUsers}</span>
<span className="text-green-600"> {tenant.successRate.toFixed(1)}%</span>
</div>
</div>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Daily Tenant Trend */}
<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>
{dailyTrendPivoted.data.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={dailyTrendPivoted.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
{dailyTrendPivoted.tenantNames.map((name, idx) => (
<Line
key={name}
type="monotone"
dataKey={name}
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
/>
))}
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
{/* Chart 2: Tenant API Key Stats */}
<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 Key </h3>
{data.apiKeyStats.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.apiKeyStats}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="tenantName" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="totalKeys" fill="#3b82f6" name="전체 키" />
<Bar dataKey="activeKeys" fill="#10b981" name="활성 키" />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
</div>
{/* Table: Tenant Details */}
<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"> </h3>
</div>
<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.tenantStats.map((tenant) => (
<tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{tenant.tenantName || 'Unknown'}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.totalRequests.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.activeUsers}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.successRate.toFixed(1)}%</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.avgResponseTime.toFixed(0)}ms</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
};
export default TenantStatsPage;

파일 보기

@ -0,0 +1,236 @@
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';
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 [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-gray-900 dark:text-gray-100 mb-6"> </h1>
{/* Period Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700 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-blue-600 text-blue-600'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
{opt.label}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400"> ...</div>
</div>
) : !data || data.items.length === 0 ? (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
) : (
<>
{/* Chart 1: 요청 수 추이 (full width) */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 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="#3b82f6" stopOpacity={0.15} />
<stop offset="95%" stopColor="#3b82f6" 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="#3b82f6"
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-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>
<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="#3b82f6"
name="평균 응답시간(ms)"
barSize={20}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Chart 3: 활성 사용자 추이 */}
<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>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="activeUsers" fill="#06b6d4" name="활성 사용자" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Table: 상세 데이터 */}
<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"> </h3>
</div>
<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>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> (ms)</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.items.map((item) => (
<tr key={item.label} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{formatLabel(item.label, period)}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.totalRequests.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.successCount.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{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-gray-900 dark:text-gray-100">
{item.avgResponseTime.toFixed(0)}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.activeUsers.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
};
export default UsageTrendPage;

파일 보기

@ -0,0 +1,193 @@
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;

파일 보기

@ -1,5 +1,5 @@
import { get } from './apiClient';
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi, TenantRatio } from '../types/dashboard';
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi } from '../types/dashboard';
import type { RequestLog } from '../types/monitoring';
import type { HeartbeatStatus } from '../types/service';
@ -8,7 +8,5 @@ export const getHourlyTrend = () => get<HourlyTrend[]>('/dashboard/hourly-trend'
export const getServiceRatio = () => get<ServiceRatio[]>('/dashboard/service-ratio');
export const getErrorTrend = () => get<ErrorTrend[]>('/dashboard/error-trend');
export const getTopApis = (limit = 10) => get<TopApi[]>(`/dashboard/top-apis?limit=${limit}`);
export const getTenantRequestRatio = () => get<TenantRatio[]>('/dashboard/tenant-request-ratio');
export const getTenantUserRatio = () => get<TenantRatio[]>('/dashboard/tenant-user-ratio');
export const getRecentLogs = () => get<RequestLog[]>('/dashboard/recent-logs');
export const getHeartbeat = () => get<HeartbeatStatus[]>('/dashboard/heartbeat');

파일 보기

@ -0,0 +1,26 @@
import { get } from './apiClient';
import type {
ServiceStatsResponse,
UserStatsResponse,
ApiStatsResponse,
TenantStatsResponse,
UsageTrendResponse,
} from '../types/statistics';
const buildQuery = (startDate: string, endDate: string) =>
`?startDate=${startDate}&endDate=${endDate}`;
export const getServiceStats = (startDate: string, endDate: string) =>
get<ServiceStatsResponse>(`/statistics/services${buildQuery(startDate, endDate)}`);
export const getUserStats = (startDate: string, endDate: string) =>
get<UserStatsResponse>(`/statistics/users${buildQuery(startDate, endDate)}`);
export const getApiStats = (startDate: string, endDate: string) =>
get<ApiStatsResponse>(`/statistics/apis${buildQuery(startDate, endDate)}`);
export const getTenantStats = (startDate: string, endDate: string) =>
get<TenantStatsResponse>(`/statistics/tenants${buildQuery(startDate, endDate)}`);
export const getUsageTrend = (period: string) =>
get<UsageTrendResponse>(`/statistics/usage-trend?period=${period}`);

파일 보기

@ -0,0 +1,132 @@
export interface ServiceRequestStats {
serviceId: number;
serviceName: string;
totalRequests: number;
successCount: number;
successRate: number;
avgResponseTime: number;
}
export interface ServiceResponseTimeRank {
serviceName: string;
avgResponseTime: number;
requestCount: number;
}
export interface HourlyServiceTrend {
hour: number;
serviceName: string;
count: number;
}
export interface ServiceStatsResponse {
serviceStats: ServiceRequestStats[];
slowestServices: ServiceResponseTimeRank[];
hourlyTrend: HourlyServiceTrend[];
}
export interface UserRequestRank {
userId: number;
userName: string;
role: string;
requestCount: number;
successRate: number;
}
export interface UserRoleDistribution {
role: string;
userCount: number;
requestCount: number;
}
export interface DailyActiveUsers {
date: string;
activeUsers: number;
}
export interface UserStatsResponse {
totalUsers: number;
usersWithActiveKey: number;
totalActiveUsers: number;
topUsers: UserRequestRank[];
roleDistribution: UserRoleDistribution[];
dailyActiveUsers: DailyActiveUsers[];
}
export interface ApiCallRank {
serviceName: string;
apiName: string;
requestUrl: string;
requestMethod: string;
callCount: number;
avgResponseTime: number;
successRate: number;
}
export interface ApiErrorRank {
serviceName: string;
apiName: string;
requestUrl: string;
errorCount: number;
totalCount: number;
errorRate: number;
}
export interface ApiMethodDistribution {
method: string;
count: number;
}
export interface HttpStatusDistribution {
statusCode: number;
count: number;
}
export interface ApiStatsResponse {
topApis: ApiCallRank[];
topErrorApis: ApiErrorRank[];
methodDistribution: ApiMethodDistribution[];
statusCodeDistribution: HttpStatusDistribution[];
}
export interface TenantRequestStats {
tenantId: number;
tenantName: string;
totalRequests: number;
activeUsers: number;
successRate: number;
avgResponseTime: number;
}
export interface DailyTenantTrend {
date: string;
tenantName: string;
count: number;
}
export interface TenantApiKeyStats {
tenantName: string;
totalKeys: number;
activeKeys: number;
}
export interface TenantStatsResponse {
tenantStats: TenantRequestStats[];
dailyTrend: DailyTenantTrend[];
apiKeyStats: TenantApiKeyStats[];
}
export interface UsageTrendItem {
label: string;
totalRequests: number;
successCount: number;
failureCount: number;
successRate: number;
avgResponseTime: number;
activeUsers: number;
}
export interface UsageTrendResponse {
period: string;
items: UsageTrendItem[];
}

파일 보기

@ -2,6 +2,7 @@ package com.gcsc.connection.apikey.repository;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
@ -13,4 +14,18 @@ public interface SnpApiKeyRepository extends JpaRepository<SnpApiKey, Long> {
List<SnpApiKey> findByUserUserId(Long userId);
List<SnpApiKey> findByApiKeyPrefix(String apiKeyPrefix);
/** 유효한 API 키가 존재하는 사용자 수 */
@Query(value = "SELECT COUNT(DISTINCT user_id) FROM common.snp_api_key " +
"WHERE status = 'ACTIVE' AND (expires_at IS NULL OR expires_at > NOW())", nativeQuery = true)
long countUsersWithActiveKey();
/** 테넌트별 API 키 현황 (전체 키 수, 활성 키 수) */
@Query(value = "SELECT t.tenant_name, COUNT(k.api_key_id), " +
"COUNT(CASE WHEN k.status = 'ACTIVE' THEN 1 END) " +
"FROM common.snp_api_key k " +
"JOIN common.snp_user u ON k.user_id = u.user_id " +
"JOIN common.snp_tenant t ON u.tenant_id = t.tenant_id " +
"GROUP BY t.tenant_name", nativeQuery = true)
List<Object[]> findTenantApiKeyStats();
}

파일 보기

@ -55,8 +55,7 @@ public class GatewayService {
long startTime = System.currentTimeMillis();
SnpApiKey apiKey = null;
SnpService service = null;
String gatewayPath = "/gateway/" + serviceCode + remainingPath
+ (request.getQueryString() != null ? "?" + request.getQueryString() : "");
String gatewayPath = "/gateway/" + serviceCode + remainingPath;
String targetUrl = null;
try {

파일 보기

@ -56,17 +56,17 @@ public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestL
"GROUP BY 1, 2 ORDER BY 1", nativeQuery = true)
List<Object[]> findErrorTrend(@Param("startOfDay") LocalDateTime startOfDay);
/** 상위 API 랭킹 (service_name, api_name 포함) */
/** 상위 API 랭킹 (service_name, api_name 포함, 쿼리스트링 제거) */
@Query(value = "SELECT COALESCE(s.service_name, 'Unknown') as serviceName, " +
"COALESCE(a.api_name, l.request_url) as apiName, " +
"l.request_url, l.request_method, COUNT(*) as cnt " +
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)) as apiName, " +
"SPLIT_PART(l.request_url, '?', 1) as request_url, l.request_method, COUNT(*) as cnt " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
"AND a.api_path = SUBSTRING(l.request_url FROM '/gateway/[^/]+(.*)') " +
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
"AND a.api_method = l.request_method " +
"WHERE l.requested_at >= :startOfDay " +
"GROUP BY s.service_name, a.api_name, l.request_url, l.request_method " +
"GROUP BY s.service_name, a.api_name, SPLIT_PART(l.request_url, '?', 1), l.request_method " +
"ORDER BY cnt DESC LIMIT :limit", nativeQuery = true)
List<Object[]> findTopApis(@Param("startOfDay") LocalDateTime startOfDay,
@Param("limit") int limit);
@ -90,4 +90,177 @@ public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestL
/** 최근 로그 20건 */
List<SnpApiRequestLog> findTop20ByOrderByRequestedAtDesc();
// ===== Statistics API 쿼리 =====
/** 서비스별 요청 통계 (기간) */
@Query(value = "SELECT s.service_id, s.service_name, COUNT(*), " +
"COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END), " +
"AVG(l.response_time) " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
"GROUP BY s.service_id, s.service_name", nativeQuery = true)
List<Object[]> findServiceRequestStats(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 응답시간 느린 서비스 TOP 5 */
@Query(value = "SELECT s.service_name, AVG(l.response_time), COUNT(*) " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
"AND l.response_time IS NOT NULL " +
"GROUP BY s.service_name ORDER BY AVG(l.response_time) DESC LIMIT 5", nativeQuery = true)
List<Object[]> findSlowestServices(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 시간별 서비스별 요청 추이 */
@Query(value = "SELECT CAST(EXTRACT(HOUR FROM l.requested_at) AS int), " +
"COALESCE(s.service_name, 'Unknown'), COUNT(*) " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
"GROUP BY 1, 2 ORDER BY 1", nativeQuery = true)
List<Object[]> findHourlyServiceTrend(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 요청 많은 사용자 TOP 10 */
@Query(value = "SELECT u.user_id, u.user_name, u.role, COUNT(*), " +
"CAST(COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END) AS float) " +
"/ NULLIF(COUNT(*), 0) * 100 " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_user u ON l.user_id = u.user_id " +
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL " +
"GROUP BY u.user_id, u.user_name, u.role ORDER BY COUNT(*) DESC LIMIT 10", nativeQuery = true)
List<Object[]> findTopUsersByRequestCount(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 역할별 사용자/요청 분포 */
@Query(value = "SELECT u.role, COUNT(DISTINCT l.user_id), COUNT(*) " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_user u ON l.user_id = u.user_id " +
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL " +
"GROUP BY u.role", nativeQuery = true)
List<Object[]> findUserRoleDistribution(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 일별 활성 사용자 수 */
@Query(value = "SELECT CAST(DATE(l.requested_at) AS text), COUNT(DISTINCT l.user_id) " +
"FROM common.snp_api_request_log l " +
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL " +
"GROUP BY DATE(l.requested_at) ORDER BY 1", nativeQuery = true)
List<Object[]> findDailyActiveUsers(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 기간 내 활성 사용자 수 (고유) */
@Query(value = "SELECT COUNT(DISTINCT l.user_id) FROM common.snp_api_request_log l " +
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL",
nativeQuery = true)
long countActiveUsers(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 상위 API 랭킹 (기간, 성공률/응답시간 포함, 쿼리파라미터 제외) */
@Query(value = "SELECT COALESCE(s.service_name, 'Unknown'), " +
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)), " +
"SPLIT_PART(l.request_url, '?', 1), l.request_method, COUNT(*), " +
"AVG(l.response_time), " +
"CAST(COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END) AS float) " +
"/ NULLIF(COUNT(*), 0) * 100 " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
"AND a.api_method = l.request_method " +
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
"GROUP BY s.service_name, a.api_name, SPLIT_PART(l.request_url, '?', 1), l.request_method " +
"ORDER BY COUNT(*) DESC LIMIT 20", nativeQuery = true)
List<Object[]> findTopApisWithStats(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 에러 많은 API TOP 10 (쿼리파라미터 제외) */
@Query(value = "SELECT COALESCE(s.service_name, 'Unknown'), " +
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)), SPLIT_PART(l.request_url, '?', 1), " +
"COUNT(CASE WHEN l.request_status != 'SUCCESS' THEN 1 END), " +
"COUNT(*), " +
"CAST(COUNT(CASE WHEN l.request_status != 'SUCCESS' THEN 1 END) AS float) " +
"/ NULLIF(COUNT(*), 0) * 100 " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
"AND a.api_method = l.request_method " +
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
"GROUP BY 1, 2, 3 ORDER BY 4 DESC LIMIT 10", nativeQuery = true)
List<Object[]> findTopErrorApis(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** HTTP 메서드별 요청 분포 */
@Query(value = "SELECT request_method, COUNT(*) " +
"FROM common.snp_api_request_log " +
"WHERE requested_at >= :start AND requested_at < :end " +
"GROUP BY request_method ORDER BY COUNT(*) DESC", nativeQuery = true)
List<Object[]> findMethodDistribution(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** HTTP 상태코드별 요청 분포 */
@Query(value = "SELECT response_status, COUNT(*) " +
"FROM common.snp_api_request_log " +
"WHERE requested_at >= :start AND requested_at < :end AND response_status IS NOT NULL " +
"GROUP BY response_status ORDER BY COUNT(*) DESC", nativeQuery = true)
List<Object[]> findStatusCodeDistribution(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 테넌트별 요청 통계 */
@Query(value = "SELECT t.tenant_id, t.tenant_name, COUNT(*), COUNT(DISTINCT l.user_id), " +
"CAST(COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END) AS float) " +
"/ NULLIF(COUNT(*), 0) * 100, " +
"AVG(l.response_time) " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_tenant t ON l.tenant_id = t.tenant_id " +
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
"GROUP BY t.tenant_id, t.tenant_name", nativeQuery = true)
List<Object[]> findTenantRequestStats(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 일별 테넌트별 요청 추이 */
@Query(value = "SELECT CAST(DATE(l.requested_at) AS text), " +
"COALESCE(t.tenant_name, 'Unknown'), COUNT(*) " +
"FROM common.snp_api_request_log l " +
"LEFT JOIN common.snp_tenant t ON l.tenant_id = t.tenant_id " +
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
"GROUP BY 1, 2 ORDER BY 1", nativeQuery = true)
List<Object[]> findDailyTenantTrend(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
// ===== Usage Trend 쿼리 =====
/** 일별 사용량 추이 (최근 30일) */
@Query(value = "SELECT DATE(requested_at) as day, COUNT(*) as total, " +
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as success, " +
"COALESCE(AVG(response_time), 0) as avg_rt, " +
"COUNT(DISTINCT user_id) as users " +
"FROM common.snp_api_request_log " +
"WHERE requested_at >= :since " +
"GROUP BY DATE(requested_at) ORDER BY day", nativeQuery = true)
List<Object[]> findDailyUsageTrend(@Param("since") LocalDateTime since);
/** 주별 사용량 추이 (최근 12주) */
@Query(value = "SELECT CAST(DATE_TRUNC('week', requested_at) AS date) as week_start, COUNT(*) as total, " +
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as success, " +
"COALESCE(AVG(response_time), 0) as avg_rt, " +
"COUNT(DISTINCT user_id) as users " +
"FROM common.snp_api_request_log " +
"WHERE requested_at >= :since " +
"GROUP BY DATE_TRUNC('week', requested_at) ORDER BY week_start", nativeQuery = true)
List<Object[]> findWeeklyUsageTrend(@Param("since") LocalDateTime since);
/** 월별 사용량 추이 (최근 12개월) */
@Query(value = "SELECT CAST(DATE_TRUNC('month', requested_at) AS date) as month_start, COUNT(*) as total, " +
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as success, " +
"COALESCE(AVG(response_time), 0) as avg_rt, " +
"COUNT(DISTINCT user_id) as users " +
"FROM common.snp_api_request_log " +
"WHERE requested_at >= :since " +
"GROUP BY DATE_TRUNC('month', requested_at) ORDER BY month_start", nativeQuery = true)
List<Object[]> findMonthlyUsageTrend(@Param("since") LocalDateTime since);
}

파일 보기

@ -0,0 +1,91 @@
package com.gcsc.connection.statistics.controller;
import com.gcsc.connection.common.dto.ApiResponse;
import com.gcsc.connection.statistics.dto.ApiStatsResponse;
import com.gcsc.connection.statistics.dto.ServiceStatsResponse;
import com.gcsc.connection.statistics.dto.TenantStatsResponse;
import com.gcsc.connection.statistics.dto.UsageTrendResponse;
import com.gcsc.connection.statistics.dto.UserStatsResponse;
import com.gcsc.connection.statistics.service.StatisticsService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 통계 API - 서비스/사용자/API/테넌트별 통계 데이터 제공
*/
@RestController
@RequestMapping("/api/statistics")
@RequiredArgsConstructor
public class StatisticsController {
private final StatisticsService statisticsService;
/**
* 서비스별 통계 조회
*/
@GetMapping("/services")
public ResponseEntity<ApiResponse<ServiceStatsResponse>> getServiceStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
LocalDateTime start = startDate.atStartOfDay();
LocalDateTime end = endDate.plusDays(1).atStartOfDay();
ServiceStatsResponse response = statisticsService.getServiceStats(start, end);
return ResponseEntity.ok(ApiResponse.ok(response));
}
/**
* 사용자별 통계 조회
*/
@GetMapping("/users")
public ResponseEntity<ApiResponse<UserStatsResponse>> getUserStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
LocalDateTime start = startDate.atStartOfDay();
LocalDateTime end = endDate.plusDays(1).atStartOfDay();
UserStatsResponse response = statisticsService.getUserStats(start, end);
return ResponseEntity.ok(ApiResponse.ok(response));
}
/**
* API 엔드포인트별 통계 조회
*/
@GetMapping("/apis")
public ResponseEntity<ApiResponse<ApiStatsResponse>> getApiStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
LocalDateTime start = startDate.atStartOfDay();
LocalDateTime end = endDate.plusDays(1).atStartOfDay();
ApiStatsResponse response = statisticsService.getApiStats(start, end);
return ResponseEntity.ok(ApiResponse.ok(response));
}
/**
* 테넌트별 통계 조회
*/
@GetMapping("/tenants")
public ResponseEntity<ApiResponse<TenantStatsResponse>> getTenantStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
LocalDateTime start = startDate.atStartOfDay();
LocalDateTime end = endDate.plusDays(1).atStartOfDay();
TenantStatsResponse response = statisticsService.getTenantStats(start, end);
return ResponseEntity.ok(ApiResponse.ok(response));
}
/**
* 사용량 추이 조회 (일별/주별/월별)
*/
@GetMapping("/usage-trend")
public ResponseEntity<ApiResponse<UsageTrendResponse>> getUsageTrend(
@RequestParam(defaultValue = "daily") String period) {
return ResponseEntity.ok(ApiResponse.ok(statisticsService.getUsageTrend(period)));
}
}

파일 보기

@ -0,0 +1,43 @@
package com.gcsc.connection.statistics.dto;
import java.util.List;
/**
* API 엔드포인트별 통계 응답 DTO
*/
public record ApiStatsResponse(
List<ApiCallRank> topApis,
List<ApiErrorRank> topErrorApis,
List<ApiMethodDistribution> methodDistribution,
List<HttpStatusDistribution> statusCodeDistribution
) {
public record ApiCallRank(
String serviceName,
String apiName,
String requestUrl,
String requestMethod,
long callCount,
double avgResponseTime,
double successRate
) {}
public record ApiErrorRank(
String serviceName,
String apiName,
String requestUrl,
long errorCount,
long totalCount,
double errorRate
) {}
public record ApiMethodDistribution(
String method,
long count
) {}
public record HttpStatusDistribution(
int statusCode,
long count
) {}
}

파일 보기

@ -0,0 +1,34 @@
package com.gcsc.connection.statistics.dto;
import java.util.List;
/**
* 서비스별 통계 응답 DTO
*/
public record ServiceStatsResponse(
List<ServiceRequestStats> serviceStats,
List<ServiceResponseTimeRank> slowestServices,
List<HourlyServiceTrend> hourlyTrend
) {
public record ServiceRequestStats(
Long serviceId,
String serviceName,
long totalRequests,
long successCount,
double successRate,
double avgResponseTime
) {}
public record ServiceResponseTimeRank(
String serviceName,
double avgResponseTime,
long requestCount
) {}
public record HourlyServiceTrend(
int hour,
String serviceName,
long count
) {}
}

파일 보기

@ -0,0 +1,34 @@
package com.gcsc.connection.statistics.dto;
import java.util.List;
/**
* 테넌트별 통계 응답 DTO
*/
public record TenantStatsResponse(
List<TenantRequestStats> tenantStats,
List<DailyTenantTrend> dailyTrend,
List<TenantApiKeyStats> apiKeyStats
) {
public record TenantRequestStats(
Long tenantId,
String tenantName,
long totalRequests,
long activeUsers,
double successRate,
double avgResponseTime
) {}
public record DailyTenantTrend(
String date,
String tenantName,
long count
) {}
public record TenantApiKeyStats(
String tenantName,
long totalKeys,
long activeKeys
) {}
}

파일 보기

@ -0,0 +1,18 @@
package com.gcsc.connection.statistics.dto;
import java.util.List;
public record UsageTrendResponse(
String period,
List<UsageTrendItem> items
) {
public record UsageTrendItem(
String label,
long totalRequests,
long successCount,
long failureCount,
double successRate,
double avgResponseTime,
long activeUsers
) {}
}

파일 보기

@ -0,0 +1,35 @@
package com.gcsc.connection.statistics.dto;
import java.util.List;
/**
* 사용자별 통계 응답 DTO
*/
public record UserStatsResponse(
long totalUsers,
long usersWithActiveKey,
long totalActiveUsers,
List<UserRequestRank> topUsers,
List<UserRoleDistribution> roleDistribution,
List<DailyActiveUsers> dailyActiveUsers
) {
public record UserRequestRank(
Long userId,
String userName,
String role,
long requestCount,
double successRate
) {}
public record UserRoleDistribution(
String role,
long userCount,
long requestCount
) {}
public record DailyActiveUsers(
String date,
long activeUsers
) {}
}

파일 보기

@ -0,0 +1,256 @@
package com.gcsc.connection.statistics.service;
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
import com.gcsc.connection.user.repository.SnpUserRepository;
import com.gcsc.connection.statistics.dto.ApiStatsResponse;
import com.gcsc.connection.statistics.dto.ApiStatsResponse.ApiCallRank;
import com.gcsc.connection.statistics.dto.ApiStatsResponse.ApiErrorRank;
import com.gcsc.connection.statistics.dto.ApiStatsResponse.ApiMethodDistribution;
import com.gcsc.connection.statistics.dto.ApiStatsResponse.HttpStatusDistribution;
import com.gcsc.connection.statistics.dto.ServiceStatsResponse;
import com.gcsc.connection.statistics.dto.ServiceStatsResponse.HourlyServiceTrend;
import com.gcsc.connection.statistics.dto.ServiceStatsResponse.ServiceRequestStats;
import com.gcsc.connection.statistics.dto.ServiceStatsResponse.ServiceResponseTimeRank;
import com.gcsc.connection.statistics.dto.TenantStatsResponse;
import com.gcsc.connection.statistics.dto.TenantStatsResponse.DailyTenantTrend;
import com.gcsc.connection.statistics.dto.TenantStatsResponse.TenantApiKeyStats;
import com.gcsc.connection.statistics.dto.TenantStatsResponse.TenantRequestStats;
import com.gcsc.connection.statistics.dto.UsageTrendResponse;
import com.gcsc.connection.statistics.dto.UserStatsResponse;
import com.gcsc.connection.statistics.dto.UserStatsResponse.DailyActiveUsers;
import com.gcsc.connection.statistics.dto.UserStatsResponse.UserRequestRank;
import com.gcsc.connection.statistics.dto.UserStatsResponse.UserRoleDistribution;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 통계 서비스 - 서비스/사용자/API/테넌트별 통계 데이터 제공
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class StatisticsService {
private final SnpApiRequestLogRepository requestLogRepository;
private final SnpApiKeyRepository apiKeyRepository;
private final SnpUserRepository snpUserRepository;
/**
* 서비스별 통계 조회
*/
@Transactional(readOnly = true)
public ServiceStatsResponse getServiceStats(LocalDateTime start, LocalDateTime end) {
List<ServiceRequestStats> serviceStats = requestLogRepository
.findServiceRequestStats(start, end).stream()
.map(row -> {
long total = ((Number) row[2]).longValue();
long success = ((Number) row[3]).longValue();
double successRate = total > 0 ? ((double) success / total) * 100 : 0.0;
double avgResponseTime = row[4] != null ? ((Number) row[4]).doubleValue() : 0.0;
return new ServiceRequestStats(
row[0] != null ? ((Number) row[0]).longValue() : null,
(String) row[1],
total,
success,
successRate,
avgResponseTime
);
})
.toList();
List<ServiceResponseTimeRank> slowestServices = requestLogRepository
.findSlowestServices(start, end).stream()
.map(row -> new ServiceResponseTimeRank(
(String) row[0],
row[1] != null ? ((Number) row[1]).doubleValue() : 0.0,
((Number) row[2]).longValue()
))
.toList();
List<HourlyServiceTrend> hourlyTrend = requestLogRepository
.findHourlyServiceTrend(start, end).stream()
.map(row -> new HourlyServiceTrend(
((Number) row[0]).intValue(),
(String) row[1],
((Number) row[2]).longValue()
))
.toList();
return new ServiceStatsResponse(serviceStats, slowestServices, hourlyTrend);
}
/**
* 사용자별 통계 조회
*/
@Transactional(readOnly = true)
public UserStatsResponse getUserStats(LocalDateTime start, LocalDateTime end) {
long totalUsers = snpUserRepository.countByIsActiveTrue();
long totalActiveUsers = requestLogRepository.countActiveUsers(start, end);
List<UserRequestRank> topUsers = requestLogRepository
.findTopUsersByRequestCount(start, end).stream()
.map(row -> new UserRequestRank(
row[0] != null ? ((Number) row[0]).longValue() : null,
(String) row[1],
(String) row[2],
((Number) row[3]).longValue(),
row[4] != null ? ((Number) row[4]).doubleValue() : 0.0
))
.toList();
List<UserRoleDistribution> roleDistribution = requestLogRepository
.findUserRoleDistribution(start, end).stream()
.map(row -> new UserRoleDistribution(
(String) row[0],
((Number) row[1]).longValue(),
((Number) row[2]).longValue()
))
.toList();
List<DailyActiveUsers> dailyActiveUsers = requestLogRepository
.findDailyActiveUsers(start, end).stream()
.map(row -> new DailyActiveUsers(
(String) row[0],
((Number) row[1]).longValue()
))
.toList();
long usersWithActiveKey = apiKeyRepository.countUsersWithActiveKey();
return new UserStatsResponse(totalUsers, usersWithActiveKey, totalActiveUsers, topUsers, roleDistribution, dailyActiveUsers);
}
/**
* API 엔드포인트별 통계 조회
*/
@Transactional(readOnly = true)
public ApiStatsResponse getApiStats(LocalDateTime start, LocalDateTime end) {
List<ApiCallRank> topApis = requestLogRepository
.findTopApisWithStats(start, end).stream()
.map(row -> new ApiCallRank(
(String) row[0],
(String) row[1],
(String) row[2],
(String) row[3],
((Number) row[4]).longValue(),
row[5] != null ? ((Number) row[5]).doubleValue() : 0.0,
row[6] != null ? ((Number) row[6]).doubleValue() : 0.0
))
.toList();
List<ApiErrorRank> topErrorApis = requestLogRepository
.findTopErrorApis(start, end).stream()
.map(row -> new ApiErrorRank(
(String) row[0],
(String) row[1],
(String) row[2],
((Number) row[3]).longValue(),
((Number) row[4]).longValue(),
row[5] != null ? ((Number) row[5]).doubleValue() : 0.0
))
.toList();
List<ApiMethodDistribution> methodDistribution = requestLogRepository
.findMethodDistribution(start, end).stream()
.map(row -> new ApiMethodDistribution(
(String) row[0],
((Number) row[1]).longValue()
))
.toList();
List<HttpStatusDistribution> statusCodeDistribution = requestLogRepository
.findStatusCodeDistribution(start, end).stream()
.map(row -> new HttpStatusDistribution(
((Number) row[0]).intValue(),
((Number) row[1]).longValue()
))
.toList();
return new ApiStatsResponse(topApis, topErrorApis, methodDistribution, statusCodeDistribution);
}
/**
* 테넌트별 통계 조회
*/
@Transactional(readOnly = true)
public TenantStatsResponse getTenantStats(LocalDateTime start, LocalDateTime end) {
List<TenantRequestStats> tenantStats = requestLogRepository
.findTenantRequestStats(start, end).stream()
.map(row -> new TenantRequestStats(
row[0] != null ? ((Number) row[0]).longValue() : null,
(String) row[1],
((Number) row[2]).longValue(),
((Number) row[3]).longValue(),
row[4] != null ? ((Number) row[4]).doubleValue() : 0.0,
row[5] != null ? ((Number) row[5]).doubleValue() : 0.0
))
.toList();
List<DailyTenantTrend> dailyTrend = requestLogRepository
.findDailyTenantTrend(start, end).stream()
.map(row -> new DailyTenantTrend(
(String) row[0],
(String) row[1],
((Number) row[2]).longValue()
))
.toList();
List<TenantApiKeyStats> apiKeyStats = apiKeyRepository
.findTenantApiKeyStats().stream()
.map(row -> new TenantApiKeyStats(
(String) row[0],
((Number) row[1]).longValue(),
((Number) row[2]).longValue()
))
.toList();
return new TenantStatsResponse(tenantStats, dailyTrend, apiKeyStats);
}
/**
* 사용량 추이 조회 (일별/주별/월별)
*/
@Transactional(readOnly = true)
public UsageTrendResponse getUsageTrend(String period) {
List<Object[]> rows;
LocalDateTime since;
switch (period) {
case "weekly":
since = LocalDateTime.now().minusWeeks(12);
rows = requestLogRepository.findWeeklyUsageTrend(since);
break;
case "monthly":
since = LocalDateTime.now().minusMonths(12);
rows = requestLogRepository.findMonthlyUsageTrend(since);
break;
default: // daily
since = LocalDateTime.now().minusDays(30);
rows = requestLogRepository.findDailyUsageTrend(since);
period = "daily";
break;
}
List<UsageTrendResponse.UsageTrendItem> items = rows.stream()
.map(row -> {
String label = row[0].toString();
long total = ((Number) row[1]).longValue();
long success = ((Number) row[2]).longValue();
double avgRt = ((Number) row[3]).doubleValue();
long users = ((Number) row[4]).longValue();
double successRate = total > 0 ? (double) success / total * 100 : 0;
return new UsageTrendResponse.UsageTrendItem(
label, total, success, total - success, successRate, avgRt, users
);
})
.toList();
return new UsageTrendResponse(period, items);
}
}

파일 보기

@ -10,4 +10,6 @@ public interface SnpUserRepository extends JpaRepository<SnpUser, Long> {
Optional<SnpUser> findByLoginId(String loginId);
boolean existsByLoginId(String loginId);
long countByIsActiveTrue();
}