generated from gc/template-java-maven
Merge pull request 'feat(frontend): 디자인 시스템 적용 및 전체 UI 개선 (#42)' (#53) from chore/release-notes-2026-04-15 into develop
This commit is contained in:
커밋
68489e40c7
@ -91,23 +91,23 @@ Accent (Warm Sand, hue≈38°):
|
|||||||
- 텍스트/아이콘: `text-[--color-primary]` → 다크모드에서 자동으로 밝은 톤 적용 (OK)
|
- 텍스트/아이콘: `text-[--color-primary]` → 다크모드에서 자동으로 밝은 톤 적용 (OK)
|
||||||
- 버튼 배경: `bg-[--color-primary]` → 다크모드에서 `dark:bg-[--color-primary-600]`으로 오버라이드 (필수)
|
- 버튼 배경: `bg-[--color-primary]` → 다크모드에서 `dark:bg-[--color-primary-600]`으로 오버라이드 (필수)
|
||||||
|
|
||||||
**차트 팔레트 (Cool+Warm 교차):**
|
**차트 팔레트 (6색 Hue 분산, Cool+Warm 교차):**
|
||||||
|
|
||||||
Light:
|
Light (흰 배경):
|
||||||
1. #507FB9 (Primary 600)
|
1. #507FB9 — Slate Blue (213°, 브랜드 Primary)
|
||||||
2. #B59854 (Accent 600)
|
2. #B59854 — Warm Gold (38°, 브랜드 Accent)
|
||||||
3. #4E88BB (Secondary 700)
|
3. #B5607D — Rose (340°)
|
||||||
4. #9A7E3E (Accent 700)
|
4. #3D998A — Teal (168°)
|
||||||
5. #3B669C (Primary 700)
|
5. #8B6DB5 — Lavender (270°)
|
||||||
6. #C4B48C (Accent 400)
|
6. #C07850 — Coral (22°)
|
||||||
|
|
||||||
Dark (채도 강화 — 400~500 스케일 사용):
|
Dark (어두운 배경, 밝기 강화):
|
||||||
1. #6D94C5 (Primary 500)
|
1. #6D94C5 — Slate Blue
|
||||||
2. #C4B48C (Accent 400)
|
2. #C4B48C — Warm Sand
|
||||||
3. #6B9AC8 (Secondary 600)
|
3. #D4839B — Rose
|
||||||
4. #B59854 (Accent 600)
|
4. #5BB5A6 — Teal
|
||||||
5. #8AA5C7 (Primary 400)
|
5. #B79FD4 — Lavender
|
||||||
6. #D5CDB9 (Accent 300)
|
6. #D4956B — Coral
|
||||||
|
|
||||||
### 1-2. docs/design/typography.md
|
### 1-2. docs/design/typography.md
|
||||||
|
|
||||||
@ -150,7 +150,24 @@ Dark (채도 강화 — 400~500 스케일 사용):
|
|||||||
|
|
||||||
**Button 사이즈:** sm(h-8 px-3 text-sm), md(h-10 px-4 text-sm), lg(h-12 px-6 text-base)
|
**Button 사이즈:** sm(h-8 px-3 text-sm), md(h-10 px-4 text-sm), lg(h-12 px-6 text-base)
|
||||||
|
|
||||||
**Badge 변형:** default, primary, secondary, accent, success, warning, danger
|
**Badge shape:**
|
||||||
|
- pill (기본): rounded-full — 상태 표시, 카테고리 태그에 사용
|
||||||
|
- filled (버튼 스타일): rounded-md — 강조 라벨, 카운트 뱃지에 사용
|
||||||
|
|
||||||
|
**Badge 변형 (pill):** default, primary, secondary, accent, success, warning, danger, info
|
||||||
|
- 연한 배경(subtle) + 색상 텍스트
|
||||||
|
|
||||||
|
**Badge 변형 (filled):** Chart Palette 1:1 맵핑, 연한 배경(subtle) + 색상 텍스트, shape rounded-md
|
||||||
|
- default (neutral)
|
||||||
|
- blue = Chart 1 (#507FB9 / dark #6D94C5)
|
||||||
|
- gold = Chart 2 (#B59854 / dark #C4B48C)
|
||||||
|
- rose = Chart 3 (#B5607D / dark #D4839B)
|
||||||
|
- teal = Chart 4 (#3D998A / dark #5BB5A6)
|
||||||
|
- lavender = Chart 5 (#8B6DB5 / dark #B79FD4)
|
||||||
|
- coral = Chart 6 (#C07850 / dark #D4956B)
|
||||||
|
- 라이트: rgba(색상, 0.12~0.14) 배경 + 진한 텍스트 / 다크: rgba(색상, 0.14) 배경 + 밝은 텍스트
|
||||||
|
- ⚠️ success, warning, danger, info는 Pill 전용 — Filled에 사용 금지
|
||||||
|
- ⚠️ primary, secondary, accent, navy는 Filled에 사용 금지 (Chart 맵핑 색상만 사용)
|
||||||
**Card:** bg-[--color-bg-surface] border border-[--color-border] rounded-lg shadow-sm
|
**Card:** bg-[--color-bg-surface] border border-[--color-border] rounded-lg shadow-sm
|
||||||
**Input:** border-[--color-border] focus:ring-2 focus:ring-[--color-primary]/30 focus:border-[--color-primary]
|
**Input:** border-[--color-border] focus:ring-2 focus:ring-[--color-primary]/30 focus:border-[--color-primary]
|
||||||
**Modal:** bg-[--color-bg-surface] rounded-xl shadow-2xl, overlay bg-black/50
|
**Modal:** bg-[--color-bg-surface] rounded-xl shadow-2xl, overlay bg-black/50
|
||||||
@ -317,10 +334,10 @@ Do/Don't 가이드:
|
|||||||
/* === Chart Palette === */
|
/* === Chart Palette === */
|
||||||
--color-chart-1: #507FB9;
|
--color-chart-1: #507FB9;
|
||||||
--color-chart-2: #B59854;
|
--color-chart-2: #B59854;
|
||||||
--color-chart-3: #4E88BB;
|
--color-chart-3: #B5607D;
|
||||||
--color-chart-4: #9A7E3E;
|
--color-chart-4: #3D998A;
|
||||||
--color-chart-5: #3B669C;
|
--color-chart-5: #8B6DB5;
|
||||||
--color-chart-6: #C4B48C;
|
--color-chart-6: #C07850;
|
||||||
|
|
||||||
/* === Typography === */
|
/* === Typography === */
|
||||||
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
|
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
|
||||||
@ -362,10 +379,10 @@ Do/Don't 가이드:
|
|||||||
|
|
||||||
--color-chart-1: #6D94C5;
|
--color-chart-1: #6D94C5;
|
||||||
--color-chart-2: #C4B48C;
|
--color-chart-2: #C4B48C;
|
||||||
--color-chart-3: #6B9AC8;
|
--color-chart-3: #D4839B;
|
||||||
--color-chart-4: #B59854;
|
--color-chart-4: #5BB5A6;
|
||||||
--color-chart-5: #8AA5C7;
|
--color-chart-5: #B79FD4;
|
||||||
--color-chart-6: #D5CDB9;
|
--color-chart-6: #D4956B;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,8 +461,8 @@ export const CHART_COLORS = [
|
|||||||
|
|
||||||
/** CSS computed value가 필요한 경우 (Recharts 등) */
|
/** CSS computed value가 필요한 경우 (Recharts 등) */
|
||||||
export const CHART_COLORS_HEX = {
|
export const CHART_COLORS_HEX = {
|
||||||
light: ['#507FB9', '#B59854', '#4E88BB', '#9A7E3E', '#3B669C', '#C4B48C'],
|
light: ['#507FB9', '#B59854', '#B5607D', '#3D998A', '#8B6DB5', '#C07850'],
|
||||||
dark: ['#6D94C5', '#C4B48C', '#6B9AC8', '#B59854', '#8AA5C7', '#D5CDB9'],
|
dark: ['#6D94C5', '#C4B48C', '#D4839B', '#5BB5A6', '#B79FD4', '#D4956B'],
|
||||||
} as const;
|
} as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -59,8 +59,8 @@
|
|||||||
--color-text-tertiary: #9198A1;
|
--color-text-tertiary: #9198A1;
|
||||||
|
|
||||||
--color-chart-1: #507FB9; --color-chart-2: #B59854;
|
--color-chart-1: #507FB9; --color-chart-2: #B59854;
|
||||||
--color-chart-3: #4E88BB; --color-chart-4: #9A7E3E;
|
--color-chart-3: #B5607D; --color-chart-4: #3D998A;
|
||||||
--color-chart-5: #3B669C; --color-chart-6: #C4B48C;
|
--color-chart-5: #8B6DB5; --color-chart-6: #C07850;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@ -84,8 +84,8 @@
|
|||||||
--color-text-tertiary: #5C6570;
|
--color-text-tertiary: #5C6570;
|
||||||
|
|
||||||
--color-chart-1: #6D94C5; --color-chart-2: #C4B48C;
|
--color-chart-1: #6D94C5; --color-chart-2: #C4B48C;
|
||||||
--color-chart-3: #6B9AC8; --color-chart-4: #B59854;
|
--color-chart-3: #D4839B; --color-chart-4: #5BB5A6;
|
||||||
--color-chart-5: #8AA5C7; --color-chart-6: #D5CDB9;
|
--color-chart-5: #B79FD4; --color-chart-6: #D4956B;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -225,6 +225,31 @@
|
|||||||
.dark .badge-danger { background: rgba(252,165,165,0.1); }
|
.dark .badge-danger { background: rgba(252,165,165,0.1); }
|
||||||
.dark .badge-info { background: rgba(56,189,248,0.1); }
|
.dark .badge-info { background: rgba(56,189,248,0.1); }
|
||||||
|
|
||||||
|
/* === Badge Filled (Button shape) === */
|
||||||
|
.badge-filled {
|
||||||
|
border-radius: 6px; padding: 4px 12px;
|
||||||
|
}
|
||||||
|
/* Filled 전용 색상 — Chart Palette 1:1 맵핑, 연한 배경 + 색상 텍스트 */
|
||||||
|
/* Blue = Chart 1 */
|
||||||
|
.badge-filled.badge-blue { background: rgba(80,127,185,0.14); color: #3B669C; }
|
||||||
|
/* Gold = Chart 2 */
|
||||||
|
.badge-filled.badge-gold { background: rgba(181,152,84,0.14); color: #8A7230; }
|
||||||
|
/* Rose = Chart 3 */
|
||||||
|
.badge-filled.badge-rose { background: rgba(181,96,125,0.12); color: #9E4A67; }
|
||||||
|
/* Teal = Chart 4 */
|
||||||
|
.badge-filled.badge-teal { background: rgba(61,153,138,0.12); color: #2E7D6E; }
|
||||||
|
/* Lavender = Chart 5 */
|
||||||
|
.badge-filled.badge-lavender { background: rgba(139,109,181,0.12); color: #7B5DA5; }
|
||||||
|
/* Coral = Chart 6 */
|
||||||
|
.badge-filled.badge-coral { background: rgba(192,120,80,0.12); color: #A86440; }
|
||||||
|
/* Dark mode filled — Chart dark palette */
|
||||||
|
.dark .badge-filled.badge-blue { background: rgba(109,148,197,0.14); color: #6D94C5; }
|
||||||
|
.dark .badge-filled.badge-gold { background: rgba(196,180,140,0.14); color: #C4B48C; }
|
||||||
|
.dark .badge-filled.badge-rose { background: rgba(212,131,155,0.14); color: #D4839B; }
|
||||||
|
.dark .badge-filled.badge-teal { background: rgba(91,181,166,0.14); color: #5BB5A6; }
|
||||||
|
.dark .badge-filled.badge-lavender { background: rgba(183,159,212,0.14); color: #B79FD4; }
|
||||||
|
.dark .badge-filled.badge-coral { background: rgba(212,149,107,0.14); color: #D4956B; }
|
||||||
|
|
||||||
/* === Table === */
|
/* === Table === */
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
@ -545,7 +570,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:24px;">
|
<div style="margin-bottom:24px;">
|
||||||
<div style="font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:10px;">Badges</div>
|
<div style="font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:10px;">Badges — Pill (기본)</div>
|
||||||
<div class="component-row">
|
<div class="component-row">
|
||||||
<span class="badge badge-default">Default</span>
|
<span class="badge badge-default">Default</span>
|
||||||
<span class="badge badge-primary">Primary</span>
|
<span class="badge badge-primary">Primary</span>
|
||||||
@ -556,6 +581,16 @@
|
|||||||
<span class="badge badge-danger">Danger</span>
|
<span class="badge badge-danger">Danger</span>
|
||||||
<span class="badge badge-info">Info</span>
|
<span class="badge badge-info">Info</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:10px;margin-top:16px;">Badges — Filled (버튼 스타일)</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="badge badge-filled badge-default">Default</span>
|
||||||
|
<span class="badge badge-filled badge-blue">Blue</span>
|
||||||
|
<span class="badge badge-filled badge-gold">Gold</span>
|
||||||
|
<span class="badge badge-filled badge-rose">Rose</span>
|
||||||
|
<span class="badge badge-filled badge-teal">Teal</span>
|
||||||
|
<span class="badge badge-filled badge-lavender">Lavender</span>
|
||||||
|
<span class="badge badge-filled badge-coral">Coral</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:24px;">
|
<div style="margin-bottom:24px;">
|
||||||
|
|||||||
@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
|
||||||
|
- 디자인 시스템 CSS 변수 토큰 전체 적용 (success/warning/danger/info) (#42)
|
||||||
|
- PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용 (#42)
|
||||||
|
- SERVICE_BADGE_VARIANTS 공통 상수 추출 (#42)
|
||||||
|
- Button xs 사이즈 추가 (테이블 내 버튼용) (#42)
|
||||||
|
- 전체 페이지 타이틀 아이콘 통일 (#42)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
|
||||||
|
- 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영 (#42)
|
||||||
|
- 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Badge sm, Button xs) (#42)
|
||||||
|
- 카드 테두리 디자인 통일 (border + rounded-xl) (#42)
|
||||||
|
- 키 신청 페이지 좌우 2분할 레이아웃 + 하단 요약 바 (#42)
|
||||||
|
- 키 관리 페이지 페이지네이션 개선 (#42)
|
||||||
|
- FHD 1920x1080 최적화 (#42)
|
||||||
|
|
||||||
## [2026-04-15]
|
## [2026-04-15]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -141,10 +141,10 @@
|
|||||||
|------|----------|-----------|----------|
|
|------|----------|-----------|----------|
|
||||||
| 1번 | `--color-chart-1` | #507FB9 | #6D94C5 |
|
| 1번 | `--color-chart-1` | #507FB9 | #6D94C5 |
|
||||||
| 2번 | `--color-chart-2` | #B59854 | #C4B48C |
|
| 2번 | `--color-chart-2` | #B59854 | #C4B48C |
|
||||||
| 3번 | `--color-chart-3` | #4E88BB | #6B9AC8 |
|
| 3번 | `--color-chart-3` | #B5607D | #D4839B |
|
||||||
| 4번 | `--color-chart-4` | #9A7E3E | #B59854 |
|
| 4번 | `--color-chart-4` | #3D998A | #5BB5A6 |
|
||||||
| 5번 | `--color-chart-5` | #3B669C | #8AA5C7 |
|
| 5번 | `--color-chart-5` | #8B6DB5 | #B79FD4 |
|
||||||
| 6번 | `--color-chart-6` | #C4B48C | #D5CDB9 |
|
| 6번 | `--color-chart-6` | #C07850 | #D4956B |
|
||||||
|
|
||||||
차트에서는 `CHART_COLORS` 상수(`src/constants/chart.ts`)를 사용한다.
|
차트에서는 `CHART_COLORS` 상수(`src/constants/chart.ts`)를 사용한다.
|
||||||
|
|
||||||
|
|||||||
@ -50,24 +50,38 @@ const buttonVariants = {
|
|||||||
|
|
||||||
## Badge
|
## Badge
|
||||||
|
|
||||||
상태, 카테고리, 태그 표시에 사용한다.
|
상태, 카테고리, 태그 표시에 사용한다. Shape에 따라 Pill과 Filled로 구분한다.
|
||||||
|
|
||||||
### 변형
|
### Pill 변형 (rounded-full) — 상태 표시용
|
||||||
|
|
||||||
| Variant | 배경 | 텍스트 |
|
| Variant | 용도 | Light 배경 | Dark 배경 |
|
||||||
|---------|------|--------|
|
|---------|------|-----------|-----------|
|
||||||
| `default` | `--color-bg-elevated` | `--color-text-primary` |
|
| `default` | 기본 | `--color-bg-base` | `--color-bg-base` |
|
||||||
| `primary` | `--color-primary-subtle` | `--color-primary-text` |
|
| `success` | 성공/활성 | green-50 | green-500/15 |
|
||||||
| `success` | #DCFCE7 | #166534 |
|
| `warning` | 경고/대기 | amber-50 | amber-500/15 |
|
||||||
| `warning` | #FEF9C3 | #854D0E |
|
| `danger` | 에러/비활성 | red-50 | red-500/15 |
|
||||||
| `danger` | #FEE2E2 | #991B1B |
|
| `info` | 정보/식별자 | blue-50 | blue-500/15 |
|
||||||
| `info` | #E0F2FE | #075985 |
|
|
||||||
|
### Filled 변형 (rounded-md) — 카테고리/서비스 라벨용
|
||||||
|
|
||||||
|
Chart Palette와 1:1 매핑. `className="rounded-md"`를 추가하여 Filled shape 적용.
|
||||||
|
|
||||||
|
| Variant | Chart # | Light 배경 | Light 텍스트 | Dark 배경 | Dark 텍스트 |
|
||||||
|
|---------|---------|-----------|-------------|-----------|-------------|
|
||||||
|
| `blue` | 1 | primary-100 | primary-900 | primary-900 | primary-300 |
|
||||||
|
| `gold` | 2 | accent-100 | accent-900 | accent-900 | accent-300 |
|
||||||
|
| `rose` | 3 | #fce4ec | #B5607D | rgba(212,131,155,0.12) | #D4839B |
|
||||||
|
| `teal` | 4 | #e0f2f1 | #2E7D6E | rgba(91,181,166,0.12) | #5BB5A6 |
|
||||||
|
| `lavender` | 5 | #f3e8ff | #7B5DA5 | rgba(183,159,212,0.12) | #B79FD4 |
|
||||||
|
| `coral` | 6 | #fff3e0 | #A86440 | rgba(212,149,107,0.12) | #D4956B |
|
||||||
|
|
||||||
|
> **주의**: success, warning, danger, info는 Pill 전용. Filled에 사용 금지.
|
||||||
|
|
||||||
### 스펙
|
### 스펙
|
||||||
|
|
||||||
- 패딩: `px-2 py-0.5`
|
- **Pill**: `rounded-full`, `px-2 py-0.5`, `text-xs font-medium`
|
||||||
- 폰트: `text-xs font-medium`
|
- **Filled**: `rounded-md`, `px-3 py-1`, `text-xs font-medium`
|
||||||
- 모양: `rounded-full` (pill) 또는 `rounded-md` (square)
|
- 사이즈: `sm` (px-1.5 py-0.5 text-[10px]), `md` (px-2 py-0.5 text-xs)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
118
frontend/src/components/PeriodFilter.tsx
Normal file
118
frontend/src/components/PeriodFilter.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const getDaysAgo = (days: number) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: '오늘', days: 0 },
|
||||||
|
{ label: '7일', days: 7 },
|
||||||
|
{ label: '30일', days: 30 },
|
||||||
|
{ label: '90일', days: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PeriodFilterProps {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
onStartDateChange: (date: string) => void;
|
||||||
|
onEndDateChange: (date: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PeriodFilter = ({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange,
|
||||||
|
onEndDateChange,
|
||||||
|
onRefresh,
|
||||||
|
}: PeriodFilterProps) => {
|
||||||
|
const activePreset = useMemo(() => {
|
||||||
|
const today = getToday();
|
||||||
|
if (endDate !== today) return null;
|
||||||
|
for (const p of PRESETS) {
|
||||||
|
const expected = p.days === 0 ? today : getDaysAgo(p.days);
|
||||||
|
if (startDate === expected) return p.days;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [startDate, endDate]);
|
||||||
|
|
||||||
|
const handlePreset = (days: number) => {
|
||||||
|
const today = getToday();
|
||||||
|
onStartDateChange(days === 0 ? today : getDaysAgo(days));
|
||||||
|
onEndDateChange(today);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl px-3 py-2">
|
||||||
|
{/* 세그먼트 컨트롤 */}
|
||||||
|
<div className="flex bg-[var(--color-bg-base)] rounded-md p-0.5">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.days}
|
||||||
|
onClick={() => handlePreset(p.days)}
|
||||||
|
className={`px-3.5 py-1 rounded text-xs font-medium transition-all duration-150 cursor-pointer ${
|
||||||
|
activePreset === p.days
|
||||||
|
? 'bg-[var(--color-primary-600)] text-white'
|
||||||
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-px h-6 bg-[var(--color-border)]" />
|
||||||
|
|
||||||
|
{/* 달력 아이콘 + 날짜 입력 */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-[var(--color-text-tertiary)] shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => onStartDateChange(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-base)] text-[var(--color-text-primary)] text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => onEndDateChange(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-base)] text-[var(--color-text-primary)] text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-px h-6 bg-[var(--color-border)]" />
|
||||||
|
|
||||||
|
{/* 새로고침 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="flex items-center p-1.5 rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)] transition-colors cursor-pointer"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polyline points="23 4 23 10 17 10" />
|
||||||
|
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PeriodFilter;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { cn } from '../../utils/cn';
|
import { cn } from '../../utils/cn';
|
||||||
|
|
||||||
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'blue' | 'gold' | 'rose' | 'teal' | 'lavender' | 'coral';
|
||||||
|
|
||||||
interface BadgeProps {
|
interface BadgeProps {
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
@ -10,14 +10,19 @@ interface BadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
|
/* Pill 전용 (시멘틱) */
|
||||||
default: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]',
|
default: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]',
|
||||||
primary: 'bg-[var(--color-primary-subtle)] text-[var(--color-primary-text)]',
|
|
||||||
secondary: 'bg-[var(--color-secondary-subtle)] text-[var(--color-secondary-text)]',
|
|
||||||
accent: 'bg-[var(--color-accent-subtle)] text-[var(--color-accent-text)]',
|
|
||||||
success: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
success: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
||||||
warning: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
warning: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||||
danger: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
danger: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||||
info: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
info: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||||
|
/* Filled 전용 (Chart Palette 1:1 매핑) */
|
||||||
|
blue: 'bg-[var(--color-primary-100)] text-[var(--color-primary-900)] dark:bg-[var(--color-primary-900)] dark:text-[var(--color-primary-300)]',
|
||||||
|
gold: 'bg-[var(--color-accent-100)] text-[var(--color-accent-900)] dark:bg-[var(--color-accent-900)] dark:text-[var(--color-accent-300)]',
|
||||||
|
rose: 'bg-[#fce4ec] text-[#B5607D] dark:bg-[rgba(212,131,155,0.12)] dark:text-[#D4839B]',
|
||||||
|
teal: 'bg-[#e0f2f1] text-[#2E7D6E] dark:bg-[rgba(91,181,166,0.12)] dark:text-[#5BB5A6]',
|
||||||
|
lavender: 'bg-[#f3e8ff] text-[#7B5DA5] dark:bg-[rgba(183,159,212,0.12)] dark:text-[#B79FD4]',
|
||||||
|
coral: 'bg-[#fff3e0] text-[#A86440] dark:bg-[rgba(212,149,107,0.12)] dark:text-[#D4956B]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { cn } from '../../utils/cn';
|
|||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'accent' | 'outline' | 'ghost' | 'danger';
|
variant?: 'primary' | 'secondary' | 'accent' | 'outline' | 'ghost' | 'danger';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
@ -28,6 +28,7 @@ const variantStyles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
|
xs: 'h-5 px-2 text-[10px] gap-1 rounded-md',
|
||||||
sm: 'h-8 px-3 text-xs gap-1.5',
|
sm: 'h-8 px-3 text-xs gap-1.5',
|
||||||
md: 'h-10 px-4 text-sm gap-2',
|
md: 'h-10 px-4 text-sm gap-2',
|
||||||
lg: 'h-12 px-6 text-base gap-2',
|
lg: 'h-12 px-6 text-base gap-2',
|
||||||
|
|||||||
@ -7,7 +7,9 @@ export const CHART_COLORS = [
|
|||||||
'var(--color-chart-6)',
|
'var(--color-chart-6)',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const SERVICE_BADGE_VARIANTS = ['blue', 'gold', 'rose', 'teal', 'lavender', 'coral'] as const;
|
||||||
|
|
||||||
export const CHART_COLORS_HEX = {
|
export const CHART_COLORS_HEX = {
|
||||||
light: ['#507FB9', '#B59854', '#4E88BB', '#9A7E3E', '#3B669C', '#C4B48C'],
|
light: ['#507FB9', '#B59854', '#B5607D', '#3D998A', '#8B6DB5', '#C07850'],
|
||||||
dark: ['#6D94C5', '#C4B48C', '#6B9AC8', '#B59854', '#8AA5C7', '#D5CDB9'],
|
dark: ['#6D94C5', '#C4B48C', '#D4839B', '#5BB5A6', '#B79FD4', '#D4956B'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -47,10 +47,10 @@
|
|||||||
/* Chart Palette (고정) */
|
/* Chart Palette (고정) */
|
||||||
--color-chart-1: #507FB9;
|
--color-chart-1: #507FB9;
|
||||||
--color-chart-2: #B59854;
|
--color-chart-2: #B59854;
|
||||||
--color-chart-3: #4E88BB;
|
--color-chart-3: #B5607D;
|
||||||
--color-chart-4: #9A7E3E;
|
--color-chart-4: #3D998A;
|
||||||
--color-chart-5: #3B669C;
|
--color-chart-5: #8B6DB5;
|
||||||
--color-chart-6: #C4B48C;
|
--color-chart-6: #C07850;
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
@ -122,10 +122,10 @@
|
|||||||
|
|
||||||
--color-chart-1: #6D94C5;
|
--color-chart-1: #6D94C5;
|
||||||
--color-chart-2: #C4B48C;
|
--color-chart-2: #C4B48C;
|
||||||
--color-chart-3: #6B9AC8;
|
--color-chart-3: #D4839B;
|
||||||
--color-chart-4: #B59854;
|
--color-chart-4: #5BB5A6;
|
||||||
--color-chart-5: #8AA5C7;
|
--color-chart-5: #B79FD4;
|
||||||
--color-chart-6: #D5CDB9;
|
--color-chart-6: #D4956B;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Dark Theme (OS 기본 설정 기반) === */
|
/* === Dark Theme (OS 기본 설정 기반) === */
|
||||||
@ -163,10 +163,10 @@
|
|||||||
|
|
||||||
--color-chart-1: #6D94C5;
|
--color-chart-1: #6D94C5;
|
||||||
--color-chart-2: #C4B48C;
|
--color-chart-2: #C4B48C;
|
||||||
--color-chart-3: #6B9AC8;
|
--color-chart-3: #D4839B;
|
||||||
--color-chart-4: #B59854;
|
--color-chart-4: #5BB5A6;
|
||||||
--color-chart-5: #8AA5C7;
|
--color-chart-5: #B79FD4;
|
||||||
--color-chart-6: #D5CDB9;
|
--color-chart-6: #D4956B;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -120,7 +120,7 @@ const ApiHubLayoutInner = () => {
|
|||||||
const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]);
|
const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex h-screen overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="fixed left-0 top-0 h-screen w-[272px] bg-[var(--color-bg-surface)] flex flex-col border-r border-[var(--color-border)]">
|
<aside className="fixed left-0 top-0 h-screen w-[272px] bg-[var(--color-bg-surface)] flex flex-col border-r border-[var(--color-border)]">
|
||||||
{/* 로고 영역 */}
|
{/* 로고 영역 */}
|
||||||
@ -287,7 +287,7 @@ const ApiHubLayoutInner = () => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 ml-[272px]">
|
<div className="flex-1 ml-[272px] flex flex-col h-screen overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<header className="h-14 bg-[var(--color-bg-surface)] border-b border-[var(--color-border)] flex items-center justify-between px-6">
|
<header className="h-14 bg-[var(--color-bg-surface)] border-b border-[var(--color-border)] flex items-center justify-between px-6">
|
||||||
<div />
|
<div />
|
||||||
@ -328,7 +328,7 @@ const ApiHubLayoutInner = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 콘텐츠 */}
|
{/* 콘텐츠 */}
|
||||||
<main className="p-6 bg-[var(--color-bg-base)] min-h-[calc(100vh-3.5rem)]">
|
<main className="flex-1 p-6 bg-[var(--color-bg-base)] overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<BasketFloatingPanel />
|
<BasketFloatingPanel />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -196,7 +196,7 @@ const MainLayout = () => {
|
|||||||
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex h-screen overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="fixed left-0 top-0 h-screen w-60 bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] flex flex-col z-40 border-r border-[var(--color-border)]">
|
<aside className="fixed left-0 top-0 h-screen w-60 bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] flex flex-col z-40 border-r border-[var(--color-border)]">
|
||||||
|
|
||||||
@ -331,7 +331,7 @@ const MainLayout = () => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 ml-60">
|
<div className="flex-1 ml-60 flex flex-col h-screen overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="h-14 bg-[var(--color-bg-surface)] border-b border-[var(--color-border)] flex items-center justify-between px-6 sticky top-0 z-30">
|
<header className="h-14 bg-[var(--color-bg-surface)] border-b border-[var(--color-border)] flex items-center justify-between px-6 sticky top-0 z-30">
|
||||||
<div />
|
<div />
|
||||||
@ -373,7 +373,7 @@ const MainLayout = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<main className="p-6 bg-[var(--color-bg-base)] min-h-[calc(100vh-3.5rem)]">
|
<main className="flex-1 p-6 bg-[var(--color-bg-base)] overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,26 +11,18 @@ import {
|
|||||||
getSummary, getHourlyTrend, getServiceRatio,
|
getSummary, getHourlyTrend, getServiceRatio,
|
||||||
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
|
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
|
||||||
} from '../services/dashboardService';
|
} from '../services/dashboardService';
|
||||||
import { CHART_COLORS_HEX } from '../constants/chart';
|
import { CHART_COLORS_HEX, SERVICE_BADGE_VARIANTS } from '../constants/chart';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
import Badge, { type BadgeVariant } from '../components/ui/Badge';
|
||||||
|
|
||||||
const SERVICE_TAG_STYLES = [
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
'bg-blue-100 text-blue-700',
|
SUCCESS: 'success',
|
||||||
'bg-emerald-100 text-emerald-700',
|
FAIL: 'danger',
|
||||||
'bg-amber-100 text-amber-700',
|
DENIED: 'warning',
|
||||||
'bg-red-100 text-red-700',
|
EXPIRED: 'warning',
|
||||||
'bg-violet-100 text-violet-700',
|
INVALID_KEY: 'danger',
|
||||||
'bg-cyan-100 text-cyan-700',
|
ERROR: 'danger',
|
||||||
];
|
FAILED: 'default',
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
|
||||||
SUCCESS: 'bg-green-100 text-green-800',
|
|
||||||
FAIL: 'bg-red-100 text-red-800',
|
|
||||||
DENIED: 'bg-red-100 text-red-800',
|
|
||||||
EXPIRED: 'bg-orange-100 text-orange-800',
|
|
||||||
INVALID_KEY: 'bg-red-100 text-red-800',
|
|
||||||
ERROR: 'bg-orange-100 text-orange-800',
|
|
||||||
FAILED: 'bg-gray-100 text-gray-800',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_REFRESH_MS = 30000;
|
const AUTO_REFRESH_MS = 30000;
|
||||||
@ -93,6 +85,28 @@ const DashboardPage = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchAll]);
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
// 모든 데이터 소스에서 서비스명을 통합 수집 → 고정 인덱스 매핑
|
||||||
|
const serviceColorMap = useMemo(() => {
|
||||||
|
const allNames = new Set<string>();
|
||||||
|
serviceRatio.forEach((s) => allNames.add(s.serviceName));
|
||||||
|
errorTrend.forEach((e) => allNames.add(e.serviceName));
|
||||||
|
topApis.forEach((a) => allNames.add(a.serviceName));
|
||||||
|
recentLogs.forEach((l) => { if (l.serviceName) allNames.add(l.serviceName); });
|
||||||
|
|
||||||
|
const map: Record<string, { index: number; chartColor: string; badgeVariant: BadgeVariant }> = {};
|
||||||
|
[...allNames].sort().forEach((name, i) => {
|
||||||
|
map[name] = {
|
||||||
|
index: i,
|
||||||
|
chartColor: chartColors[i % chartColors.length],
|
||||||
|
badgeVariant: SERVICE_BADGE_VARIANTS[i % SERVICE_BADGE_VARIANTS.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [serviceRatio, errorTrend, topApis, recentLogs, chartColors]);
|
||||||
|
|
||||||
|
const getServiceChartColor = (name: string) => serviceColorMap[name]?.chartColor ?? chartColors[0];
|
||||||
|
const getServiceVariant = (name: string) => serviceColorMap[name]?.badgeVariant ?? 'blue';
|
||||||
|
|
||||||
const errorTrendPivoted = useMemo(() => {
|
const errorTrendPivoted = useMemo(() => {
|
||||||
const serviceNames = [...new Set(errorTrend.map((e) => e.serviceName))];
|
const serviceNames = [...new Set(errorTrend.map((e) => e.serviceName))];
|
||||||
const byHour: Record<number, Record<string, number>> = {};
|
const byHour: Record<number, Record<string, number>> = {};
|
||||||
@ -108,18 +122,6 @@ const DashboardPage = () => {
|
|||||||
};
|
};
|
||||||
}, [errorTrend]);
|
}, [errorTrend]);
|
||||||
|
|
||||||
const topApiServiceColorMap = useMemo(() => {
|
|
||||||
const serviceNames = [...new Set(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: chartColors[i % chartColors.length],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [topApis, chartColors]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -131,211 +133,238 @@ const DashboardPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Dashboard</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Dashboard</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">API Gateway 요약 및 실시간 현황을 확인합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<span className="text-sm text-[var(--color-text-secondary)]">마지막 갱신: {lastUpdated}</span>
|
<span className="text-sm text-[var(--color-text-secondary)]">마지막 갱신: {lastUpdated}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 1: Summary Cards */}
|
{/* Row 1: Summary Cards (50%) + Heartbeat (50%) */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
{/* 좌측: 요약 카드 1x4 */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">오늘 총 요청</p>
|
<p className="text-xs text-[var(--color-text-secondary)]">오늘 총 요청</p>
|
||||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.totalRequests.toLocaleString()}</p>
|
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.totalRequests.toLocaleString()}</p>
|
||||||
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
|
<p className={`text-xs ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
|
||||||
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {stats.changePercent.toFixed(2)}%
|
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} {stats.changePercent.toFixed(1)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">성공률</p>
|
<p className="text-xs text-[var(--color-text-secondary)]">성공률</p>
|
||||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.successRate.toFixed(1)}%</p>
|
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.successRate.toFixed(1)}%</p>
|
||||||
<p className="text-sm text-red-500">실패 {stats.failureCount}건</p>
|
<p className="text-xs text-red-500">실패 {stats.failureCount}건</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">평균 응답 시간</p>
|
<p className="text-xs text-[var(--color-text-secondary)]">평균 응답</p>
|
||||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.avgResponseTime.toFixed(0)}ms</p>
|
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.avgResponseTime.toFixed(0)}ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">API 요청 사용자</p>
|
<p className="text-xs text-[var(--color-text-secondary)]">요청 사용자</p>
|
||||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.activeUserCount}</p>
|
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.activeUserCount}</p>
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">오늘</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 2: Heartbeat Status Cards */}
|
{/* 우측: 서비스 상태 카드 (필 그리드) */}
|
||||||
<div className="mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col h-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className={`h-3.5 w-3.5 ${heartbeat.length > 0 && heartbeat.every((s) => s.healthStatus === 'UP') ? 'text-green-500' : 'text-amber-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-semibold text-[var(--color-text-primary)]">서비스 상태</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-semibold ${heartbeat.length > 0 && heartbeat.every((s) => s.healthStatus === 'UP') ? 'text-green-500' : 'text-amber-500'}`}>
|
||||||
|
{heartbeat.filter((s) => s.healthStatus === 'UP').length}/{heartbeat.length} 정상
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필 그리드 */}
|
||||||
|
<div className="flex flex-wrap gap-2 flex-1 content-start">
|
||||||
{heartbeat.length > 0 ? (
|
{heartbeat.length > 0 ? (
|
||||||
<div className="flex gap-4">
|
heartbeat.map((svc) => {
|
||||||
{heartbeat.map((svc) => {
|
|
||||||
const isUp = svc.healthStatus === 'UP';
|
const isUp = svc.healthStatus === 'UP';
|
||||||
const isDown = svc.healthStatus === 'DOWN';
|
const isDown = svc.healthStatus === 'DOWN';
|
||||||
const borderColor = isUp ? 'border-green-500' : isDown ? 'border-red-500' : 'border-gray-400';
|
const dotColor = isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400';
|
||||||
|
const borderHover = isUp ? 'hover:border-green-500/40' : isDown ? 'hover:border-red-500/40' : 'hover:border-gray-400/40';
|
||||||
|
const borderBase = isUp ? 'border-green-500/15' : isDown ? 'border-red-500/15' : 'border-gray-400/15';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={svc.serviceId}
|
key={svc.serviceId}
|
||||||
className={`flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-4 border-l-4 ${borderColor} cursor-pointer hover:shadow-md transition-shadow`}
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--color-bg-base)] border ${borderBase} ${borderHover} cursor-pointer transition-all`}
|
||||||
onClick={() => navigate('/monitoring/service-status')}
|
onClick={() => navigate('/monitoring/service-status')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="relative shrink-0">
|
||||||
<div
|
{isUp && <div className={`absolute -inset-0.5 rounded-full ${dotColor} opacity-20`} />}
|
||||||
className={`w-3 h-3 rounded-full ${
|
<div className={`w-1.5 h-1.5 rounded-full ${dotColor} relative`} />
|
||||||
isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-[var(--color-text-primary)]">{svc.serviceName}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<span className="text-xs font-medium text-[var(--color-text-primary)]">{svc.serviceName}</span>
|
||||||
<span className={`font-medium ${isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
|
{svc.healthResponseTime !== null && (
|
||||||
{isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'}
|
<span className="text-[10px] text-[var(--color-text-tertiary)] ml-0.5">{svc.healthResponseTime}ms</span>
|
||||||
</span>
|
|
||||||
{svc.healthCheckedAt && (
|
|
||||||
<span className="text-[var(--color-text-tertiary)] text-xs">{svc.healthCheckedAt}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-4">
|
<p className="text-[var(--color-text-secondary)] text-xs">등록된 서비스가 없습니다</p>
|
||||||
<p className="text-[var(--color-text-secondary)] text-sm">등록된 서비스가 없습니다</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Charts 2x2 */}
|
{/* Row 3: Charts 2x2 */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||||
{/* Chart 1: Hourly Trend */}
|
{/* Chart 1: Hourly Trend (3/4) */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-3 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">시간별 요청 추이</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">시간별 요청 추이</h3>
|
||||||
{hourlyTrend.length > 0 ? (
|
{hourlyTrend.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<LineChart data={hourlyTrend}>
|
<LineChart data={hourlyTrend}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} />
|
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} tick={{ fontSize: 10 }} />
|
||||||
<YAxis />
|
<YAxis tick={{ fontSize: 10 }} width={35} />
|
||||||
<Tooltip labelFormatter={(h) => `${h}시`} />
|
<Tooltip labelFormatter={(h) => `${h}시`} contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Line type="monotone" dataKey="successCount" stroke={chartColors[0]} name="성공" />
|
<Line type="monotone" dataKey="successCount" stroke={chartColors[0]} name="성공" />
|
||||||
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
|
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<div className="flex items-center justify-center h-[150px]">
|
||||||
|
<p className="text-[var(--color-text-tertiary)]">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Service Ratio */}
|
{/* Chart 2: Service Ratio */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-1 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 요청 비율</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">서비스별 요청 비율</h3>
|
||||||
{serviceRatio.length > 0 ? (
|
{serviceRatio.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={serviceRatio}
|
data={serviceRatio}
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
nameKey="serviceName"
|
nameKey="serviceName"
|
||||||
innerRadius={60}
|
innerRadius={30}
|
||||||
outerRadius={100}
|
outerRadius={52}
|
||||||
|
cx="50%"
|
||||||
|
cy="45%"
|
||||||
>
|
>
|
||||||
{serviceRatio.map((_, idx) => (
|
{serviceRatio.map((entry, idx) => (
|
||||||
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
<Cell key={idx} fill={getServiceChartColor(entry.serviceName)} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
<Legend layout="horizontal" align="center" verticalAlign="bottom" wrapperStyle={{ fontSize: 11 }} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<div className="flex items-center justify-center h-[150px]">
|
||||||
|
<p className="text-[var(--color-text-tertiary)]">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 3: Error Trend */}
|
{/* Chart 3: Error Trend (1/4) */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-1 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">에러율 추이</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">에러율 추이</h3>
|
||||||
{errorTrendPivoted.data.length > 0 ? (
|
{errorTrendPivoted.data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={errorTrendPivoted.data}>
|
<AreaChart data={errorTrendPivoted.data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} />
|
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} tick={{ fontSize: 10 }} />
|
||||||
<YAxis unit="%" />
|
<YAxis unit="%" tick={{ fontSize: 10 }} width={35} />
|
||||||
<Tooltip labelFormatter={(h) => `${h}시`} />
|
<Tooltip labelFormatter={(h) => `${h}시`} contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
{errorTrendPivoted.serviceNames.map((name, idx) => (
|
{errorTrendPivoted.serviceNames.map((name) => (
|
||||||
<Area
|
<Area
|
||||||
key={name}
|
key={name}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={name}
|
dataKey={name}
|
||||||
stackId="1"
|
stackId="1"
|
||||||
stroke={chartColors[idx % chartColors.length]}
|
stroke={getServiceChartColor(name)}
|
||||||
fill={chartColors[idx % chartColors.length]}
|
fill={getServiceChartColor(name)}
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<div className="flex items-center justify-center h-[120px]">
|
||||||
|
<p className="text-[var(--color-text-tertiary)]">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 4: Top APIs */}
|
{/* Chart 4: Top APIs (3/4) */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-3 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">상위 호출 API</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">상위 호출 API</h3>
|
||||||
{topApis.length > 0 ? (
|
{topApis.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{topApis.map((api, idx) => {
|
{topApis.slice(0, 5).map((api, idx) => {
|
||||||
const maxCount = topApis[0]?.count || 1;
|
const maxCount = topApis[0]?.count || 1;
|
||||||
const pct = (api.count / maxCount) * 100;
|
const pct = (api.count / maxCount) * 100;
|
||||||
const colors = topApiServiceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: chartColors[0] };
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="flex items-center gap-3">
|
<div key={idx} className="grid grid-cols-[24px_50px_1fr_2fr_48px] items-center gap-3">
|
||||||
<span className="text-xs text-[var(--color-text-tertiary)] w-5 text-right">{idx + 1}</span>
|
<span className="text-xs text-[var(--color-text-tertiary)] text-right">{idx + 1}</span>
|
||||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
|
<Badge variant={getServiceVariant(api.serviceName)} className="rounded-md truncate text-center w-full justify-center">{api.serviceName}</Badge>
|
||||||
{api.serviceName}
|
<span className="text-sm text-[var(--color-text-primary)] truncate" title={api.apiName}>
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-sm text-[var(--color-text-primary)] w-48 truncate" title={api.apiName}>
|
|
||||||
{api.apiName}
|
{api.apiName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 bg-[var(--color-bg-base)] rounded-full h-5 relative">
|
<div className="bg-[var(--color-bg-base)] rounded-full h-5">
|
||||||
<div
|
<div
|
||||||
className="h-5 rounded-full"
|
className="h-5 rounded-full"
|
||||||
style={{ width: `${pct}%`, backgroundColor: colors.bar }}
|
style={{ width: `${pct}%`, backgroundColor: getServiceChartColor(api.serviceName) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-[var(--color-text-primary)] w-12 text-right">{api.count}</span>
|
<span className="text-sm font-medium text-[var(--color-text-primary)] text-right">{api.count}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<div className="flex items-center justify-center h-[120px]">
|
||||||
|
<p className="text-[var(--color-text-tertiary)]">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 4: Recent Logs */}
|
{/* Row 4: Recent Logs */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="px-4 py-3 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">최근 요청 로그</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">최근 요청 로그</h3>
|
||||||
</div>
|
</div>
|
||||||
{recentLogs.length > 0 ? (
|
{recentLogs.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">시간</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-[70px]">서비스</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">서비스</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">사용자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">사용자</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">URL</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">URL</th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase w-[80px]">응답시간</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">상태</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-[80px]">상태</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">응답시간</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">시간</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
@ -345,26 +374,34 @@ const DashboardPage = () => {
|
|||||||
className="hover:bg-[var(--color-bg-base)] cursor-pointer"
|
className="hover:bg-[var(--color-bg-base)] cursor-pointer"
|
||||||
onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)}
|
onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-tertiary)] whitespace-nowrap">{log.requestedAt}</td>
|
<td className="px-3 py-1 w-[70px]">
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName ?? '-'}</td>
|
{log.serviceName ? (
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.userName ?? '-'}</td>
|
<Badge variant={getServiceVariant(log.serviceName)} className="rounded-md w-full justify-center truncate">{log.serviceName}</Badge>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]" title={log.requestUrl}>
|
) : (
|
||||||
{truncate(log.requestUrl, 40)}
|
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1 text-xs text-[var(--color-text-primary)]">{log.userName ?? '-'}</td>
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_BADGE[log.requestStatus] ?? 'bg-gray-100 text-gray-800'}`}>
|
<td className="px-3 py-1 text-xs text-[var(--color-text-primary)]" title={log.requestUrl}>
|
||||||
{log.requestStatus}
|
{truncate(log.requestUrl, 70)}
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-tertiary)]">
|
<td className="px-3 py-1 text-right text-xs text-[var(--color-text-tertiary)] w-[80px]">
|
||||||
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
|
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1 w-[80px]">
|
||||||
|
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'} size="sm">
|
||||||
|
{log.requestStatus}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1 text-xs text-[var(--color-text-tertiary)] whitespace-nowrap">
|
||||||
|
{log.requestedAt?.length > 23 ? log.requestedAt.substring(0, 23) : log.requestedAt}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-3 border-t border-[var(--color-border)] text-center">
|
<div className="px-4 py-2 border-t border-[var(--color-border)] text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/monitoring/request-logs')}
|
onClick={() => navigate('/monitoring/request-logs')}
|
||||||
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] font-medium"
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] font-medium"
|
||||||
|
|||||||
@ -376,19 +376,26 @@ const ApiEditPage = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">{apiName}</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">{apiName}</h1>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<Badge className={METHOD_CLASS[apiMethod] ?? 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)]'}>
|
<Badge className={METHOD_CLASS[apiMethod] ?? 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)]'}>
|
||||||
{apiMethod}
|
{apiMethod}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-mono text-sm text-[var(--color-text-tertiary)]">{apiPath}</span>
|
<span className="text-sm text-[var(--color-text-tertiary)]">{apiPath}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button onClick={handleDelete} variant="danger">
|
<Button onClick={handleDelete} variant="danger" size="sm">
|
||||||
삭제
|
삭제
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving} variant="primary">
|
<Button onClick={handleSave} disabled={saving} variant="primary" size="sm">
|
||||||
{saving ? '저장 중...' : '저장'}
|
{saving ? '저장 중...' : '저장'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -410,7 +417,7 @@ const ApiEditPage = () => {
|
|||||||
{/* Sections */}
|
{/* Sections */}
|
||||||
<div className="space-y-6 mt-6">
|
<div className="space-y-6 mt-6">
|
||||||
{/* Section 1: 기본 정보 */}
|
{/* Section 1: 기본 정보 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
@ -432,7 +439,7 @@ const ApiEditPage = () => {
|
|||||||
value={apiPath}
|
value={apiPath}
|
||||||
onChange={(e) => setApiPath(e.target.value)}
|
onChange={(e) => setApiPath(e.target.value)}
|
||||||
placeholder="/api/v1/example"
|
placeholder="/api/v1/example"
|
||||||
className={`${INPUT_CLS} font-mono`}
|
className={INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -494,7 +501,7 @@ const ApiEditPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 2: API 명세 */}
|
{/* Section 2: API 명세 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">API 명세</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">API 명세</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -584,7 +591,7 @@ const ApiEditPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3: 요청인자 */}
|
{/* Section 3: 요청인자 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
||||||
<Button type="button" onClick={() => addParam()} variant="secondary" size="sm">
|
<Button type="button" onClick={() => addParam()} variant="secondary" size="sm">
|
||||||
@ -592,16 +599,16 @@ const ApiEditPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--color-border)]">
|
<tr className="h-8 border-b border-[var(--color-border)]">
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] w-10">#</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-10">#</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">인자명</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">인자명</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">의미</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">의미</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">설명</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">설명</th>
|
||||||
<th className="px-2 py-2 text-center text-xs font-medium text-[var(--color-text-secondary)] w-14">필수</th>
|
<th className="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-14">필수</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] w-28">입력유형</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-28">입력유형</th>
|
||||||
<th className="px-2 py-2 w-10" />
|
<th className="px-3 py-2 w-10" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
@ -616,11 +623,11 @@ const ApiEditPage = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
requestParams.map((param, idx) => (
|
requestParams.map((param, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx} className="h-7">
|
||||||
<td className="px-2 py-1.5 text-[var(--color-text-secondary)] text-center text-xs">
|
<td className="px-3 py-1 text-[var(--color-text-secondary)] text-center text-xs">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-3 py-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={param.paramName}
|
value={param.paramName}
|
||||||
@ -629,7 +636,7 @@ const ApiEditPage = () => {
|
|||||||
className={TABLE_INPUT_CLS}
|
className={TABLE_INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-3 py-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={param.paramMeaning ?? ''}
|
value={param.paramMeaning ?? ''}
|
||||||
@ -640,7 +647,7 @@ const ApiEditPage = () => {
|
|||||||
className={TABLE_INPUT_CLS}
|
className={TABLE_INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-3 py-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={param.paramDescription ?? ''}
|
value={param.paramDescription ?? ''}
|
||||||
@ -651,7 +658,7 @@ const ApiEditPage = () => {
|
|||||||
className={TABLE_INPUT_CLS}
|
className={TABLE_INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<td className="px-3 py-1 text-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={param.required ?? false}
|
checked={param.required ?? false}
|
||||||
@ -659,7 +666,7 @@ const ApiEditPage = () => {
|
|||||||
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-3 py-1">
|
||||||
<select
|
<select
|
||||||
value={param.inputType ?? 'TEXT'}
|
value={param.inputType ?? 'TEXT'}
|
||||||
onChange={(e) => updateParam(idx, 'inputType', e.target.value)}
|
onChange={(e) => updateParam(idx, 'inputType', e.target.value)}
|
||||||
@ -671,7 +678,7 @@ const ApiEditPage = () => {
|
|||||||
<option value="DATETIME">DATETIME</option>
|
<option value="DATETIME">DATETIME</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<td className="px-3 py-1 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeParam(idx)}
|
onClick={() => removeParam(idx)}
|
||||||
@ -690,7 +697,7 @@ const ApiEditPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 4: 출력결과 */}
|
{/* Section 4: 출력결과 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">출력결과</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">출력결과</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -718,7 +725,7 @@ const ApiEditPage = () => {
|
|||||||
onChange={(e) => { setJsonInput(e.target.value); setJsonError(null); }}
|
onChange={(e) => { setJsonInput(e.target.value); setJsonError(null); }}
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder={'{\n "result": "success",\n "data": {\n "id": 1,\n "name": "example"\n }\n}'}
|
placeholder={'{\n "result": "success",\n "data": {\n "id": 1,\n "name": "example"\n }\n}'}
|
||||||
className={`${INPUT_CLS} font-mono resize-none`}
|
className={`${INPUT_CLS} resize-none`}
|
||||||
/>
|
/>
|
||||||
{jsonError && (
|
{jsonError && (
|
||||||
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
|
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
|
||||||
@ -737,13 +744,13 @@ const ApiEditPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--color-border)]">
|
<tr className="h-8 border-b border-[var(--color-border)]">
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] w-10">#</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-10">#</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">변수명</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">변수명</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">의미(단위)</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">의미(단위)</th>
|
||||||
<th className="px-2 py-2 w-10" />
|
<th className="px-3 py-2 w-10" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
@ -758,11 +765,11 @@ const ApiEditPage = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
responseParams.map((param, idx) => (
|
responseParams.map((param, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx} className="h-7">
|
||||||
<td className="px-2 py-1.5 text-[var(--color-text-secondary)] text-center text-xs">
|
<td className="px-3 py-1 text-[var(--color-text-secondary)] text-center text-xs">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-3 py-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={param.paramName}
|
value={param.paramName}
|
||||||
@ -771,7 +778,7 @@ const ApiEditPage = () => {
|
|||||||
className={TABLE_INPUT_CLS}
|
className={TABLE_INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-3 py-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={param.paramMeaning ?? ''}
|
value={param.paramMeaning ?? ''}
|
||||||
@ -782,7 +789,7 @@ const ApiEditPage = () => {
|
|||||||
className={TABLE_INPUT_CLS}
|
className={TABLE_INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<td className="px-3 py-1 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeResponseParam(idx)}
|
onClick={() => removeResponseParam(idx)}
|
||||||
|
|||||||
@ -160,8 +160,18 @@ const ApisPage = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">API 관리</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<Button onClick={handleOpenModal}>API 생성</Button>
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">API 관리</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">API 등록 및 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenModal} size="sm">API 생성</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global error */}
|
{/* Global error */}
|
||||||
@ -214,18 +224,18 @@ const ApisPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">서비스</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">서비스</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Method</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Path</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Path</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">API명</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">API명</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Domain</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Domain</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Section</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Section</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">명세</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">명세</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
@ -233,33 +243,33 @@ const ApisPage = () => {
|
|||||||
<tr
|
<tr
|
||||||
key={`${api.serviceId}-${api.apiId}`}
|
key={`${api.serviceId}-${api.apiId}`}
|
||||||
onClick={() => navigate(`/admin/apis/${api.serviceId}/${api.apiId}`)}
|
onClick={() => navigate(`/admin/apis/${api.serviceId}/${api.apiId}`)}
|
||||||
className="cursor-pointer hover:bg-[var(--color-bg-base)]"
|
className="h-7 cursor-pointer hover:bg-[var(--color-bg-base)]"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.serviceName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.serviceName}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge className={METHOD_COLOR[api.apiMethod] ?? ''}>
|
<Badge size="sm" className={METHOD_COLOR[api.apiMethod] ?? ''}>
|
||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)] truncate max-w-[240px]">
|
<td className="px-3 py-1 text-[var(--color-text-primary)] truncate max-w-[240px]">
|
||||||
{api.apiPath}
|
{api.apiPath}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.apiName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.apiName}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={api.isActive ? 'success' : 'danger'}>
|
<Badge variant={api.isActive ? 'success' : 'danger'} size="sm">
|
||||||
{api.isActive ? 'Active' : 'Inactive'}
|
{api.isActive ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<span className="text-[var(--color-text-tertiary)]">-</span>
|
<span className="text-[var(--color-text-tertiary)]">-</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filteredApis.length === 0 && (
|
{filteredApis.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={8} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 API가 없습니다
|
등록된 API가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -329,7 +339,7 @@ const ApisPage = () => {
|
|||||||
onChange={(e) => setModalPath(e.target.value)}
|
onChange={(e) => setModalPath(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="/api/v1/example"
|
placeholder="/api/v1/example"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -128,30 +128,40 @@ const DomainsPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Domains</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<Button onClick={handleOpenCreate}>도메인 추가</Button>
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20" /><path d="M2 12h20" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Domains</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">API 도메인 분류 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate} size="sm">도메인 추가</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isModalOpen && (
|
{error && !isModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">#</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">#</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">아이콘</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">아이콘</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">도메인명</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">도메인명</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">정렬순서</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">정렬순서</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{domains.map((domain, index) => (
|
{domains.map((domain, index) => (
|
||||||
<tr key={domain.domainId} className="hover:bg-[var(--color-bg-base)]">
|
<tr key={domain.domainId} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{index + 1}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{index + 1}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-[var(--color-text-secondary)]"
|
className="h-5 w-5 text-[var(--color-text-secondary)]"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -166,14 +176,14 @@ const DomainsPage = () => {
|
|||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{domain.domainName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)] font-medium">{domain.domainName}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{domain.sortOrder}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{domain.sortOrder}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(domain)}>
|
<Button variant="outline" size="xs" onClick={() => handleOpenEdit(domain)}>
|
||||||
수정
|
수정
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" size="sm" onClick={() => handleDelete(domain)}>
|
<Button variant="danger" size="xs" onClick={() => handleDelete(domain)}>
|
||||||
삭제
|
삭제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -182,7 +192,7 @@ const DomainsPage = () => {
|
|||||||
))}
|
))}
|
||||||
{domains.length === 0 && (
|
{domains.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={5} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 도메인이 없습니다.
|
등록된 도메인이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -230,7 +240,7 @@ const DomainsPage = () => {
|
|||||||
onChange={(e) => setIconPath(e.target.value)}
|
onChange={(e) => setIconPath(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="M4 6a2 2 0 012-2h8..."
|
placeholder="M4 6a2 2 0 012-2h8..."
|
||||||
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none font-mono text-xs"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Button from '../../components/ui/Button';
|
|||||||
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
||||||
|
|
||||||
const INPUT_CLS =
|
const INPUT_CLS =
|
||||||
'w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none text-sm font-mono';
|
'w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none text-sm';
|
||||||
|
|
||||||
const SampleCodePage = () => {
|
const SampleCodePage = () => {
|
||||||
const [sampleCode, setSampleCode] = useState('');
|
const [sampleCode, setSampleCode] = useState('');
|
||||||
@ -59,15 +59,21 @@ const SampleCodePage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<polyline points="16,18 22,12 16,6" /><polyline points="8,6 2,12 8,18" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">공통 샘플 코드 관리</h1>
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">공통 샘플 코드 관리</h1>
|
||||||
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
|
<p className="text-sm text-[var(--color-text-secondary)]">API 사용 예제 코드를 관리합니다</p>
|
||||||
API HUB 상세 페이지에 공통으로 표시되는 샘플 코드를 관리합니다.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{saving ? '저장 중...' : '저장'}
|
{saving ? '저장 중...' : '저장'}
|
||||||
@ -86,7 +92,7 @@ const SampleCodePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||||
샘플 코드
|
샘플 코드
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@ -197,26 +197,36 @@ const ServicesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Services</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<Button onClick={handleOpenCreateService}>Create Service</Button>
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<polygon points="12,2 2,7 12,12 22,7" /><polyline points="2,17 12,22 22,17" /><polyline points="2,12 12,17 22,12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Services</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">서비스 등록 및 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreateService} size="sm">Create Service</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isServiceModalOpen && (
|
{error && !isServiceModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl mb-6">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Code</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Code</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">URL</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">URL</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Health Status</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Health Status</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Response Time</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Response Time</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Last Checked</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Last Checked</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
@ -233,39 +243,39 @@ const ServicesPage = () => {
|
|||||||
<tr
|
<tr
|
||||||
key={service.serviceId}
|
key={service.serviceId}
|
||||||
onClick={() => handleSelectService(service)}
|
onClick={() => handleSelectService(service)}
|
||||||
className={`cursor-pointer ${
|
className={`h-7 cursor-pointer ${
|
||||||
isSelected ? 'bg-[var(--color-primary-subtle)]' : 'hover:bg-[var(--color-bg-base)]'
|
isSelected ? 'bg-[var(--color-primary-subtle)]' : 'hover:bg-[var(--color-bg-base)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{service.serviceCode}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{service.serviceCode}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{service.serviceName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{service.serviceName}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[200px]">
|
<td className="px-3 py-1 text-[var(--color-text-secondary)] truncate max-w-[200px]">
|
||||||
{service.serviceUrl || '-'}
|
{service.serviceUrl || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={healthVariant} className="gap-1.5">
|
<Badge variant={healthVariant} size="sm" className="gap-1.5">
|
||||||
<span className={`w-2 h-2 rounded-full ${dot}`} />
|
<span className={`w-2 h-2 rounded-full ${dot}`} />
|
||||||
{service.healthStatus}
|
{service.healthStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">
|
||||||
{service.healthResponseTime != null
|
{service.healthResponseTime != null
|
||||||
? `${service.healthResponseTime}ms`
|
? `${service.healthResponseTime}ms`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">
|
||||||
{formatRelativeTime(service.healthCheckedAt)}
|
{formatRelativeTime(service.healthCheckedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={service.isActive ? 'success' : 'danger'}>
|
<Badge variant={service.isActive ? 'success' : 'danger'} size="sm">
|
||||||
{service.isActive ? 'Active' : 'Inactive'}
|
{service.isActive ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="xs"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleOpenEditService(service);
|
handleOpenEditService(service);
|
||||||
@ -275,7 +285,7 @@ const ServicesPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="xs"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteService(service);
|
handleDeleteService(service);
|
||||||
@ -290,7 +300,7 @@ const ServicesPage = () => {
|
|||||||
})}
|
})}
|
||||||
{services.length === 0 && (
|
{services.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={8} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 서비스가 없습니다.
|
등록된 서비스가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -300,7 +310,7 @@ const ServicesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedService && (
|
{selectedService && (
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
APIs for {selectedService.serviceName}
|
APIs for {selectedService.serviceName}
|
||||||
@ -308,22 +318,22 @@ const ServicesPage = () => {
|
|||||||
<Button size="sm" onClick={() => navigate('/admin/apis')}>API 관리</Button>
|
<Button size="sm" onClick={() => navigate('/admin/apis')}>API 관리</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Method</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Path</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Path</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Domain</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Domain</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Section</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Section</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Description</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Description</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{serviceApis.map((api) => (
|
{serviceApis.map((api) => (
|
||||||
<tr key={api.apiId} className="hover:bg-[var(--color-bg-base)]">
|
<tr key={api.apiId} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
|
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
|
||||||
@ -332,13 +342,13 @@ const ServicesPage = () => {
|
|||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{api.apiPath}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.apiPath}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.apiName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.apiName}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.description || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.description || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={api.isActive ? 'success' : 'danger'}>
|
<Badge variant={api.isActive ? 'success' : 'danger'} size="sm">
|
||||||
{api.isActive ? 'Active' : 'Inactive'}
|
{api.isActive ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
@ -346,7 +356,7 @@ const ServicesPage = () => {
|
|||||||
))}
|
))}
|
||||||
{serviceApis.length === 0 && (
|
{serviceApis.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={7} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 API가 없습니다.
|
등록된 API가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -102,42 +102,52 @@ const TenantsPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Tenants</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<Button onClick={handleOpenCreate}>Create Tenant</Button>
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M9 21V9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Tenants</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">부서 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate} size="sm">Create Tenant</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isModalOpen && (
|
{error && !isModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Code</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Code</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Description</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Description</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Created</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Created</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{tenants.map((tenant) => (
|
{tenants.map((tenant) => (
|
||||||
<tr key={tenant.tenantId} className="hover:bg-[var(--color-bg-base)]">
|
<tr key={tenant.tenantId} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{tenant.tenantCode}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{tenant.tenantCode}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.tenantName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{tenant.tenantName}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{tenant.description || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{tenant.description || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={tenant.isActive ? 'success' : 'danger'}>
|
<Badge variant={tenant.isActive ? 'success' : 'danger'} size="sm">
|
||||||
{tenant.isActive ? 'Active' : 'Inactive'}
|
{tenant.isActive ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">
|
||||||
{new Date(tenant.createdAt).toLocaleDateString()}
|
{new Date(tenant.createdAt).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(tenant)}>
|
<Button variant="outline" size="xs" onClick={() => handleOpenEdit(tenant)}>
|
||||||
수정
|
수정
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
@ -145,7 +155,7 @@ const TenantsPage = () => {
|
|||||||
))}
|
))}
|
||||||
{tenants.length === 0 && (
|
{tenants.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={6} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 테넌트가 없습니다.
|
등록된 테넌트가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type { BadgeVariant } from '../../components/ui/Badge';
|
|||||||
const ROLE_BADGE_VARIANT: Record<string, BadgeVariant> = {
|
const ROLE_BADGE_VARIANT: Record<string, BadgeVariant> = {
|
||||||
ADMIN: 'danger',
|
ADMIN: 'danger',
|
||||||
MANAGER: 'warning',
|
MANAGER: 'warning',
|
||||||
USER: 'primary',
|
USER: 'blue',
|
||||||
VIEWER: 'default',
|
VIEWER: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -144,57 +144,67 @@ const UsersPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Users</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<Button onClick={handleOpenCreate}>Create User</Button>
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle cx="9" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Users</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">사용자 계정 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate} size="sm">Create User</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isModalOpen && (
|
{error && !isModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Login ID</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Login ID</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Email</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Email</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Tenant</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Tenant</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Role</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Role</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Last Login</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Last Login</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<tr key={user.userId} className="hover:bg-[var(--color-bg-base)]">
|
<tr key={user.userId} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{user.loginId}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{user.loginId}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.userName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{user.userName}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{user.email || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{user.email || '-'}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{user.tenantName || '-'}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{user.tenantName || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'}>
|
<Badge variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'} size="sm">
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={user.isActive ? 'success' : 'danger'}>
|
<Badge variant={user.isActive ? 'success' : 'danger'} size="sm">
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">
|
||||||
{user.lastLoginAt
|
{user.lastLoginAt
|
||||||
? new Date(user.lastLoginAt).toLocaleString()
|
? new Date(user.lastLoginAt).toLocaleString()
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(user)}>
|
<Button variant="outline" size="xs" onClick={() => handleOpenEdit(user)}>
|
||||||
수정
|
수정
|
||||||
</Button>
|
</Button>
|
||||||
{user.isActive && (
|
{user.isActive && (
|
||||||
<Button variant="danger" size="sm" onClick={() => handleDeactivate(user)}>
|
<Button variant="danger" size="xs" onClick={() => handleDeactivate(user)}>
|
||||||
비활성화
|
비활성화
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -204,7 +214,7 @@ const UsersPage = () => {
|
|||||||
))}
|
))}
|
||||||
{users.length === 0 && (
|
{users.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={8} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 사용자가 없습니다.
|
등록된 사용자가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -342,13 +342,15 @@ const KeyAdminPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-4">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">API Key 관리</h1>
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">API Key 관리</h1>
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">API Key 신청 검토 및 발급된 키의 수명주기를 관리합니다</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">API Key 신청 검토 및 발급된 키의 수명주기를 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -405,7 +407,7 @@ const KeyAdminPage = () => {
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabSwitch('requests')}
|
onClick={() => handleTabSwitch('requests')}
|
||||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
className={`flex items-center gap-2 px-3 py-2.5 text-xs -mb-px border-b-2 transition-colors ${
|
||||||
activeTab === 'requests'
|
activeTab === 'requests'
|
||||||
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
||||||
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||||
@ -419,7 +421,7 @@ const KeyAdminPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabSwitch('keys')}
|
onClick={() => handleTabSwitch('keys')}
|
||||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
className={`flex items-center gap-2 px-3 py-2.5 text-xs -mb-px border-b-2 transition-colors ${
|
||||||
activeTab === 'keys'
|
activeTab === 'keys'
|
||||||
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
||||||
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||||
@ -428,31 +430,31 @@ const KeyAdminPage = () => {
|
|||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||||
키 관리
|
키 관리
|
||||||
{activeKeyCount > 0 && (
|
{activeKeyCount > 0 && (
|
||||||
<Badge variant="primary" size="sm">{activeKeyCount}</Badge>
|
<Badge variant="blue" size="sm">{activeKeyCount}</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg className="w-4 h-4 text-[var(--color-text-tertiary)] absolute left-3 top-1/2 -translate-y-1/2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)] absolute left-2.5 top-1/2 -translate-y-1/2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
className="bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-lg pl-9 pr-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-gray-400 w-56 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
className="bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-md pl-8 pr-3 py-1.5 text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] w-48 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Chips */}
|
{/* Filter Chips */}
|
||||||
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center gap-2">
|
<div className="px-4 py-2 border-b border-[var(--color-border)] flex items-center gap-2">
|
||||||
{activeTab === 'requests' ? (
|
{activeTab === 'requests' ? (
|
||||||
<>
|
<>
|
||||||
{([['ALL', '전체'], ['PENDING', '대기'], ['APPROVED', '승인'], ['REJECTED', '반려']] as const).map(([value, label]) => (
|
{([['ALL', '전체'], ['PENDING', '대기'], ['APPROVED', '승인'], ['REJECTED', '반려']] as const).map(([value, label]) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => { setRequestFilter(value); setRequestPage(0); }}
|
onClick={() => { setRequestFilter(value); setRequestPage(0); }}
|
||||||
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
className={`px-3 py-1 rounded-full text-xs font-medium border cursor-pointer transition-colors ${
|
||||||
requestFilter === value
|
requestFilter === value
|
||||||
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
||||||
@ -469,7 +471,7 @@ const KeyAdminPage = () => {
|
|||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => { setKeyFilter(value); setKeyPage(0); }}
|
onClick={() => { setKeyFilter(value); setKeyPage(0); }}
|
||||||
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
className={`px-3 py-1 rounded-full text-xs font-medium border cursor-pointer transition-colors ${
|
||||||
keyFilter === value
|
keyFilter === value
|
||||||
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
||||||
@ -491,43 +493,43 @@ const KeyAdminPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">신청자</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">신청자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">키 이름</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">키 이름</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">상태</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">상태</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">API 수</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">API 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">신청일시</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">신청일시</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{pagedRequests.map((req) => (
|
{pagedRequests.map((req) => (
|
||||||
<tr key={req.requestId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
<tr key={req.requestId} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
||||||
{req.userName}
|
{req.userName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{req.keyName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)] font-medium">{req.keyName}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={STATUS_VARIANT[req.status] || 'default'}>
|
<Badge variant={STATUS_VARIANT[req.status] || 'default'} size="sm">
|
||||||
{req.status === 'PENDING' ? '대기' : req.status === 'APPROVED' ? '승인' : '반려'}
|
{req.status === 'PENDING' ? '대기' : req.status === 'APPROVED' ? '승인' : '반려'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<span className="bg-[var(--color-primary-subtle)] text-[var(--color-primary)] px-2 py-0.5 rounded text-xs font-semibold">{req.requestedApiIds.length}개</span>
|
<span className="bg-[var(--color-primary-subtle)] text-[var(--color-primary)] px-2 py-0.5 rounded text-xs font-semibold">{req.requestedApiIds.length}개</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(req.createdAt)}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(req.createdAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{req.status === 'PENDING' && (
|
{req.status === 'PENDING' && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleOpenReview(req)}
|
onClick={() => handleOpenReview(req)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="xs"
|
||||||
>
|
>
|
||||||
검토
|
검토
|
||||||
</Button>
|
</Button>
|
||||||
@ -535,8 +537,8 @@ const KeyAdminPage = () => {
|
|||||||
{(req.status === 'APPROVED' || req.status === 'REJECTED') && (
|
{(req.status === 'APPROVED' || req.status === 'REJECTED') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleOpenDetail(req)}
|
onClick={() => handleOpenDetail(req)}
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
size="sm"
|
size="xs"
|
||||||
>
|
>
|
||||||
상세
|
상세
|
||||||
</Button>
|
</Button>
|
||||||
@ -547,7 +549,7 @@ const KeyAdminPage = () => {
|
|||||||
))}
|
))}
|
||||||
{filteredRequests.length === 0 && (
|
{filteredRequests.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={6} className="px-3 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||||
{searchQuery || requestFilter !== 'ALL' ? '조건에 맞는 신청이 없습니다.' : '신청 내역이 없습니다.'}
|
{searchQuery || requestFilter !== 'ALL' ? '조건에 맞는 신청이 없습니다.' : '신청 내역이 없습니다.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -556,17 +558,38 @@ const KeyAdminPage = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/* Table Footer */}
|
{/* Table Footer */}
|
||||||
<div className="px-4 py-3 border-t border-[var(--color-border)] flex items-center justify-between">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)]">
|
||||||
<span className="text-sm text-[var(--color-text-secondary)]">총 {filteredRequests.length}건</span>
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
{requestTotalPages > 1 && (
|
총{' '}
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-[var(--color-text-primary)] font-semibold">{filteredRequests.length}</span>건 중{' '}
|
||||||
<button onClick={() => setRequestPage(Math.max(0, requestPage - 1))} disabled={requestPage === 0}
|
<span className="text-[var(--color-text-primary)] font-semibold">
|
||||||
className="px-2.5 py-1 text-xs rounded border border-[var(--color-border)] text-[var(--color-text-secondary)] disabled:opacity-40">이전</button>
|
{filteredRequests.length === 0 ? 0 : requestPage * PAGE_SIZE + 1}-{Math.min((requestPage + 1) * PAGE_SIZE, filteredRequests.length)}
|
||||||
<span className="text-xs text-[var(--color-text-secondary)] px-2">{requestPage + 1} / {requestTotalPages}</span>
|
</span>
|
||||||
<button onClick={() => setRequestPage(Math.min(requestTotalPages - 1, requestPage + 1))} disabled={requestPage >= requestTotalPages - 1}
|
건 표시
|
||||||
className="px-2.5 py-1 text-xs rounded border border-[var(--color-border)] text-[var(--color-text-secondary)] disabled:opacity-40">다음</button>
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRequestPage(Math.max(0, requestPage - 1))}
|
||||||
|
disabled={requestPage === 0}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
← 이전
|
||||||
|
</button>
|
||||||
|
<span className="text-xs">
|
||||||
|
<span className="text-[var(--color-text-primary)] font-bold">{requestPage + 1}</span>
|
||||||
|
<span className="text-[var(--color-text-tertiary)]"> / </span>
|
||||||
|
<span className="text-[var(--color-text-secondary)]">{requestTotalPages || 1}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRequestPage(Math.min(requestTotalPages - 1, requestPage + 1))}
|
||||||
|
disabled={requestPage >= requestTotalPages - 1}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
다음 →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -581,16 +604,16 @@ const KeyAdminPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">소유자</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">소유자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">키 이름</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">키 이름</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Prefix</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Prefix</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">상태</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">상태</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">만료일시</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">만료일시</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">마지막 사용일시</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">마지막 사용일시</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
@ -598,23 +621,23 @@ const KeyAdminPage = () => {
|
|||||||
const daysLeft = getDaysUntilExpiry(key.expiresAt);
|
const daysLeft = getDaysUntilExpiry(key.expiresAt);
|
||||||
const isExpiringSoon = key.status === 'ACTIVE' && daysLeft !== null && daysLeft <= 14;
|
const isExpiringSoon = key.status === 'ACTIVE' && daysLeft !== null && daysLeft <= 14;
|
||||||
return (
|
return (
|
||||||
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
<tr key={key.apiKeyId} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
||||||
{key.userName || '-'}
|
{key.userName || '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{key.keyName}</td>
|
<td className="px-3 py-1 text-gray-900 dark:text-gray-100 font-medium">{key.keyName}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant="info" className="font-mono font-semibold">{key.apiKeyPrefix}</Badge>
|
<Badge variant="info" size="sm" className="font-semibold">{key.apiKeyPrefix}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={STATUS_VARIANT[key.status] || 'default'}>
|
<Badge variant={STATUS_VARIANT[key.status] || 'default'} size="sm">
|
||||||
{KEY_STATUS_CONFIG[key.status]?.label || key.status}
|
{KEY_STATUS_CONFIG[key.status]?.label || key.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[var(--color-text-secondary)]">{key.expiresAt ? formatDateTime(key.expiresAt) : '영구'}</span>
|
<span className="text-[var(--color-text-secondary)]">{key.expiresAt ? formatDateTime(key.expiresAt) : '영구'}</span>
|
||||||
{isExpiringSoon && (
|
{isExpiringSoon && (
|
||||||
@ -624,13 +647,13 @@ const KeyAdminPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
<td className="px-3 py-1 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleViewDetail(key)}
|
onClick={() => handleViewDetail(key)}
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
size="sm"
|
size="xs"
|
||||||
>
|
>
|
||||||
상세
|
상세
|
||||||
</Button>
|
</Button>
|
||||||
@ -638,7 +661,7 @@ const KeyAdminPage = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => handleRevokeKey(key)}
|
onClick={() => handleRevokeKey(key)}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="xs"
|
||||||
>
|
>
|
||||||
폐기
|
폐기
|
||||||
</Button>
|
</Button>
|
||||||
@ -650,7 +673,7 @@ const KeyAdminPage = () => {
|
|||||||
})}
|
})}
|
||||||
{filteredKeys.length === 0 && (
|
{filteredKeys.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={7} className="px-3 py-8 text-center text-xs text-gray-400 dark:text-gray-500">
|
||||||
{searchQuery || keyFilter !== 'ALL' ? '조건에 맞는 키가 없습니다.' : '등록된 API Key가 없습니다.'}
|
{searchQuery || keyFilter !== 'ALL' ? '조건에 맞는 키가 없습니다.' : '등록된 API Key가 없습니다.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -659,17 +682,38 @@ const KeyAdminPage = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/* Table Footer */}
|
{/* Table Footer */}
|
||||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/50 flex items-center justify-between">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)]">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">총 {filteredKeys.length}건</span>
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
{keyTotalPages > 1 && (
|
총{' '}
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-[var(--color-text-primary)] font-semibold">{filteredKeys.length}</span>건 중{' '}
|
||||||
<button onClick={() => setKeyPage(Math.max(0, keyPage - 1))} disabled={keyPage === 0}
|
<span className="text-[var(--color-text-primary)] font-semibold">
|
||||||
className="px-2.5 py-1 text-xs rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 disabled:opacity-40">이전</button>
|
{filteredKeys.length === 0 ? 0 : keyPage * PAGE_SIZE + 1}-{Math.min((keyPage + 1) * PAGE_SIZE, filteredKeys.length)}
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">{keyPage + 1} / {keyTotalPages}</span>
|
</span>
|
||||||
<button onClick={() => setKeyPage(Math.min(keyTotalPages - 1, keyPage + 1))} disabled={keyPage >= keyTotalPages - 1}
|
건 표시
|
||||||
className="px-2.5 py-1 text-xs rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 disabled:opacity-40">다음</button>
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKeyPage(Math.max(0, keyPage - 1))}
|
||||||
|
disabled={keyPage === 0}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
← 이전
|
||||||
|
</button>
|
||||||
|
<span className="text-xs">
|
||||||
|
<span className="text-[var(--color-text-primary)] font-bold">{keyPage + 1}</span>
|
||||||
|
<span className="text-[var(--color-text-tertiary)]"> / </span>
|
||||||
|
<span className="text-[var(--color-text-secondary)]">{keyTotalPages || 1}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKeyPage(Math.min(keyTotalPages - 1, keyPage + 1))}
|
||||||
|
disabled={keyPage >= keyTotalPages - 1}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
다음 →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -894,7 +938,7 @@ const KeyAdminPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-500">서비스 IP</span>
|
<span className="text-xs text-gray-500 dark:text-gray-500">서비스 IP</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10 font-mono">{selectedRequest.serviceIp || '-'}</p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10">{selectedRequest.serviceIp || '-'}</p>
|
||||||
{selectedRequest.servicePurpose && (
|
{selectedRequest.servicePurpose && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 pl-10 mt-0.5">{selectedRequest.servicePurpose}</p>
|
<p className="text-xs text-gray-500 dark:text-gray-500 pl-10 mt-0.5">{selectedRequest.servicePurpose}</p>
|
||||||
)}
|
)}
|
||||||
@ -1149,7 +1193,7 @@ const KeyAdminPage = () => {
|
|||||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${METHOD_BADGE_STYLE[api.apiMethod] || 'bg-gray-700 text-gray-300 border border-gray-600'}`}>
|
<span className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${METHOD_BADGE_STYLE[api.apiMethod] || 'bg-gray-700 text-gray-300 border border-gray-600'}`}>
|
||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</span>
|
</span>
|
||||||
<span className={`font-mono text-sm text-gray-700 dark:text-gray-300 ${isRemoved ? 'line-through' : ''}`}>{api.apiPath}</span>
|
<span className={`text-sm text-gray-700 dark:text-gray-300 ${isRemoved ? 'line-through' : ''}`}>{api.apiPath}</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{api.apiName}</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">{api.apiName}</span>
|
||||||
{isAdded && (
|
{isAdded && (
|
||||||
<span className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-[9px] font-bold px-1.5 py-0.5 rounded">추가</span>
|
<span className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-[9px] font-bold px-1.5 py-0.5 rounded">추가</span>
|
||||||
@ -1217,7 +1261,7 @@ const KeyAdminPage = () => {
|
|||||||
<input type="checkbox" checked={addApiSelected.has(api.apiId)} readOnly className="rounded" />
|
<input type="checkbox" checked={addApiSelected.has(api.apiId)} readOnly className="rounded" />
|
||||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${METHOD_BADGE_STYLE[api.apiMethod] || 'bg-gray-700 text-gray-300'}`}>{api.apiMethod}</span>
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${METHOD_BADGE_STYLE[api.apiMethod] || 'bg-gray-700 text-gray-300'}`}>{api.apiMethod}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-mono text-xs text-gray-600 dark:text-gray-400 truncate">{api.apiPath}</div>
|
<div className="text-xs text-gray-600 dark:text-gray-400 truncate">{api.apiPath}</div>
|
||||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">{api.serviceName} · {api.apiName}</div>
|
<div className="text-[11px] text-gray-400 dark:text-gray-500">{api.serviceName} · {api.apiName}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1309,22 +1353,22 @@ const KeyAdminPage = () => {
|
|||||||
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">관리자 수정 내역</span>
|
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">관리자 수정 내역</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-amber-200 dark:border-amber-700/30 rounded-lg overflow-hidden mb-3">
|
<div className="border border-amber-200 dark:border-amber-700/30 rounded-lg overflow-hidden mb-3">
|
||||||
<table className="w-full">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-amber-50/50 dark:bg-amber-900/10">
|
<tr className="h-8 bg-amber-50/50 dark:bg-amber-900/10">
|
||||||
<th className="text-[11px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 px-3 py-2 text-left">항목</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400">항목</th>
|
||||||
<th className="text-[11px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 px-3 py-2 text-left">신청 원본</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400">신청 원본</th>
|
||||||
<th className="text-[11px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 px-3 py-2 text-center w-8"></th>
|
<th className="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400 w-8"></th>
|
||||||
<th className="text-[11px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 px-3 py-2 text-left">승인 내용</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400">승인 내용</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{apiChanged && (
|
{apiChanged && (
|
||||||
<tr className="bg-amber-50/30 dark:bg-amber-900/5">
|
<tr className="h-7 bg-amber-50/30 dark:bg-amber-900/5">
|
||||||
<td className="px-3 py-2 text-sm text-gray-700 dark:text-gray-300">API 권한</td>
|
<td className="px-3 py-1 text-gray-700 dark:text-gray-300">API 권한</td>
|
||||||
<td className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">{originalApiIds.size}개 API</td>
|
<td className="px-3 py-1 text-gray-500 dark:text-gray-400">{originalApiIds.size}개 API</td>
|
||||||
<td className="text-center text-gray-400 w-8">→</td>
|
<td className="text-center text-gray-400 w-8">→</td>
|
||||||
<td className="px-3 py-2 text-sm text-gray-900 dark:text-gray-100 font-medium">
|
<td className="px-3 py-1 text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{adjustedApiIds.size}개 API
|
{adjustedApiIds.size}개 API
|
||||||
{changes.added > 0 && <span className="ml-1.5 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-[10px] font-semibold px-1.5 rounded">+{changes.added}</span>}
|
{changes.added > 0 && <span className="ml-1.5 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-[10px] font-semibold px-1.5 rounded">+{changes.added}</span>}
|
||||||
{changes.removed > 0 && <span className="ml-1 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-[10px] font-semibold px-1.5 rounded">-{changes.removed}</span>}
|
{changes.removed > 0 && <span className="ml-1 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-[10px] font-semibold px-1.5 rounded">-{changes.removed}</span>}
|
||||||
@ -1332,13 +1376,13 @@ const KeyAdminPage = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{dateChanged && (
|
{dateChanged && (
|
||||||
<tr className="bg-amber-50/30 dark:bg-amber-900/5">
|
<tr className="h-7 bg-amber-50/30 dark:bg-amber-900/5">
|
||||||
<td className="px-3 py-2 text-sm text-gray-700 dark:text-gray-300">사용 기간</td>
|
<td className="px-3 py-1 text-gray-700 dark:text-gray-300">사용 기간</td>
|
||||||
<td className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 line-through">
|
<td className="px-3 py-1 text-gray-500 dark:text-gray-400 line-through">
|
||||||
{selectedRequest.usageFromDate?.split('T')[0] || '-'} ~ {selectedRequest.usageToDate?.split('T')[0] || '-'}
|
{selectedRequest.usageFromDate?.split('T')[0] || '-'} ~ {selectedRequest.usageToDate?.split('T')[0] || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center text-gray-400 w-8">→</td>
|
<td className="text-center text-gray-400 w-8">→</td>
|
||||||
<td className="px-3 py-2 text-sm text-gray-900 dark:text-gray-100 font-medium">
|
<td className="px-3 py-1 text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{adjustedFromDate || '-'} ~ {adjustedToDate || '-'}
|
{adjustedFromDate || '-'} ~ {adjustedToDate || '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -1467,7 +1511,7 @@ const KeyAdminPage = () => {
|
|||||||
{/* Status Banner */}
|
{/* Status Banner */}
|
||||||
<div className="px-6 pt-5">
|
<div className="px-6 pt-5">
|
||||||
<div className={`bg-gray-50 dark:bg-gray-800/60 border ${statusConf.borderClass} rounded-lg px-4 py-3 flex items-center justify-between`}>
|
<div className={`bg-gray-50 dark:bg-gray-800/60 border ${statusConf.borderClass} rounded-lg px-4 py-3 flex items-center justify-between`}>
|
||||||
<span className="font-mono font-bold text-sm text-gray-900 dark:text-gray-100">{selectedKeyDetail.keyName}</span>
|
<span className="font-bold text-sm text-gray-900 dark:text-gray-100">{selectedKeyDetail.keyName}</span>
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${statusConf.bgClass} ${statusConf.borderClass} ${statusConf.textClass}`}>
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${statusConf.bgClass} ${statusConf.borderClass} ${statusConf.textClass}`}>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${statusConf.dotClass}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${statusConf.dotClass}`} />
|
||||||
{statusConf.label}
|
{statusConf.label}
|
||||||
@ -1499,7 +1543,7 @@ const KeyAdminPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-500">PREFIX</div>
|
<div className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-500">PREFIX</div>
|
||||||
<div className="text-sm font-medium font-mono text-gray-900 dark:text-gray-100">{selectedKeyDetail.apiKeyPrefix}</div>
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedKeyDetail.apiKeyPrefix}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* EXPIRES AT */}
|
{/* EXPIRES AT */}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { getCatalog } from '../../services/apiHubService';
|
|||||||
import { createKeyRequest } from '../../services/apiKeyService';
|
import { createKeyRequest } from '../../services/apiKeyService';
|
||||||
import type { ServiceCatalog } from '../../types/apihub';
|
import type { ServiceCatalog } from '../../types/apihub';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
|
||||||
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
|
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
@ -24,6 +25,8 @@ interface FlatDomainGroup {
|
|||||||
apis: FlatApi[];
|
apis: FlatApi[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRESET_MONTHS = [3, 6, 9] as const;
|
||||||
|
|
||||||
const KeyRequestPage = () => {
|
const KeyRequestPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@ -40,6 +43,7 @@ const KeyRequestPage = () => {
|
|||||||
const [dailyRequestEstimate, setDailyRequestEstimate] = useState('');
|
const [dailyRequestEstimate, setDailyRequestEstimate] = useState('');
|
||||||
const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
|
const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
|
||||||
const [isPermanent, setIsPermanent] = useState(false);
|
const [isPermanent, setIsPermanent] = useState(false);
|
||||||
|
const [selectedPresetMonths, setSelectedPresetMonths] = useState<number | null>(null);
|
||||||
const [usageFromDate, setUsageFromDate] = useState('');
|
const [usageFromDate, setUsageFromDate] = useState('');
|
||||||
const [usageToDate, setUsageToDate] = useState('');
|
const [usageToDate, setUsageToDate] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@ -208,9 +212,6 @@ const KeyRequestPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearSelection = () => {
|
|
||||||
setSelectedApiIds(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePresetPeriod = (months: number) => {
|
const handlePresetPeriod = (months: number) => {
|
||||||
const from = new Date();
|
const from = new Date();
|
||||||
@ -220,12 +221,14 @@ const KeyRequestPage = () => {
|
|||||||
setUsageToDate(to.toISOString().split('T')[0]);
|
setUsageToDate(to.toISOString().split('T')[0]);
|
||||||
setUsagePeriodMode('preset');
|
setUsagePeriodMode('preset');
|
||||||
setIsPermanent(false);
|
setIsPermanent(false);
|
||||||
|
setSelectedPresetMonths(months);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePermanent = () => {
|
const handlePermanent = () => {
|
||||||
setIsPermanent(true);
|
setIsPermanent(true);
|
||||||
setUsageFromDate('');
|
setUsageFromDate('');
|
||||||
setUsageToDate('');
|
setUsageToDate('');
|
||||||
|
setSelectedPresetMonths(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -265,6 +268,26 @@ const KeyRequestPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const periodSummary = isPermanent
|
||||||
|
? '영구'
|
||||||
|
: selectedPresetMonths
|
||||||
|
? `${selectedPresetMonths}개월`
|
||||||
|
: usageFromDate && usageToDate
|
||||||
|
? `${usageFromDate} ~ ${usageToDate}`
|
||||||
|
: '미설정';
|
||||||
|
|
||||||
|
const dailyRequestLabel = (() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'100': '100 이하',
|
||||||
|
'500': '100~500',
|
||||||
|
'1000': '500~1,000',
|
||||||
|
'5000': '1,000~5,000',
|
||||||
|
'10000': '5,000~10,000',
|
||||||
|
'50000': '10,000 이상',
|
||||||
|
};
|
||||||
|
return dailyRequestEstimate ? (map[dailyRequestEstimate] ?? dailyRequestEstimate) : '미설정';
|
||||||
|
})();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
@ -291,17 +314,40 @@ const KeyRequestPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<form onSubmit={handleSubmit} className="max-w-7xl mx-auto flex flex-col h-full">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">API Key 신청</h1>
|
{/* 제목 행 */}
|
||||||
|
<div className="mb-3 shrink-0">
|
||||||
{error && (
|
<div className="flex items-center gap-3">
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
)}
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||||
<form onSubmit={handleSubmit}>
|
</svg>
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 space-y-4">
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">API Key 신청</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">API Key 발급을 위한 신청 정보를 입력해주세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-2 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<div className="flex flex-row gap-4 flex-1 min-h-0">
|
||||||
|
{/* 좌측: 기본 정보 카드 (40%) */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 w-2/5 shrink-0 overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-1.5">
|
||||||
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||||
|
</svg>
|
||||||
|
기본 정보
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Key Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Key Name <span className="text-red-500">*</span>
|
Key Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -310,97 +356,132 @@ const KeyRequestPage = () => {
|
|||||||
onChange={(e) => setKeyName(e.target.value)}
|
onChange={(e) => setKeyName(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="API Key 이름을 입력하세요"
|
placeholder="API Key 이름을 입력하세요"
|
||||||
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-md px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 사용 목적 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">사용 목적</label>
|
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">사용 목적</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={purpose}
|
value={purpose}
|
||||||
onChange={(e) => setPurpose(e.target.value)}
|
onChange={(e) => setPurpose(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="사용 목적을 입력하세요"
|
placeholder="사용 목적을 입력하세요"
|
||||||
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-md px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 사용 기간 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
사용 기간 <span className="text-red-500">*</span>
|
사용 기간 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* 세그먼트 컨트롤 */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<button type="button" onClick={() => handlePresetPeriod(3)}
|
<div className={`flex bg-[var(--color-bg-base)] rounded-md p-0.5 ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40' : ''}`}>
|
||||||
|
{PRESET_MONTHS.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetPeriod(m)}
|
||||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
disabled={isPermanent || usagePeriodMode === 'custom'}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-bg-base)]'}`}>
|
className={`px-2.5 py-1 text-xs rounded transition-colors ${
|
||||||
3개월
|
!isPermanent && usagePeriodMode === 'preset' && selectedPresetMonths === m
|
||||||
|
? 'bg-[var(--color-primary)] text-white'
|
||||||
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}개월
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handlePresetPeriod(6)}
|
))}
|
||||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
</div>
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-bg-base)]'}`}>
|
|
||||||
6개월
|
<span className="text-[var(--color-text-tertiary)]">|</span>
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => handlePresetPeriod(9)}
|
<Button
|
||||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
type="button"
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-bg-base)]'}`}>
|
size="sm"
|
||||||
9개월
|
onClick={handlePermanent}
|
||||||
</button>
|
variant={isPermanent ? 'primary' : 'outline'}
|
||||||
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
>
|
||||||
<Button type="button" size="sm" onClick={handlePermanent}
|
|
||||||
variant={isPermanent ? 'primary' : 'outline'}>
|
|
||||||
영구
|
영구
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-[var(--color-text-primary)] cursor-pointer select-none">
|
<span className="text-[var(--color-text-tertiary)]">|</span>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--color-text-primary)] cursor-pointer select-none">
|
||||||
직접 선택
|
직접 선택
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
|
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
|
||||||
setIsPermanent(false);
|
setIsPermanent(false);
|
||||||
|
setSelectedPresetMonths(null);
|
||||||
}}
|
}}
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' && !isPermanent ? 'bg-[var(--color-primary)] dark:bg-[var(--color-primary-600)]' : 'bg-[var(--color-border-strong)]'}`}>
|
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' && !isPermanent ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-border-strong)]'}`}
|
||||||
|
>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${usagePeriodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${usagePeriodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 날짜 입력 */}
|
||||||
{isPermanent ? (
|
{isPermanent ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg">
|
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg">
|
||||||
<span className="text-indigo-700 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
<span className="text-indigo-700 text-xs font-medium">영구 사용 (만료일 없음)</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="date" value={usageFromDate}
|
<input
|
||||||
|
type="date"
|
||||||
|
value={usageFromDate}
|
||||||
onChange={(e) => setUsageFromDate(e.target.value)}
|
onChange={(e) => setUsageFromDate(e.target.value)}
|
||||||
readOnly={usagePeriodMode !== 'custom'}
|
readOnly={usagePeriodMode !== 'custom'}
|
||||||
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`} />
|
className={`border border-[var(--color-border-strong)] rounded-md px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`}
|
||||||
|
/>
|
||||||
<span className="text-[var(--color-text-secondary)]">~</span>
|
<span className="text-[var(--color-text-secondary)]">~</span>
|
||||||
<input type="date" value={usageToDate}
|
<input
|
||||||
|
type="date"
|
||||||
|
value={usageToDate}
|
||||||
onChange={(e) => setUsageToDate(e.target.value)}
|
onChange={(e) => setUsageToDate(e.target.value)}
|
||||||
readOnly={usagePeriodMode !== 'custom'}
|
readOnly={usagePeriodMode !== 'custom'}
|
||||||
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`} />
|
className={`border border-[var(--color-border-strong)] rounded-md px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 서비스 정보 */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* 서비스 IP */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
서비스 IP <span className="text-red-500">*</span>
|
서비스 IP <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" value={serviceIp}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serviceIp}
|
||||||
onChange={(e) => setServiceIp(e.target.value)}
|
onChange={(e) => setServiceIp(e.target.value)}
|
||||||
required placeholder="예: 192.168.1.100"
|
required
|
||||||
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none" />
|
placeholder="예: 192.168.1.100"
|
||||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-md px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">프록시 서버에 요청하는 서비스 IP</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* 서비스 용도 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
서비스 용도 <span className="text-red-500">*</span>
|
서비스 용도 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select value={servicePurpose}
|
<select
|
||||||
|
value={servicePurpose}
|
||||||
onChange={(e) => setServicePurpose(e.target.value)}
|
onChange={(e) => setServicePurpose(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none">
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-md px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option value="로컬 환경">로컬 환경</option>
|
<option value="로컬 환경">로컬 환경</option>
|
||||||
<option value="개발 서버">개발 서버</option>
|
<option value="개발 서버">개발 서버</option>
|
||||||
@ -408,14 +489,18 @@ const KeyRequestPage = () => {
|
|||||||
<option value="운영 서버">운영 서버</option>
|
<option value="운영 서버">운영 서버</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 하루 예상 요청량 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
하루 예상 요청량 <span className="text-red-500">*</span>
|
하루 예상 요청량 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select value={dailyRequestEstimate}
|
<select
|
||||||
|
value={dailyRequestEstimate}
|
||||||
onChange={(e) => setDailyRequestEstimate(e.target.value)}
|
onChange={(e) => setDailyRequestEstimate(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none">
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-md px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option value="100">100 이하</option>
|
<option value="100">100 이하</option>
|
||||||
<option value="500">100~500</option>
|
<option value="500">100~500</option>
|
||||||
@ -427,49 +512,44 @@ const KeyRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* API Selection Section */}
|
{/* 우측: API 선택 카드 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-xl border border-[var(--color-border)] shadow mb-6 overflow-hidden">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex-1 flex flex-col">
|
||||||
{/* Header bar */}
|
{/* 헤더 행 */}
|
||||||
<div className="flex items-center justify-between gap-4 px-5 py-3.5 border-b border-[var(--color-border)] bg-[var(--color-bg-surface)]">
|
<div className="shrink-0 flex items-center gap-3 mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-1.5">
|
||||||
<label className="flex items-center gap-2 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
API 선택
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
<IndeterminateCheckbox
|
<IndeterminateCheckbox
|
||||||
checked={allApisSelected}
|
checked={allApisSelected}
|
||||||
indeterminate={!allApisSelected && someApisSelected}
|
indeterminate={!allApisSelected && someApisSelected}
|
||||||
onChange={handleToggleAll}
|
onChange={handleToggleAll}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">전체</span>
|
||||||
</label>
|
</label>
|
||||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">API 선택</h2>
|
|
||||||
{selectedApiIds.size > 0 && (
|
<Badge variant="info" size="sm">
|
||||||
<span className="text-xs font-medium bg-blue-100 text-[var(--color-primary)] px-2.5 py-0.5 rounded-full">
|
{selectedApiIds.size} / {allApis.length}
|
||||||
{selectedApiIds.size}개 선택
|
</Badge>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="API 검색..."
|
placeholder="API 검색..."
|
||||||
className="bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-lg px-3 py-1.5 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none w-56"
|
className="ml-auto bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-md px-2.5 py-1 text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none w-44"
|
||||||
/>
|
/>
|
||||||
{selectedApiIds.size > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearSelection}
|
|
||||||
className="text-xs text-[var(--color-text-secondary)] hover:text-red-500 px-2 py-1.5 rounded-lg hover:bg-[var(--color-bg-base)] transition-colors"
|
|
||||||
>
|
|
||||||
선택 해제
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Domain cards */}
|
{/* API 카테고리 목록 */}
|
||||||
<div className="p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto space-y-2">
|
||||||
{filteredDomainGroups.map((domainGroup) => {
|
{filteredDomainGroups.map((domainGroup) => {
|
||||||
const isDomainExpanded = expandedDomains.has(domainGroup.domain);
|
const isDomainExpanded = expandedDomains.has(domainGroup.domain);
|
||||||
const domainApis = domainGroup.apis;
|
const domainApis = domainGroup.apis;
|
||||||
@ -481,15 +561,15 @@ const KeyRequestPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={domainGroup.domain}
|
key={domainGroup.domain}
|
||||||
className={`rounded-xl border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300' : 'border-[var(--color-border)]'}`}
|
className={`rounded-lg border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300' : 'border-[var(--color-border)]'}`}
|
||||||
>
|
>
|
||||||
{/* Domain header */}
|
{/* 카테고리 헤더 */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between px-5 py-3.5 cursor-pointer ${hasSelections ? 'bg-blue-50/50' : 'bg-[var(--color-bg-base)]'}`}
|
className={`flex items-center justify-between px-3 py-2 cursor-pointer ${hasSelections ? 'bg-blue-50/50' : 'bg-[var(--color-bg-base)]'}`}
|
||||||
onClick={() => handleToggleDomain(domainGroup.domain)}
|
onClick={() => handleToggleDomain(domainGroup.domain)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2.5">
|
||||||
<svg className={`h-4 w-4 text-[var(--color-text-tertiary)] transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className={`h-4 w-4 text-[var(--color-text-tertiary)] transition-transform shrink-0 ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
@ -500,19 +580,21 @@ const KeyRequestPage = () => {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="font-semibold text-[var(--color-text-primary)]">{/^[a-zA-Z\s\-_]+$/.test(domainGroup.domain) ? domainGroup.domain.toUpperCase() : domainGroup.domain}</span>
|
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{/^[a-zA-Z\s\-_]+$/.test(domainGroup.domain) ? domainGroup.domain.toUpperCase() : domainGroup.domain}
|
||||||
|
</span>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<span className="text-xs font-medium bg-blue-100 text-[var(--color-primary)] px-2 py-0.5 rounded-full">
|
<span className="text-[10px] font-medium bg-[var(--color-accent-subtle)] text-[var(--color-accent-600)] h-4 min-w-4 inline-flex items-center justify-center px-1 rounded-full">
|
||||||
{selectedCount} selected
|
{selectedCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium bg-[var(--color-bg-base)] text-[var(--color-text-secondary)] px-2 py-0.5 rounded-full">
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
{domainApis.length}개 API
|
{domainApis.length}개
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API list */}
|
{/* API 목록 */}
|
||||||
{isDomainExpanded && (
|
{isDomainExpanded && (
|
||||||
<div className="divide-y divide-[var(--color-border)] bg-[var(--color-bg-surface)]">
|
<div className="divide-y divide-[var(--color-border)] bg-[var(--color-bg-surface)]">
|
||||||
{domainApis.map((api) => {
|
{domainApis.map((api) => {
|
||||||
@ -520,21 +602,17 @@ const KeyRequestPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={api.apiId}
|
key={api.apiId}
|
||||||
className={`flex items-start gap-3 px-5 py-3 cursor-pointer hover:bg-[var(--color-bg-base)] ${isSelected ? 'bg-blue-50/50' : ''}`}
|
className={`flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-[var(--color-bg-base)] ${isSelected ? 'bg-blue-50/50' : ''}`}
|
||||||
onClick={() => handleToggleApi(api.apiId)}
|
onClick={() => handleToggleApi(api.apiId)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center pt-0.5">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => handleToggleApi(api.apiId)}
|
onChange={() => handleToggleApi(api.apiId)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="rounded"
|
className="rounded shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-[var(--color-text-primary)] truncate">{api.apiName}</p>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{api.apiName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -544,40 +622,59 @@ const KeyRequestPage = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{filteredDomainGroups.length === 0 && (
|
{filteredDomainGroups.length === 0 && (
|
||||||
<div className="px-6 py-8 text-center text-[var(--color-text-tertiary)]">
|
<div className="px-6 py-8 text-center text-[var(--color-text-tertiary)] text-sm">
|
||||||
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bottom sticky summary bar */}
|
{/* 하단 요약 바 */}
|
||||||
{selectedApiIds.size > 0 && (
|
<div className="mt-3 shrink-0 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl px-4 py-2 flex items-center justify-between">
|
||||||
<div className="sticky bottom-4 z-10 mx-auto mb-4">
|
{/* 좌측 요약 */}
|
||||||
<div className="bg-[var(--color-primary)] dark:bg-[var(--color-primary-600)] text-white rounded-xl px-5 py-3 shadow-lg flex items-center justify-between">
|
<div className="flex items-center gap-4 text-sm text-[var(--color-text-secondary)]">
|
||||||
<span className="text-sm font-medium">{selectedApiIds.size}개 API가 선택되었습니다</span>
|
<span>
|
||||||
<button
|
<span className="font-medium text-[var(--color-text-primary)]">사용 기간:</span> {periodSummary}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-border-strong)]">|</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-[var(--color-text-primary)]">선택 API:</span> {selectedApiIds.size}개
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-border-strong)]">|</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-[var(--color-text-primary)]">예상 요청량:</span> {dailyRequestLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 버튼 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearSelection}
|
variant="outline"
|
||||||
className="text-sm text-blue-200 hover:text-white transition-colors"
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
선택 해제
|
취소
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? '신청 중...' : '신청하기'}
|
{isSubmitting ? '신청 중...' : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||||
|
</svg>
|
||||||
|
신청하기
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -76,10 +76,20 @@ const MyKeysPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">My API Keys</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="3" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">My API Keys</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">발급된 API Key를 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => navigate('/apikeys/request')}>
|
<Button onClick={() => navigate('/apikeys/request')} size="sm">
|
||||||
API Key 신청
|
API Key 신청
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -89,38 +99,38 @@ const MyKeysPage = () => {
|
|||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Key Name</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Key Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Prefix</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Prefix</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Status</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Status</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Expires At</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Expires At</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Last Used At</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Last Used At</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Created At</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Created At</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)]">
|
<tr key={key.apiKeyId} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{key.keyName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{key.keyName}</td>
|
||||||
<td className="px-4 py-3 font-mono text-[var(--color-text-secondary)]">{key.apiKeyPrefix}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{key.apiKeyPrefix}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge variant={STATUS_VARIANT[key.status] ?? 'default'}>
|
<Badge variant={STATUS_VARIANT[key.status] ?? 'default'} size="sm">
|
||||||
{key.status}
|
{key.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.expiresAt)}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(key.expiresAt)}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.lastUsedAt)}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(key.lastUsedAt)}</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.createdAt)}</td>
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(key.createdAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
{key.status === 'ACTIVE' && (
|
{key.status === 'ACTIVE' && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRevoke(key)}
|
onClick={() => handleRevoke(key)}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="xs"
|
||||||
>
|
>
|
||||||
폐기
|
폐기
|
||||||
</Button>
|
</Button>
|
||||||
@ -130,7 +140,7 @@ const MyKeysPage = () => {
|
|||||||
))}
|
))}
|
||||||
{keys.length === 0 && (
|
{keys.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={7} className="px-3 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||||
등록된 API Key가 없습니다.
|
등록된 API Key가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -102,7 +102,7 @@ const RequestLogDetailPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
@ -175,7 +175,7 @@ const RequestLogDetailPage = () => {
|
|||||||
|
|
||||||
{/* 요청 정보 */}
|
{/* 요청 정보 */}
|
||||||
{(formattedHeaders || formattedParams) && (
|
{(formattedHeaders || formattedParams) && (
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">요청 정보</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">요청 정보</h2>
|
||||||
{formattedHeaders && (
|
{formattedHeaders && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@ -198,7 +198,7 @@ const RequestLogDetailPage = () => {
|
|||||||
|
|
||||||
{/* 에러 정보 */}
|
{/* 에러 정보 */}
|
||||||
{log.errorMessage && (
|
{log.errorMessage && (
|
||||||
<div className="bg-red-50 rounded-lg shadow p-6">
|
<div className="bg-red-50 border border-[var(--color-border)] rounded-xl p-6">
|
||||||
<h2 className="text-lg font-semibold text-red-900 mb-2">에러 정보</h2>
|
<h2 className="text-lg font-semibold text-red-900 mb-2">에러 정보</h2>
|
||||||
<p className="text-sm text-red-800">{log.errorMessage}</p>
|
<p className="text-sm text-red-800">{log.errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,13 +7,7 @@ import { getServices } from '../../services/serviceService';
|
|||||||
import Badge from '../../components/ui/Badge';
|
import Badge from '../../components/ui/Badge';
|
||||||
import type { BadgeVariant } from '../../components/ui/Badge';
|
import type { BadgeVariant } from '../../components/ui/Badge';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
|
import { SERVICE_BADGE_VARIANTS } from '../../constants/chart';
|
||||||
const METHOD_CLASS: Record<string, string> = {
|
|
||||||
GET: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
|
|
||||||
POST: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
|
|
||||||
PUT: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-400',
|
|
||||||
DELETE: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
SUCCESS: 'success',
|
SUCCESS: 'success',
|
||||||
@ -25,6 +19,13 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
|||||||
FAILED: 'default',
|
FAILED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const METHOD_VARIANT: Record<string, BadgeVariant> = {
|
||||||
|
GET: 'success',
|
||||||
|
POST: 'info',
|
||||||
|
PUT: 'warning',
|
||||||
|
DELETE: 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
|
const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
|
||||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
@ -47,17 +48,34 @@ const formatDateTime = (dateStr: string): string => {
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusCodeVariant = (code: number): BadgeVariant => {
|
||||||
|
if (code >= 500) return 'danger';
|
||||||
|
if (code >= 400) return 'warning';
|
||||||
|
if (code >= 200) return 'success';
|
||||||
|
return 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponseTimeClass = (ms: number): string => {
|
||||||
|
if (ms > 1000) return 'text-[var(--color-danger)]';
|
||||||
|
if (ms > 500) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-text-primary)]';
|
||||||
|
};
|
||||||
|
|
||||||
const RequestLogsPage = () => {
|
const RequestLogsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [startDate, setStartDate] = useState(getTodayString());
|
const [startDate, setStartDate] = useState(() => {
|
||||||
|
const d = new Date(); d.setDate(d.getDate() - 6); return formatDate(d);
|
||||||
|
});
|
||||||
const [endDate, setEndDate] = useState(getTodayString());
|
const [endDate, setEndDate] = useState(getTodayString());
|
||||||
const [datePreset, setDatePreset] = useState('오늘');
|
const [datePreset, setDatePreset] = useState('최근 7일');
|
||||||
const [serviceId, setServiceId] = useState('');
|
const [serviceId, setServiceId] = useState('');
|
||||||
const [requestStatus, setRequestStatus] = useState('');
|
const [requestStatus, setRequestStatus] = useState('');
|
||||||
const [requestMethod, setRequestMethod] = useState('');
|
const [requestMethod, setRequestMethod] = useState('');
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
|
||||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||||
|
const [serviceBadgeMap, setServiceBadgeMap] = useState<Record<string, BadgeVariant>>({}); // key: serviceName
|
||||||
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
|
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -68,6 +86,12 @@ const RequestLogsPage = () => {
|
|||||||
const res = await getServices();
|
const res = await getServices();
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setServices(res.data);
|
setServices(res.data);
|
||||||
|
const sorted = [...res.data].sort((a, b) => a.serviceName.localeCompare(b.serviceName));
|
||||||
|
const map: Record<string, BadgeVariant> = {};
|
||||||
|
sorted.forEach((s, idx) => {
|
||||||
|
map[s.serviceName] = SERVICE_BADGE_VARIANTS[idx % SERVICE_BADGE_VARIANTS.length];
|
||||||
|
});
|
||||||
|
setServiceBadgeMap(map);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 서비스 목록 로딩 실패는 무시
|
// 서비스 목록 로딩 실패는 무시
|
||||||
@ -86,6 +110,7 @@ const RequestLogsPage = () => {
|
|||||||
serviceId: serviceId ? Number(serviceId) : undefined,
|
serviceId: serviceId ? Number(serviceId) : undefined,
|
||||||
requestStatus: requestStatus || undefined,
|
requestStatus: requestStatus || undefined,
|
||||||
requestMethod: requestMethod || undefined,
|
requestMethod: requestMethod || undefined,
|
||||||
|
keyword: searchKeyword || undefined,
|
||||||
page,
|
page,
|
||||||
size: DEFAULT_PAGE_SIZE,
|
size: DEFAULT_PAGE_SIZE,
|
||||||
};
|
};
|
||||||
@ -103,12 +128,14 @@ const RequestLogsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setStartDate(getTodayString());
|
const d = new Date(); d.setDate(d.getDate() - 6);
|
||||||
|
setStartDate(formatDate(d));
|
||||||
setEndDate(getTodayString());
|
setEndDate(getTodayString());
|
||||||
setDatePreset('오늘');
|
setDatePreset('최근 7일');
|
||||||
setServiceId('');
|
setServiceId('');
|
||||||
setRequestStatus('');
|
setRequestStatus('');
|
||||||
setRequestMethod('');
|
setRequestMethod('');
|
||||||
|
setSearchKeyword('');
|
||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -120,13 +147,14 @@ const RequestLogsPage = () => {
|
|||||||
|
|
||||||
const handleResetAndSearch = () => {
|
const handleResetAndSearch = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
// 초기화 후 기본값으로 검색 (setState는 비동기이므로 직접 호출)
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
const today = getTodayString();
|
const today = getTodayString();
|
||||||
|
const d = new Date(); d.setDate(d.getDate() - 6);
|
||||||
|
const weekAgo = formatDate(d);
|
||||||
const params: Record<string, string | number | undefined> = {
|
const params: Record<string, string | number | undefined> = {
|
||||||
startDate: today,
|
startDate: weekAgo,
|
||||||
endDate: today,
|
endDate: today,
|
||||||
page: 0,
|
page: 0,
|
||||||
size: DEFAULT_PAGE_SIZE,
|
size: DEFAULT_PAGE_SIZE,
|
||||||
@ -163,126 +191,312 @@ const RequestLogsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DATE_PRESETS = [
|
||||||
|
{
|
||||||
|
label: '오늘',
|
||||||
|
fn: () => {
|
||||||
|
const t = getToday();
|
||||||
|
setStartDate(t);
|
||||||
|
setEndDate(t);
|
||||||
|
setDatePreset('오늘');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '어제',
|
||||||
|
fn: () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
const y = formatDate(d);
|
||||||
|
setStartDate(y);
|
||||||
|
setEndDate(y);
|
||||||
|
setDatePreset('어제');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '최근 7일',
|
||||||
|
fn: () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 6);
|
||||||
|
setStartDate(formatDate(d));
|
||||||
|
setEndDate(getToday());
|
||||||
|
setDatePreset('최근 7일');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '이번 달',
|
||||||
|
fn: () => {
|
||||||
|
const d = new Date();
|
||||||
|
setStartDate(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`);
|
||||||
|
setEndDate(getToday());
|
||||||
|
setDatePreset('이번 달');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '지난 달',
|
||||||
|
fn: () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMonth(d.getMonth() - 1);
|
||||||
|
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
const e = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||||
|
setStartDate(s);
|
||||||
|
setEndDate(formatDate(e));
|
||||||
|
setDatePreset('지난 달');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeFilters: { label: string; onRemove: () => void }[] = [];
|
||||||
|
if (serviceId) {
|
||||||
|
const svc = services.find((s) => String(s.serviceId) === serviceId);
|
||||||
|
activeFilters.push({ label: `서비스: ${svc?.serviceName ?? serviceId}`, onRemove: () => setServiceId('') });
|
||||||
|
}
|
||||||
|
if (requestStatus) {
|
||||||
|
activeFilters.push({ label: `상태: ${requestStatus}`, onRemove: () => setRequestStatus('') });
|
||||||
|
}
|
||||||
|
if (requestMethod) {
|
||||||
|
activeFilters.push({ label: `Method: ${requestMethod}`, onRemove: () => setRequestMethod('') });
|
||||||
|
}
|
||||||
|
if (searchKeyword) {
|
||||||
|
activeFilters.push({ label: `검색: ${searchKeyword}`, onRemove: () => setSearchKeyword('') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalElements = result?.totalElements ?? 0;
|
||||||
|
const totalPages = result?.totalPages ?? 1;
|
||||||
|
const start = totalElements === 0 ? 0 : currentPage * DEFAULT_PAGE_SIZE + 1;
|
||||||
|
const end = Math.min((currentPage + 1) * DEFAULT_PAGE_SIZE, totalElements);
|
||||||
|
|
||||||
|
const selectClassName =
|
||||||
|
'bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-md px-2.5 py-1.5 text-xs text-[var(--color-text-primary)] focus:outline-none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Request Logs</h1>
|
{/* 1행: 제목 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">요청 로그</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">모든 API 요청/응답 로그를 조회합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Form */}
|
{/* 2행: 필터 카드 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 mb-4 space-y-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
{/* 1줄: 기간 프리셋 + 날짜 입력 */}
|
||||||
<div className="md:col-span-3">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">기간</label>
|
{/* 세그먼트 컨트롤 */}
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
<div className="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
||||||
{([
|
{DATE_PRESETS.map((preset) => (
|
||||||
{ label: '오늘', fn: () => { const t = getToday(); setStartDate(t); setEndDate(t); setDatePreset('오늘'); } },
|
|
||||||
{ label: '어제', fn: () => { const d = new Date(); d.setDate(d.getDate() - 1); const y = formatDate(d); setStartDate(y); setEndDate(y); setDatePreset('어제'); } },
|
|
||||||
{ label: '최근 7일', fn: () => { const d = new Date(); d.setDate(d.getDate() - 6); setStartDate(formatDate(d)); setEndDate(getToday()); setDatePreset('최근 7일'); } },
|
|
||||||
{ label: '이번 달', fn: () => { const d = new Date(); setStartDate(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`); setEndDate(getToday()); setDatePreset('이번 달'); } },
|
|
||||||
{ label: '지난 달', fn: () => { const d = new Date(); d.setMonth(d.getMonth() - 1); const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; const e = new Date(d.getFullYear(), d.getMonth() + 1, 0); setStartDate(s); setEndDate(formatDate(e)); setDatePreset('지난 달'); } },
|
|
||||||
{ label: '직접 선택', fn: () => { setDatePreset('직접 선택'); } },
|
|
||||||
]).map((btn) => (
|
|
||||||
<button
|
<button
|
||||||
key={btn.label}
|
key={preset.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={btn.fn}
|
onClick={preset.fn}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
datePreset === btn.label
|
datePreset === preset.label
|
||||||
? 'bg-blue-50 border-blue-300 text-[var(--color-primary)]'
|
? 'bg-[var(--color-primary)] text-white'
|
||||||
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-base)]'
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{btn.label}
|
{preset.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-px h-6 bg-[var(--color-border)]" />
|
||||||
|
|
||||||
|
{/* 날짜 입력 */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => { setStartDate(e.target.value); setDatePreset('직접 선택'); }}
|
onChange={(e) => {
|
||||||
className="flex-1 border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
setStartDate(e.target.value);
|
||||||
|
setDatePreset('');
|
||||||
|
}}
|
||||||
|
className={selectClassName}
|
||||||
/>
|
/>
|
||||||
<span className="text-[var(--color-text-secondary)]">~</span>
|
<span className="text-xs text-[var(--color-text-tertiary)]">~</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => { setEndDate(e.target.value); setDatePreset('직접 선택'); }}
|
onChange={(e) => {
|
||||||
className="flex-1 border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
setEndDate(e.target.value);
|
||||||
|
setDatePreset('');
|
||||||
|
}}
|
||||||
|
className={selectClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-3 flex-wrap">
|
{/* 2줄: 필터 셀렉트 + URL 검색 + 버튼 */}
|
||||||
<div>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">서비스</label>
|
{/* 필터 아이콘 */}
|
||||||
<select
|
<svg
|
||||||
value={serviceId}
|
className="w-3.5 h-3.5 text-[var(--color-text-tertiary)] flex-shrink-0"
|
||||||
onChange={(e) => setServiceId(e.target.value)}
|
fill="none"
|
||||||
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 서비스 */}
|
||||||
|
<select value={serviceId} onChange={(e) => setServiceId(e.target.value)} className={selectClassName}>
|
||||||
|
<option value="">서비스 전체</option>
|
||||||
{services.map((s) => (
|
{services.map((s) => (
|
||||||
<option key={s.serviceId} value={s.serviceId}>{s.serviceName}</option>
|
<option key={s.serviceId} value={s.serviceId}>
|
||||||
|
{s.serviceName}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div>
|
{/* 상태 */}
|
||||||
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">상태</label>
|
<select value={requestStatus} onChange={(e) => setRequestStatus(e.target.value)} className={selectClassName}>
|
||||||
<select
|
<option value="">상태 전체</option>
|
||||||
value={requestStatus}
|
|
||||||
onChange={(e) => setRequestStatus(e.target.value)}
|
|
||||||
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">전체</option>
|
|
||||||
{REQUEST_STATUSES.map((s) => (
|
{REQUEST_STATUSES.map((s) => (
|
||||||
<option key={s} value={s}>{s}</option>
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div>
|
{/* Method */}
|
||||||
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">Method</label>
|
<select value={requestMethod} onChange={(e) => setRequestMethod(e.target.value)} className={selectClassName}>
|
||||||
<select
|
<option value="">Method 전체</option>
|
||||||
value={requestMethod}
|
|
||||||
onChange={(e) => setRequestMethod(e.target.value)}
|
|
||||||
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">전체</option>
|
|
||||||
{HTTP_METHODS.map((m) => (
|
{HTTP_METHODS.map((m) => (
|
||||||
<option key={m} value={m}>{m}</option>
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* URL 검색 */}
|
||||||
|
<div className="flex-1 min-w-[200px] relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch(0)}
|
||||||
|
placeholder="URL 또는 IP로 검색..."
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-md text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2 ml-auto">
|
|
||||||
<Button onClick={() => handleSearch(0)} variant="primary">
|
{/* 버튼 */}
|
||||||
|
<Button onClick={() => handleSearch(0)} variant="primary" size="sm">
|
||||||
검색
|
검색
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleResetAndSearch} variant="secondary">
|
<Button onClick={handleResetAndSearch} variant="outline" size="sm">
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3줄: 활성 필터 칩 */}
|
||||||
|
{activeFilters.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap pt-2 border-t border-[var(--color-border)]">
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">적용된 필터:</span>
|
||||||
|
{activeFilters.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.label}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-subtle)] text-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={f.onRemove}
|
||||||
|
className="hover:opacity-70 transition-opacity"
|
||||||
|
aria-label="필터 해제"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 rounded-lg text-sm bg-[var(--color-danger)]/10 text-[var(--color-danger)]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results Table */}
|
{/* 3행: 로그 테이블 */}
|
||||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl overflow-hidden mb-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>
|
<div className="text-center py-10 text-[var(--color-text-secondary)] text-sm">로딩 중...</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">시간</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">서비스</th>
|
시간
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">URL</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Status Code</th>
|
서비스
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">응답시간(ms)</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">상태</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">IP</th>
|
Method
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
|
URL
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
|
응답코드
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
|
응답시간
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
|
응답결과
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||||
|
IP
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
@ -291,71 +505,125 @@ const RequestLogsPage = () => {
|
|||||||
<tr
|
<tr
|
||||||
key={log.logId}
|
key={log.logId}
|
||||||
onClick={() => handleRowClick(log.logId)}
|
onClick={() => handleRowClick(log.logId)}
|
||||||
className="cursor-pointer hover:bg-[var(--color-bg-base)]"
|
className="cursor-pointer hover:bg-[var(--color-bg-base)] transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-primary)]">
|
{/* 시간 */}
|
||||||
|
<td className="px-3 py-1 whitespace-nowrap text-xs text-[var(--color-text-secondary)]">
|
||||||
{formatDateTime(log.requestedAt)}
|
{formatDateTime(log.requestedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName || '-'}</td>
|
|
||||||
<td className="px-4 py-3">
|
{/* 서비스 — filled Badge (rounded-md, 고정 너비) */}
|
||||||
<Badge className={METHOD_CLASS[log.requestMethod]}>
|
<td className="px-3 py-1 whitespace-nowrap">
|
||||||
|
{log.serviceName ? (
|
||||||
|
<Badge variant={serviceBadgeMap[log.serviceName] ?? 'blue'} size="sm" className="rounded-md w-16 justify-center">
|
||||||
|
{log.serviceName}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Method — pill Badge */}
|
||||||
|
<td className="px-3 py-1 whitespace-nowrap">
|
||||||
|
<Badge variant={METHOD_VARIANT[log.requestMethod] ?? 'default'} size="sm">
|
||||||
{log.requestMethod}
|
{log.requestMethod}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[250px]" title={log.requestUrl}>
|
|
||||||
|
{/* URL */}
|
||||||
|
<td
|
||||||
|
className="px-3 py-1 text-xs text-[var(--color-text-secondary)] truncate max-w-[340px]"
|
||||||
|
title={log.requestUrl}
|
||||||
|
>
|
||||||
{log.requestUrl}
|
{log.requestUrl}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
|
||||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
{/* 응답코드 — pill Badge */}
|
||||||
|
<td className="px-3 py-1 whitespace-nowrap">
|
||||||
|
{log.responseStatus != null ? (
|
||||||
|
<Badge variant={getStatusCodeVariant(log.responseStatus)} size="sm">
|
||||||
|
{log.responseStatus}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
|
||||||
{log.responseTime != null ? log.responseTime : '-'}
|
{/* 응답시간 */}
|
||||||
|
<td className="px-3 py-1 whitespace-nowrap">
|
||||||
|
{log.responseTime != null ? (
|
||||||
|
<span className={`text-xs ${getResponseTimeClass(log.responseTime)}`}>
|
||||||
|
{log.responseTime}
|
||||||
|
<span className="text-[var(--color-text-tertiary)] ml-0.5">ms</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'}>
|
{/* 응답결과 — pill Badge */}
|
||||||
|
<td className="px-3 py-1 whitespace-nowrap">
|
||||||
|
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'} size="sm">
|
||||||
{log.requestStatus}
|
{log.requestStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-tertiary)]">{log.requestIp}</td>
|
|
||||||
|
{/* IP */}
|
||||||
|
<td className="px-3 py-1 whitespace-nowrap text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{log.requestIp}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
<td colSpan={8} className="px-3 py-10 text-center text-sm text-[var(--color-text-tertiary)]">
|
||||||
검색 결과가 없습니다
|
검색 결과가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* 4행: 페이지네이션 (테이블 카드 내부 하단) */}
|
||||||
{result && result.totalElements > 0 && (
|
{result && result.totalElements > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)]">
|
||||||
<span className="text-sm text-[var(--color-text-secondary)]">
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
총 {result.totalElements}건 / {result.page + 1} / {result.totalPages} 페이지
|
총{' '}
|
||||||
|
<span className="text-[var(--color-text-primary)] font-semibold">{totalElements}</span>건 중{' '}
|
||||||
|
<span className="text-[var(--color-text-primary)] font-semibold">
|
||||||
|
{start}-{end}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
건 표시
|
||||||
<Button
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
disabled={currentPage === 0}
|
disabled={currentPage === 0}
|
||||||
variant="outline"
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
이전
|
← 이전
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<span className="text-xs">
|
||||||
|
<span className="text-[var(--color-text-primary)] font-bold">{currentPage + 1}</span>
|
||||||
|
<span className="text-[var(--color-text-tertiary)]"> / </span>
|
||||||
|
<span className="text-[var(--color-text-secondary)]">{totalPages}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={!result || currentPage >= result.totalPages - 1}
|
disabled={!result || currentPage >= result.totalPages - 1}
|
||||||
variant="outline"
|
className="px-3 py-1.5 rounded-md border border-[var(--color-border)] text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)] disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
다음
|
다음 →
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -70,7 +70,7 @@ const ServiceStatusDetailPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6 mb-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-4 h-4 rounded-full ${STATUS_COLOR[detail.currentStatus] || 'bg-gray-400'}`} />
|
<div className={`w-4 h-4 rounded-full ${STATUS_COLOR[detail.currentStatus] || 'bg-gray-400'}`} />
|
||||||
@ -89,7 +89,7 @@ const ServiceStatusDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Uptime Summary */}
|
{/* Uptime Summary */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">90일 Uptime</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">90일 Uptime</h2>
|
||||||
<div className="text-4xl font-bold text-[var(--color-text-primary)] mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
<div className="text-4xl font-bold text-[var(--color-text-primary)] mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ const ServiceStatusDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Checks */}
|
{/* Recent Checks */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl mb-6">
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">최근 체크 이력</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">최근 체크 이력</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -63,7 +63,17 @@ const ServiceStatusPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Service Status</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<polyline points="22,12 18,12 15,21 9,3 6,12 2,12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">서비스 상태</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">서비스 헬스체크 및 가동 현황을 모니터링합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span className="text-sm text-[var(--color-text-secondary)]">마지막 갱신: {lastUpdated}</span>
|
<span className="text-sm text-[var(--color-text-secondary)]">마지막 갱신: {lastUpdated}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -80,7 +90,7 @@ const ServiceStatusPage = () => {
|
|||||||
{/* Service List */}
|
{/* Service List */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{services.map((svc) => (
|
{services.map((svc) => (
|
||||||
<div key={svc.serviceId} className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div key={svc.serviceId} className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
{/* Service Header */}
|
{/* Service Header */}
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@ -1,39 +1,46 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
Tooltip, Legend, ResponsiveContainer,
|
||||||
PieChart, Pie, Cell,
|
PieChart, Pie, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { ApiStatsResponse } from '../../types/statistics';
|
import type { ApiStatsResponse } from '../../types/statistics';
|
||||||
import { getApiStats } from '../../services/statisticsService';
|
import { getApiStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import PeriodFilter from '../../components/PeriodFilter';
|
||||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
import { CHART_COLORS_HEX, SERVICE_BADGE_VARIANTS } from '../../constants/chart';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import Badge from '../../components/ui/Badge';
|
import Badge, { type BadgeVariant } from '../../components/ui/Badge';
|
||||||
|
|
||||||
const SERVICE_TAG_CLASSES = [
|
const getRankBadgeClass = (rank: number) =>
|
||||||
'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
rank <= 3
|
||||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400',
|
? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
|
||||||
'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]';
|
||||||
'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
|
||||||
'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400',
|
|
||||||
'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/15 dark:text-cyan-400',
|
|
||||||
];
|
|
||||||
|
|
||||||
const METHOD_CLASS: Record<string, string> = {
|
const getSuccessRateClass = (rate: number) => {
|
||||||
GET: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
|
if (rate >= 90) return 'text-[var(--color-success)]';
|
||||||
POST: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
|
if (rate >= 70) return 'text-[var(--color-warning)]';
|
||||||
PUT: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400',
|
return 'text-[var(--color-danger)]';
|
||||||
DELETE: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-400',
|
|
||||||
PATCH: 'bg-purple-100 text-purple-800 dark:bg-purple-500/15 dark:text-purple-400',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
const getResponseTimeClass = (ms: number) => {
|
||||||
|
if (ms < 100) return 'text-[var(--color-success)]';
|
||||||
|
if (ms < 500) return 'text-[var(--color-text-primary)]';
|
||||||
|
if (ms < 1000) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-danger)]';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorRateClass = (rate: number) => {
|
||||||
|
if (rate >= 30) return 'text-[var(--color-danger)]';
|
||||||
|
if (rate >= 10) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-text-primary)]';
|
||||||
|
};
|
||||||
|
|
||||||
const ApiStatsPage = () => {
|
const ApiStatsPage = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const chartColors = CHART_COLORS_HEX[theme];
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
const [startDate, setStartDate] = useState(() => {
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
const d = new Date(); d.setDate(d.getDate() - 7); return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [data, setData] = useState<ApiStatsResponse | null>(null);
|
const [data, setData] = useState<ApiStatsResponse | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@ -53,30 +60,31 @@ const ApiStatsPage = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const handlePreset = (days: number) => {
|
// 서비스명 알파벳순 정렬 후 고정 색상 매핑 (대시보드와 동일 기준)
|
||||||
const today = getToday();
|
const serviceVariantMap = useMemo(() => {
|
||||||
if (days === 0) {
|
if (!data) return {} as Record<string, BadgeVariant>;
|
||||||
setStartDate(today);
|
const allNames = new Set<string>();
|
||||||
} else {
|
data.topApis.forEach((a) => allNames.add(a.serviceName));
|
||||||
const d = new Date();
|
data.topErrorApis.forEach((a) => allNames.add(a.serviceName));
|
||||||
d.setDate(d.getDate() - days);
|
const map: Record<string, BadgeVariant> = {};
|
||||||
setStartDate(d.toISOString().slice(0, 10));
|
[...allNames].sort().forEach((name, i) => {
|
||||||
}
|
map[name] = SERVICE_BADGE_VARIANTS[i % SERVICE_BADGE_VARIANTS.length];
|
||||||
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_CLASSES[i % SERVICE_TAG_CLASSES.length],
|
|
||||||
bar: chartColors[i % chartColors.length],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [data, chartColors]);
|
}, [data]);
|
||||||
|
|
||||||
|
const getServiceVariant = (name: string) => serviceVariantMap[name] ?? 'blue';
|
||||||
|
|
||||||
|
const kpi = useMemo(() => {
|
||||||
|
if (!data) return { totalCalls: 0, successRate: 0, avgResponse: 0, errorCount: 0 };
|
||||||
|
const totalCalls = data.topApis.reduce((sum, a) => sum + a.callCount, 0);
|
||||||
|
const weightedSuccess = data.topApis.reduce((sum, a) => sum + a.successRate * a.callCount, 0);
|
||||||
|
const successRate = totalCalls > 0 ? weightedSuccess / totalCalls : 0;
|
||||||
|
const weightedResponse = data.topApis.reduce((sum, a) => sum + a.avgResponseTime * a.callCount, 0);
|
||||||
|
const avgResponse = totalCalls > 0 ? weightedResponse / totalCalls : 0;
|
||||||
|
const errorCount = data.topErrorApis.reduce((sum, a) => sum + a.errorCount, 0);
|
||||||
|
return { totalCalls, successRate, avgResponse, errorCount };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const statusChartData = useMemo(() => {
|
const statusChartData = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
@ -95,119 +103,197 @@ const ApiStatsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto flex flex-col gap-3">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">API 통계</h1>
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<DateRangeFilter
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||||
|
<path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">API 통계</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">API별 호출 현황 및 성능 지표를 분석합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PeriodFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
onPreset={handlePreset}
|
onRefresh={fetchData}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Charts */}
|
{/* 1행: KPI(좌) + 차트(우) */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Chart 1: HTTP Method Distribution */}
|
{/* 좌: KPI 4개 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="grid grid-cols-2 grid-rows-2 gap-3">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP 메서드 분포</h3>
|
{/* 총 호출 수 */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl px-4 py-3 flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">총 호출 수</span>
|
||||||
|
<span className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||||
|
{kpi.totalCalls.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">전체 API 합계</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 성공률 */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl px-4 py-3 flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">성공률</span>
|
||||||
|
<span className={`text-lg font-bold ${getSuccessRateClass(kpi.successRate)}`}>
|
||||||
|
{kpi.successRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${getSuccessRateClass(kpi.successRate)}`}>
|
||||||
|
{kpi.successRate >= 90 ? '양호' : kpi.successRate >= 70 ? '주의' : '위험'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 평균 응답시간 */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl px-4 py-3 flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">평균 응답시간</span>
|
||||||
|
<span className={`text-lg font-bold ${getResponseTimeClass(kpi.avgResponse)}`}>
|
||||||
|
{kpi.avgResponse.toFixed(0)}ms
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${getResponseTimeClass(kpi.avgResponse)}`}>
|
||||||
|
{kpi.avgResponse < 100 ? '빠름' : kpi.avgResponse < 500 ? '보통' : kpi.avgResponse < 1000 ? '느림' : '매우 느림'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 수 */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl px-4 py-3 flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">에러 수</span>
|
||||||
|
<span className={`text-lg font-bold ${kpi.errorCount > 0 ? 'text-[var(--color-danger)]' : 'text-[var(--color-text-primary)]'}`}>
|
||||||
|
{kpi.errorCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${kpi.errorCount > 0 ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}`}>
|
||||||
|
{kpi.errorCount > 0 ? '에러 발생' : '정상'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우: 차트 2개 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* HTTP 메서드 분포 */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
|
||||||
|
<h3 className="text-xs font-semibold text-[var(--color-text-primary)] mb-2">HTTP 메서드 분포</h3>
|
||||||
{data.methodDistribution.length > 0 ? (
|
{data.methodDistribution.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={data.methodDistribution}
|
data={data.methodDistribution}
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
nameKey="method"
|
nameKey="method"
|
||||||
innerRadius={60}
|
innerRadius={38}
|
||||||
outerRadius={100}
|
outerRadius={60}
|
||||||
>
|
>
|
||||||
{data.methodDistribution.map((_, idx) => (
|
{data.methodDistribution.map((_, idx) => (
|
||||||
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip contentStyle={{ fontSize: 10 }} labelStyle={{ fontSize: 10 }} />
|
||||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
<Legend layout="vertical" align="right" verticalAlign="middle" wrapperStyle={{ fontSize: 10 }} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-12 text-xs">데이터 없음</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: HTTP Status Code Distribution */}
|
{/* HTTP 상태코드 분포 (가로 바) */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP 상태 코드 분포</h3>
|
<h3 className="text-xs font-semibold text-[var(--color-text-primary)] mb-2">HTTP 상태코드 분포</h3>
|
||||||
{statusChartData.length > 0 ? (
|
{statusChartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div className="flex flex-col gap-1.5 flex-1 justify-center">
|
||||||
<BarChart data={statusChartData}>
|
{(() => {
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
const maxCount = Math.max(...statusChartData.map((s) => s.count));
|
||||||
<XAxis dataKey="statusCode" />
|
return statusChartData.map((s) => {
|
||||||
<YAxis />
|
const code = Number(s.statusCode);
|
||||||
<Tooltip />
|
const barColor = code < 300 ? 'var(--color-success)' : code < 500 ? 'var(--color-warning)' : 'var(--color-danger)';
|
||||||
<Legend />
|
const pct = maxCount > 0 ? (s.count / maxCount) * 100 : 0;
|
||||||
<Bar dataKey="count" fill={chartColors[0]} name="건수" />
|
return (
|
||||||
</BarChart>
|
<div key={s.statusCode} className="flex items-center gap-2">
|
||||||
</ResponsiveContainer>
|
<span className="text-xs font-semibold text-[var(--color-text-primary)] w-8 shrink-0">{s.statusCode}</span>
|
||||||
|
<div className="flex-1 h-4 rounded bg-[var(--color-bg-base)] overflow-hidden">
|
||||||
|
<div className="h-full rounded opacity-60" style={{ width: `${pct}%`, minWidth: pct > 0 ? 4 : 0, transition: 'width 0.3s', backgroundColor: barColor }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text-secondary)] w-6 text-right shrink-0">{s.count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-12 text-xs">데이터 없음</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Table 1: Top APIs */}
|
{/* 2행: API 호출 순위 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API 호출 순위</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">API 호출 순위</h3>
|
||||||
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-[var(--color-primary-subtle)] text-[var(--color-primary)]">
|
||||||
|
Top 8
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{data.topApis.length > 0 ? (
|
{data.topApis.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">순위</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-12">순위</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">서비스</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-20">서비스</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-64">API명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">메서드</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">호출 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">호출 수</th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-24">평균 응답</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">평균 응답시간</th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-20">성공률</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.topApis.slice(0, 20).map((api, idx) => {
|
{data.topApis.slice(0, 8).map((api, idx) => {
|
||||||
const maxCount = data.topApis[0]?.callCount || 1;
|
const maxCount = data.topApis[0]?.callCount || 1;
|
||||||
const pct = (api.callCount / maxCount) * 100;
|
const pct = (api.callCount / maxCount) * 100;
|
||||||
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_CLASSES[0], bar: chartColors[0] };
|
|
||||||
return (
|
return (
|
||||||
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
<tr key={idx} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
<td className="px-3 py-1 w-12">
|
||||||
<td className="px-4 py-3">
|
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${getRankBadgeClass(idx + 1)}`}>
|
||||||
<Badge className={colors.tag}>
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1 w-20">
|
||||||
|
<Badge variant={getServiceVariant(api.serviceName)} size="sm" className="rounded-md w-full justify-center truncate">
|
||||||
{api.serviceName}
|
{api.serviceName}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)] truncate max-w-[250px]" title={api.apiName}>
|
<td className="px-3 py-1 text-[var(--color-text-primary)] truncate w-64" title={api.apiName}>
|
||||||
{api.apiName}
|
{api.apiName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-3 py-1">
|
||||||
<Badge className={METHOD_CLASS[api.requestMethod]}>
|
|
||||||
{api.requestMethod}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-24 bg-[var(--color-bg-base)] rounded-full h-4">
|
<div className="flex-1 bg-[var(--color-bg-base)] rounded-full h-2">
|
||||||
<div className="h-4 rounded-full" style={{ width: `${pct}%`, backgroundColor: colors.bar }} />
|
<div
|
||||||
|
className="h-2 rounded-full bg-[var(--color-primary)]"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span>{api.callCount.toLocaleString()}</span>
|
<span className="text-xs text-[var(--color-text-primary)] w-10 text-right shrink-0">
|
||||||
|
{api.callCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.avgResponseTime.toFixed(0)}ms</td>
|
<td className={`px-3 py-1 text-xs font-medium text-right w-24 ${getResponseTimeClass(api.avgResponseTime)}`}>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.successRate.toFixed(1)}%</td>
|
{api.avgResponseTime.toFixed(0)}ms
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-1 text-xs font-medium text-right w-20 ${getSuccessRateClass(api.successRate)}`}>
|
||||||
|
{api.successRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -215,51 +301,63 @@ const ApiStatsPage = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-8">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-8 text-sm">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table 2: Top Error APIs */}
|
{/* 3행: 에러 순위 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API 에러 순위</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">에러 순위</h3>
|
||||||
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-[var(--color-primary-subtle)] text-[var(--color-primary)]">
|
||||||
|
Top 3
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{data.topErrorApis.length > 0 ? (
|
{data.topErrorApis.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">순위</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-12">순위</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">서비스</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-20">서비스</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">API명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">에러 수</th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-24">에러 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">전체 수</th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-24">전체 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">에러율</th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-20">에러율</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.topErrorApis.slice(0, 10).map((api, idx) => {
|
{data.topErrorApis.slice(0, 3).map((api, idx) => (
|
||||||
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_CLASSES[0], bar: chartColors[0] };
|
<tr key={idx} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
return (
|
<td className="px-3 py-1 w-12">
|
||||||
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${getRankBadgeClass(idx + 1)}`}>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
{idx + 1}
|
||||||
<td className="px-4 py-3">
|
</span>
|
||||||
<Badge className={colors.tag}>
|
</td>
|
||||||
|
<td className="px-3 py-1 w-20">
|
||||||
|
<Badge variant={getServiceVariant(api.serviceName)} size="sm" className="rounded-md w-full justify-center truncate">
|
||||||
{api.serviceName}
|
{api.serviceName}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]" title={api.apiName}>{api.apiName}</td>
|
<td className="px-3 py-1 text-[var(--color-text-primary)] truncate" title={api.apiName}>
|
||||||
<td className="px-4 py-3 text-red-600">{api.errorCount.toLocaleString()}</td>
|
{api.apiName}
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.totalCount.toLocaleString()}</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-red-600">{api.errorRate.toFixed(1)}%</td>
|
<td className="px-3 py-1 text-xs font-medium text-right text-[var(--color-danger)] w-24">
|
||||||
|
{api.errorCount.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1 text-xs text-right text-[var(--color-text-primary)] w-24">
|
||||||
|
{api.totalCount.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-1 text-xs font-medium text-right w-20 ${getErrorRateClass(api.errorRate)}`}>
|
||||||
|
{api.errorRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-8">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-8 text-sm">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,21 +1,267 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||||
LineChart, Line, Cell,
|
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { ServiceStatsResponse } from '../../types/statistics';
|
import type { ServiceStatsResponse, ServiceRequestStats } from '../../types/statistics';
|
||||||
import { getServiceStats } from '../../services/statisticsService';
|
import { getServiceStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import PeriodFilter from '../../components/PeriodFilter';
|
||||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
import Badge, { type BadgeVariant } from '../../components/ui/Badge';
|
||||||
|
import { CHART_COLORS_HEX, SERVICE_BADGE_VARIANTS } from '../../constants/chart';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
// 카드 상단 컬러 보더용 hex 색상 (light 기준, dark는 opacity 조정)
|
||||||
|
const CARD_BORDER_COLORS = [
|
||||||
|
'#507FB9',
|
||||||
|
'#B59854',
|
||||||
|
'#B5607D',
|
||||||
|
'#3D998A',
|
||||||
|
'#8B6DB5',
|
||||||
|
'#C07850',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 성공률 → 색상 (SVG/inline style용 CSS 변수)
|
||||||
|
const successRateColor = (rate: number) => {
|
||||||
|
if (rate >= 90) return 'var(--color-success)';
|
||||||
|
if (rate >= 70) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-danger)';
|
||||||
|
};
|
||||||
|
|
||||||
|
const successRateClass = (rate: number) => {
|
||||||
|
if (rate >= 90) return 'text-[var(--color-success)]';
|
||||||
|
if (rate >= 70) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-danger)]';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 응답시간 → Tailwind 텍스트 색상 클래스
|
||||||
|
const responseTimeColorClass = (ms: number) => {
|
||||||
|
if (ms < 100) return 'text-[var(--color-success)]';
|
||||||
|
if (ms < 500) return 'text-[var(--color-text-primary)]';
|
||||||
|
if (ms < 1000) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-danger)]';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 응답시간 바 → CSS 변수 색상
|
||||||
|
const responseTimeBarColor = (ms: number) => {
|
||||||
|
if (ms < 100) return 'var(--color-success)';
|
||||||
|
if (ms < 500) return 'var(--color-info)';
|
||||||
|
if (ms < 1000) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-danger)';
|
||||||
|
};
|
||||||
|
|
||||||
|
// SVG 도넛 링
|
||||||
|
interface RingProps {
|
||||||
|
rate: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuccessRing = ({ rate, size = 52 }: RingProps) => {
|
||||||
|
const r = (size - 8) / 2;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const offset = circumference * (1 - rate / 100);
|
||||||
|
const color = successRateColor(rate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
strokeWidth={5}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={5}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={size / 2}
|
||||||
|
y={size / 2 + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={10}
|
||||||
|
fontWeight={700}
|
||||||
|
fill={color}
|
||||||
|
>
|
||||||
|
{rate.toFixed(0)}%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// KPI 카드
|
||||||
|
interface KpiCardProps {
|
||||||
|
svc: ServiceRequestStats;
|
||||||
|
borderColor: string;
|
||||||
|
badgeVariant: BadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KpiCard = ({ svc, borderColor, badgeVariant }: KpiCardProps) => {
|
||||||
|
const isHealthy = svc.successRate >= 90;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl flex flex-col overflow-hidden"
|
||||||
|
style={{ borderTop: `3px solid ${borderColor}` }}
|
||||||
|
>
|
||||||
|
<div className="p-4 flex flex-col gap-3 flex-1">
|
||||||
|
{/* 서비스 뱃지 + 상태 */}
|
||||||
|
<div className="flex items-center justify-between gap-1 min-w-0">
|
||||||
|
<Badge variant={badgeVariant} className="rounded-md truncate">
|
||||||
|
{svc.serviceName}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={isHealthy ? 'success' : 'warning'} size="sm">
|
||||||
|
{isHealthy ? '정상' : '주의'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 총 호출 수(좌) + 성공률 링(우) */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-[var(--color-text-primary)] leading-none">
|
||||||
|
{svc.totalRequests.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-1">총 호출 수</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<SuccessRing rate={svc.successRate} size={48} />
|
||||||
|
<span className={`absolute text-[10px] font-bold ${successRateClass(svc.successRate)}`}>
|
||||||
|
{svc.successRate.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 평균 응답시간 */}
|
||||||
|
<div className="flex items-center gap-1.5 pt-2 border-t border-[var(--color-border)]">
|
||||||
|
<svg className="h-3 w-3 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)]">평균 응답</span>
|
||||||
|
<span className={`text-xs font-bold ml-auto ${responseTimeColorClass(svc.avgResponseTime)}`}>
|
||||||
|
{svc.avgResponseTime.toFixed(0)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 가로 바 (요청 수)
|
||||||
|
interface RequestBarProps {
|
||||||
|
svc: ServiceRequestStats;
|
||||||
|
maxRequests: number;
|
||||||
|
color: string;
|
||||||
|
badgeVariant: BadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RequestBar = ({ svc, maxRequests, color, badgeVariant }: RequestBarProps) => {
|
||||||
|
const pct = maxRequests > 0 ? (svc.totalRequests / maxRequests) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-20 shrink-0">
|
||||||
|
<Badge variant={badgeVariant} size="sm" className="w-full justify-center truncate rounded-md">
|
||||||
|
{svc.serviceName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-[var(--color-bg-base)] rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text-primary)] w-16 text-right shrink-0">
|
||||||
|
{svc.totalRequests.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 성공률 스택 바
|
||||||
|
interface SuccessStackBarProps {
|
||||||
|
svc: ServiceRequestStats;
|
||||||
|
badgeVariant: BadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuccessStackBar = ({ svc, badgeVariant }: SuccessStackBarProps) => {
|
||||||
|
const successPct = Math.min(100, Math.max(0, svc.successRate));
|
||||||
|
const errorPct = 100 - successPct;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-20 shrink-0">
|
||||||
|
<Badge variant={badgeVariant} size="sm" className="w-full justify-center truncate rounded-md">
|
||||||
|
{svc.serviceName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex h-5 rounded overflow-hidden text-[10px] font-semibold">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center bg-[var(--color-success)] text-white"
|
||||||
|
style={{ width: `${successPct}%` }}
|
||||||
|
>
|
||||||
|
{successPct >= 15 && `${successPct.toFixed(1)}%`}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center bg-[var(--color-danger)] text-white"
|
||||||
|
style={{ width: `${errorPct}%` }}
|
||||||
|
>
|
||||||
|
{errorPct >= 15 && `${errorPct.toFixed(1)}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)] w-16 text-right shrink-0">
|
||||||
|
성공 {successPct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 응답시간 가로 바
|
||||||
|
interface ResponseTimeBarProps {
|
||||||
|
svc: ServiceRequestStats;
|
||||||
|
maxMs: number;
|
||||||
|
badgeVariant: BadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResponseTimeBar = ({ svc, maxMs, badgeVariant }: ResponseTimeBarProps) => {
|
||||||
|
const pct = maxMs > 0 ? (svc.avgResponseTime / maxMs) * 100 : 0;
|
||||||
|
const color = responseTimeBarColor(svc.avgResponseTime);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-20 shrink-0">
|
||||||
|
<Badge variant={badgeVariant} size="sm" className="w-full justify-center truncate rounded-md">
|
||||||
|
{svc.serviceName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-[var(--color-bg-base)] rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium w-16 text-right shrink-0 ${responseTimeColorClass(svc.avgResponseTime)}`}>
|
||||||
|
{svc.avgResponseTime.toFixed(0)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메인 페이지
|
||||||
const ServiceStatsPage = () => {
|
const ServiceStatsPage = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const chartColors = CHART_COLORS_HEX[theme];
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
const [startDate, setStartDate] = useState(() => {
|
||||||
|
const d = new Date(); d.setDate(d.getDate() - 7); return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [data, setData] = useState<ServiceStatsResponse | null>(null);
|
const [data, setData] = useState<ServiceStatsResponse | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@ -35,17 +281,22 @@ const ServiceStatsPage = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const handlePreset = (days: number) => {
|
// 서비스별 색상 맵 (알파벳 순 정렬)
|
||||||
const today = getToday();
|
const serviceColorMap = useMemo(() => {
|
||||||
if (days === 0) {
|
if (!data) return {};
|
||||||
setStartDate(today);
|
const sorted = [...data.serviceStats].sort((a, b) =>
|
||||||
} else {
|
a.serviceName.localeCompare(b.serviceName)
|
||||||
const d = new Date();
|
);
|
||||||
d.setDate(d.getDate() - days);
|
const map: Record<number, { chartColor: string; borderColor: string; badgeVariant: BadgeVariant }> = {};
|
||||||
setStartDate(d.toISOString().slice(0, 10));
|
sorted.forEach((svc, i) => {
|
||||||
}
|
map[svc.serviceId] = {
|
||||||
setEndDate(today);
|
chartColor: chartColors[i % chartColors.length],
|
||||||
|
borderColor: CARD_BORDER_COLORS[i % CARD_BORDER_COLORS.length],
|
||||||
|
badgeVariant: SERVICE_BADGE_VARIANTS[i % SERVICE_BADGE_VARIANTS.length],
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [data, chartColors]);
|
||||||
|
|
||||||
const hourlyTrendPivoted = useMemo(() => {
|
const hourlyTrendPivoted = useMemo(() => {
|
||||||
if (!data) return { data: [], serviceNames: [] };
|
if (!data) return { data: [], serviceNames: [] };
|
||||||
@ -60,14 +311,39 @@ const ServiceStatsPage = () => {
|
|||||||
return { data: Object.values(byHour), serviceNames };
|
return { data: Object.values(byHour), serviceNames };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const barChartData = useMemo(() => {
|
// 서비스 이름 → serviceId 역방향 맵 (차트 색상용)
|
||||||
if (!data) return [];
|
const nameToId = useMemo(() => {
|
||||||
return data.serviceStats.map((s) => ({
|
if (!data) return {};
|
||||||
serviceName: s.serviceName,
|
const map: Record<string, number> = {};
|
||||||
totalRequests: s.totalRequests,
|
data.serviceStats.forEach((s) => { map[s.serviceName] = s.serviceId; });
|
||||||
}));
|
return map;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const maxRequests = useMemo(() => {
|
||||||
|
if (!data) return 1;
|
||||||
|
return Math.max(...data.serviceStats.map((s) => s.totalRequests), 1);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const maxResponseTime = useMemo(() => {
|
||||||
|
if (!data) return 1;
|
||||||
|
return Math.max(...data.serviceStats.map((s) => s.avgResponseTime), 1);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 알파벳 순 정렬된 서비스 목록
|
||||||
|
const sortedServices = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [...data.serviceStats].sort((a, b) => a.serviceName.localeCompare(b.serviceName));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const colsClass = useMemo(() => {
|
||||||
|
const n = sortedServices.length;
|
||||||
|
if (n <= 2) return 'grid-cols-2';
|
||||||
|
if (n === 3) return 'grid-cols-3';
|
||||||
|
if (n === 4) return 'grid-cols-4';
|
||||||
|
if (n === 5) return 'grid-cols-5';
|
||||||
|
return 'grid-cols-6';
|
||||||
|
}, [sortedServices.length]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -77,130 +353,151 @@ const ServiceStatsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto flex flex-col gap-4 h-full">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">서비스 통계</h1>
|
{/* 제목 + PeriodFilter (한 행) */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<DateRangeFilter
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">서비스 통계</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">서비스별 요청 현황 및 성능 지표를 분석합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PeriodFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
onPreset={handlePreset}
|
onRefresh={fetchData}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!data || data.serviceStats.length === 0 ? (
|
{!data || data.serviceStats.length === 0 ? (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* 1행: KPI 카드 */}
|
||||||
<div className="flex flex-wrap gap-4 mb-6">
|
<div className={`grid ${colsClass} gap-3`}>
|
||||||
{data.serviceStats.map((svc) => (
|
{sortedServices.map((svc) => (
|
||||||
<div key={svc.serviceId} className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 flex-1 min-w-[200px]">
|
<KpiCard
|
||||||
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{svc.serviceName}</p>
|
key={svc.serviceId}
|
||||||
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
|
svc={svc}
|
||||||
{svc.totalRequests.toLocaleString()}
|
borderColor={serviceColorMap[svc.serviceId]?.borderColor ?? CARD_BORDER_COLORS[0]}
|
||||||
</p>
|
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
|
||||||
<div className="flex items-center gap-3 mt-2 text-sm">
|
/>
|
||||||
<span className="text-green-600">성공 {svc.successRate.toFixed(1)}%</span>
|
|
||||||
<span className="text-[var(--color-text-secondary)]">{svc.avgResponseTime.toFixed(0)}ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* 2행: 요청 수 가로 바 + 시간별 추이 차트 */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Chart 1: Service Request Count Bar */}
|
{/* 좌: 서비스별 요청 수 가로 바 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 요청 수</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">서비스별 요청 수</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div className="flex flex-col gap-2.5 justify-center flex-1">
|
||||||
<BarChart data={barChartData} layout="vertical">
|
{sortedServices.map((svc) => (
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<RequestBar
|
||||||
<XAxis type="number" />
|
key={svc.serviceId}
|
||||||
<YAxis dataKey="serviceName" type="category" width={120} />
|
svc={svc}
|
||||||
<Tooltip />
|
maxRequests={maxRequests}
|
||||||
<Legend />
|
color={serviceColorMap[svc.serviceId]?.chartColor ?? chartColors[0]}
|
||||||
<Bar dataKey="totalRequests" fill={chartColors[0]} name="요청 수" />
|
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
|
||||||
</BarChart>
|
/>
|
||||||
</ResponsiveContainer>
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Hourly Service Trend */}
|
{/* 우: 시간별 서비스 요청 추이 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">시간별 서비스 요청 추이</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">시간별 서비스 요청 추이</h3>
|
||||||
{hourlyTrendPivoted.data.length > 0 ? (
|
{hourlyTrendPivoted.data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={170}>
|
||||||
<LineChart data={hourlyTrendPivoted.data}>
|
<LineChart data={hourlyTrendPivoted.data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} />
|
<XAxis
|
||||||
<YAxis />
|
dataKey="hour"
|
||||||
<Tooltip labelFormatter={(h) => `${h}시`} />
|
tickFormatter={(h: number) => `${h}시`}
|
||||||
<Legend />
|
tick={{ fontSize: 10 }}
|
||||||
{hourlyTrendPivoted.serviceNames.map((name, idx) => (
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 10 }} width={35} />
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(h) => `${h}시`}
|
||||||
|
contentStyle={{ fontSize: 11 }}
|
||||||
|
labelStyle={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{hourlyTrendPivoted.serviceNames.map((name) => (
|
||||||
<Line
|
<Line
|
||||||
key={name}
|
key={name}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={name}
|
dataKey={name}
|
||||||
stroke={chartColors[idx % chartColors.length]}
|
stroke={serviceColorMap[nameToId[name]]?.chartColor ?? chartColors[0]}
|
||||||
|
dot={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-16">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row 2: Error Rate + Response Time */}
|
{/* 3행: 성공률/에러율 스택 바 + 응답시간 가로 바 */}
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Chart: Error Rate Comparison */}
|
{/* 좌: 성공률 스택 바 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 에러율 비교</h3>
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">서비스별 성공률 / 에러율</h3>
|
||||||
<BarChart
|
<div className="flex items-center gap-2 ml-auto text-[10px] text-[var(--color-text-secondary)]">
|
||||||
data={data.serviceStats.map((s) => ({
|
<span className="flex items-center gap-1">
|
||||||
serviceName: s.serviceName,
|
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-[var(--color-success)]" />
|
||||||
successRate: Number(s.successRate.toFixed(1)),
|
<span className="text-[var(--color-success)]">성공</span>
|
||||||
errorRate: Number((100 - s.successRate).toFixed(1)),
|
</span>
|
||||||
}))}
|
<span className="flex items-center gap-1">
|
||||||
layout="vertical"
|
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-[var(--color-danger)]" />
|
||||||
>
|
<span className="text-[var(--color-danger)]">에러</span>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
</span>
|
||||||
<XAxis type="number" domain={[0, 100]} unit="%" />
|
</div>
|
||||||
<YAxis dataKey="serviceName" type="category" width={120} />
|
</div>
|
||||||
<Tooltip />
|
<div className="flex flex-col gap-2.5 justify-center flex-1">
|
||||||
<Legend />
|
{sortedServices.map((svc) => (
|
||||||
<Bar dataKey="successRate" stackId="a" fill="#10b981" name="성공률" />
|
<SuccessStackBar
|
||||||
<Bar dataKey="errorRate" stackId="a" fill="#ef4444" name="에러율" />
|
key={svc.serviceId}
|
||||||
</BarChart>
|
svc={svc}
|
||||||
</ResponsiveContainer>
|
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart: Avg Response Time Comparison */}
|
{/* 우: 응답시간 가로 바 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 평균 응답시간 비교</h3>
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">서비스별 평균 응답시간</h3>
|
||||||
<BarChart
|
<div className="flex items-center gap-2 ml-auto text-[10px] text-[var(--color-text-secondary)]">
|
||||||
data={data.serviceStats.map((s) => ({
|
<span className="text-[var(--color-success)]">{"< 100ms"}</span>
|
||||||
serviceName: s.serviceName,
|
<span>•</span>
|
||||||
avgResponseTime: Number(s.avgResponseTime.toFixed(0)),
|
<span className="text-[var(--color-warning)]">{"< 1000ms"}</span>
|
||||||
}))}
|
<span>•</span>
|
||||||
layout="vertical"
|
<span className="text-[var(--color-danger)]">{"≥ 1000ms"}</span>
|
||||||
>
|
</div>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
</div>
|
||||||
<XAxis type="number" unit="ms" />
|
<div className="flex flex-col gap-2.5 justify-center flex-1">
|
||||||
<YAxis dataKey="serviceName" type="category" width={120} />
|
{sortedServices.map((svc) => (
|
||||||
<Tooltip />
|
<ResponseTimeBar
|
||||||
<Bar dataKey="avgResponseTime" name="평균 응답시간 (ms)">
|
key={svc.serviceId}
|
||||||
{data.serviceStats.map((s, idx) => {
|
svc={svc}
|
||||||
const rt = s.avgResponseTime;
|
maxMs={maxResponseTime}
|
||||||
const color = rt < 100 ? '#10b981' : rt < 300 ? '#f59e0b' : '#ef4444';
|
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
|
||||||
return <Cell key={idx} fill={color} />;
|
/>
|
||||||
})}
|
))}
|
||||||
</Bar>
|
</div>
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,21 +1,270 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||||
LineChart, Line,
|
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { TenantStatsResponse } from '../../types/statistics';
|
import type { TenantRequestStats, TenantStatsResponse } from '../../types/statistics';
|
||||||
import { getTenantStats } from '../../services/statisticsService';
|
import { getTenantStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import PeriodFilter from '../../components/PeriodFilter';
|
||||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
import Badge, { type BadgeVariant } from '../../components/ui/Badge';
|
||||||
|
import { CHART_COLORS_HEX, SERVICE_BADGE_VARIANTS } from '../../constants/chart';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
const CARD_BORDER_COLORS = [
|
||||||
|
'#507FB9',
|
||||||
|
'#B59854',
|
||||||
|
'#B5607D',
|
||||||
|
'#3D998A',
|
||||||
|
'#8B6DB5',
|
||||||
|
'#C07850',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Unknown(null) 테넌트 기본 색상
|
||||||
|
const DEFAULT_TENANT_COLOR = {
|
||||||
|
chartColor: '#94A3B8',
|
||||||
|
borderColor: '#94A3B8',
|
||||||
|
badgeVariant: 'default' as BadgeVariant,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 성공률 → hex 색상 (SVG용)
|
||||||
|
const successRateColor = (rate: number) => {
|
||||||
|
if (rate >= 90) return '#10b981';
|
||||||
|
if (rate >= 70) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 성공률 → CSS 클래스
|
||||||
|
const successRateClass = (rate: number) => {
|
||||||
|
if (rate >= 90) return 'text-[var(--color-success)]';
|
||||||
|
if (rate >= 70) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-danger)]';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 성공률 → 상태 뱃지 variant
|
||||||
|
const successRateBadgeVariant = (rate: number): BadgeVariant => {
|
||||||
|
if (rate >= 90) return 'success';
|
||||||
|
if (rate >= 70) return 'warning';
|
||||||
|
return 'danger';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 성공률 → 상태 라벨
|
||||||
|
const successRateLabel = (rate: number) => {
|
||||||
|
if (rate >= 90) return '정상';
|
||||||
|
if (rate >= 70) return '주의';
|
||||||
|
return '위험';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 응답시간 → CSS 클래스
|
||||||
|
const responseTimeClass = (ms: number) => {
|
||||||
|
if (ms > 1000) return 'text-[var(--color-danger)]';
|
||||||
|
if (ms > 500) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-success)]';
|
||||||
|
};
|
||||||
|
|
||||||
|
// SVG 도넛 링
|
||||||
|
interface SuccessRingProps {
|
||||||
|
rate: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuccessRing = ({ rate, size = 52 }: SuccessRingProps) => {
|
||||||
|
const r = (size - 8) / 2;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const offset = circumference * (1 - rate / 100);
|
||||||
|
const color = successRateColor(rate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
strokeWidth={5}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={5}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={size / 2}
|
||||||
|
y={size / 2 + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={10}
|
||||||
|
fontWeight={700}
|
||||||
|
fill={color}
|
||||||
|
>
|
||||||
|
{rate.toFixed(0)}%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// KPI 카드 (1행)
|
||||||
|
interface KpiCardProps {
|
||||||
|
tenant: TenantRequestStats;
|
||||||
|
borderColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KpiCard = ({ tenant, borderColor }: KpiCardProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl flex flex-col overflow-hidden"
|
||||||
|
style={{ borderTop: `3px solid ${borderColor}` }}
|
||||||
|
>
|
||||||
|
<div className="p-4 flex flex-col gap-3 flex-1">
|
||||||
|
{/* 빌딩 아이콘 + 부서명(좌) + 상태 뱃지(우) */}
|
||||||
|
<div className="flex items-center justify-between gap-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ color: borderColor }}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<rect x="4" y="2" width="16" height="20" rx="2" />
|
||||||
|
<path d="M9 22v-4h6v4" />
|
||||||
|
<line x1="8" y1="6" x2="8.01" y2="6" />
|
||||||
|
<line x1="12" y1="6" x2="12.01" y2="6" />
|
||||||
|
<line x1="16" y1="6" x2="16.01" y2="6" />
|
||||||
|
<line x1="8" y1="10" x2="8.01" y2="10" />
|
||||||
|
<line x1="12" y1="10" x2="12.01" y2="10" />
|
||||||
|
<line x1="16" y1="10" x2="16.01" y2="10" />
|
||||||
|
<line x1="8" y1="14" x2="8.01" y2="14" />
|
||||||
|
<line x1="12" y1="14" x2="12.01" y2="14" />
|
||||||
|
<line x1="16" y1="14" x2="16.01" y2="14" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-bold text-[var(--color-text-primary)] truncate">
|
||||||
|
{tenant.tenantName || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={successRateBadgeVariant(tenant.successRate)} size="sm">
|
||||||
|
{successRateLabel(tenant.successRate)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 총 호출 수(좌) + 성공률 링(우) */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-[var(--color-text-primary)] leading-none">
|
||||||
|
{tenant.totalRequests.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-1">총 호출 수</p>
|
||||||
|
</div>
|
||||||
|
<SuccessRing rate={tenant.successRate} size={48} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 + 사용자 수 + 평균 응답시간 */}
|
||||||
|
<div className="flex items-center gap-3 pt-2 border-t border-[var(--color-border)]">
|
||||||
|
{/* 사용자 아이콘 + 사용자 수 */}
|
||||||
|
<svg className="h-3 w-3 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-primary)] font-medium">
|
||||||
|
{tenant.activeUsers}명
|
||||||
|
</span>
|
||||||
|
{/* 시계 아이콘 + 평균 응답시간 */}
|
||||||
|
<svg className="h-3 w-3 text-[var(--color-text-tertiary)] shrink-0 ml-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span className={`text-[10px] font-bold ${successRateClass(tenant.successRate)}`}>
|
||||||
|
{tenant.avgResponseTime.toFixed(0)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Key 수직 그룹 바 차트
|
||||||
|
interface ApiKeyGroupedBarProps {
|
||||||
|
data: { tenantName: string; totalKeys: number; activeKeys: number }[];
|
||||||
|
tenantColorMap: Record<number, { chartColor: string; borderColor: string; badgeVariant: BadgeVariant }>;
|
||||||
|
nameToId: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeyGroupedBar = ({ data, tenantColorMap, nameToId }: ApiKeyGroupedBarProps) => {
|
||||||
|
const maxVal = Math.max(...data.map((d) => d.totalKeys), 1);
|
||||||
|
const barH = 120;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 flex-1">
|
||||||
|
{/* 바 차트 영역 */}
|
||||||
|
<div className="flex items-end justify-center gap-8 flex-1" style={{ height: `${barH}px` }}>
|
||||||
|
{data.map((item) => {
|
||||||
|
const tenantId = nameToId[item.tenantName] ?? -1;
|
||||||
|
const colorMap = tenantColorMap[tenantId];
|
||||||
|
const borderColor = colorMap?.borderColor ?? CARD_BORDER_COLORS[0];
|
||||||
|
const badgeVariant = colorMap?.badgeVariant ?? 'blue';
|
||||||
|
|
||||||
|
const totalH = Math.max((item.totalKeys / maxVal) * barH, 4);
|
||||||
|
const activeH = Math.max((item.activeKeys / maxVal) * barH, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.tenantName} className="flex flex-col items-center gap-1">
|
||||||
|
{/* 두 바 그룹 */}
|
||||||
|
<div className="flex items-end gap-1" style={{ height: `${barH}px` }}>
|
||||||
|
{/* 전체 키 바 */}
|
||||||
|
<div className="flex flex-col items-center justify-end" style={{ height: `${barH}px` }}>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">{item.totalKeys}</span>
|
||||||
|
<div
|
||||||
|
className="w-7 rounded-t transition-all duration-500 opacity-40"
|
||||||
|
style={{ height: `${totalH}px`, backgroundColor: borderColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 활성 키 바 */}
|
||||||
|
<div className="flex flex-col items-center justify-end" style={{ height: `${barH}px` }}>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-primary)] font-medium mb-0.5">{item.activeKeys}</span>
|
||||||
|
<div
|
||||||
|
className="w-7 rounded-t transition-all duration-500"
|
||||||
|
style={{ height: `${activeH}px`, backgroundColor: borderColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 부서 Badge */}
|
||||||
|
<Badge variant={badgeVariant} size="sm" className="rounded-md truncate max-w-20 justify-center">
|
||||||
|
{item.tenantName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="flex items-center justify-center gap-4 text-[10px] text-[var(--color-text-secondary)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-[var(--color-border)] opacity-40" />
|
||||||
|
전체 키
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-sm bg-[var(--color-border)]" />
|
||||||
|
활성 키
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메인 페이지
|
||||||
const TenantStatsPage = () => {
|
const TenantStatsPage = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const chartColors = CHART_COLORS_HEX[theme];
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
const [startDate, setStartDate] = useState(() => {
|
||||||
|
const d = new Date(); d.setDate(d.getDate() - 7); return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [data, setData] = useState<TenantStatsResponse | null>(null);
|
const [data, setData] = useState<TenantStatsResponse | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@ -35,18 +284,44 @@ const TenantStatsPage = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const handlePreset = (days: number) => {
|
// 알파벳 순 정렬 + Unknown(null)은 항상 마지막
|
||||||
const today = getToday();
|
const sortedTenants = useMemo(() => {
|
||||||
if (days === 0) {
|
if (!data) return [];
|
||||||
setStartDate(today);
|
return [...data.tenantStats]
|
||||||
} else {
|
.map((t) => ({
|
||||||
const d = new Date();
|
...t,
|
||||||
d.setDate(d.getDate() - days);
|
tenantId: t.tenantId ?? -1,
|
||||||
setStartDate(d.toISOString().slice(0, 10));
|
tenantName: t.tenantName || 'Unknown',
|
||||||
}
|
}))
|
||||||
setEndDate(today);
|
.sort((a, b) => {
|
||||||
};
|
const aUnknown = a.tenantId === -1;
|
||||||
|
const bUnknown = b.tenantId === -1;
|
||||||
|
if (aUnknown && !bUnknown) return 1;
|
||||||
|
if (!aUnknown && bUnknown) return -1;
|
||||||
|
return a.tenantName.localeCompare(b.tenantName);
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 테넌트별 색상 맵 (Unknown은 기본 색상, 나머지는 알파벳 순 매핑)
|
||||||
|
const tenantColorMap = useMemo(() => {
|
||||||
|
const map: Record<number, { chartColor: string; borderColor: string; badgeVariant: BadgeVariant }> = {};
|
||||||
|
let colorIdx = 0;
|
||||||
|
sortedTenants.forEach((t) => {
|
||||||
|
if (t.tenantId === -1) {
|
||||||
|
map[t.tenantId] = DEFAULT_TENANT_COLOR;
|
||||||
|
} else {
|
||||||
|
map[t.tenantId] = {
|
||||||
|
chartColor: chartColors[colorIdx % chartColors.length],
|
||||||
|
borderColor: CARD_BORDER_COLORS[colorIdx % CARD_BORDER_COLORS.length],
|
||||||
|
badgeVariant: SERVICE_BADGE_VARIANTS[colorIdx % SERVICE_BADGE_VARIANTS.length],
|
||||||
|
};
|
||||||
|
colorIdx++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [sortedTenants, chartColors]);
|
||||||
|
|
||||||
|
// 일별 추이 피벗
|
||||||
const dailyTrendPivoted = useMemo(() => {
|
const dailyTrendPivoted = useMemo(() => {
|
||||||
if (!data) return { data: [], tenantNames: [] };
|
if (!data) return { data: [], tenantNames: [] };
|
||||||
const tenantNames = [...new Set(data.dailyTrend.map((e) => e.tenantName))];
|
const tenantNames = [...new Set(data.dailyTrend.map((e) => e.tenantName))];
|
||||||
@ -60,6 +335,36 @@ const TenantStatsPage = () => {
|
|||||||
return { data: Object.values(byDate), tenantNames };
|
return { data: Object.values(byDate), tenantNames };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// 테넌트 이름 → tenantId 역방향 맵 (차트 색상용)
|
||||||
|
const nameToId = useMemo(() => {
|
||||||
|
if (!data) return {};
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
data.tenantStats.forEach((t) => { map[t.tenantName || 'Unknown'] = t.tenantId ?? -1; });
|
||||||
|
return map;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 요청 수 최대값 (테이블 프로그레스 바용)
|
||||||
|
const maxRequests = useMemo(() => {
|
||||||
|
if (!data) return 1;
|
||||||
|
return Math.max(...sortedTenants.map((t) => t.totalRequests), 1);
|
||||||
|
}, [data, sortedTenants]);
|
||||||
|
|
||||||
|
// 총 요청 수 합계
|
||||||
|
const totalRequestsSum = useMemo(() => {
|
||||||
|
if (!data) return 0;
|
||||||
|
return data.tenantStats.reduce((sum, t) => sum + t.totalRequests, 0);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// KPI 카드 grid 컬럼 클래스
|
||||||
|
const colsClass = useMemo(() => {
|
||||||
|
const n = sortedTenants.length;
|
||||||
|
if (n <= 2) return 'grid-cols-2';
|
||||||
|
if (n === 3) return 'grid-cols-3';
|
||||||
|
if (n === 4) return 'grid-cols-4';
|
||||||
|
if (n === 5) return 'grid-cols-5';
|
||||||
|
return 'grid-cols-6';
|
||||||
|
}, [sortedTenants.length]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -69,112 +374,156 @@ const TenantStatsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto flex flex-col gap-4 h-full">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">테넌트 통계</h1>
|
{/* 제목 + DateRangeFilter (한 행) */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<DateRangeFilter
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M9 21V9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">부서 통계</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">부서별 API 사용 현황을 분석합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PeriodFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
onPreset={handlePreset}
|
onRefresh={fetchData}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!data || data.tenantStats.length === 0 ? (
|
{!data || data.tenantStats.length === 0 ? (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* 1행: 부서별 KPI 카드 */}
|
||||||
<div className="flex gap-4 mb-6">
|
<div className={`grid ${colsClass} gap-3`}>
|
||||||
{data.tenantStats.map((tenant) => (
|
{sortedTenants.map((tenant) => (
|
||||||
<div key={tenant.tenantId} className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<KpiCard
|
||||||
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{tenant.tenantName || 'Unknown'}</p>
|
key={tenant.tenantId}
|
||||||
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
|
tenant={tenant}
|
||||||
{tenant.totalRequests.toLocaleString()}
|
borderColor={tenantColorMap[tenant.tenantId]?.borderColor ?? CARD_BORDER_COLORS[0]}
|
||||||
</p>
|
/>
|
||||||
<div className="flex items-center gap-3 mt-2 text-sm">
|
|
||||||
<span className="text-[var(--color-text-secondary)]">사용자 {tenant.activeUsers}</span>
|
|
||||||
<span className="text-green-600">성공 {tenant.successRate.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* 2행: 일별 추이(좌 60%) + API Key 현황(우 40%) */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
{/* Chart 1: Daily Tenant Trend */}
|
{/* 좌: 일별 부서별 요청 추이 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-3 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">일별 테넌트 요청 추이</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">일별 부서별 요청 추이</h3>
|
||||||
{dailyTrendPivoted.data.length > 0 ? (
|
{dailyTrendPivoted.data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<LineChart data={dailyTrendPivoted.data}>
|
<LineChart data={dailyTrendPivoted.data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||||
<YAxis />
|
<YAxis tick={{ fontSize: 10 }} width={35} />
|
||||||
<Tooltip />
|
<Tooltip contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
{dailyTrendPivoted.tenantNames.map((name, idx) => (
|
{dailyTrendPivoted.tenantNames.map((name) => (
|
||||||
<Line
|
<Line
|
||||||
key={name}
|
key={name}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={name}
|
dataKey={name}
|
||||||
stroke={chartColors[idx % chartColors.length]}
|
stroke={tenantColorMap[nameToId[name]]?.chartColor ?? chartColors[0]}
|
||||||
|
dot={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-16">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Tenant API Key Stats */}
|
{/* 우: API Key 현황 수직 그룹 바 차트 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-2 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">테넌트별 API Key 현황</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">부서별 API Key 현황</h3>
|
||||||
{data.apiKeyStats.length > 0 ? (
|
{data.apiKeyStats.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ApiKeyGroupedBar
|
||||||
<BarChart data={data.apiKeyStats}>
|
data={data.apiKeyStats}
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
tenantColorMap={tenantColorMap}
|
||||||
<XAxis dataKey="tenantName" />
|
nameToId={nameToId}
|
||||||
<YAxis />
|
/>
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="totalKeys" fill={chartColors[0]} name="전체 키" />
|
|
||||||
<Bar dataKey="activeKeys" fill="#10b981" name="활성 키" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-16">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table: Tenant Details */}
|
{/* 3행: 부서 상세 테이블 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="flex items-center justify-between p-4 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">테넌트 상세</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">부서 상세</h3>
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
총 요청 {totalRequestsSum.toLocaleString()}건
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">테넌트</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">부서</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">요청 수</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-64">요청 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">활성 사용자</th>
|
<th className="px-3 py-2 text-center text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">활성 사용자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">성공률</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">평균 응답시간</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">평균 응답시간</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.tenantStats.map((tenant) => (
|
{sortedTenants
|
||||||
<tr key={tenant.tenantId} className="hover:bg-[var(--color-bg-base)]">
|
.slice()
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{tenant.tenantName || 'Unknown'}</td>
|
.sort((a, b) => b.totalRequests - a.totalRequests)
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.totalRequests.toLocaleString()}</td>
|
.map((tenant) => {
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.activeUsers}</td>
|
const pct = maxRequests > 0 ? (tenant.totalRequests / maxRequests) * 100 : 0;
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.successRate.toFixed(1)}%</td>
|
const colorMap = tenantColorMap[tenant.tenantId];
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.avgResponseTime.toFixed(0)}ms</td>
|
return (
|
||||||
|
<tr key={tenant.tenantId} className="h-7 hover:bg-[var(--color-bg-base)]">
|
||||||
|
{/* 부서 Badge */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<Badge variant={colorMap?.badgeVariant ?? 'blue'} size="sm" className="rounded-md">
|
||||||
|
{tenant.tenantName || 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
{/* 요청 수 프로그레스 바 + 숫자 */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-[var(--color-bg-base)] rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: colorMap?.borderColor ?? CARD_BORDER_COLORS[0] }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text-primary)] w-16 text-right shrink-0">
|
||||||
|
{tenant.totalRequests.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 활성 사용자: 아이콘 + 숫자 */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<svg className="h-3.5 w-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-[var(--color-text-primary)]">{tenant.activeUsers}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* 성공률 (색상 코딩) */}
|
||||||
|
<td className={`px-3 py-1 text-xs font-semibold ${successRateClass(tenant.successRate)}`}>
|
||||||
|
{tenant.successRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
{/* 평균 응답시간 (색상 코딩) */}
|
||||||
|
<td className={`px-3 py-1 text-xs font-medium ${responseTimeClass(tenant.avgResponseTime)}`}>
|
||||||
|
{tenant.avgResponseTime.toFixed(0)}ms
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip, Legend, ResponsiveContainer, Area, ComposedChart,
|
Tooltip, Legend, ResponsiveContainer, ComposedChart, Line,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { UsageTrendResponse } from '../../types/statistics';
|
import type { UsageTrendResponse } from '../../types/statistics';
|
||||||
import { getUsageTrend } from '../../services/statisticsService';
|
import { getUsageTrend } from '../../services/statisticsService';
|
||||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
|
||||||
|
|
||||||
type Period = 'daily' | 'weekly' | 'monthly';
|
type Period = 'daily' | 'weekly' | 'monthly';
|
||||||
|
|
||||||
@ -29,14 +27,18 @@ const formatLabel = (label: string, period: Period): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSuccessRateColor = (rate: number): string => {
|
const getSuccessRateColor = (rate: number): string => {
|
||||||
if (rate >= 99) return 'text-green-600';
|
if (rate >= 90) return 'text-[var(--color-success)]';
|
||||||
if (rate >= 95) return 'text-yellow-600';
|
if (rate >= 70) return 'text-[var(--color-warning)]';
|
||||||
return 'text-red-600';
|
return 'text-[var(--color-danger)]';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponseTimeColor = (ms: number): string => {
|
||||||
|
if (ms > 1000) return 'text-[var(--color-danger)]';
|
||||||
|
if (ms > 500) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-success)]';
|
||||||
};
|
};
|
||||||
|
|
||||||
const UsageTrendPage = () => {
|
const UsageTrendPage = () => {
|
||||||
const { theme } = useTheme();
|
|
||||||
const chartColors = CHART_COLORS_HEX[theme];
|
|
||||||
const [period, setPeriod] = useState<Period>('daily');
|
const [period, setPeriod] = useState<Period>('daily');
|
||||||
const [data, setData] = useState<UsageTrendResponse | null>(null);
|
const [data, setData] = useState<UsageTrendResponse | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@ -57,31 +59,75 @@ const UsageTrendPage = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const chartData = data?.items.map((item) => ({
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
formattedLabel: formatLabel(item.label, period),
|
formattedLabel: formatLabel(item.label, period),
|
||||||
})) ?? [];
|
})) ?? [],
|
||||||
|
[data, period],
|
||||||
|
);
|
||||||
|
|
||||||
|
const kpiData = useMemo(() => {
|
||||||
|
if (!data || data.items.length === 0) return null;
|
||||||
|
const items = data.items;
|
||||||
|
const totalRequests = items.reduce((s, i) => s + i.totalRequests, 0);
|
||||||
|
const totalSuccess = items.reduce((s, i) => s + i.successCount, 0);
|
||||||
|
const successRate = totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0;
|
||||||
|
const avgResponseTime = items.reduce((s, i) => s + i.avgResponseTime, 0) / items.length;
|
||||||
|
const maxActiveUsers = Math.max(...items.map((i) => i.activeUsers));
|
||||||
|
return { totalRequests, successRate, avgResponseTime, maxActiveUsers };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const maxRequests = useMemo(
|
||||||
|
() => (data ? Math.max(...data.items.map((i) => i.totalRequests), 1) : 1),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto flex flex-col gap-3">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">사용량 추이</h1>
|
{/* 1행: 제목 + 필터 */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
{/* Period Tabs */}
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<div className="flex border-b border-[var(--color-border)] mb-6">
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<polyline points="23,6 13.5,15.5 8.5,10.5 1,18" /><polyline points="17,6 23,6 23,12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">사용량 추이</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">기간별 API 사용량 변화를 추적합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl px-3 py-2">
|
||||||
|
<div className="flex bg-[var(--color-bg-base)] rounded-md p-0.5">
|
||||||
{PERIOD_OPTIONS.map((opt) => (
|
{PERIOD_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
onClick={() => setPeriod(opt.key)}
|
onClick={() => setPeriod(opt.key)}
|
||||||
className={`px-4 py-2 text-sm font-medium -mb-px ${
|
className={`px-3.5 py-1 rounded text-xs font-medium transition-all duration-150 cursor-pointer ${
|
||||||
period === opt.key
|
period === opt.key
|
||||||
? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]'
|
? 'bg-[var(--color-primary-600)] text-white'
|
||||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-px h-6 bg-[var(--color-border)]" />
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
className="flex items-center p-1.5 rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)] transition-colors cursor-pointer"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polyline points="23 4 23 10 17 10" />
|
||||||
|
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -91,138 +137,237 @@ const UsageTrendPage = () => {
|
|||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Chart 1: 요청 수 추이 (full width) */}
|
{/* 2행: KPI 카드 4개 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">요청 수 추이</h3>
|
{/* 총 요청 */}
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col gap-2">
|
||||||
<LineChart data={chartData}>
|
<div className="flex items-center gap-2">
|
||||||
<defs>
|
<div className="w-8 h-8 rounded-full bg-[var(--color-bg-base)] flex items-center justify-center">
|
||||||
<linearGradient id="totalRequestsFill" x1="0" y1="0" x2="0" y2="1">
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--color-info)" strokeWidth={2}>
|
||||||
<stop offset="5%" stopColor={chartColors[0]} stopOpacity={0.15} />
|
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
|
||||||
<stop offset="95%" stopColor={chartColors[0]} stopOpacity={0} />
|
<polyline points="17 6 23 6 23 12" />
|
||||||
</linearGradient>
|
</svg>
|
||||||
</defs>
|
</div>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<span className="text-xs text-[var(--color-text-secondary)]">총 요청</span>
|
||||||
<XAxis dataKey="formattedLabel" />
|
</div>
|
||||||
<YAxis />
|
<span className="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||||
<Tooltip />
|
{kpiData?.totalRequests.toLocaleString() ?? '-'}
|
||||||
<Legend />
|
</span>
|
||||||
<Area
|
</div>
|
||||||
type="monotone"
|
|
||||||
dataKey="totalRequests"
|
{/* 성공률 */}
|
||||||
stroke="none"
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col gap-2">
|
||||||
fill="url(#totalRequestsFill)"
|
<div className="flex items-center gap-2">
|
||||||
name="총 요청"
|
<div className="w-8 h-8 rounded-full bg-[var(--color-bg-base)] flex items-center justify-center">
|
||||||
/>
|
<svg
|
||||||
<Line
|
width="16"
|
||||||
type="monotone"
|
height="16"
|
||||||
dataKey="totalRequests"
|
fill="none"
|
||||||
stroke={chartColors[0]}
|
viewBox="0 0 24 24"
|
||||||
name="총 요청"
|
stroke={
|
||||||
|
kpiData && kpiData.successRate >= 90
|
||||||
|
? 'var(--color-success)'
|
||||||
|
: kpiData && kpiData.successRate >= 70
|
||||||
|
? 'var(--color-warning)'
|
||||||
|
: 'var(--color-danger)'
|
||||||
|
}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
>
|
||||||
/>
|
<polyline points="20 6 9 17 4 12" />
|
||||||
<Line
|
</svg>
|
||||||
type="monotone"
|
</div>
|
||||||
dataKey="failureCount"
|
<span className="text-xs text-[var(--color-text-secondary)]">성공률</span>
|
||||||
stroke="#ef4444"
|
</div>
|
||||||
name="실패 수"
|
<span
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
kpiData
|
||||||
|
? kpiData.successRate >= 90
|
||||||
|
? 'text-[var(--color-success)]'
|
||||||
|
: kpiData.successRate >= 70
|
||||||
|
? 'text-[var(--color-warning)]'
|
||||||
|
: 'text-[var(--color-danger)]'
|
||||||
|
: 'text-[var(--color-text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kpiData ? `${kpiData.successRate.toFixed(1)}%` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 평균 응답시간 */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[var(--color-bg-base)] flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={
|
||||||
|
kpiData && kpiData.avgResponseTime > 1000
|
||||||
|
? 'var(--color-danger)'
|
||||||
|
: kpiData && kpiData.avgResponseTime > 500
|
||||||
|
? 'var(--color-warning)'
|
||||||
|
: 'var(--color-success)'
|
||||||
|
}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
>
|
||||||
/>
|
<circle cx="12" cy="12" r="10" />
|
||||||
</LineChart>
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">평균 응답시간</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
kpiData
|
||||||
|
? kpiData.avgResponseTime > 1000
|
||||||
|
? 'text-[var(--color-danger)]'
|
||||||
|
: kpiData.avgResponseTime > 500
|
||||||
|
? 'text-[var(--color-warning)]'
|
||||||
|
: 'text-[var(--color-success)]'
|
||||||
|
: 'text-[var(--color-text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kpiData ? `${kpiData.avgResponseTime.toFixed(0)}ms` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최대 활성 사용자 */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[var(--color-bg-base)] flex items-center justify-center">
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--color-primary)" strokeWidth={2}>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">최대 활성 사용자</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-[var(--color-primary)]">
|
||||||
|
{kpiData?.maxActiveUsers.toLocaleString() ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3행: 요청 수 추이 (전체 너비) — Stacked Bar */}
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">요청 수 추이</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
|
<XAxis dataKey="formattedLabel" tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} width={35} />
|
||||||
|
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar dataKey="successCount" stackId="a" fill="var(--color-info)" name="성공" />
|
||||||
|
<Bar dataKey="failureCount" stackId="a" fill="var(--color-danger)" name="실패" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts 2 & 3: 2 column grid */}
|
{/* 4행: 2-column */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Chart 2: 성공률 + 응답시간 추이 */}
|
{/* 좌: 성공률 + 응답시간 추이 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">성공률 + 응답시간 추이</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">성공률 + 응답시간 추이</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<ComposedChart data={chartData}>
|
<ComposedChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
<XAxis dataKey="formattedLabel" />
|
<XAxis dataKey="formattedLabel" tick={{ fontSize: 10 }} />
|
||||||
<YAxis yAxisId="left" unit="%" domain={[0, 100]} />
|
<YAxis yAxisId="left" unit="%" domain={[0, 100]} tick={{ fontSize: 10 }} width={35} />
|
||||||
<YAxis yAxisId="right" orientation="right" unit="ms" />
|
<YAxis yAxisId="right" orientation="right" unit="ms" tick={{ fontSize: 10 }} width={40} />
|
||||||
<Tooltip />
|
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar
|
||||||
|
yAxisId="right"
|
||||||
|
dataKey="avgResponseTime"
|
||||||
|
fill="var(--color-info)"
|
||||||
|
name="평균 응답시간(ms)"
|
||||||
|
barSize={16}
|
||||||
|
opacity={0.4}
|
||||||
|
/>
|
||||||
<Line
|
<Line
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="successRate"
|
dataKey="successRate"
|
||||||
stroke="#10b981"
|
stroke="var(--color-success)"
|
||||||
name="성공률(%)"
|
name="성공률(%)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
<Bar
|
|
||||||
yAxisId="right"
|
|
||||||
dataKey="avgResponseTime"
|
|
||||||
fill={chartColors[0]}
|
|
||||||
name="평균 응답시간(ms)"
|
|
||||||
barSize={20}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 3: 활성 사용자 추이 */}
|
{/* 우: 활성 사용자 추이 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">활성 사용자 추이</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">활성 사용자 추이</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
<XAxis dataKey="formattedLabel" />
|
<XAxis dataKey="formattedLabel" tick={{ fontSize: 10 }} />
|
||||||
<YAxis />
|
<YAxis tick={{ fontSize: 10 }} width={35} />
|
||||||
<Tooltip />
|
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Bar dataKey="activeUsers" fill={chartColors[1]} name="활성 사용자" />
|
<Bar dataKey="activeUsers" fill="var(--color-primary)" name="활성 사용자" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table: 상세 데이터 */}
|
{/* 5행: 상세 데이터 테이블 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">상세 데이터</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">상세 데이터</h3>
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">{data.items.length}개 기간</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">기간</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">기간</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">총 요청</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">총 요청</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">성공</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">실패</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">실패</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률(%)</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">성공률</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">평균 응답시간(ms)</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">평균 응답시간</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">활성 사용자</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">활성 사용자</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.items.map((item) => (
|
{data.items.map((item) => (
|
||||||
<tr key={item.label} className="hover:bg-[var(--color-bg-base)]">
|
<tr key={item.label} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className="px-3 py-1 text-[var(--color-text-primary)] text-xs">
|
||||||
{formatLabel(item.label, period)}
|
{formatLabel(item.label, period)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className="px-3 py-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-20 h-1.5 bg-[var(--color-bg-base)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--color-info)] rounded-full"
|
||||||
|
style={{ width: `${(item.totalRequests / maxRequests) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--color-text-primary)]">
|
||||||
{item.totalRequests.toLocaleString()}
|
{item.totalRequests.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className="px-3 py-1 text-xs text-[var(--color-success)]">
|
||||||
{item.successCount.toLocaleString()}
|
{item.successCount.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className={`px-3 py-1 text-xs ${item.failureCount > 0 ? 'text-[var(--color-danger)]' : 'text-[var(--color-text-tertiary)]'}`}>
|
||||||
{item.failureCount.toLocaleString()}
|
{item.failureCount.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-4 py-3 font-medium ${getSuccessRateColor(item.successRate)}`}>
|
<td className={`px-3 py-1 text-xs font-medium ${getSuccessRateColor(item.successRate)}`}>
|
||||||
{item.successRate.toFixed(1)}
|
{item.successRate.toFixed(1)}%
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className={`px-3 py-1 text-xs ${getResponseTimeColor(item.avgResponseTime)}`}>
|
||||||
{item.avgResponseTime.toFixed(0)}
|
{item.avgResponseTime.toFixed(0)}ms
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
<td className="px-3 py-1 text-xs text-[var(--color-text-primary)]">
|
||||||
{item.activeUsers.toLocaleString()}
|
{item.activeUsers.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,27 +1,178 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||||
PieChart, Pie, Cell,
|
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { UserStatsResponse } from '../../types/statistics';
|
import type { UserStatsResponse, UserRoleDistribution } from '../../types/statistics';
|
||||||
import { getUserStats } from '../../services/statisticsService';
|
import { getUserStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import PeriodFilter from '../../components/PeriodFilter';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const ROLE_BADGE: Record<string, string> = {
|
// 역할별 Badge variant 매핑
|
||||||
ADMIN: 'bg-red-100 text-red-800',
|
const ROLE_BADGE_VARIANT: Record<string, 'rose' | 'blue' | 'teal' | 'lavender' | 'coral' | 'gold'> = {
|
||||||
MANAGER: 'bg-blue-100 text-blue-800',
|
ADMIN: 'rose',
|
||||||
USER: 'bg-green-100 text-green-800',
|
MANAGER: 'blue',
|
||||||
|
USER: 'teal',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
const ROLE_DONUT_COLORS_HEX = {
|
||||||
|
light: ['#507FB9', '#B59854', '#B5607D', '#3D998A', '#8B6DB5', '#C07850'],
|
||||||
|
dark: ['#6D94C5', '#C4B48C', '#D4839B', '#5BB5A6', '#B79FD4', '#D4956B'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const successRateClass = (rate: number) => {
|
||||||
|
if (rate >= 90) return 'text-[var(--color-success)]';
|
||||||
|
if (rate >= 70) return 'text-[var(--color-warning)]';
|
||||||
|
return 'text-[var(--color-danger)]';
|
||||||
|
};
|
||||||
|
|
||||||
|
// SVG 도넛 차트 (역할별 분포)
|
||||||
|
interface DonutChartProps {
|
||||||
|
data: UserRoleDistribution[];
|
||||||
|
colors: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DonutChart = ({ data, colors }: DonutChartProps) => {
|
||||||
|
const total = data.reduce((sum, d) => sum + d.requestCount, 0);
|
||||||
|
const size = 100;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
const r = 36;
|
||||||
|
const strokeWidth = 18;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
|
||||||
|
let cumulativePct = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0">
|
||||||
|
{/* 배경 트랙 */}
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
{total === 0 ? null : data.map((segment, idx) => {
|
||||||
|
const pct = segment.requestCount / total;
|
||||||
|
const dashArray = circumference * pct;
|
||||||
|
const dashOffset = circumference * (1 - cumulativePct);
|
||||||
|
cumulativePct += pct;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={segment.role}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors[idx % colors.length]}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${dashArray} ${circumference - dashArray}`}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<text x={cx} y={cy - 5} textAnchor="middle" fontSize={10} fill="var(--color-text-tertiary)">전체</text>
|
||||||
|
<text x={cx} y={cy + 9} textAnchor="middle" fontSize={11} fontWeight={700} fill="var(--color-text-primary)">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="flex flex-col gap-2 flex-1 min-w-0">
|
||||||
|
{data.map((segment, idx) => {
|
||||||
|
const pct = total > 0 ? ((segment.requestCount / total) * 100).toFixed(1) : '0.0';
|
||||||
|
return (
|
||||||
|
<div key={segment.role} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: colors[idx % colors.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)] min-w-[52px]">{segment.role}</span>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text-primary)] ml-auto">
|
||||||
|
{segment.requestCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)] w-10 text-right shrink-0">
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// KPI 카드
|
||||||
|
interface KpiCardProps {
|
||||||
|
icon: 'users' | 'key' | 'activity';
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KpiCard = ({ icon, label, value, total }: KpiCardProps) => {
|
||||||
|
const pct = total && total > 0 ? Math.min(100, (value / total) * 100) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex items-center justify-center w-8 h-8 rounded-md bg-[var(--color-primary-subtle)]">
|
||||||
|
{icon === 'users' && (
|
||||||
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{icon === 'key' && (
|
||||||
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{icon === 'activity' && (
|
||||||
|
<svg className="w-4 h-4 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">{label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-3xl font-extrabold text-[var(--color-text-primary)] leading-none">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{pct !== null && (
|
||||||
|
<div className="flex items-center gap-2 mt-auto">
|
||||||
|
<div className="flex-1 h-1.5 bg-[var(--color-bg-base)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)] shrink-0 w-10 text-right">
|
||||||
|
{pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메인 페이지
|
||||||
const UserStatsPage = () => {
|
const UserStatsPage = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const chartColors = CHART_COLORS_HEX[theme];
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
const donutColors = ROLE_DONUT_COLORS_HEX[theme];
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
|
||||||
|
const [startDate, setStartDate] = useState(() => {
|
||||||
|
const d = new Date(); d.setDate(d.getDate() - 7); return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [data, setData] = useState<UserStatsResponse | null>(null);
|
const [data, setData] = useState<UserStatsResponse | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@ -41,17 +192,10 @@ const UserStatsPage = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const handlePreset = (days: number) => {
|
const maxRequests = useMemo(() => {
|
||||||
const today = getToday();
|
if (!data || data.topUsers.length === 0) return 1;
|
||||||
if (days === 0) {
|
return Math.max(...data.topUsers.map((u) => u.requestCount), 1);
|
||||||
setStartDate(today);
|
}, [data]);
|
||||||
} else {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - days);
|
|
||||||
setStartDate(d.toISOString().slice(0, 10));
|
|
||||||
}
|
|
||||||
setEndDate(today);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@ -62,128 +206,185 @@ const UserStatsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">사용자 통계</h1>
|
{/* 헤더: 제목 + PeriodFilter */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<DateRangeFilter
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">사용자 통계</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">사용자별 API 사용 현황을 분석합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PeriodFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
onPreset={handlePreset}
|
onRefresh={fetchData}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* 1행: KPI 카드 3개 */}
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<KpiCard
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">전체 사용자</p>
|
icon="users"
|
||||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalUsers}</p>
|
label="전체 사용자"
|
||||||
</div>
|
value={data.totalUsers}
|
||||||
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
/>
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">API Key 보유 사용자</p>
|
<KpiCard
|
||||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.usersWithActiveKey}</p>
|
icon="key"
|
||||||
</div>
|
label="API Key 보유 사용자"
|
||||||
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
value={data.usersWithActiveKey}
|
||||||
<p className="text-sm text-[var(--color-text-secondary)]">API 요청 사용자</p>
|
total={data.totalUsers}
|
||||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalActiveUsers}</p>
|
/>
|
||||||
</div>
|
<KpiCard
|
||||||
|
icon="activity"
|
||||||
|
label="API 요청 사용자"
|
||||||
|
value={data.totalActiveUsers}
|
||||||
|
total={data.totalUsers}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* 2행: 일별 추이(좌 60%) + 역할별 분포(우 40%) */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
{/* Chart 1: Daily Active Users */}
|
{/* 좌: 일별 활성 사용자 추이 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-3 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">일별 API 요청 사용자 추이</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">일별 API 요청 사용자 추이</h3>
|
||||||
{data.dailyActiveUsers.length > 0 ? (
|
{data.dailyActiveUsers.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<AreaChart data={data.dailyActiveUsers}>
|
<AreaChart data={data.dailyActiveUsers}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||||
<YAxis />
|
<YAxis tick={{ fontSize: 10 }} width={35} />
|
||||||
<Tooltip />
|
<Tooltip contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
|
||||||
<Legend />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="activeUsers"
|
dataKey="activeUsers"
|
||||||
stroke={chartColors[0]}
|
stroke={chartColors[0]}
|
||||||
fill={chartColors[0]}
|
fill={chartColors[0]}
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.2}
|
||||||
name="API 요청 사용자"
|
name="API 요청 사용자"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<div className="flex items-center justify-center flex-1">
|
||||||
|
<p className="text-[var(--color-text-tertiary)] text-sm">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Role Distribution Donut */}
|
{/* 우: 역할별 요청 분포 (CSS 도넛) */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
<div className="col-span-2 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">역할별 요청 분포</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">역할별 요청 분포</h3>
|
||||||
{data.roleDistribution.length > 0 ? (
|
{data.roleDistribution.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div className="flex items-center justify-center flex-1">
|
||||||
<PieChart>
|
<DonutChart data={data.roleDistribution} colors={donutColors} />
|
||||||
<Pie
|
</div>
|
||||||
data={data.roleDistribution}
|
|
||||||
dataKey="requestCount"
|
|
||||||
nameKey="role"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={100}
|
|
||||||
>
|
|
||||||
{data.roleDistribution.map((_, idx) => (
|
|
||||||
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
<div className="flex items-center justify-center flex-1">
|
||||||
|
<p className="text-[var(--color-text-tertiary)] text-sm">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table: Top Users */}
|
{/* 3행: 상위 사용자 Top 5 테이블 */}
|
||||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
||||||
<div className="p-6 border-b border-[var(--color-border)]">
|
<div className="px-4 py-3 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">상위 사용자 Top 10</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">상위 사용자 Top 5</h3>
|
||||||
</div>
|
</div>
|
||||||
{data.topUsers.length > 0 ? (
|
{data.topUsers.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-[var(--color-bg-base)]">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr className="h-8">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">순위</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-16">순위</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">사용자</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">사용자명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">역할</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-28">역할</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">요청 수</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">요청 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-28">성공률</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--color-border)]">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.topUsers.slice(0, 10).map((user, idx) => (
|
{data.topUsers.slice(0, 5).map((user, idx) => {
|
||||||
<tr key={user.userId} className="hover:bg-[var(--color-bg-base)]">
|
const rank = idx + 1;
|
||||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
const rankBg = rank <= 3
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.userName}</td>
|
? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary-text)]'
|
||||||
<td className="px-4 py-3">
|
: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]';
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ROLE_BADGE[user.role] ?? 'bg-gray-100 text-gray-800'}`}>
|
const reqPct = maxRequests > 0 ? (user.requestCount / maxRequests) * 100 : 0;
|
||||||
{user.role}
|
|
||||||
|
return (
|
||||||
|
<tr key={user.userId} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
|
{/* 순위 뱃지 */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-md text-xs font-bold ${rankBg}`}>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 사용자명 (마스킹) */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="h-4 w-4 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-[var(--color-text-primary)]">
|
||||||
|
{user.userName.length > 0 ? `${user.userName.charAt(0)}***` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 역할 Badge pill */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<Badge
|
||||||
|
variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 요청 수 + 프로그레스 바 */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-1.5 bg-[var(--color-bg-base)] rounded-full overflow-hidden min-w-[80px]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${reqPct}%`, backgroundColor: 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text-primary)] w-16 text-right shrink-0">
|
||||||
|
{user.requestCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 성공률 색상 코딩 */}
|
||||||
|
<td className="px-3 py-1">
|
||||||
|
<span className={`text-xs font-semibold ${successRateClass(user.successRate)}`}>
|
||||||
|
{user.successRate.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.requestCount.toLocaleString()}</td>
|
|
||||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.successRate.toFixed(1)}%</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--color-text-tertiary)] text-center py-8">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-8 text-sm">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user