diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md
index 17331f7..d90e197 100644
--- a/docs/RELEASE-NOTES.md
+++ b/docs/RELEASE-NOTES.md
@@ -6,22 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased]
-## [2026-04-08.2]
+## [2026-04-09]
### 추가
-- 다크/라이트 모드 전체 적용 (ThemeContext, 토글 버튼, 전 페이지 dark 클래스) (#15)
-- API Key 신청 영구 사용 옵션 (#15)
-- API Key Admin 키 관리 만료일 컬럼 (#15)
-- Gateway API 경로 {변수} 패턴 매칭 지원 (#15)
+- 통계 메뉴 5개 (서비스/사용자/API/테넌트/사용량 추이) (#23)
+- 사용량 추이: 일별/주별/월별 탭, 요청수+성공률+응답시간+사용자 차트 (#23)
+- snp_api_request_log 월별 Range 파티셔닝 + 자동 관리 배치 (#11)
+- 데이터 정리 배치 (health_log 90일 이전 자동 삭제) (#11)
+- 에러 핸들링 보완 (DataAccessException, IllegalArgument, HttpMessageNotReadable) (#11)
### 변경
-- 사이드바 아이콘 링크체인으로 변경, 헤더/사이드바 높이 통일 (#15)
-- 컨텐츠 영역 max-w-7xl 마진 통일 (#15)
-- 전체 Actions 버튼 bg-color-100 스타일 통일 (#15)
-- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15)
-- My Keys ADMIN 직접 생성 제거 → Request 폼 통일 (#15)
+- 대시보드: 하트비트 카드형, 테넌트 차트 제거, URL 쿼리파라미터 정규화 (#23)
+- Gateway: request_url 저장 시 쿼리스트링 제외 (#23)
+- 통계 차트 개선 (에러율 비교, 응답시간 분포, flex 균등분할) (#23)
+- 라벨: "활성 사용자" → "API 요청 사용자" (#23)
## [2026-04-08]
@@ -31,10 +31,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
- 테넌트/사용자/서비스 CRUD API + 하트비트 스케줄러 (#7)
- API Key AES-256-GCM 암호화, 신청→승인 워크플로우, Permission 관리 (#8)
- API Gateway 프록시, API Key 인증 필터, 비동기 요청 로깅 (#9)
-- 대시보드 통계 (Recharts 차트 6개, 요약 카드, 30초 자동 갱신) (#10)
+- 대시보드 통계 (Recharts 차트, 요약 카드, 30초 자동 갱신) (#10)
- Service Status 페이지 (90일 일별 uptime, status.claude.com 스타일) (#10)
-- 테넌트별 요청/사용자 비율 통계 (#10)
-- 프론트엔드: 관리 페이지, API Key 관리, 요청 로그 검색/상세 (#7~#10)
+- 다크/라이트 모드 전체 적용 (#15)
+- API Key 신청 영구 사용 옵션, Gateway {변수} 패턴 매칭 (#15)
+
+### 변경
+
+- 사이드바 아이콘/높이 통일, Actions 버튼 스타일 통일, max-w-7xl 레이아웃 (#15)
+- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15)
## [2026-04-07]
@@ -43,7 +48,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
- Spring Boot 3.2.1 백엔드 초기 구조 (com.gcsc.connection, 포트 8042, context /snp-connection)
- React 19 + TypeScript + Vite 7 + Tailwind CSS 4 프론트엔드 통합
- frontend-maven-plugin 기반 통합 빌드 설정
-- SPA fallback WebViewController, SecurityConfig, SwaggerConfig
-- 공통 모듈 (ApiResponse, GlobalExceptionHandler)
-- 파비콘 등록 (favicon_io_red)
+- CI/CD 자동 배포 (Gitea Actions)
- 팀 워크플로우 v1.6.1 동기화
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/docs/schema/partition_migration.sql b/docs/schema/partition_migration.sql
new file mode 100644
index 0000000..9f684c3
--- /dev/null
+++ b/docs/schema/partition_migration.sql
@@ -0,0 +1,69 @@
+-- =============================================================
+-- SNP Connection Monitoring - 파티션 마이그레이션
+-- snp_api_request_log 테이블을 월별 Range 파티션으로 전환
+-- 스키마: common
+--
+-- 주의: 운영 DB에서 수동 실행. 서비스 중지 후 진행 권장.
+-- =============================================================
+
+BEGIN;
+
+-- 1. 기존 데이터 백업
+CREATE TABLE common.snp_api_request_log_backup AS
+SELECT * FROM common.snp_api_request_log;
+
+-- 2. 기존 테이블 삭제
+DROP TABLE IF EXISTS common.snp_api_request_log CASCADE;
+
+-- 3. 파티션 테이블 생성
+CREATE TABLE common.snp_api_request_log (
+ log_id BIGSERIAL,
+ request_url VARCHAR(2000),
+ request_params TEXT,
+ request_method VARCHAR(10),
+ request_status VARCHAR(20),
+ request_headers TEXT,
+ request_ip VARCHAR(45),
+ service_id BIGINT,
+ user_id BIGINT,
+ api_key_id BIGINT,
+ response_size BIGINT,
+ response_time INTEGER,
+ response_status INTEGER,
+ error_message TEXT,
+ requested_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ tenant_id BIGINT,
+ PRIMARY KEY (log_id, requested_at)
+) PARTITION BY RANGE (requested_at);
+
+-- 4. 현재 월 + 미래 2개월 파티션 생성 (실행 시점에 맞게 날짜 수정)
+-- 예시: 2026년 4월 실행 기준
+CREATE TABLE common.snp_api_request_log_202604
+ PARTITION OF common.snp_api_request_log
+ FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
+
+CREATE TABLE common.snp_api_request_log_202605
+ PARTITION OF common.snp_api_request_log
+ FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
+
+CREATE TABLE common.snp_api_request_log_202606
+ PARTITION OF common.snp_api_request_log
+ FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
+
+-- 5. 인덱스 재생성 (파티션 테이블에 자동 상속됨)
+CREATE INDEX idx_snp_request_log_service ON common.snp_api_request_log (service_id);
+CREATE INDEX idx_snp_request_log_user ON common.snp_api_request_log (user_id);
+CREATE INDEX idx_snp_request_log_requested ON common.snp_api_request_log (requested_at);
+CREATE INDEX idx_snp_request_log_tenant ON common.snp_api_request_log (tenant_id);
+CREATE INDEX idx_snp_request_log_daily_stats ON common.snp_api_request_log (requested_at, request_status);
+CREATE INDEX idx_snp_request_log_daily_user ON common.snp_api_request_log (requested_at, user_id);
+CREATE INDEX idx_snp_request_log_svc_stats ON common.snp_api_request_log (requested_at, service_id, request_status);
+CREATE INDEX idx_snp_request_log_top_api ON common.snp_api_request_log (requested_at, request_url, request_method);
+
+-- 6. 데이터 복원
+INSERT INTO common.snp_api_request_log SELECT * FROM common.snp_api_request_log_backup;
+
+-- 7. 백업 테이블 삭제 (데이터 복원 확인 후)
+-- DROP TABLE common.snp_api_request_log_backup;
+
+COMMIT;
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')}
+ >
+
+
+
+ {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 ? (
+
+
+
+
+ | 순위 |
+ 서비스 |
+ API |
+ 메서드 |
+ 호출 수 |
+ 평균 응답시간 |
+ 성공률 |
+
+
+
+ {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 (
+
+ | {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 ? (
+
+
+
+
+ | 순위 |
+ 서비스 |
+ API |
+ 에러 수 |
+ 전체 수 |
+ 에러율 |
+
+
+
+ {data.topErrorApis.slice(0, 10).map((api, idx) => {
+ const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] };
+ return (
+
+ | {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: 상세 데이터 */}
+
+
+
상세 데이터
+
+
+
+
+
+ | 기간 |
+ 총 요청 |
+ 성공 |
+ 실패 |
+ 성공률(%) |
+ 평균 응답시간(ms) |
+ 활성 사용자 |
+
+
+
+ {data.items.map((item) => (
+
+ |
+ {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