Merge pull request 'release: 2026-04-09 (14건 커밋)' (#28) from develop into main
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s

This commit is contained in:
HYOJIN 2026-04-09 11:30:00 +09:00
커밋 7027e4be31
31개의 변경된 파일2490개의 추가작업 그리고 118개의 파일을 삭제

파일 보기

@ -6,22 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased] ## [Unreleased]
## [2026-04-08.2] ## [2026-04-09]
### 추가 ### 추가
- 다크/라이트 모드 전체 적용 (ThemeContext, 토글 버튼, 전 페이지 dark 클래스) (#15) - 통계 메뉴 5개 (서비스/사용자/API/테넌트/사용량 추이) (#23)
- API Key 신청 영구 사용 옵션 (#15) - 사용량 추이: 일별/주별/월별 탭, 요청수+성공률+응답시간+사용자 차트 (#23)
- API Key Admin 키 관리 만료일 컬럼 (#15) - snp_api_request_log 월별 Range 파티셔닝 + 자동 관리 배치 (#11)
- Gateway API 경로 {변수} 패턴 매칭 지원 (#15) - 데이터 정리 배치 (health_log 90일 이전 자동 삭제) (#11)
- 에러 핸들링 보완 (DataAccessException, IllegalArgument, HttpMessageNotReadable) (#11)
### 변경 ### 변경
- 사이드바 아이콘 링크체인으로 변경, 헤더/사이드바 높이 통일 (#15) - 대시보드: 하트비트 카드형, 테넌트 차트 제거, URL 쿼리파라미터 정규화 (#23)
- 컨텐츠 영역 max-w-7xl 마진 통일 (#15) - Gateway: request_url 저장 시 쿼리스트링 제외 (#23)
- 전체 Actions 버튼 bg-color-100 스타일 통일 (#15) - 통계 차트 개선 (에러율 비교, 응답시간 분포, flex 균등분할) (#23)
- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15) - 라벨: "활성 사용자" → "API 요청 사용자" (#23)
- My Keys ADMIN 직접 생성 제거 → Request 폼 통일 (#15)
## [2026-04-08] ## [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) - 테넌트/사용자/서비스 CRUD API + 하트비트 스케줄러 (#7)
- API Key AES-256-GCM 암호화, 신청→승인 워크플로우, Permission 관리 (#8) - API Key AES-256-GCM 암호화, 신청→승인 워크플로우, Permission 관리 (#8)
- API Gateway 프록시, API Key 인증 필터, 비동기 요청 로깅 (#9) - API Gateway 프록시, API Key 인증 필터, 비동기 요청 로깅 (#9)
- 대시보드 통계 (Recharts 차트 6개, 요약 카드, 30초 자동 갱신) (#10) - 대시보드 통계 (Recharts 차트, 요약 카드, 30초 자동 갱신) (#10)
- Service Status 페이지 (90일 일별 uptime, status.claude.com 스타일) (#10) - Service Status 페이지 (90일 일별 uptime, status.claude.com 스타일) (#10)
- 테넌트별 요청/사용자 비율 통계 (#10) - 다크/라이트 모드 전체 적용 (#15)
- 프론트엔드: 관리 페이지, API Key 관리, 요청 로그 검색/상세 (#7~#10) - API Key 신청 영구 사용 옵션, Gateway {변수} 패턴 매칭 (#15)
### 변경
- 사이드바 아이콘/높이 통일, Actions 버튼 스타일 통일, max-w-7xl 레이아웃 (#15)
- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15)
## [2026-04-07] ## [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) - Spring Boot 3.2.1 백엔드 초기 구조 (com.gcsc.connection, 포트 8042, context /snp-connection)
- React 19 + TypeScript + Vite 7 + Tailwind CSS 4 프론트엔드 통합 - React 19 + TypeScript + Vite 7 + Tailwind CSS 4 프론트엔드 통합
- frontend-maven-plugin 기반 통합 빌드 설정 - frontend-maven-plugin 기반 통합 빌드 설정
- SPA fallback WebViewController, SecurityConfig, SwaggerConfig - CI/CD 자동 배포 (Gitea Actions)
- 공통 모듈 (ApiResponse, GlobalExceptionHandler)
- 파비콘 등록 (favicon_io_red)
- 팀 워크플로우 v1.6.1 동기화 - 팀 워크플로우 v1.6.1 동기화

파일 보기

@ -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개월 |

파일 보기

@ -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;

파일 보기

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

파일 보기

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

파일 보기

@ -17,6 +17,16 @@ const navGroups: NavGroup[] = [
{ label: 'Service Status', path: '/monitoring/service-status' }, { label: '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', label: 'API Keys',
items: [ items: [
@ -41,6 +51,7 @@ const MainLayout = () => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true, Monitoring: true,
Statistics: true,
'API Keys': true, 'API Keys': true,
Admin: true, Admin: true,
}); });

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,210 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
LineChart, Line, Cell,
} from 'recharts';
import type { ServiceStatsResponse } from '../../types/statistics';
import { getServiceStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const getToday = () => new Date().toISOString().slice(0, 10);
const ServiceStatsPage = () => {
const [startDate, setStartDate] = useState(getToday());
const [endDate, setEndDate] = useState(getToday());
const [data, setData] = useState<ServiceStatsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const res = await getServiceStats(startDate, endDate);
if (res.success && res.data) {
setData(res.data);
}
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handlePreset = (days: number) => {
const today = getToday();
if (days === 0) {
setStartDate(today);
} else {
const d = new Date();
d.setDate(d.getDate() - days);
setStartDate(d.toISOString().slice(0, 10));
}
setEndDate(today);
};
const hourlyTrendPivoted = useMemo(() => {
if (!data) return { data: [], serviceNames: [] };
const serviceNames = [...new Set(data.hourlyTrend.map((e) => e.serviceName))];
const byHour: Record<number, Record<string, number | string>> = {};
for (const item of data.hourlyTrend) {
if (!byHour[item.hour]) {
byHour[item.hour] = { hour: item.hour };
}
byHour[item.hour][item.serviceName] = item.count;
}
return { data: Object.values(byHour), serviceNames };
}, [data]);
const barChartData = useMemo(() => {
if (!data) return [];
return data.serviceStats.map((s) => ({
serviceName: s.serviceName,
totalRequests: s.totalRequests,
}));
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400"> ...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6"> </h1>
<DateRangeFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
{!data || data.serviceStats.length === 0 ? (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
) : (
<>
{/* Summary Cards */}
<div className="flex flex-wrap gap-4 mb-6">
{data.serviceStats.map((svc) => (
<div key={svc.serviceId} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 flex-1 min-w-[200px]">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{svc.serviceName}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{svc.totalRequests.toLocaleString()}
</p>
<div className="flex items-center gap-3 mt-2 text-sm">
<span className="text-green-600"> {svc.successRate.toFixed(1)}%</span>
<span className="text-gray-500 dark:text-gray-400">{svc.avgResponseTime.toFixed(0)}ms</span>
</div>
</div>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Service Request Count Bar */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barChartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="serviceName" type="category" width={120} />
<Tooltip />
<Legend />
<Bar dataKey="totalRequests" fill="#3b82f6" name="요청 수" />
</BarChart>
</ResponsiveContainer>
</div>
{/* Chart 2: Hourly Service Trend */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{hourlyTrendPivoted.data.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={hourlyTrendPivoted.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} />
<YAxis />
<Tooltip labelFormatter={(h) => `${h}`} />
<Legend />
{hourlyTrendPivoted.serviceNames.map((name, idx) => (
<Line
key={name}
type="monotone"
dataKey={name}
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
/>
))}
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
</div>
{/* Charts Row 2: Error Rate + Response Time */}
<div className="grid grid-cols-2 gap-6">
{/* Chart: Error Rate Comparison */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={data.serviceStats.map((s) => ({
serviceName: s.serviceName,
successRate: Number(s.successRate.toFixed(1)),
errorRate: Number((100 - s.successRate).toFixed(1)),
}))}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} unit="%" />
<YAxis dataKey="serviceName" type="category" width={120} />
<Tooltip />
<Legend />
<Bar dataKey="successRate" stackId="a" fill="#10b981" name="성공률" />
<Bar dataKey="errorRate" stackId="a" fill="#ef4444" name="에러율" />
</BarChart>
</ResponsiveContainer>
</div>
{/* Chart: Avg Response Time Comparison */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={data.serviceStats.map((s) => ({
serviceName: s.serviceName,
avgResponseTime: Number(s.avgResponseTime.toFixed(0)),
}))}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" unit="ms" />
<YAxis dataKey="serviceName" type="category" width={120} />
<Tooltip />
<Bar dataKey="avgResponseTime" name="평균 응답시간 (ms)">
{data.serviceStats.map((s, idx) => {
const rt = s.avgResponseTime;
const color = rt < 100 ? '#10b981' : rt < 300 ? '#f59e0b' : '#ef4444';
return <Cell key={idx} fill={color} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</>
)}
</div>
);
};
export default ServiceStatsPage;

파일 보기

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

파일 보기

@ -0,0 +1,236 @@
import { useState, useEffect, useCallback } from 'react';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Area, ComposedChart,
} from 'recharts';
import type { UsageTrendResponse } from '../../types/statistics';
import { getUsageTrend } from '../../services/statisticsService';
type Period = 'daily' | 'weekly' | 'monthly';
const PERIOD_OPTIONS: { key: Period; label: string }[] = [
{ key: 'daily', label: '일별' },
{ key: 'weekly', label: '주별' },
{ key: 'monthly', label: '월별' },
];
const formatLabel = (label: string, period: Period): string => {
if (period === 'daily') {
const d = new Date(label);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
if (period === 'weekly') {
const d = new Date(label);
return `${d.getMonth() + 1}/${d.getDate()}~`;
}
return label;
};
const getSuccessRateColor = (rate: number): string => {
if (rate >= 99) return 'text-green-600';
if (rate >= 95) return 'text-yellow-600';
return 'text-red-600';
};
const UsageTrendPage = () => {
const [period, setPeriod] = useState<Period>('daily');
const [data, setData] = useState<UsageTrendResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const res = await getUsageTrend(period);
if (res.success && res.data) {
setData(res.data);
}
} finally {
setIsLoading(false);
}
}, [period]);
useEffect(() => {
fetchData();
}, [fetchData]);
const chartData = data?.items.map((item) => ({
...item,
formattedLabel: formatLabel(item.label, period),
})) ?? [];
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6"> </h1>
{/* Period Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-6">
{PERIOD_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setPeriod(opt.key)}
className={`px-4 py-2 text-sm font-medium -mb-px ${
period === opt.key
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
{opt.label}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400"> ...</div>
</div>
) : !data || data.items.length === 0 ? (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
) : (
<>
{/* Chart 1: 요청 수 추이 (full width) */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={chartData}>
<defs>
<linearGradient id="totalRequestsFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.15} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis />
<Tooltip />
<Legend />
<Area
type="monotone"
dataKey="totalRequests"
stroke="none"
fill="url(#totalRequestsFill)"
name="총 요청"
/>
<Line
type="monotone"
dataKey="totalRequests"
stroke="#3b82f6"
name="총 요청"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="failureCount"
stroke="#ef4444"
name="실패 수"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Charts 2 & 3: 2 column grid */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 2: 성공률 + 응답시간 추이 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> + </h3>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis yAxisId="left" unit="%" domain={[0, 100]} />
<YAxis yAxisId="right" orientation="right" unit="ms" />
<Tooltip />
<Legend />
<Line
yAxisId="left"
type="monotone"
dataKey="successRate"
stroke="#10b981"
name="성공률(%)"
strokeWidth={2}
dot={false}
/>
<Bar
yAxisId="right"
dataKey="avgResponseTime"
fill="#3b82f6"
name="평균 응답시간(ms)"
barSize={20}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Chart 3: 활성 사용자 추이 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="activeUsers" fill="#06b6d4" name="활성 사용자" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Table: 상세 데이터 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> </h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">(%)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> (ms)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> </th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{data.items.map((item) => (
<tr key={item.label} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{formatLabel(item.label, period)}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.totalRequests.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.successCount.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.failureCount.toLocaleString()}
</td>
<td className={`px-4 py-3 font-medium ${getSuccessRateColor(item.successRate)}`}>
{item.successRate.toFixed(1)}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.avgResponseTime.toFixed(0)}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
{item.activeUsers.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
};
export default UsageTrendPage;

파일 보기

@ -0,0 +1,193 @@
import { useState, useEffect, useCallback } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
PieChart, Pie, Cell,
} from 'recharts';
import type { UserStatsResponse } from '../../types/statistics';
import { getUserStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const ROLE_BADGE: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-blue-100 text-blue-800',
USER: 'bg-green-100 text-green-800',
};
const getToday = () => new Date().toISOString().slice(0, 10);
const UserStatsPage = () => {
const [startDate, setStartDate] = useState(getToday());
const [endDate, setEndDate] = useState(getToday());
const [data, setData] = useState<UserStatsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const res = await getUserStats(startDate, endDate);
if (res.success && res.data) {
setData(res.data);
}
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handlePreset = (days: number) => {
const today = getToday();
if (days === 0) {
setStartDate(today);
} else {
const d = new Date();
d.setDate(d.getDate() - days);
setStartDate(d.toISOString().slice(0, 10));
}
setEndDate(today);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400"> ...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6"> </h1>
<DateRangeFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
{!data ? (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
) : (
<>
{/* Summary Cards */}
<div className="flex gap-4 mb-6">
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500 dark:text-gray-400"> </p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalUsers}</p>
</div>
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500 dark:text-gray-400">API Key </p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.usersWithActiveKey}</p>
</div>
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500 dark:text-gray-400">API </p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalActiveUsers}</p>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Daily Active Users */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> API </h3>
{data.dailyActiveUsers.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data.dailyActiveUsers}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Area
type="monotone"
dataKey="activeUsers"
stroke="#3b82f6"
fill="#3b82f6"
fillOpacity={0.3}
name="API 요청 사용자"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
{/* Chart 2: Role Distribution Donut */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{data.roleDistribution.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.roleDistribution}
dataKey="requestCount"
nameKey="role"
innerRadius={60}
outerRadius={100}
>
{data.roleDistribution.map((_, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-20"> </p>
)}
</div>
</div>
{/* Table: Top Users */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> Top 10</h3>
</div>
{data.topUsers.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{data.topUsers.slice(0, 10).map((user, idx) => (
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ROLE_BADGE[user.role] ?? 'bg-gray-100 text-gray-800'}`}>
{user.role}
</span>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.requestCount.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.successRate.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-gray-400 dark:text-gray-500 text-center py-8"> </p>
)}
</div>
</>
)}
</div>
);
};
export default UserStatsPage;

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -2,8 +2,10 @@ package com.gcsc.connection.common.exception;
import com.gcsc.connection.common.dto.ApiResponse; import com.gcsc.connection.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@ -52,6 +54,39 @@ public class GlobalExceptionHandler {
.body(ApiResponse.error("접근 권한이 없습니다")); .body(ApiResponse.error("접근 권한이 없습니다"));
} }
/**
* 데이터베이스 오류 처리
*/
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ApiResponse<Void>> handleDataAccessException(DataAccessException e) {
log.error("Database error: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("데이터베이스 오류가 발생했습니다"));
}
/**
* 잘못된 인자 예외 처리
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
log.warn("Invalid argument: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(e.getMessage()));
}
/**
* 요청 본문 읽기 실패 처리
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
log.warn("Message not readable: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("요청 본문을 읽을 수 없습니다"));
}
/** /**
* 처리되지 않은 예외 처리 * 처리되지 않은 예외 처리
*/ */

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,38 @@
package com.gcsc.connection.monitoring.scheduler;
import com.gcsc.connection.monitoring.repository.SnpServiceHealthLogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Slf4j
@Component
@RequiredArgsConstructor
public class DataCleanupScheduler {
private final SnpServiceHealthLogRepository healthLogRepository;
@Value("${app.retention.health-log-days:90}")
private int healthLogRetentionDays;
/**
* 매일 02:00 실행 - 오래된 데이터 정리
*/
@Scheduled(cron = "0 0 2 * * *")
@Transactional
public void cleanupOldData() {
log.info("데이터 정리 시작");
// Health log 정리
LocalDateTime healthCutoff = LocalDateTime.now().minusDays(healthLogRetentionDays);
int deletedHealthLogs = healthLogRepository.deleteOlderThan(healthCutoff);
log.info("Health log 정리 완료: {}건 삭제 (기준: {}일 이전)", deletedHealthLogs, healthLogRetentionDays);
log.info("데이터 정리 완료");
}
}

파일 보기

@ -0,0 +1,75 @@
package com.gcsc.connection.monitoring.scheduler;
import com.gcsc.connection.monitoring.service.PartitionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class PartitionManageScheduler {
private final PartitionService partitionService;
@Value("${app.partition.enabled:false}")
private boolean partitionEnabled;
@Value("${app.partition.advance-months:2}")
private int advanceMonths;
@Value("${app.retention.request-log-days:90}")
private int retentionDays;
/**
* 매월 1일 00:00 실행 - 파티션 생성/삭제
*/
@Scheduled(cron = "0 0 0 1 * *")
public void managePartitions() {
if (!partitionEnabled) {
log.debug("파티션 관리 비활성화 상태");
return;
}
log.info("파티션 관리 시작");
YearMonth now = YearMonth.now();
// 미래 파티션 생성
for (int i = 0; i <= advanceMonths; i++) {
partitionService.createPartition(now.plusMonths(i));
}
// 보관 기간 이전 파티션 삭제 (3개월 )
int retentionMonths = retentionDays / 30;
YearMonth cutoff = now.minusMonths(retentionMonths);
List<String> existing = partitionService.getExistingPartitions();
String cutoffStr = "snp_api_request_log_" + cutoff.format(DateTimeFormatter.ofPattern("yyyyMM"));
for (String partition : existing) {
if (partition.compareTo(cutoffStr) < 0) {
YearMonth ym = parsePartitionYearMonth(partition);
if (ym != null) {
partitionService.dropPartition(ym);
}
}
}
log.info("파티션 관리 완료");
}
private YearMonth parsePartitionYearMonth(String partitionName) {
try {
String suffix = partitionName.replace("snp_api_request_log_", "");
return YearMonth.parse(suffix, DateTimeFormatter.ofPattern("yyyyMM"));
} catch (Exception e) {
return null;
}
}
}

파일 보기

@ -0,0 +1,67 @@
package com.gcsc.connection.monitoring.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class PartitionService {
private final JdbcTemplate jdbcTemplate;
private static final String TABLE_NAME = "common.snp_api_request_log";
private static final String PARTITION_PREFIX = "snp_api_request_log_";
/**
* 월별 파티션 생성
*/
public void createPartition(YearMonth yearMonth) {
String partitionName = TABLE_NAME + "_" + yearMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
LocalDate start = yearMonth.atDay(1);
LocalDate end = yearMonth.plusMonths(1).atDay(1);
String sql = String.format(
"CREATE TABLE IF NOT EXISTS %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
partitionName, TABLE_NAME, start, end);
try {
jdbcTemplate.execute(sql);
log.info("파티션 생성 완료: {}", partitionName);
} catch (Exception e) {
log.error("파티션 생성 실패: {}", partitionName, e);
}
}
/**
* 월별 파티션 삭제
*/
public void dropPartition(YearMonth yearMonth) {
String partitionName = TABLE_NAME + "_" + yearMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
String sql = String.format("DROP TABLE IF EXISTS %s", partitionName);
try {
jdbcTemplate.execute(sql);
log.info("파티션 삭제 완료: {}", partitionName);
} catch (Exception e) {
log.error("파티션 삭제 실패: {}", partitionName, e);
}
}
/**
* 기존 파티션 목록 조회
*/
public List<String> getExistingPartitions() {
String sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'common' AND tablename LIKE '"
+ PARTITION_PREFIX + "%' ORDER BY tablename";
return jdbcTemplate.queryForList(sql, String.class);
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -64,3 +64,9 @@ app:
refresh-token-expiration: 604800000 refresh-token-expiration: 604800000
apikey: apikey:
aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c= aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c=
retention:
request-log-days: 90
health-log-days: 90
partition:
enabled: true
advance-months: 2