diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 17331f7..bf42442 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -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] ### 추가 diff --git a/docs/dashboard-statistics-guide.md b/docs/dashboard-statistics-guide.md new file mode 100644 index 0000000..2a8c2cf --- /dev/null +++ b/docs/dashboard-statistics-guide.md @@ -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개월 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f4f463..9f6a8c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/DateRangeFilter.tsx b/frontend/src/components/DateRangeFilter.tsx new file mode 100644 index 0000000..dc79421 --- /dev/null +++ b/frontend/src/components/DateRangeFilter.tsx @@ -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 ( +
+ 기간: + {presets.map((preset) => ( + + ))} + 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" + /> + ~ + 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" + /> +
+ ); +}; + +export default DateRangeFilter; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index d29c28e..6c0a812 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -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>({ Monitoring: true, + Statistics: true, 'API Keys': true, Admin: true, }); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 17e1bf7..8bac68e 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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([]); const [errorTrend, setErrorTrend] = useState([]); const [topApis, setTopApis] = useState([]); - const [tenantRequestRatio, setTenantRequestRatio] = useState([]); - const [tenantUserRatio, setTenantUserRatio] = useState([]); const [recentLogs, setRecentLogs] = useState([]); const [lastUpdated, setLastUpdated] = useState(''); 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(serviceRes, [])); setErrorTrend(extractSettled(errorRes, [])); setTopApis(extractSettled(topRes, [])); - setTenantRequestRatio(extractSettled(tenantReqRes, [])); - setTenantUserRatio(extractSettled(tenantUserRes, [])); setRecentLogs(extractSettled(logsRes, [])); setLastUpdated(new Date().toLocaleTimeString('ko-KR')); } finally { @@ -150,7 +143,7 @@ const DashboardPage = () => {

오늘 총 요청

{stats.totalRequests.toLocaleString()}

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)}%

@@ -163,44 +156,52 @@ const DashboardPage = () => {

{stats.avgResponseTime.toFixed(0)}ms

-

활성 사용자

+

API 요청 사용자

{stats.activeUserCount}

오늘

)} - {/* Row 2: Heartbeat Status Bar */} -
+ {/* Row 2: Heartbeat Status Cards */} +
{heartbeat.length > 0 ? ( -
- {heartbeat.map((svc) => ( -
navigate('/monitoring/service-status')} - > +
+ {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 (
- {svc.serviceName} - {svc.healthResponseTime !== null && ( - {svc.healthResponseTime}ms - )} - {svc.healthCheckedAt && ( - {svc.healthCheckedAt} - )} -
- ))} + 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')} + > +
+
+ {svc.serviceName} +
+
+ + {isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'} + + {svc.healthCheckedAt && ( + {svc.healthCheckedAt} + )} +
+
+ ); + })}
) : ( -

등록된 서비스가 없습니다

+
+

등록된 서비스가 없습니다

+
)}
@@ -316,60 +317,7 @@ const DashboardPage = () => {
- {/* Row 4: Tenant Stats */} -
-
-

테넌트별 요청 비율

- {tenantRequestRatio.length > 0 ? ( - - - - {tenantRequestRatio.map((_, idx) => ( - - ))} - - - - - - ) : ( -

데이터가 없습니다

- )} -
- -
-

테넌트별 사용자 비율

- {tenantUserRatio.length > 0 ? ( - - - - {tenantUserRatio.map((_, idx) => ( - - ))} - - - - - - ) : ( -

데이터가 없습니다

- )} -
-
- - {/* Row 5: Recent Logs */} + {/* Row 4: Recent Logs */}

최근 요청 로그

diff --git a/frontend/src/pages/statistics/ApiStatsPage.tsx b/frontend/src/pages/statistics/ApiStatsPage.tsx new file mode 100644 index 0000000..bf9f0a7 --- /dev/null +++ b/frontend/src/pages/statistics/ApiStatsPage.tsx @@ -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 = { + 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(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 = {}; + 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 ( +
+
로딩 중...
+
+ ); + } + + return ( +
+

API 통계

+ + + + {!data ? ( +

데이터가 없습니다

+ ) : ( + <> + {/* Charts */} +
+ {/* Chart 1: HTTP Method Distribution */} +
+

HTTP 메서드 분포

+ {data.methodDistribution.length > 0 ? ( + + + + {data.methodDistribution.map((_, idx) => ( + + ))} + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+ + {/* Chart 2: HTTP Status Code Distribution */} +
+

HTTP 상태 코드 분포

+ {statusChartData.length > 0 ? ( + + + + + + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+
+ + {/* Table 1: Top APIs */} +
+
+

API 호출 순위

+
+ {data.topApis.length > 0 ? ( +
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + ); + })} + +
순위서비스API메서드호출 수평균 응답시간성공률
{idx + 1} + + {api.serviceName} + + + {api.apiName} + + + {api.requestMethod} + + +
+
+
+
+ {api.callCount.toLocaleString()} +
+
{api.avgResponseTime.toFixed(0)}ms{api.successRate.toFixed(1)}%
+
+ ) : ( +

데이터가 없습니다

+ )} +
+ + {/* Table 2: Top Error APIs */} +
+
+

API 에러 순위

+
+ {data.topErrorApis.length > 0 ? ( +
+ + + + + + + + + + + + + {data.topErrorApis.slice(0, 10).map((api, idx) => { + const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] }; + return ( + + + + + + + + + ); + })} + +
순위서비스API에러 수전체 수에러율
{idx + 1} + + {api.serviceName} + + {api.apiName}{api.errorCount.toLocaleString()}{api.totalCount.toLocaleString()}{api.errorRate.toFixed(1)}%
+
+ ) : ( +

데이터가 없습니다

+ )} +
+ + )} +
+ ); +}; + +export default ApiStatsPage; diff --git a/frontend/src/pages/statistics/ServiceStatsPage.tsx b/frontend/src/pages/statistics/ServiceStatsPage.tsx new file mode 100644 index 0000000..a13ae1e --- /dev/null +++ b/frontend/src/pages/statistics/ServiceStatsPage.tsx @@ -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(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> = {}; + 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 ( +
+
로딩 중...
+
+ ); + } + + return ( +
+

서비스 통계

+ + + + {!data || data.serviceStats.length === 0 ? ( +

데이터가 없습니다

+ ) : ( + <> + {/* Summary Cards */} +
+ {data.serviceStats.map((svc) => ( +
+

{svc.serviceName}

+

+ {svc.totalRequests.toLocaleString()} +

+
+ 성공 {svc.successRate.toFixed(1)}% + {svc.avgResponseTime.toFixed(0)}ms +
+
+ ))} +
+ + {/* Charts */} +
+ {/* Chart 1: Service Request Count Bar */} +
+

서비스별 요청 수

+ + + + + + + + + + +
+ + {/* Chart 2: Hourly Service Trend */} +
+

시간별 서비스 요청 추이

+ {hourlyTrendPivoted.data.length > 0 ? ( + + + + `${h}시`} /> + + `${h}시`} /> + + {hourlyTrendPivoted.serviceNames.map((name, idx) => ( + + ))} + + + ) : ( +

데이터가 없습니다

+ )} +
+
+ + {/* Charts Row 2: Error Rate + Response Time */} +
+ {/* Chart: Error Rate Comparison */} +
+

서비스별 에러율 비교

+ + ({ + serviceName: s.serviceName, + successRate: Number(s.successRate.toFixed(1)), + errorRate: Number((100 - s.successRate).toFixed(1)), + }))} + layout="vertical" + > + + + + + + + + + +
+ + {/* Chart: Avg Response Time Comparison */} +
+

서비스별 평균 응답시간 비교

+ + ({ + serviceName: s.serviceName, + avgResponseTime: Number(s.avgResponseTime.toFixed(0)), + }))} + layout="vertical" + > + + + + + + {data.serviceStats.map((s, idx) => { + const rt = s.avgResponseTime; + const color = rt < 100 ? '#10b981' : rt < 300 ? '#f59e0b' : '#ef4444'; + return ; + })} + + + +
+
+ + )} +
+ ); +}; + +export default ServiceStatsPage; diff --git a/frontend/src/pages/statistics/TenantStatsPage.tsx b/frontend/src/pages/statistics/TenantStatsPage.tsx new file mode 100644 index 0000000..04cd892 --- /dev/null +++ b/frontend/src/pages/statistics/TenantStatsPage.tsx @@ -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(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> = {}; + 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 ( +
+
로딩 중...
+
+ ); + } + + return ( +
+

테넌트 통계

+ + + + {!data || data.tenantStats.length === 0 ? ( +

데이터가 없습니다

+ ) : ( + <> + {/* Summary Cards */} +
+ {data.tenantStats.map((tenant) => ( +
+

{tenant.tenantName || 'Unknown'}

+

+ {tenant.totalRequests.toLocaleString()} +

+
+ 사용자 {tenant.activeUsers} + 성공 {tenant.successRate.toFixed(1)}% +
+
+ ))} +
+ + {/* Charts */} +
+ {/* Chart 1: Daily Tenant Trend */} +
+

일별 테넌트 요청 추이

+ {dailyTrendPivoted.data.length > 0 ? ( + + + + + + + + {dailyTrendPivoted.tenantNames.map((name, idx) => ( + + ))} + + + ) : ( +

데이터가 없습니다

+ )} +
+ + {/* Chart 2: Tenant API Key Stats */} +
+

테넌트별 API Key 현황

+ {data.apiKeyStats.length > 0 ? ( + + + + + + + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+
+ + {/* Table: Tenant Details */} +
+
+

테넌트 상세

+
+
+ + + + + + + + + + + + {data.tenantStats.map((tenant) => ( + + + + + + + + ))} + +
테넌트요청 수활성 사용자성공률평균 응답시간
{tenant.tenantName || 'Unknown'}{tenant.totalRequests.toLocaleString()}{tenant.activeUsers}{tenant.successRate.toFixed(1)}%{tenant.avgResponseTime.toFixed(0)}ms
+
+
+ + )} +
+ ); +}; + +export default TenantStatsPage; diff --git a/frontend/src/pages/statistics/UsageTrendPage.tsx b/frontend/src/pages/statistics/UsageTrendPage.tsx new file mode 100644 index 0000000..6cabe8b --- /dev/null +++ b/frontend/src/pages/statistics/UsageTrendPage.tsx @@ -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('daily'); + const [data, setData] = useState(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 ( +
+

사용량 추이

+ + {/* Period Tabs */} +
+ {PERIOD_OPTIONS.map((opt) => ( + + ))} +
+ + {isLoading ? ( +
+
로딩 중...
+
+ ) : !data || data.items.length === 0 ? ( +

데이터가 없습니다

+ ) : ( + <> + {/* Chart 1: 요청 수 추이 (full width) */} +
+

요청 수 추이

+ + + + + + + + + + + + + + + + + + +
+ + {/* Charts 2 & 3: 2 column grid */} +
+ {/* Chart 2: 성공률 + 응답시간 추이 */} +
+

성공률 + 응답시간 추이

+ + + + + + + + + + + + +
+ + {/* Chart 3: 활성 사용자 추이 */} +
+

활성 사용자 추이

+ + + + + + + + + + +
+
+ + {/* Table: 상세 데이터 */} +
+
+

상세 데이터

+
+
+ + + + + + + + + + + + + + {data.items.map((item) => ( + + + + + + + + + + ))} + +
기간총 요청성공실패성공률(%)평균 응답시간(ms)활성 사용자
+ {formatLabel(item.label, period)} + + {item.totalRequests.toLocaleString()} + + {item.successCount.toLocaleString()} + + {item.failureCount.toLocaleString()} + + {item.successRate.toFixed(1)} + + {item.avgResponseTime.toFixed(0)} + + {item.activeUsers.toLocaleString()} +
+
+
+ + )} +
+ ); +}; + +export default UsageTrendPage; diff --git a/frontend/src/pages/statistics/UserStatsPage.tsx b/frontend/src/pages/statistics/UserStatsPage.tsx new file mode 100644 index 0000000..05bcc81 --- /dev/null +++ b/frontend/src/pages/statistics/UserStatsPage.tsx @@ -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 = { + 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(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 ( +
+
로딩 중...
+
+ ); + } + + return ( +
+

사용자 통계

+ + + + {!data ? ( +

데이터가 없습니다

+ ) : ( + <> + {/* Summary Cards */} +
+
+

전체 사용자

+

{data.totalUsers}

+
+
+

API Key 보유 사용자

+

{data.usersWithActiveKey}

+
+
+

API 요청 사용자

+

{data.totalActiveUsers}

+
+
+ + {/* Charts */} +
+ {/* Chart 1: Daily Active Users */} +
+

일별 API 요청 사용자 추이

+ {data.dailyActiveUsers.length > 0 ? ( + + + + + + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+ + {/* Chart 2: Role Distribution Donut */} +
+

역할별 요청 분포

+ {data.roleDistribution.length > 0 ? ( + + + + {data.roleDistribution.map((_, idx) => ( + + ))} + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+
+ + {/* Table: Top Users */} +
+
+

상위 사용자 Top 10

+
+ {data.topUsers.length > 0 ? ( +
+ + + + + + + + + + + + {data.topUsers.slice(0, 10).map((user, idx) => ( + + + + + + + + ))} + +
순위사용자역할요청 수성공률
{idx + 1}{user.userName} + + {user.role} + + {user.requestCount.toLocaleString()}{user.successRate.toFixed(1)}%
+
+ ) : ( +

데이터가 없습니다

+ )} +
+ + )} +
+ ); +}; + +export default UserStatsPage; diff --git a/frontend/src/services/dashboardService.ts b/frontend/src/services/dashboardService.ts index 9751aa9..5fb8de7 100644 --- a/frontend/src/services/dashboardService.ts +++ b/frontend/src/services/dashboardService.ts @@ -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('/dashboard/hourly-trend' export const getServiceRatio = () => get('/dashboard/service-ratio'); export const getErrorTrend = () => get('/dashboard/error-trend'); export const getTopApis = (limit = 10) => get(`/dashboard/top-apis?limit=${limit}`); -export const getTenantRequestRatio = () => get('/dashboard/tenant-request-ratio'); -export const getTenantUserRatio = () => get('/dashboard/tenant-user-ratio'); export const getRecentLogs = () => get('/dashboard/recent-logs'); export const getHeartbeat = () => get('/dashboard/heartbeat'); diff --git a/frontend/src/services/statisticsService.ts b/frontend/src/services/statisticsService.ts new file mode 100644 index 0000000..5b832cc --- /dev/null +++ b/frontend/src/services/statisticsService.ts @@ -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(`/statistics/services${buildQuery(startDate, endDate)}`); + +export const getUserStats = (startDate: string, endDate: string) => + get(`/statistics/users${buildQuery(startDate, endDate)}`); + +export const getApiStats = (startDate: string, endDate: string) => + get(`/statistics/apis${buildQuery(startDate, endDate)}`); + +export const getTenantStats = (startDate: string, endDate: string) => + get(`/statistics/tenants${buildQuery(startDate, endDate)}`); + +export const getUsageTrend = (period: string) => + get(`/statistics/usage-trend?period=${period}`); diff --git a/frontend/src/types/statistics.ts b/frontend/src/types/statistics.ts new file mode 100644 index 0000000..ecfdb8e --- /dev/null +++ b/frontend/src/types/statistics.ts @@ -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[]; +} diff --git a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java index 56b7860..de82657 100644 --- a/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java +++ b/src/main/java/com/gcsc/connection/apikey/repository/SnpApiKeyRepository.java @@ -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 { List findByUserUserId(Long userId); List 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 findTenantApiKeyStats(); } diff --git a/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java index e75beb6..fcd50ec 100644 --- a/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java +++ b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java @@ -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 { diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java index b060bf6..bff32e8 100644 --- a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java +++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java @@ -56,17 +56,17 @@ public interface SnpApiRequestLogRepository extends JpaRepository 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 findTopApis(@Param("startOfDay") LocalDateTime startOfDay, @Param("limit") int limit); @@ -90,4 +90,177 @@ public interface SnpApiRequestLogRepository extends JpaRepository 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 findMonthlyUsageTrend(@Param("since") LocalDateTime since); } diff --git a/src/main/java/com/gcsc/connection/statistics/controller/StatisticsController.java b/src/main/java/com/gcsc/connection/statistics/controller/StatisticsController.java new file mode 100644 index 0000000..9c10ec2 --- /dev/null +++ b/src/main/java/com/gcsc/connection/statistics/controller/StatisticsController.java @@ -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> 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> 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> 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> 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> getUsageTrend( + @RequestParam(defaultValue = "daily") String period) { + return ResponseEntity.ok(ApiResponse.ok(statisticsService.getUsageTrend(period))); + } +} diff --git a/src/main/java/com/gcsc/connection/statistics/dto/ApiStatsResponse.java b/src/main/java/com/gcsc/connection/statistics/dto/ApiStatsResponse.java new file mode 100644 index 0000000..cc623b1 --- /dev/null +++ b/src/main/java/com/gcsc/connection/statistics/dto/ApiStatsResponse.java @@ -0,0 +1,43 @@ +package com.gcsc.connection.statistics.dto; + +import java.util.List; + +/** + * API 엔드포인트별 통계 응답 DTO + */ +public record ApiStatsResponse( + List topApis, + List topErrorApis, + List methodDistribution, + List 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 + ) {} +} diff --git a/src/main/java/com/gcsc/connection/statistics/dto/ServiceStatsResponse.java b/src/main/java/com/gcsc/connection/statistics/dto/ServiceStatsResponse.java new file mode 100644 index 0000000..c0b1b3d --- /dev/null +++ b/src/main/java/com/gcsc/connection/statistics/dto/ServiceStatsResponse.java @@ -0,0 +1,34 @@ +package com.gcsc.connection.statistics.dto; + +import java.util.List; + +/** + * 서비스별 통계 응답 DTO + */ +public record ServiceStatsResponse( + List serviceStats, + List slowestServices, + List 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 + ) {} +} diff --git a/src/main/java/com/gcsc/connection/statistics/dto/TenantStatsResponse.java b/src/main/java/com/gcsc/connection/statistics/dto/TenantStatsResponse.java new file mode 100644 index 0000000..5ca45d5 --- /dev/null +++ b/src/main/java/com/gcsc/connection/statistics/dto/TenantStatsResponse.java @@ -0,0 +1,34 @@ +package com.gcsc.connection.statistics.dto; + +import java.util.List; + +/** + * 테넌트별 통계 응답 DTO + */ +public record TenantStatsResponse( + List tenantStats, + List dailyTrend, + List 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 + ) {} +} diff --git a/src/main/java/com/gcsc/connection/statistics/dto/UsageTrendResponse.java b/src/main/java/com/gcsc/connection/statistics/dto/UsageTrendResponse.java new file mode 100644 index 0000000..0df72e6 --- /dev/null +++ b/src/main/java/com/gcsc/connection/statistics/dto/UsageTrendResponse.java @@ -0,0 +1,18 @@ +package com.gcsc.connection.statistics.dto; + +import java.util.List; + +public record UsageTrendResponse( + String period, + List items +) { + public record UsageTrendItem( + String label, + long totalRequests, + long successCount, + long failureCount, + double successRate, + double avgResponseTime, + long activeUsers + ) {} +} diff --git a/src/main/java/com/gcsc/connection/statistics/dto/UserStatsResponse.java b/src/main/java/com/gcsc/connection/statistics/dto/UserStatsResponse.java new file mode 100644 index 0000000..85453f0 --- /dev/null +++ b/src/main/java/com/gcsc/connection/statistics/dto/UserStatsResponse.java @@ -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 topUsers, + List roleDistribution, + List 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 + ) {} +} diff --git a/src/main/java/com/gcsc/connection/statistics/service/StatisticsService.java b/src/main/java/com/gcsc/connection/statistics/service/StatisticsService.java new file mode 100644 index 0000000..8924538 --- /dev/null +++ b/src/main/java/com/gcsc/connection/statistics/service/StatisticsService.java @@ -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 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 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 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 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 roleDistribution = requestLogRepository + .findUserRoleDistribution(start, end).stream() + .map(row -> new UserRoleDistribution( + (String) row[0], + ((Number) row[1]).longValue(), + ((Number) row[2]).longValue() + )) + .toList(); + + List 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 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 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 methodDistribution = requestLogRepository + .findMethodDistribution(start, end).stream() + .map(row -> new ApiMethodDistribution( + (String) row[0], + ((Number) row[1]).longValue() + )) + .toList(); + + List 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 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 dailyTrend = requestLogRepository + .findDailyTenantTrend(start, end).stream() + .map(row -> new DailyTenantTrend( + (String) row[0], + (String) row[1], + ((Number) row[2]).longValue() + )) + .toList(); + + List 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 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 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); + } +} diff --git a/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java b/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java index 6802372..3ad4f2b 100644 --- a/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java +++ b/src/main/java/com/gcsc/connection/user/repository/SnpUserRepository.java @@ -10,4 +10,6 @@ public interface SnpUserRepository extends JpaRepository { Optional findByLoginId(String loginId); boolean existsByLoginId(String loginId); + + long countByIsActiveTrue(); }