generated from gc/template-java-maven
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
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
This commit is contained in:
커밋
7027e4be31
@ -6,22 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-08.2]
|
||||
## [2026-04-09]
|
||||
|
||||
### 추가
|
||||
|
||||
- 다크/라이트 모드 전체 적용 (ThemeContext, 토글 버튼, 전 페이지 dark 클래스) (#15)
|
||||
- API Key 신청 영구 사용 옵션 (#15)
|
||||
- API Key Admin 키 관리 만료일 컬럼 (#15)
|
||||
- Gateway API 경로 {변수} 패턴 매칭 지원 (#15)
|
||||
- 통계 메뉴 5개 (서비스/사용자/API/테넌트/사용량 추이) (#23)
|
||||
- 사용량 추이: 일별/주별/월별 탭, 요청수+성공률+응답시간+사용자 차트 (#23)
|
||||
- snp_api_request_log 월별 Range 파티셔닝 + 자동 관리 배치 (#11)
|
||||
- 데이터 정리 배치 (health_log 90일 이전 자동 삭제) (#11)
|
||||
- 에러 핸들링 보완 (DataAccessException, IllegalArgument, HttpMessageNotReadable) (#11)
|
||||
|
||||
### 변경
|
||||
|
||||
- 사이드바 아이콘 링크체인으로 변경, 헤더/사이드바 높이 통일 (#15)
|
||||
- 컨텐츠 영역 max-w-7xl 마진 통일 (#15)
|
||||
- 전체 Actions 버튼 bg-color-100 스타일 통일 (#15)
|
||||
- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15)
|
||||
- My Keys ADMIN 직접 생성 제거 → Request 폼 통일 (#15)
|
||||
- 대시보드: 하트비트 카드형, 테넌트 차트 제거, URL 쿼리파라미터 정규화 (#23)
|
||||
- Gateway: request_url 저장 시 쿼리스트링 제외 (#23)
|
||||
- 통계 차트 개선 (에러율 비교, 응답시간 분포, flex 균등분할) (#23)
|
||||
- 라벨: "활성 사용자" → "API 요청 사용자" (#23)
|
||||
|
||||
## [2026-04-08]
|
||||
|
||||
@ -31,10 +31,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
||||
- 테넌트/사용자/서비스 CRUD API + 하트비트 스케줄러 (#7)
|
||||
- API Key AES-256-GCM 암호화, 신청→승인 워크플로우, Permission 관리 (#8)
|
||||
- API Gateway 프록시, API Key 인증 필터, 비동기 요청 로깅 (#9)
|
||||
- 대시보드 통계 (Recharts 차트 6개, 요약 카드, 30초 자동 갱신) (#10)
|
||||
- 대시보드 통계 (Recharts 차트, 요약 카드, 30초 자동 갱신) (#10)
|
||||
- Service Status 페이지 (90일 일별 uptime, status.claude.com 스타일) (#10)
|
||||
- 테넌트별 요청/사용자 비율 통계 (#10)
|
||||
- 프론트엔드: 관리 페이지, API Key 관리, 요청 로그 검색/상세 (#7~#10)
|
||||
- 다크/라이트 모드 전체 적용 (#15)
|
||||
- API Key 신청 영구 사용 옵션, Gateway {변수} 패턴 매칭 (#15)
|
||||
|
||||
### 변경
|
||||
|
||||
- 사이드바 아이콘/높이 통일, Actions 버튼 스타일 통일, max-w-7xl 레이아웃 (#15)
|
||||
- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15)
|
||||
|
||||
## [2026-04-07]
|
||||
|
||||
@ -43,7 +48,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
||||
- Spring Boot 3.2.1 백엔드 초기 구조 (com.gcsc.connection, 포트 8042, context /snp-connection)
|
||||
- React 19 + TypeScript + Vite 7 + Tailwind CSS 4 프론트엔드 통합
|
||||
- frontend-maven-plugin 기반 통합 빌드 설정
|
||||
- SPA fallback WebViewController, SecurityConfig, SwaggerConfig
|
||||
- 공통 모듈 (ApiResponse, GlobalExceptionHandler)
|
||||
- 파비콘 등록 (favicon_io_red)
|
||||
- CI/CD 자동 배포 (Gitea Actions)
|
||||
- 팀 워크플로우 v1.6.1 동기화
|
||||
|
||||
88
docs/dashboard-statistics-guide.md
Normal file
88
docs/dashboard-statistics-guide.md
Normal file
@ -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개월 |
|
||||
69
docs/schema/partition_migration.sql
Normal file
69
docs/schema/partition_migration.sql
Normal file
@ -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 UsersPage from './pages/admin/UsersPage';
|
||||
import TenantsPage from './pages/admin/TenantsPage';
|
||||
import ServiceStatsPage from './pages/statistics/ServiceStatsPage';
|
||||
import UserStatsPage from './pages/statistics/UserStatsPage';
|
||||
import ApiStatsPage from './pages/statistics/ApiStatsPage';
|
||||
import TenantStatsPage from './pages/statistics/TenantStatsPage';
|
||||
import UsageTrendPage from './pages/statistics/UsageTrendPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
const BASE_PATH = '/snp-connection';
|
||||
@ -38,6 +43,11 @@ const App = () => {
|
||||
<Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
|
||||
<Route path="/monitoring/service-status" element={<ServiceStatusPage />} />
|
||||
<Route path="/monitoring/service-status/:serviceId" element={<ServiceStatusDetailPage />} />
|
||||
<Route path="/statistics/services" element={<ServiceStatsPage />} />
|
||||
<Route path="/statistics/users" element={<UserStatsPage />} />
|
||||
<Route path="/statistics/apis" element={<ApiStatsPage />} />
|
||||
<Route path="/statistics/tenants" element={<TenantStatsPage />} />
|
||||
<Route path="/statistics/usage-trend" element={<UsageTrendPage />} />
|
||||
<Route path="/apikeys/my-keys" element={<MyKeysPage />} />
|
||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||
<Route path="/apikeys/admin" element={<KeyAdminPage />} />
|
||||
|
||||
73
frontend/src/components/DateRangeFilter.tsx
Normal file
73
frontend/src/components/DateRangeFilter.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
interface DateRangeFilterProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onStartDateChange: (date: string) => void;
|
||||
onEndDateChange: (date: string) => void;
|
||||
onPreset: (days: number) => void;
|
||||
}
|
||||
|
||||
const getToday = () => {
|
||||
const d = new Date();
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const getDaysAgo = (days: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const isPresetActive = (startDate: string, endDate: string, days: number): boolean => {
|
||||
const today = getToday();
|
||||
if (endDate !== today) return false;
|
||||
if (days === 0) return startDate === today;
|
||||
return startDate === getDaysAgo(days);
|
||||
};
|
||||
|
||||
const DateRangeFilter = ({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onPreset,
|
||||
}: DateRangeFilterProps) => {
|
||||
const presets = [
|
||||
{ label: '오늘', days: 0 },
|
||||
{ label: '7일', days: 7 },
|
||||
{ label: '30일', days: 30 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">기간:</span>
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.days}
|
||||
onClick={() => onPreset(preset.days)}
|
||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
isPresetActive(startDate, endDate, preset.days)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
|
||||
/>
|
||||
<span className="text-gray-500 dark:text-gray-400">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateRangeFilter;
|
||||
@ -17,6 +17,16 @@ const navGroups: NavGroup[] = [
|
||||
{ label: 'Service Status', path: '/monitoring/service-status' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
items: [
|
||||
{ label: '서비스 통계', path: '/statistics/services' },
|
||||
{ label: '사용자 통계', path: '/statistics/users' },
|
||||
{ label: 'API 통계', path: '/statistics/apis' },
|
||||
{ label: '테넌트 통계', path: '/statistics/tenants' },
|
||||
{ label: '사용량 추이', path: '/statistics/usage-trend' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'API Keys',
|
||||
items: [
|
||||
@ -41,6 +51,7 @@ const MainLayout = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
Monitoring: true,
|
||||
Statistics: true,
|
||||
'API Keys': true,
|
||||
Admin: true,
|
||||
});
|
||||
|
||||
@ -4,13 +4,12 @@ import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, AreaChart, Area,
|
||||
} from 'recharts';
|
||||
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi, TenantRatio } from '../types/dashboard';
|
||||
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi } from '../types/dashboard';
|
||||
import type { RequestLog } from '../types/monitoring';
|
||||
import type { HeartbeatStatus } from '../types/service';
|
||||
import {
|
||||
getSummary, getHourlyTrend, getServiceRatio,
|
||||
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
|
||||
getTenantRequestRatio, getTenantUserRatio,
|
||||
} from '../services/dashboardService';
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
@ -56,15 +55,13 @@ const DashboardPage = () => {
|
||||
const [serviceRatio, setServiceRatio] = useState<ServiceRatio[]>([]);
|
||||
const [errorTrend, setErrorTrend] = useState<ErrorTrend[]>([]);
|
||||
const [topApis, setTopApis] = useState<TopApi[]>([]);
|
||||
const [tenantRequestRatio, setTenantRequestRatio] = useState<TenantRatio[]>([]);
|
||||
const [tenantUserRatio, setTenantUserRatio] = useState<TenantRatio[]>([]);
|
||||
const [recentLogs, setRecentLogs] = useState<RequestLog[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const [summaryRes, heartbeatRes, hourlyRes, serviceRes, errorRes, topRes, tenantReqRes, tenantUserRes, logsRes] =
|
||||
const [summaryRes, heartbeatRes, hourlyRes, serviceRes, errorRes, topRes, logsRes] =
|
||||
await Promise.allSettled([
|
||||
getSummary(),
|
||||
getHeartbeat(),
|
||||
@ -72,8 +69,6 @@ const DashboardPage = () => {
|
||||
getServiceRatio(),
|
||||
getErrorTrend(),
|
||||
getTopApis(),
|
||||
getTenantRequestRatio(),
|
||||
getTenantUserRatio(),
|
||||
getRecentLogs(),
|
||||
]);
|
||||
|
||||
@ -83,8 +78,6 @@ const DashboardPage = () => {
|
||||
setServiceRatio(extractSettled<ServiceRatio[]>(serviceRes, []));
|
||||
setErrorTrend(extractSettled<ErrorTrend[]>(errorRes, []));
|
||||
setTopApis(extractSettled<TopApi[]>(topRes, []));
|
||||
setTenantRequestRatio(extractSettled<TenantRatio[]>(tenantReqRes, []));
|
||||
setTenantUserRatio(extractSettled<TenantRatio[]>(tenantUserRes, []));
|
||||
setRecentLogs(extractSettled<RequestLog[]>(logsRes, []));
|
||||
setLastUpdated(new Date().toLocaleTimeString('ko-KR'));
|
||||
} finally {
|
||||
@ -150,7 +143,7 @@ const DashboardPage = () => {
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘 총 요청</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.totalRequests.toLocaleString()}</p>
|
||||
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {stats.changePercent}%
|
||||
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {stats.changePercent.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
@ -163,44 +156,52 @@ const DashboardPage = () => {
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.avgResponseTime.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">활성 사용자</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API 요청 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.activeUserCount}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: Heartbeat Status Bar */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
|
||||
{/* Row 2: Heartbeat Status Cards */}
|
||||
<div className="mb-6">
|
||||
{heartbeat.length > 0 ? (
|
||||
<div className="flex flex-row gap-6">
|
||||
{heartbeat.map((svc) => (
|
||||
<div
|
||||
key={svc.serviceId}
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg px-2 py-1 transition-colors"
|
||||
onClick={() => navigate('/monitoring/service-status')}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{heartbeat.map((svc) => {
|
||||
const isUp = svc.healthStatus === 'UP';
|
||||
const isDown = svc.healthStatus === 'DOWN';
|
||||
const borderColor = isUp ? 'border-green-500' : isDown ? 'border-red-500' : 'border-gray-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
svc.healthStatus === 'UP'
|
||||
? 'bg-green-500'
|
||||
: svc.healthStatus === 'DOWN'
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{svc.serviceName}</span>
|
||||
{svc.healthResponseTime !== null && (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">{svc.healthResponseTime}ms</span>
|
||||
)}
|
||||
{svc.healthCheckedAt && (
|
||||
<span className="text-gray-400 dark:text-gray-500 text-xs">{svc.healthCheckedAt}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
key={svc.serviceId}
|
||||
className={`flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 ${borderColor} cursor-pointer hover:shadow-md transition-shadow`}
|
||||
onClick={() => navigate('/monitoring/service-status')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{svc.serviceName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className={`font-medium ${isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'}
|
||||
</span>
|
||||
{svc.healthCheckedAt && (
|
||||
<span className="text-gray-400 dark:text-gray-500 text-xs">{svc.healthCheckedAt}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">등록된 서비스가 없습니다</p>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">등록된 서비스가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -316,60 +317,7 @@ const DashboardPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Tenant Stats */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">테넌트별 요청 비율</h3>
|
||||
{tenantRequestRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={tenantRequestRatio}
|
||||
dataKey="count"
|
||||
nameKey="tenantName"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
>
|
||||
{tenantRequestRatio.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">테넌트별 사용자 비율</h3>
|
||||
{tenantUserRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={tenantUserRatio}
|
||||
dataKey="count"
|
||||
nameKey="tenantName"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
>
|
||||
{tenantUserRatio.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Recent Logs */}
|
||||
{/* Row 4: Recent Logs */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최근 요청 로그</h3>
|
||||
|
||||
268
frontend/src/pages/statistics/ApiStatsPage.tsx
Normal file
268
frontend/src/pages/statistics/ApiStatsPage.tsx
Normal file
@ -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;
|
||||
210
frontend/src/pages/statistics/ServiceStatsPage.tsx
Normal file
210
frontend/src/pages/statistics/ServiceStatsPage.tsx
Normal file
@ -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;
|
||||
186
frontend/src/pages/statistics/TenantStatsPage.tsx
Normal file
186
frontend/src/pages/statistics/TenantStatsPage.tsx
Normal file
@ -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;
|
||||
236
frontend/src/pages/statistics/UsageTrendPage.tsx
Normal file
236
frontend/src/pages/statistics/UsageTrendPage.tsx
Normal file
@ -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;
|
||||
193
frontend/src/pages/statistics/UserStatsPage.tsx
Normal file
193
frontend/src/pages/statistics/UserStatsPage.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
PieChart, Pie, Cell,
|
||||
} from 'recharts';
|
||||
import type { UserStatsResponse } from '../../types/statistics';
|
||||
import { getUserStats } from '../../services/statisticsService';
|
||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
|
||||
const ROLE_BADGE: Record<string, string> = {
|
||||
ADMIN: 'bg-red-100 text-red-800',
|
||||
MANAGER: 'bg-blue-100 text-blue-800',
|
||||
USER: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const UserStatsPage = () => {
|
||||
const [startDate, setStartDate] = useState(getToday());
|
||||
const [endDate, setEndDate] = useState(getToday());
|
||||
const [data, setData] = useState<UserStatsResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await getUserStats(startDate, endDate);
|
||||
if (res.success && res.data) {
|
||||
setData(res.data);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handlePreset = (days: number) => {
|
||||
const today = getToday();
|
||||
if (days === 0) {
|
||||
setStartDate(today);
|
||||
} else {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
setStartDate(d.toISOString().slice(0, 10));
|
||||
}
|
||||
setEndDate(today);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">사용자 통계</h1>
|
||||
|
||||
<DateRangeFilter
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPreset={handlePreset}
|
||||
/>
|
||||
|
||||
{!data ? (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">전체 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalUsers}</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API Key 보유 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.usersWithActiveKey}</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API 요청 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalActiveUsers}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 1: Daily Active Users */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">일별 API 요청 사용자 추이</h3>
|
||||
{data.dailyActiveUsers.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data.dailyActiveUsers}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="activeUsers"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="API 요청 사용자"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 2: Role Distribution Donut */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">역할별 요청 분포</h3>
|
||||
{data.roleDistribution.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.roleDistribution}
|
||||
dataKey="requestCount"
|
||||
nameKey="role"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
>
|
||||
{data.roleDistribution.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table: Top Users */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">상위 사용자 Top 10</h3>
|
||||
</div>
|
||||
{data.topUsers.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">순위</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">사용자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">역할</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">요청 수</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">성공률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.topUsers.slice(0, 10).map((user, idx) => (
|
||||
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ROLE_BADGE[user.role] ?? 'bg-gray-100 text-gray-800'}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.requestCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.successRate.toFixed(1)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-center py-8">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserStatsPage;
|
||||
@ -1,5 +1,5 @@
|
||||
import { get } from './apiClient';
|
||||
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi, TenantRatio } from '../types/dashboard';
|
||||
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi } from '../types/dashboard';
|
||||
import type { RequestLog } from '../types/monitoring';
|
||||
import type { HeartbeatStatus } from '../types/service';
|
||||
|
||||
@ -8,7 +8,5 @@ export const getHourlyTrend = () => get<HourlyTrend[]>('/dashboard/hourly-trend'
|
||||
export const getServiceRatio = () => get<ServiceRatio[]>('/dashboard/service-ratio');
|
||||
export const getErrorTrend = () => get<ErrorTrend[]>('/dashboard/error-trend');
|
||||
export const getTopApis = (limit = 10) => get<TopApi[]>(`/dashboard/top-apis?limit=${limit}`);
|
||||
export const getTenantRequestRatio = () => get<TenantRatio[]>('/dashboard/tenant-request-ratio');
|
||||
export const getTenantUserRatio = () => get<TenantRatio[]>('/dashboard/tenant-user-ratio');
|
||||
export const getRecentLogs = () => get<RequestLog[]>('/dashboard/recent-logs');
|
||||
export const getHeartbeat = () => get<HeartbeatStatus[]>('/dashboard/heartbeat');
|
||||
|
||||
26
frontend/src/services/statisticsService.ts
Normal file
26
frontend/src/services/statisticsService.ts
Normal file
@ -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}`);
|
||||
132
frontend/src/types/statistics.ts
Normal file
132
frontend/src/types/statistics.ts
Normal file
@ -0,0 +1,132 @@
|
||||
export interface ServiceRequestStats {
|
||||
serviceId: number;
|
||||
serviceName: string;
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
successRate: number;
|
||||
avgResponseTime: number;
|
||||
}
|
||||
|
||||
export interface ServiceResponseTimeRank {
|
||||
serviceName: string;
|
||||
avgResponseTime: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface HourlyServiceTrend {
|
||||
hour: number;
|
||||
serviceName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ServiceStatsResponse {
|
||||
serviceStats: ServiceRequestStats[];
|
||||
slowestServices: ServiceResponseTimeRank[];
|
||||
hourlyTrend: HourlyServiceTrend[];
|
||||
}
|
||||
|
||||
export interface UserRequestRank {
|
||||
userId: number;
|
||||
userName: string;
|
||||
role: string;
|
||||
requestCount: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface UserRoleDistribution {
|
||||
role: string;
|
||||
userCount: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface DailyActiveUsers {
|
||||
date: string;
|
||||
activeUsers: number;
|
||||
}
|
||||
|
||||
export interface UserStatsResponse {
|
||||
totalUsers: number;
|
||||
usersWithActiveKey: number;
|
||||
totalActiveUsers: number;
|
||||
topUsers: UserRequestRank[];
|
||||
roleDistribution: UserRoleDistribution[];
|
||||
dailyActiveUsers: DailyActiveUsers[];
|
||||
}
|
||||
|
||||
export interface ApiCallRank {
|
||||
serviceName: string;
|
||||
apiName: string;
|
||||
requestUrl: string;
|
||||
requestMethod: string;
|
||||
callCount: number;
|
||||
avgResponseTime: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface ApiErrorRank {
|
||||
serviceName: string;
|
||||
apiName: string;
|
||||
requestUrl: string;
|
||||
errorCount: number;
|
||||
totalCount: number;
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface ApiMethodDistribution {
|
||||
method: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HttpStatusDistribution {
|
||||
statusCode: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ApiStatsResponse {
|
||||
topApis: ApiCallRank[];
|
||||
topErrorApis: ApiErrorRank[];
|
||||
methodDistribution: ApiMethodDistribution[];
|
||||
statusCodeDistribution: HttpStatusDistribution[];
|
||||
}
|
||||
|
||||
export interface TenantRequestStats {
|
||||
tenantId: number;
|
||||
tenantName: string;
|
||||
totalRequests: number;
|
||||
activeUsers: number;
|
||||
successRate: number;
|
||||
avgResponseTime: number;
|
||||
}
|
||||
|
||||
export interface DailyTenantTrend {
|
||||
date: string;
|
||||
tenantName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TenantApiKeyStats {
|
||||
tenantName: string;
|
||||
totalKeys: number;
|
||||
activeKeys: number;
|
||||
}
|
||||
|
||||
export interface TenantStatsResponse {
|
||||
tenantStats: TenantRequestStats[];
|
||||
dailyTrend: DailyTenantTrend[];
|
||||
apiKeyStats: TenantApiKeyStats[];
|
||||
}
|
||||
|
||||
export interface UsageTrendItem {
|
||||
label: string;
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
avgResponseTime: number;
|
||||
activeUsers: number;
|
||||
}
|
||||
|
||||
export interface UsageTrendResponse {
|
||||
period: string;
|
||||
items: UsageTrendItem[];
|
||||
}
|
||||
@ -2,6 +2,7 @@ package com.gcsc.connection.apikey.repository;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@ -13,4 +14,18 @@ public interface SnpApiKeyRepository extends JpaRepository<SnpApiKey, Long> {
|
||||
List<SnpApiKey> findByUserUserId(Long userId);
|
||||
|
||||
List<SnpApiKey> findByApiKeyPrefix(String apiKeyPrefix);
|
||||
|
||||
/** 유효한 API 키가 존재하는 사용자 수 */
|
||||
@Query(value = "SELECT COUNT(DISTINCT user_id) FROM common.snp_api_key " +
|
||||
"WHERE status = 'ACTIVE' AND (expires_at IS NULL OR expires_at > NOW())", nativeQuery = true)
|
||||
long countUsersWithActiveKey();
|
||||
|
||||
/** 테넌트별 API 키 현황 (전체 키 수, 활성 키 수) */
|
||||
@Query(value = "SELECT t.tenant_name, COUNT(k.api_key_id), " +
|
||||
"COUNT(CASE WHEN k.status = 'ACTIVE' THEN 1 END) " +
|
||||
"FROM common.snp_api_key k " +
|
||||
"JOIN common.snp_user u ON k.user_id = u.user_id " +
|
||||
"JOIN common.snp_tenant t ON u.tenant_id = t.tenant_id " +
|
||||
"GROUP BY t.tenant_name", nativeQuery = true)
|
||||
List<Object[]> findTenantApiKeyStats();
|
||||
}
|
||||
|
||||
@ -2,8 +2,10 @@ package com.gcsc.connection.common.exception;
|
||||
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
@ -52,6 +54,39 @@ public class GlobalExceptionHandler {
|
||||
.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();
|
||||
SnpApiKey apiKey = null;
|
||||
SnpService service = null;
|
||||
String gatewayPath = "/gateway/" + serviceCode + remainingPath
|
||||
+ (request.getQueryString() != null ? "?" + request.getQueryString() : "");
|
||||
String gatewayPath = "/gateway/" + serviceCode + remainingPath;
|
||||
String targetUrl = null;
|
||||
|
||||
try {
|
||||
|
||||
@ -56,17 +56,17 @@ public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestL
|
||||
"GROUP BY 1, 2 ORDER BY 1", nativeQuery = true)
|
||||
List<Object[]> findErrorTrend(@Param("startOfDay") LocalDateTime startOfDay);
|
||||
|
||||
/** 상위 API 랭킹 (service_name, api_name 포함) */
|
||||
/** 상위 API 랭킹 (service_name, api_name 포함, 쿼리스트링 제거) */
|
||||
@Query(value = "SELECT COALESCE(s.service_name, 'Unknown') as serviceName, " +
|
||||
"COALESCE(a.api_name, l.request_url) as apiName, " +
|
||||
"l.request_url, l.request_method, COUNT(*) as cnt " +
|
||||
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)) as apiName, " +
|
||||
"SPLIT_PART(l.request_url, '?', 1) as request_url, l.request_method, COUNT(*) as cnt " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
|
||||
"AND a.api_path = SUBSTRING(l.request_url FROM '/gateway/[^/]+(.*)') " +
|
||||
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
|
||||
"AND a.api_method = l.request_method " +
|
||||
"WHERE l.requested_at >= :startOfDay " +
|
||||
"GROUP BY s.service_name, a.api_name, l.request_url, l.request_method " +
|
||||
"GROUP BY s.service_name, a.api_name, SPLIT_PART(l.request_url, '?', 1), l.request_method " +
|
||||
"ORDER BY cnt DESC LIMIT :limit", nativeQuery = true)
|
||||
List<Object[]> findTopApis(@Param("startOfDay") LocalDateTime startOfDay,
|
||||
@Param("limit") int limit);
|
||||
@ -90,4 +90,177 @@ public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestL
|
||||
|
||||
/** 최근 로그 20건 */
|
||||
List<SnpApiRequestLog> findTop20ByOrderByRequestedAtDesc();
|
||||
|
||||
// ===== Statistics API 쿼리 =====
|
||||
|
||||
/** 서비스별 요청 통계 (기간) */
|
||||
@Query(value = "SELECT s.service_id, s.service_name, COUNT(*), " +
|
||||
"COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END), " +
|
||||
"AVG(l.response_time) " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
|
||||
"GROUP BY s.service_id, s.service_name", nativeQuery = true)
|
||||
List<Object[]> findServiceRequestStats(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 응답시간 느린 서비스 TOP 5 */
|
||||
@Query(value = "SELECT s.service_name, AVG(l.response_time), COUNT(*) " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
|
||||
"AND l.response_time IS NOT NULL " +
|
||||
"GROUP BY s.service_name ORDER BY AVG(l.response_time) DESC LIMIT 5", nativeQuery = true)
|
||||
List<Object[]> findSlowestServices(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 시간별 서비스별 요청 추이 */
|
||||
@Query(value = "SELECT CAST(EXTRACT(HOUR FROM l.requested_at) AS int), " +
|
||||
"COALESCE(s.service_name, 'Unknown'), COUNT(*) " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
|
||||
"GROUP BY 1, 2 ORDER BY 1", nativeQuery = true)
|
||||
List<Object[]> findHourlyServiceTrend(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 요청 많은 사용자 TOP 10 */
|
||||
@Query(value = "SELECT u.user_id, u.user_name, u.role, COUNT(*), " +
|
||||
"CAST(COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END) AS float) " +
|
||||
"/ NULLIF(COUNT(*), 0) * 100 " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_user u ON l.user_id = u.user_id " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL " +
|
||||
"GROUP BY u.user_id, u.user_name, u.role ORDER BY COUNT(*) DESC LIMIT 10", nativeQuery = true)
|
||||
List<Object[]> findTopUsersByRequestCount(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 역할별 사용자/요청 분포 */
|
||||
@Query(value = "SELECT u.role, COUNT(DISTINCT l.user_id), COUNT(*) " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_user u ON l.user_id = u.user_id " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL " +
|
||||
"GROUP BY u.role", nativeQuery = true)
|
||||
List<Object[]> findUserRoleDistribution(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 일별 활성 사용자 수 */
|
||||
@Query(value = "SELECT CAST(DATE(l.requested_at) AS text), COUNT(DISTINCT l.user_id) " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL " +
|
||||
"GROUP BY DATE(l.requested_at) ORDER BY 1", nativeQuery = true)
|
||||
List<Object[]> findDailyActiveUsers(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 기간 내 활성 사용자 수 (고유) */
|
||||
@Query(value = "SELECT COUNT(DISTINCT l.user_id) FROM common.snp_api_request_log l " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end AND l.user_id IS NOT NULL",
|
||||
nativeQuery = true)
|
||||
long countActiveUsers(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 상위 API 랭킹 (기간, 성공률/응답시간 포함, 쿼리파라미터 제외) */
|
||||
@Query(value = "SELECT COALESCE(s.service_name, 'Unknown'), " +
|
||||
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)), " +
|
||||
"SPLIT_PART(l.request_url, '?', 1), l.request_method, COUNT(*), " +
|
||||
"AVG(l.response_time), " +
|
||||
"CAST(COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END) AS float) " +
|
||||
"/ NULLIF(COUNT(*), 0) * 100 " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
|
||||
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
|
||||
"AND a.api_method = l.request_method " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
|
||||
"GROUP BY s.service_name, a.api_name, SPLIT_PART(l.request_url, '?', 1), l.request_method " +
|
||||
"ORDER BY COUNT(*) DESC LIMIT 20", nativeQuery = true)
|
||||
List<Object[]> findTopApisWithStats(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 에러 많은 API TOP 10 (쿼리파라미터 제외) */
|
||||
@Query(value = "SELECT COALESCE(s.service_name, 'Unknown'), " +
|
||||
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)), SPLIT_PART(l.request_url, '?', 1), " +
|
||||
"COUNT(CASE WHEN l.request_status != 'SUCCESS' THEN 1 END), " +
|
||||
"COUNT(*), " +
|
||||
"CAST(COUNT(CASE WHEN l.request_status != 'SUCCESS' THEN 1 END) AS float) " +
|
||||
"/ NULLIF(COUNT(*), 0) * 100 " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
|
||||
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
|
||||
"AND a.api_method = l.request_method " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
|
||||
"GROUP BY 1, 2, 3 ORDER BY 4 DESC LIMIT 10", nativeQuery = true)
|
||||
List<Object[]> findTopErrorApis(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** HTTP 메서드별 요청 분포 */
|
||||
@Query(value = "SELECT request_method, COUNT(*) " +
|
||||
"FROM common.snp_api_request_log " +
|
||||
"WHERE requested_at >= :start AND requested_at < :end " +
|
||||
"GROUP BY request_method ORDER BY COUNT(*) DESC", nativeQuery = true)
|
||||
List<Object[]> findMethodDistribution(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** HTTP 상태코드별 요청 분포 */
|
||||
@Query(value = "SELECT response_status, COUNT(*) " +
|
||||
"FROM common.snp_api_request_log " +
|
||||
"WHERE requested_at >= :start AND requested_at < :end AND response_status IS NOT NULL " +
|
||||
"GROUP BY response_status ORDER BY COUNT(*) DESC", nativeQuery = true)
|
||||
List<Object[]> findStatusCodeDistribution(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 테넌트별 요청 통계 */
|
||||
@Query(value = "SELECT t.tenant_id, t.tenant_name, COUNT(*), COUNT(DISTINCT l.user_id), " +
|
||||
"CAST(COUNT(CASE WHEN l.request_status = 'SUCCESS' THEN 1 END) AS float) " +
|
||||
"/ NULLIF(COUNT(*), 0) * 100, " +
|
||||
"AVG(l.response_time) " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_tenant t ON l.tenant_id = t.tenant_id " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
|
||||
"GROUP BY t.tenant_id, t.tenant_name", nativeQuery = true)
|
||||
List<Object[]> findTenantRequestStats(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 일별 테넌트별 요청 추이 */
|
||||
@Query(value = "SELECT CAST(DATE(l.requested_at) AS text), " +
|
||||
"COALESCE(t.tenant_name, 'Unknown'), COUNT(*) " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_tenant t ON l.tenant_id = t.tenant_id " +
|
||||
"WHERE l.requested_at >= :start AND l.requested_at < :end " +
|
||||
"GROUP BY 1, 2 ORDER BY 1", nativeQuery = true)
|
||||
List<Object[]> findDailyTenantTrend(@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
// ===== Usage Trend 쿼리 =====
|
||||
|
||||
/** 일별 사용량 추이 (최근 30일) */
|
||||
@Query(value = "SELECT DATE(requested_at) as day, COUNT(*) as total, " +
|
||||
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as success, " +
|
||||
"COALESCE(AVG(response_time), 0) as avg_rt, " +
|
||||
"COUNT(DISTINCT user_id) as users " +
|
||||
"FROM common.snp_api_request_log " +
|
||||
"WHERE requested_at >= :since " +
|
||||
"GROUP BY DATE(requested_at) ORDER BY day", nativeQuery = true)
|
||||
List<Object[]> findDailyUsageTrend(@Param("since") LocalDateTime since);
|
||||
|
||||
/** 주별 사용량 추이 (최근 12주) */
|
||||
@Query(value = "SELECT CAST(DATE_TRUNC('week', requested_at) AS date) as week_start, COUNT(*) as total, " +
|
||||
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as success, " +
|
||||
"COALESCE(AVG(response_time), 0) as avg_rt, " +
|
||||
"COUNT(DISTINCT user_id) as users " +
|
||||
"FROM common.snp_api_request_log " +
|
||||
"WHERE requested_at >= :since " +
|
||||
"GROUP BY DATE_TRUNC('week', requested_at) ORDER BY week_start", nativeQuery = true)
|
||||
List<Object[]> findWeeklyUsageTrend(@Param("since") LocalDateTime since);
|
||||
|
||||
/** 월별 사용량 추이 (최근 12개월) */
|
||||
@Query(value = "SELECT CAST(DATE_TRUNC('month', requested_at) AS date) as month_start, COUNT(*) as total, " +
|
||||
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as success, " +
|
||||
"COALESCE(AVG(response_time), 0) as avg_rt, " +
|
||||
"COUNT(DISTINCT user_id) as users " +
|
||||
"FROM common.snp_api_request_log " +
|
||||
"WHERE requested_at >= :since " +
|
||||
"GROUP BY DATE_TRUNC('month', requested_at) ORDER BY month_start", nativeQuery = true)
|
||||
List<Object[]> findMonthlyUsageTrend(@Param("since") LocalDateTime since);
|
||||
}
|
||||
|
||||
@ -0,0 +1,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);
|
||||
|
||||
boolean existsByLoginId(String loginId);
|
||||
|
||||
long countByIsActiveTrue();
|
||||
}
|
||||
|
||||
@ -64,3 +64,9 @@ app:
|
||||
refresh-token-expiration: 604800000
|
||||
apikey:
|
||||
aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c=
|
||||
retention:
|
||||
request-log-days: 90
|
||||
health-log-days: 90
|
||||
partition:
|
||||
enabled: true
|
||||
advance-months: 2
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user