Compare commits

...

6 커밋

작성자 SHA1 메시지 날짜
be7e6769e3 Merge pull request 'release: 2026-04-17 (15건 커밋)' (#55) from develop into main
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-04-17 14:51:23 +09:00
7d8c1172fe Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17)' (#54) from chore/release-notes-2026-04-17 into develop 2026-04-17 14:50:35 +09:00
311d60d65f docs: 릴리즈 노트 정리 (2026-04-17) 2026-04-17 14:50:12 +09:00
68489e40c7 Merge pull request 'feat(frontend): 디자인 시스템 적용 및 전체 UI 개선 (#42)' (#53) from chore/release-notes-2026-04-15 into develop 2026-04-17 14:47:50 +09:00
544ff992d6 docs: 릴리즈 노트 업데이트 2026-04-17 14:46:37 +09:00
88e25abe14 feat(frontend): 디자인 시스템 적용 및 전체 UI 개선 (#42)
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info)
- PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용
- SERVICE_BADGE_VARIANTS 공통 상수 추출
- 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영
- 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs)
- 타이틀 아이콘 전체 페이지 통일
- 카드 테두리 디자인 통일 (border + rounded-xl)
- FHD 1920x1080 최적화
2026-04-17 14:45:27 +09:00
32개의 변경된 파일3380개의 추가작업 그리고 1550개의 파일을 삭제

파일 보기

@ -91,23 +91,23 @@ Accent (Warm Sand, hue≈38°):
- 텍스트/아이콘: `text-[--color-primary]` → 다크모드에서 자동으로 밝은 톤 적용 (OK)
- 버튼 배경: `bg-[--color-primary]` → 다크모드에서 `dark:bg-[--color-primary-600]`으로 오버라이드 (필수)
**차트 팔레트 (Cool+Warm 교차):**
**차트 팔레트 (6색 Hue 분산, Cool+Warm 교차):**
Light:
1. #507FB9 (Primary 600)
2. #B59854 (Accent 600)
3. #4E88BB (Secondary 700)
4. #9A7E3E (Accent 700)
5. #3B669C (Primary 700)
6. #C4B48C (Accent 400)
Light (흰 배경):
1. #507FB9 — Slate Blue (213°, 브랜드 Primary)
2. #B59854 — Warm Gold (38°, 브랜드 Accent)
3. #B5607D — Rose (340°)
4. #3D998A — Teal (168°)
5. #8B6DB5 — Lavender (270°)
6. #C07850 — Coral (22°)
Dark (채도 강화 — 400~500 스케일 사용):
1. #6D94C5 (Primary 500)
2. #C4B48C (Accent 400)
3. #6B9AC8 (Secondary 600)
4. #B59854 (Accent 600)
5. #8AA5C7 (Primary 400)
6. #D5CDB9 (Accent 300)
Dark (어두운 배경, 밝기 강화):
1. #6D94C5 — Slate Blue
2. #C4B48C — Warm Sand
3. #D4839B — Rose
4. #5BB5A6 — Teal
5. #B79FD4 — Lavender
6. #D4956B — Coral
### 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)
**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
**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
@ -317,10 +334,10 @@ Do/Don't 가이드:
/* === Chart Palette === */
--color-chart-1: #507FB9;
--color-chart-2: #B59854;
--color-chart-3: #4E88BB;
--color-chart-4: #9A7E3E;
--color-chart-5: #3B669C;
--color-chart-6: #C4B48C;
--color-chart-3: #B5607D;
--color-chart-4: #3D998A;
--color-chart-5: #8B6DB5;
--color-chart-6: #C07850;
/* === 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;
@ -362,10 +379,10 @@ Do/Don't 가이드:
--color-chart-1: #6D94C5;
--color-chart-2: #C4B48C;
--color-chart-3: #6B9AC8;
--color-chart-4: #B59854;
--color-chart-5: #8AA5C7;
--color-chart-6: #D5CDB9;
--color-chart-3: #D4839B;
--color-chart-4: #5BB5A6;
--color-chart-5: #B79FD4;
--color-chart-6: #D4956B;
}
}
@ -444,8 +461,8 @@ export const CHART_COLORS = [
/** CSS computed value가 필요한 경우 (Recharts 등) */
export const CHART_COLORS_HEX = {
light: ['#507FB9', '#B59854', '#4E88BB', '#9A7E3E', '#3B669C', '#C4B48C'],
dark: ['#6D94C5', '#C4B48C', '#6B9AC8', '#B59854', '#8AA5C7', '#D5CDB9'],
light: ['#507FB9', '#B59854', '#B5607D', '#3D998A', '#8B6DB5', '#C07850'],
dark: ['#6D94C5', '#C4B48C', '#D4839B', '#5BB5A6', '#B79FD4', '#D4956B'],
} as const;
```

파일 보기

@ -59,8 +59,8 @@
--color-text-tertiary: #9198A1;
--color-chart-1: #507FB9; --color-chart-2: #B59854;
--color-chart-3: #4E88BB; --color-chart-4: #9A7E3E;
--color-chart-5: #3B669C; --color-chart-6: #C4B48C;
--color-chart-3: #B5607D; --color-chart-4: #3D998A;
--color-chart-5: #8B6DB5; --color-chart-6: #C07850;
}
.dark {
@ -84,8 +84,8 @@
--color-text-tertiary: #5C6570;
--color-chart-1: #6D94C5; --color-chart-2: #C4B48C;
--color-chart-3: #6B9AC8; --color-chart-4: #B59854;
--color-chart-5: #8AA5C7; --color-chart-6: #D5CDB9;
--color-chart-3: #D4839B; --color-chart-4: #5BB5A6;
--color-chart-5: #B79FD4; --color-chart-6: #D4956B;
}
body {
@ -225,6 +225,31 @@
.dark .badge-danger { background: rgba(252,165,165,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-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
@ -545,7 +570,7 @@
</div>
<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">
<span class="badge badge-default">Default</span>
<span class="badge badge-primary">Primary</span>
@ -556,6 +581,16 @@
<span class="badge badge-danger">Danger</span>
<span class="badge badge-info">Info</span>
</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 style="margin-bottom:24px;">

파일 보기

@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased]
## [2026-04-17]
### 추가
- 디자인 시스템 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]
### 추가

파일 보기

@ -141,10 +141,10 @@
|------|----------|-----------|----------|
| 1번 | `--color-chart-1` | #507FB9 | #6D94C5 |
| 2번 | `--color-chart-2` | #B59854 | #C4B48C |
| 3번 | `--color-chart-3` | #4E88BB | #6B9AC8 |
| 4번 | `--color-chart-4` | #9A7E3E | #B59854 |
| 5번 | `--color-chart-5` | #3B669C | #8AA5C7 |
| 6번 | `--color-chart-6` | #C4B48C | #D5CDB9 |
| 3번 | `--color-chart-3` | #B5607D | #D4839B |
| 4번 | `--color-chart-4` | #3D998A | #5BB5A6 |
| 5번 | `--color-chart-5` | #8B6DB5 | #B79FD4 |
| 6번 | `--color-chart-6` | #C07850 | #D4956B |
차트에서는 `CHART_COLORS` 상수(`src/constants/chart.ts`)를 사용한다.

파일 보기

@ -50,24 +50,38 @@ const buttonVariants = {
## Badge
상태, 카테고리, 태그 표시에 사용한다.
상태, 카테고리, 태그 표시에 사용한다. Shape에 따라 Pill과 Filled로 구분한다.
### 변형
### Pill 변형 (rounded-full) — 상태 표시용
| Variant | 배경 | 텍스트 |
|---------|------|--------|
| `default` | `--color-bg-elevated` | `--color-text-primary` |
| `primary` | `--color-primary-subtle` | `--color-primary-text` |
| `success` | #DCFCE7 | #166534 |
| `warning` | #FEF9C3 | #854D0E |
| `danger` | #FEE2E2 | #991B1B |
| `info` | #E0F2FE | #075985 |
| Variant | 용도 | Light 배경 | Dark 배경 |
|---------|------|-----------|-----------|
| `default` | 기본 | `--color-bg-base` | `--color-bg-base` |
| `success` | 성공/활성 | green-50 | green-500/15 |
| `warning` | 경고/대기 | amber-50 | amber-500/15 |
| `danger` | 에러/비활성 | red-50 | red-500/15 |
| `info` | 정보/식별자 | blue-50 | blue-500/15 |
### 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`
- 폰트: `text-xs font-medium`
- 모양: `rounded-full` (pill) 또는 `rounded-md` (square)
- **Pill**: `rounded-full`, `px-2 py-0.5`, `text-xs font-medium`
- **Filled**: `rounded-md`, `px-3 py-1`, `text-xs font-medium`
- 사이즈: `sm` (px-1.5 py-0.5 text-[10px]), `md` (px-2 py-0.5 text-xs)
---

파일 보기

@ -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';
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 {
variant?: BadgeVariant;
@ -10,14 +10,19 @@ interface BadgeProps {
}
const variantStyles = {
/* Pill 전용 (시멘틱) */
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',
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',
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 = {

파일 보기

@ -3,7 +3,7 @@ import { cn } from '../../utils/cn';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'accent' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
size?: 'xs' | 'sm' | 'md' | 'lg';
}
const variantStyles = {
@ -28,6 +28,7 @@ const variantStyles = {
};
const sizeStyles = {
xs: 'h-5 px-2 text-[10px] gap-1 rounded-md',
sm: 'h-8 px-3 text-xs gap-1.5',
md: 'h-10 px-4 text-sm gap-2',
lg: 'h-12 px-6 text-base gap-2',

파일 보기

@ -7,7 +7,9 @@ export const CHART_COLORS = [
'var(--color-chart-6)',
] as const;
export const SERVICE_BADGE_VARIANTS = ['blue', 'gold', 'rose', 'teal', 'lavender', 'coral'] as const;
export const CHART_COLORS_HEX = {
light: ['#507FB9', '#B59854', '#4E88BB', '#9A7E3E', '#3B669C', '#C4B48C'],
dark: ['#6D94C5', '#C4B48C', '#6B9AC8', '#B59854', '#8AA5C7', '#D5CDB9'],
light: ['#507FB9', '#B59854', '#B5607D', '#3D998A', '#8B6DB5', '#C07850'],
dark: ['#6D94C5', '#C4B48C', '#D4839B', '#5BB5A6', '#B79FD4', '#D4956B'],
} as const;

파일 보기

@ -47,10 +47,10 @@
/* Chart Palette (고정) */
--color-chart-1: #507FB9;
--color-chart-2: #B59854;
--color-chart-3: #4E88BB;
--color-chart-4: #9A7E3E;
--color-chart-5: #3B669C;
--color-chart-6: #C4B48C;
--color-chart-3: #B5607D;
--color-chart-4: #3D998A;
--color-chart-5: #8B6DB5;
--color-chart-6: #C07850;
/* 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;
@ -122,10 +122,10 @@
--color-chart-1: #6D94C5;
--color-chart-2: #C4B48C;
--color-chart-3: #6B9AC8;
--color-chart-4: #B59854;
--color-chart-5: #8AA5C7;
--color-chart-6: #D5CDB9;
--color-chart-3: #D4839B;
--color-chart-4: #5BB5A6;
--color-chart-5: #B79FD4;
--color-chart-6: #D4956B;
}
/* === Dark Theme (OS 기본 설정 기반) === */
@ -163,10 +163,10 @@
--color-chart-1: #6D94C5;
--color-chart-2: #C4B48C;
--color-chart-3: #6B9AC8;
--color-chart-4: #B59854;
--color-chart-5: #8AA5C7;
--color-chart-6: #D5CDB9;
--color-chart-3: #D4839B;
--color-chart-4: #5BB5A6;
--color-chart-5: #B79FD4;
--color-chart-6: #D4956B;
}
}

파일 보기

@ -120,7 +120,7 @@ const ApiHubLayoutInner = () => {
const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]);
return (
<div className="flex min-h-screen">
<div className="flex h-screen overflow-hidden">
{/* 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)]">
{/* 로고 영역 */}
@ -287,7 +287,7 @@ const ApiHubLayoutInner = () => {
</aside>
{/* 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">
<div />
@ -328,7 +328,7 @@ const ApiHubLayoutInner = () => {
</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 />
<BasketFloatingPanel />
</main>

파일 보기

@ -196,7 +196,7 @@ const MainLayout = () => {
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
return (
<div className="flex min-h-screen">
<div className="flex h-screen overflow-hidden">
{/* 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)]">
@ -331,7 +331,7 @@ const MainLayout = () => {
</aside>
{/* Main Content */}
<div className="flex-1 ml-60">
<div className="flex-1 ml-60 flex flex-col h-screen overflow-hidden">
{/* 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">
<div />
@ -373,7 +373,7 @@ const MainLayout = () => {
</header>
{/* 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 />
</main>
</div>

파일 보기

@ -11,26 +11,18 @@ import {
getSummary, getHourlyTrend, getServiceRatio,
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
} 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 Badge, { type BadgeVariant } from '../components/ui/Badge';
const SERVICE_TAG_STYLES = [
'bg-blue-100 text-blue-700',
'bg-emerald-100 text-emerald-700',
'bg-amber-100 text-amber-700',
'bg-red-100 text-red-700',
'bg-violet-100 text-violet-700',
'bg-cyan-100 text-cyan-700',
];
const 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 STATUS_VARIANT: Record<string, BadgeVariant> = {
SUCCESS: 'success',
FAIL: 'danger',
DENIED: 'warning',
EXPIRED: 'warning',
INVALID_KEY: 'danger',
ERROR: 'danger',
FAILED: 'default',
};
const AUTO_REFRESH_MS = 30000;
@ -93,6 +85,28 @@ const DashboardPage = () => {
return () => clearInterval(interval);
}, [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 serviceNames = [...new Set(errorTrend.map((e) => e.serviceName))];
const byHour: Record<number, Record<string, number>> = {};
@ -108,18 +122,6 @@ const DashboardPage = () => {
};
}, [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) {
return (
<div className="flex items-center justify-center h-64">
@ -131,211 +133,238 @@ const DashboardPage = () => {
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Dashboard</h1>
<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}>
<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 && (
<span className="text-sm text-[var(--color-text-secondary)]"> : {lastUpdated}</span>
)}
</div>
{/* Row 1: Summary Cards */}
{stats && (
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
<p className="text-3xl 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)]'}`}>
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} {stats.changePercent.toFixed(2)}%
</p>
</div>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm 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-sm text-red-500"> {stats.failureCount}</p>
</div>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.avgResponseTime.toFixed(0)}ms</p>
</div>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]">API </p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.activeUserCount}</p>
<p className="text-sm text-[var(--color-text-secondary)]"></p>
</div>
</div>
)}
{/* Row 2: Heartbeat Status Cards */}
<div className="mb-6">
{heartbeat.length > 0 ? (
<div className="flex gap-4">
{heartbeat.map((svc) => {
const isUp = svc.healthStatus === 'UP';
const isDown = svc.healthStatus === 'DOWN';
const borderColor = isUp ? 'border-green-500' : isDown ? 'border-red-500' : 'border-gray-400';
return (
<div
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`}
onClick={() => navigate('/monitoring/service-status')}
>
<div className="flex items-center gap-2 mb-2">
<div
className={`w-3 h-3 rounded-full ${
isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400'
}`}
/>
<span className="font-medium text-[var(--color-text-primary)]">{svc.serviceName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className={`font-medium ${isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
{isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'}
</span>
{svc.healthCheckedAt && (
<span className="text-[var(--color-text-tertiary)] text-xs">{svc.healthCheckedAt}</span>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-4">
<p className="text-[var(--color-text-secondary)] text-sm"> </p>
{/* Row 1: Summary Cards (50%) + Heartbeat (50%) */}
<div className="grid grid-cols-2 gap-4 mb-4">
{/* 좌측: 요약 카드 1x4 */}
{stats && (
<div className="grid grid-cols-4 gap-3">
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
<p className="text-xs text-[var(--color-text-secondary)]"> </p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.totalRequests.toLocaleString()}</p>
<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(1)}%
</p>
</div>
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
<p className="text-xs text-[var(--color-text-secondary)]"></p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.successRate.toFixed(1)}%</p>
<p className="text-xs text-red-500"> {stats.failureCount}</p>
</div>
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
<p className="text-xs text-[var(--color-text-secondary)]"> </p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.avgResponseTime.toFixed(0)}ms</p>
</div>
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3">
<p className="text-xs text-[var(--color-text-secondary)]"> </p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{stats.activeUserCount}</p>
</div>
</div>
)}
{/* 우측: 서비스 상태 카드 (필 그리드) */}
<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.map((svc) => {
const isUp = svc.healthStatus === 'UP';
const isDown = svc.healthStatus === 'DOWN';
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 (
<div
key={svc.serviceId}
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')}
>
<div className="relative shrink-0">
{isUp && <div className={`absolute -inset-0.5 rounded-full ${dotColor} opacity-20`} />}
<div className={`w-1.5 h-1.5 rounded-full ${dotColor} relative`} />
</div>
<span className="text-xs font-medium text-[var(--color-text-primary)]">{svc.serviceName}</span>
{svc.healthResponseTime !== null && (
<span className="text-[10px] text-[var(--color-text-tertiary)] ml-0.5">{svc.healthResponseTime}ms</span>
)}
</div>
);
})
) : (
<p className="text-[var(--color-text-secondary)] text-xs"> </p>
)}
</div>
</div>
</div>
{/* Row 3: Charts 2x2 */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Hourly Trend */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<div className="grid grid-cols-4 gap-4 mb-4">
{/* Chart 1: Hourly Trend (3/4) */}
<div className="col-span-3 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-2"> </h3>
{hourlyTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={hourlyTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} />
<YAxis />
<Tooltip labelFormatter={(h) => `${h}`} />
<Legend />
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} width={35} />
<Tooltip labelFormatter={(h) => `${h}`} contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="successCount" stroke={chartColors[0]} name="성공" />
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
</LineChart>
</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>
{/* Chart 2: Service Ratio */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<div className="col-span-1 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-2"> </h3>
{serviceRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={150}>
<PieChart>
<Pie
data={serviceRatio}
dataKey="count"
nameKey="serviceName"
innerRadius={60}
outerRadius={100}
innerRadius={30}
outerRadius={52}
cx="50%"
cy="45%"
>
{serviceRatio.map((_, idx) => (
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
{serviceRatio.map((entry, idx) => (
<Cell key={idx} fill={getServiceChartColor(entry.serviceName)} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend layout="horizontal" align="center" verticalAlign="bottom" wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</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>
{/* Chart 3: Error Trend */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
{/* Chart 3: Error Trend (1/4) */}
<div className="col-span-1 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-2"> </h3>
{errorTrendPivoted.data.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={errorTrendPivoted.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} />
<YAxis unit="%" />
<Tooltip labelFormatter={(h) => `${h}`} />
<Legend />
{errorTrendPivoted.serviceNames.map((name, idx) => (
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} tick={{ fontSize: 10 }} />
<YAxis unit="%" tick={{ fontSize: 10 }} width={35} />
<Tooltip labelFormatter={(h) => `${h}`} contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{errorTrendPivoted.serviceNames.map((name) => (
<Area
key={name}
type="monotone"
dataKey={name}
stackId="1"
stroke={chartColors[idx % chartColors.length]}
fill={chartColors[idx % chartColors.length]}
stroke={getServiceChartColor(name)}
fill={getServiceChartColor(name)}
fillOpacity={0.3}
/>
))}
</AreaChart>
</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>
{/* Chart 4: Top APIs */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> API</h3>
{/* Chart 4: Top APIs (3/4) */}
<div className="col-span-3 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-2"> API</h3>
{topApis.length > 0 ? (
<div className="space-y-2">
{topApis.map((api, idx) => {
<div className="space-y-1.5">
{topApis.slice(0, 5).map((api, idx) => {
const maxCount = topApis[0]?.count || 1;
const pct = (api.count / maxCount) * 100;
const colors = topApiServiceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: chartColors[0] };
return (
<div key={idx} className="flex items-center gap-3">
<span className="text-xs text-[var(--color-text-tertiary)] w-5 text-right">{idx + 1}</span>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
{api.serviceName}
</span>
<span className="shrink-0 text-sm text-[var(--color-text-primary)] w-48 truncate" title={api.apiName}>
<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)] text-right">{idx + 1}</span>
<Badge variant={getServiceVariant(api.serviceName)} className="rounded-md truncate text-center w-full justify-center">{api.serviceName}</Badge>
<span className="text-sm text-[var(--color-text-primary)] truncate" title={api.apiName}>
{api.apiName}
</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
className="h-5 rounded-full"
style={{ width: `${pct}%`, backgroundColor: colors.bar }}
style={{ width: `${pct}%`, backgroundColor: getServiceChartColor(api.serviceName) }}
/>
</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>
) : (
<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>
{/* Row 4: Recent Logs */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]"> </h3>
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<div className="px-4 py-3 border-b border-[var(--color-border)]">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]"> </h3>
</div>
{recentLogs.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 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-4 py-3 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">URL</th>
<th className="px-4 py-3 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 w-[70px]"></th>
<th className="px-3 py-2 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-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase w-[80px]"></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-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
@ -345,26 +374,34 @@ const DashboardPage = () => {
className="hover:bg-[var(--color-bg-base)] cursor-pointer"
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-4 py-3 text-[var(--color-text-primary)]">{log.serviceName ?? '-'}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.userName ?? '-'}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]" title={log.requestUrl}>
{truncate(log.requestUrl, 40)}
<td className="px-3 py-1 w-[70px]">
{log.serviceName ? (
<Badge variant={getServiceVariant(log.serviceName)} className="rounded-md w-full justify-center truncate">{log.serviceName}</Badge>
) : (
<span className="text-xs text-[var(--color-text-tertiary)]">-</span>
)}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_BADGE[log.requestStatus] ?? 'bg-gray-100 text-gray-800'}`}>
{log.requestStatus}
</span>
<td className="px-3 py-1 text-xs text-[var(--color-text-primary)]">{log.userName ?? '-'}</td>
<td className="px-3 py-1 text-xs text-[var(--color-text-primary)]" title={log.requestUrl}>
{truncate(log.requestUrl, 70)}
</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` : '-'}
</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>
))}
</tbody>
</table>
</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
onClick={() => navigate('/monitoring/request-logs')}
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] font-medium"

파일 보기

@ -376,19 +376,26 @@ const ApiEditPage = () => {
{/* Header */}
<div className="flex items-start justify-between mb-2">
<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">
<Badge className={METHOD_CLASS[apiMethod] ?? 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)]'}>
{apiMethod}
</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 className="flex items-center gap-2 shrink-0">
<Button onClick={handleDelete} variant="danger">
<Button onClick={handleDelete} variant="danger" size="sm">
</Button>
<Button onClick={handleSave} disabled={saving} variant="primary">
<Button onClick={handleSave} disabled={saving} variant="primary" size="sm">
{saving ? '저장 중...' : '저장'}
</Button>
</div>
@ -410,7 +417,7 @@ const ApiEditPage = () => {
{/* Sections */}
<div className="space-y-6 mt-6">
{/* 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>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
@ -432,7 +439,7 @@ const ApiEditPage = () => {
value={apiPath}
onChange={(e) => setApiPath(e.target.value)}
placeholder="/api/v1/example"
className={`${INPUT_CLS} font-mono`}
className={INPUT_CLS}
/>
</div>
<div>
@ -494,7 +501,7 @@ const ApiEditPage = () => {
</div>
{/* 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>
<div className="space-y-4">
<div>
@ -584,7 +591,7 @@ const ApiEditPage = () => {
</div>
{/* 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">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]"></h2>
<Button type="button" onClick={() => addParam()} variant="secondary" size="sm">
@ -592,16 +599,16 @@ const ApiEditPage = () => {
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead>
<tr className="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-2 py-2 text-left text-xs font-medium 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-2 py-2 text-left text-xs font-medium 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-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] w-28"></th>
<th className="px-2 py-2 w-10" />
<tr className="h-8 border-b border-[var(--color-border)]">
<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-3 py-2 text-left text-xs font-medium 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-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]"></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-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-28"></th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
@ -616,11 +623,11 @@ const ApiEditPage = () => {
</tr>
) : (
requestParams.map((param, idx) => (
<tr key={idx}>
<td className="px-2 py-1.5 text-[var(--color-text-secondary)] text-center text-xs">
<tr key={idx} className="h-7">
<td className="px-3 py-1 text-[var(--color-text-secondary)] text-center text-xs">
{idx + 1}
</td>
<td className="px-2 py-1.5">
<td className="px-3 py-1">
<input
type="text"
value={param.paramName}
@ -629,7 +636,7 @@ const ApiEditPage = () => {
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5">
<td className="px-3 py-1">
<input
type="text"
value={param.paramMeaning ?? ''}
@ -640,7 +647,7 @@ const ApiEditPage = () => {
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5">
<td className="px-3 py-1">
<input
type="text"
value={param.paramDescription ?? ''}
@ -651,7 +658,7 @@ const ApiEditPage = () => {
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5 text-center">
<td className="px-3 py-1 text-center">
<input
type="checkbox"
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)]"
/>
</td>
<td className="px-2 py-1.5">
<td className="px-3 py-1">
<select
value={param.inputType ?? 'TEXT'}
onChange={(e) => updateParam(idx, 'inputType', e.target.value)}
@ -671,7 +678,7 @@ const ApiEditPage = () => {
<option value="DATETIME">DATETIME</option>
</select>
</td>
<td className="px-2 py-1.5 text-center">
<td className="px-3 py-1 text-center">
<button
type="button"
onClick={() => removeParam(idx)}
@ -690,7 +697,7 @@ const ApiEditPage = () => {
</div>
{/* 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">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]"></h2>
<div className="flex items-center gap-2">
@ -718,7 +725,7 @@ const ApiEditPage = () => {
onChange={(e) => { setJsonInput(e.target.value); setJsonError(null); }}
rows={6}
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 && (
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
@ -737,13 +744,13 @@ const ApiEditPage = () => {
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead>
<tr className="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-2 py-2 text-left text-xs font-medium 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-2 py-2 w-10" />
<tr className="h-8 border-b border-[var(--color-border)]">
<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-3 py-2 text-left text-xs font-medium 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-3 py-2 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
@ -758,11 +765,11 @@ const ApiEditPage = () => {
</tr>
) : (
responseParams.map((param, idx) => (
<tr key={idx}>
<td className="px-2 py-1.5 text-[var(--color-text-secondary)] text-center text-xs">
<tr key={idx} className="h-7">
<td className="px-3 py-1 text-[var(--color-text-secondary)] text-center text-xs">
{idx + 1}
</td>
<td className="px-2 py-1.5">
<td className="px-3 py-1">
<input
type="text"
value={param.paramName}
@ -771,7 +778,7 @@ const ApiEditPage = () => {
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5">
<td className="px-3 py-1">
<input
type="text"
value={param.paramMeaning ?? ''}
@ -782,7 +789,7 @@ const ApiEditPage = () => {
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5 text-center">
<td className="px-3 py-1 text-center">
<button
type="button"
onClick={() => removeResponseParam(idx)}

파일 보기

@ -160,8 +160,18 @@ const ApisPage = () => {
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">API </h1>
<Button onClick={handleOpenModal}>API </Button>
<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>
<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>
{/* Global error */}
@ -214,18 +224,18 @@ const ApisPage = () => {
</div>
{/* Table */}
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<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-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<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 className="px-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]"></th>
<tr className="h-8">
<th className="px-3 py-2 text-left text-xs font-medium 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)]">Method</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-3 py-2 text-left text-xs font-medium 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)]">Domain</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
@ -233,33 +243,33 @@ const ApisPage = () => {
<tr
key={`${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-4 py-3">
<Badge className={METHOD_COLOR[api.apiMethod] ?? ''}>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.serviceName}</td>
<td className="px-3 py-1">
<Badge size="sm" className={METHOD_COLOR[api.apiMethod] ?? ''}>
{api.apiMethod}
</Badge>
</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}
</td>
<td className="px-4 py-3 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-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
<td className="px-4 py-3">
<Badge variant={api.isActive ? 'success' : 'danger'}>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.apiName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
<td className="px-3 py-1">
<Badge variant={api.isActive ? 'success' : 'danger'} size="sm">
{api.isActive ? 'Active' : 'Inactive'}
</Badge>
</td>
<td className="px-4 py-3">
<td className="px-3 py-1">
<span className="text-[var(--color-text-tertiary)]">-</span>
</td>
</tr>
))}
{filteredApis.length === 0 && (
<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가
</td>
</tr>
@ -329,7 +339,7 @@ const ApisPage = () => {
onChange={(e) => setModalPath(e.target.value)}
required
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>

파일 보기

@ -128,30 +128,40 @@ const DomainsPage = () => {
return (
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Domains</h1>
<Button onClick={handleOpenCreate}> </Button>
<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="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>
{error && !isModalOpen && (
<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">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<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-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<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)]"></th>
<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)]"></th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
<tr className="h-8">
<th className="px-3 py-2 text-left text-xs font-medium 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-3 py-2 text-left text-xs font-medium 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-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{domains.map((domain, index) => (
<tr key={domain.domainId} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{index + 1}</td>
<td className="px-4 py-3">
<tr key={domain.domainId} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{index + 1}</td>
<td className="px-3 py-1">
<svg
className="h-5 w-5 text-[var(--color-text-secondary)]"
fill="none"
@ -166,14 +176,14 @@ const DomainsPage = () => {
))}
</svg>
</td>
<td className="px-4 py-3 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-4 py-3">
<td className="px-3 py-1 text-[var(--color-text-primary)] font-medium">{domain.domainName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{domain.sortOrder}</td>
<td className="px-3 py-1">
<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 variant="danger" size="sm" onClick={() => handleDelete(domain)}>
<Button variant="danger" size="xs" onClick={() => handleDelete(domain)}>
</Button>
</div>
@ -182,7 +192,7 @@ const DomainsPage = () => {
))}
{domains.length === 0 && (
<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>
</tr>
@ -230,7 +240,7 @@ const DomainsPage = () => {
onChange={(e) => setIconPath(e.target.value)}
rows={3}
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>

파일 보기

@ -5,7 +5,7 @@ import Button from '../../components/ui/Button';
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
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 [sampleCode, setSampleCode] = useState('');
@ -59,15 +59,21 @@ const SampleCodePage = () => {
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]"> </h1>
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
API HUB .
</p>
<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>
<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>
<Button
onClick={handleSave}
disabled={saving}
size="sm"
className="shrink-0"
>
{saving ? '저장 중...' : '저장'}
@ -86,7 +92,7 @@ const SampleCodePage = () => {
</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>

파일 보기

@ -197,26 +197,36 @@ const ServicesPage = () => {
return (
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Services</h1>
<Button onClick={handleOpenCreateService}>Create Service</Button>
<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}>
<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>
{error && !isServiceModalOpen && (
<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">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<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-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
<tr className="h-8">
<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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">URL</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Last Checked</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
@ -233,39 +243,39 @@ const ServicesPage = () => {
<tr
key={service.serviceId}
onClick={() => handleSelectService(service)}
className={`cursor-pointer ${
className={`h-7 cursor-pointer ${
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-4 py-3 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-primary)]">{service.serviceCode}</td>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{service.serviceName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)] truncate max-w-[200px]">
{service.serviceUrl || '-'}
</td>
<td className="px-4 py-3">
<Badge variant={healthVariant} className="gap-1.5">
<td className="px-3 py-1">
<Badge variant={healthVariant} size="sm" className="gap-1.5">
<span className={`w-2 h-2 rounded-full ${dot}`} />
{service.healthStatus}
</Badge>
</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}ms`
: '-'}
</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)}
</td>
<td className="px-4 py-3">
<Badge variant={service.isActive ? 'success' : 'danger'}>
<td className="px-3 py-1">
<Badge variant={service.isActive ? 'success' : 'danger'} size="sm">
{service.isActive ? 'Active' : 'Inactive'}
</Badge>
</td>
<td className="px-4 py-3">
<td className="px-3 py-1">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
size="xs"
onClick={(e) => {
e.stopPropagation();
handleOpenEditService(service);
@ -275,7 +285,7 @@ const ServicesPage = () => {
</Button>
<Button
variant="danger"
size="sm"
size="xs"
onClick={(e) => {
e.stopPropagation();
handleDeleteService(service);
@ -290,7 +300,7 @@ const ServicesPage = () => {
})}
{services.length === 0 && (
<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>
</tr>
@ -300,7 +310,7 @@ const ServicesPage = () => {
</div>
{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)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
APIs for {selectedService.serviceName}
@ -308,22 +318,22 @@ const ServicesPage = () => {
<Button size="sm" onClick={() => navigate('/admin/apis')}>API </Button>
</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-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
<tr className="h-8">
<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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Name</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Description</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{serviceApis.map((api) => (
<tr key={api.apiId} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3">
<tr key={api.apiId} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1">
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
@ -332,13 +342,13 @@ const ServicesPage = () => {
{api.apiMethod}
</span>
</td>
<td className="px-4 py-3 font-mono 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-4 py-3 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-4 py-3 text-[var(--color-text-secondary)]">{api.description || '-'}</td>
<td className="px-4 py-3">
<Badge variant={api.isActive ? 'success' : 'danger'}>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.apiPath}</td>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{api.apiName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{api.description || '-'}</td>
<td className="px-3 py-1">
<Badge variant={api.isActive ? 'success' : 'danger'} size="sm">
{api.isActive ? 'Active' : 'Inactive'}
</Badge>
</td>
@ -346,7 +356,7 @@ const ServicesPage = () => {
))}
{serviceApis.length === 0 && (
<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가 .
</td>
</tr>

파일 보기

@ -102,42 +102,52 @@ const TenantsPage = () => {
return (
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Tenants</h1>
<Button onClick={handleOpenCreate}>Create Tenant</Button>
<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)]">Tenants</h1>
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
</div>
</div>
<Button onClick={handleOpenCreate} size="sm">Create Tenant</Button>
</div>
{error && !isModalOpen && (
<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">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<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-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Created</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
<tr className="h-8">
<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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Description</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{tenants.map((tenant) => (
<tr key={tenant.tenantId} className="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-4 py-3 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-4 py-3">
<Badge variant={tenant.isActive ? 'success' : 'danger'}>
<tr key={tenant.tenantId} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1 text-[var(--color-text-primary)]">{tenant.tenantCode}</td>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{tenant.tenantName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{tenant.description || '-'}</td>
<td className="px-3 py-1">
<Badge variant={tenant.isActive ? 'success' : 'danger'} size="sm">
{tenant.isActive ? 'Active' : 'Inactive'}
</Badge>
</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()}
</td>
<td className="px-4 py-3">
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(tenant)}>
<td className="px-3 py-1">
<Button variant="outline" size="xs" onClick={() => handleOpenEdit(tenant)}>
</Button>
</td>
@ -145,7 +155,7 @@ const TenantsPage = () => {
))}
{tenants.length === 0 && (
<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>
</tr>

파일 보기

@ -10,7 +10,7 @@ import type { BadgeVariant } from '../../components/ui/Badge';
const ROLE_BADGE_VARIANT: Record<string, BadgeVariant> = {
ADMIN: 'danger',
MANAGER: 'warning',
USER: 'primary',
USER: 'blue',
VIEWER: 'default',
};
@ -144,57 +144,67 @@ const UsersPage = () => {
return (
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Users</h1>
<Button onClick={handleOpenCreate}>Create User</Button>
<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)]">Users</h1>
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
</div>
</div>
<Button onClick={handleOpenCreate} size="sm">Create User</Button>
</div>
{error && !isModalOpen && (
<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">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<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-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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>
<tr className="h-8">
<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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Email</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Active</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{users.map((user) => (
<tr key={user.userId} className="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-4 py-3 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-4 py-3 text-[var(--color-text-secondary)]">{user.tenantName || '-'}</td>
<td className="px-4 py-3">
<Badge variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'}>
<tr key={user.userId} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1 text-[var(--color-text-primary)]">{user.loginId}</td>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{user.userName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{user.email || '-'}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{user.tenantName || '-'}</td>
<td className="px-3 py-1">
<Badge variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'} size="sm">
{user.role}
</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={user.isActive ? 'success' : 'danger'}>
<td className="px-3 py-1">
<Badge variant={user.isActive ? 'success' : 'danger'} size="sm">
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</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
? new Date(user.lastLoginAt).toLocaleString()
: '-'}
</td>
<td className="px-4 py-3">
<td className="px-3 py-1">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(user)}>
<Button variant="outline" size="xs" onClick={() => handleOpenEdit(user)}>
</Button>
{user.isActive && (
<Button variant="danger" size="sm" onClick={() => handleDeactivate(user)}>
<Button variant="danger" size="xs" onClick={() => handleDeactivate(user)}>
</Button>
)}
@ -204,7 +214,7 @@ const UsersPage = () => {
))}
{users.length === 0 && (
<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>
</tr>

파일 보기

@ -342,13 +342,15 @@ const KeyAdminPage = () => {
return (
<div className="max-w-7xl mx-auto">
{/* Page Header */}
<div className="mb-6">
<div className="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"><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>
<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>
</div>
</div>
@ -405,7 +407,7 @@ const KeyAdminPage = () => {
<div className="flex">
<button
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'
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
@ -419,7 +421,7 @@ const KeyAdminPage = () => {
</button>
<button
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'
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
: '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>
{activeKeyCount > 0 && (
<Badge variant="primary" size="sm">{activeKeyCount}</Badge>
<Badge variant="blue" size="sm">{activeKeyCount}</Badge>
)}
</button>
</div>
<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
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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>
{/* 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' ? (
<>
{([['ALL', '전체'], ['PENDING', '대기'], ['APPROVED', '승인'], ['REJECTED', '반려']] as const).map(([value, label]) => (
<button
key={value}
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
? '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)]'
@ -469,7 +471,7 @@ const KeyAdminPage = () => {
<button
key={value}
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
? '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)]'
@ -491,43 +493,43 @@ const KeyAdminPage = () => {
) : (
<>
<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)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold 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-4 py-3 text-left text-xs font-semibold 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-4 py-3 text-left text-xs font-semibold 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>
<tr className="h-8">
<th className="px-3 py-2 text-left text-xs font-medium 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-3 py-2 text-left text-xs font-medium 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)]">API </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-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{pagedRequests.map((req) => (
<tr key={req.requestId} className="hover:bg-[var(--color-bg-base)] transition-colors">
<td className="px-4 py-3 text-[var(--color-text-primary)]">
<tr key={req.requestId} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
<td className="px-3 py-1 text-[var(--color-text-primary)]">
<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>
{req.userName}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{req.keyName}</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[req.status] || 'default'}>
<td className="px-3 py-1 text-[var(--color-text-primary)] font-medium">{req.keyName}</td>
<td className="px-3 py-1">
<Badge variant={STATUS_VARIANT[req.status] || 'default'} size="sm">
{req.status === 'PENDING' ? '대기' : req.status === 'APPROVED' ? '승인' : '반려'}
</Badge>
</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>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(req.createdAt)}</td>
<td className="px-4 py-3">
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(req.createdAt)}</td>
<td className="px-3 py-1">
<div className="flex gap-2">
{req.status === 'PENDING' && (
<Button
onClick={() => handleOpenReview(req)}
variant="secondary"
size="sm"
size="xs"
>
</Button>
@ -535,8 +537,8 @@ const KeyAdminPage = () => {
{(req.status === 'APPROVED' || req.status === 'REJECTED') && (
<Button
onClick={() => handleOpenDetail(req)}
variant="secondary"
size="sm"
variant="outline"
size="xs"
>
</Button>
@ -547,7 +549,7 @@ const KeyAdminPage = () => {
))}
{filteredRequests.length === 0 && (
<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' ? '조건에 맞는 신청이 없습니다.' : '신청 내역이 없습니다.'}
</td>
</tr>
@ -556,17 +558,38 @@ const KeyAdminPage = () => {
</table>
</div>
{/* Table Footer */}
<div className="px-4 py-3 border-t border-[var(--color-border)] flex items-center justify-between">
<span className="text-sm text-[var(--color-text-secondary)]"> {filteredRequests.length}</span>
{requestTotalPages > 1 && (
<div className="flex items-center gap-1">
<button onClick={() => setRequestPage(Math.max(0, requestPage - 1))} disabled={requestPage === 0}
className="px-2.5 py-1 text-xs rounded border border-[var(--color-border)] text-[var(--color-text-secondary)] disabled:opacity-40"></button>
<span className="text-xs text-[var(--color-text-secondary)] px-2">{requestPage + 1} / {requestTotalPages}</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>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)]">
<span className="text-xs text-[var(--color-text-tertiary)]">
{' '}
<span className="text-[var(--color-text-primary)] font-semibold">{filteredRequests.length}</span> {' '}
<span className="text-[var(--color-text-primary)] font-semibold">
{filteredRequests.length === 0 ? 0 : requestPage * PAGE_SIZE + 1}-{Math.min((requestPage + 1) * PAGE_SIZE, filteredRequests.length)}
</span>
</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>
</>
)}
@ -581,16 +604,16 @@ const KeyAdminPage = () => {
) : (
<>
<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)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold 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-4 py-3 text-left text-xs font-semibold 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-4 py-3 text-left text-xs font-semibold 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-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
<tr className="h-8">
<th className="px-3 py-2 text-left text-xs font-medium 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-3 py-2 text-left text-xs font-medium 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)]"></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-3 py-2 text-left text-xs font-medium 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)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
@ -598,23 +621,23 @@ const KeyAdminPage = () => {
const daysLeft = getDaysUntilExpiry(key.expiresAt);
const isExpiringSoon = key.status === 'ACTIVE' && daysLeft !== null && daysLeft <= 14;
return (
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)] transition-colors">
<td className="px-4 py-3 text-[var(--color-text-primary)]">
<tr key={key.apiKeyId} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
<td className="px-3 py-1 text-[var(--color-text-primary)]">
<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>
{key.userName || '-'}
</span>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{key.keyName}</td>
<td className="px-4 py-3">
<Badge variant="info" className="font-mono font-semibold">{key.apiKeyPrefix}</Badge>
<td className="px-3 py-1 text-gray-900 dark:text-gray-100 font-medium">{key.keyName}</td>
<td className="px-3 py-1">
<Badge variant="info" size="sm" className="font-semibold">{key.apiKeyPrefix}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[key.status] || 'default'}>
<td className="px-3 py-1">
<Badge variant={STATUS_VARIANT[key.status] || 'default'} size="sm">
{KEY_STATUS_CONFIG[key.status]?.label || key.status}
</Badge>
</td>
<td className="px-4 py-3">
<td className="px-3 py-1">
<div className="flex items-center gap-2">
<span className="text-[var(--color-text-secondary)]">{key.expiresAt ? formatDateTime(key.expiresAt) : '영구'}</span>
{isExpiringSoon && (
@ -624,13 +647,13 @@ const KeyAdminPage = () => {
)}
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
<td className="px-4 py-3">
<td className="px-3 py-1 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
<td className="px-3 py-1">
<div className="flex gap-2">
<Button
onClick={() => handleViewDetail(key)}
variant="secondary"
size="sm"
variant="outline"
size="xs"
>
</Button>
@ -638,7 +661,7 @@ const KeyAdminPage = () => {
<Button
onClick={() => handleRevokeKey(key)}
variant="danger"
size="sm"
size="xs"
>
</Button>
@ -650,7 +673,7 @@ const KeyAdminPage = () => {
})}
{filteredKeys.length === 0 && (
<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가 없습니다.'}
</td>
</tr>
@ -659,17 +682,38 @@ const KeyAdminPage = () => {
</table>
</div>
{/* Table Footer */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/50 flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400"> {filteredKeys.length}</span>
{keyTotalPages > 1 && (
<div className="flex items-center gap-1">
<button onClick={() => setKeyPage(Math.max(0, keyPage - 1))} disabled={keyPage === 0}
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 className="text-xs text-gray-500 dark:text-gray-400 px-2">{keyPage + 1} / {keyTotalPages}</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>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)]">
<span className="text-xs text-[var(--color-text-tertiary)]">
{' '}
<span className="text-[var(--color-text-primary)] font-semibold">{filteredKeys.length}</span> {' '}
<span className="text-[var(--color-text-primary)] font-semibold">
{filteredKeys.length === 0 ? 0 : keyPage * PAGE_SIZE + 1}-{Math.min((keyPage + 1) * PAGE_SIZE, filteredKeys.length)}
</span>
</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>
</>
)}
@ -894,7 +938,7 @@ const KeyAdminPage = () => {
</div>
<span className="text-xs text-gray-500 dark:text-gray-500"> IP</span>
</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 && (
<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'}`}>
{api.apiMethod}
</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>
{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>
@ -1217,7 +1261,7 @@ const KeyAdminPage = () => {
<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>
<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>
</div>
@ -1309,22 +1353,22 @@ const KeyAdminPage = () => {
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400"> </span>
</div>
<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>
<tr className="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="text-[11px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 px-3 py-2 text-left"> </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="text-[11px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 px-3 py-2 text-left"> </th>
<tr className="h-8 bg-amber-50/50 dark:bg-amber-900/10">
<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="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400"> </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="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400"> </th>
</tr>
</thead>
<tbody>
{apiChanged && (
<tr className="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-2 text-sm text-gray-500 dark:text-gray-400">{originalApiIds.size} API</td>
<tr className="h-7 bg-amber-50/30 dark:bg-amber-900/5">
<td className="px-3 py-1 text-gray-700 dark:text-gray-300">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">&rarr;</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
{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>}
@ -1332,13 +1376,13 @@ const KeyAdminPage = () => {
</tr>
)}
{dateChanged && (
<tr className="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-2 text-sm text-gray-500 dark:text-gray-400 line-through">
<tr className="h-7 bg-amber-50/30 dark:bg-amber-900/5">
<td className="px-3 py-1 text-gray-700 dark:text-gray-300"> </td>
<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] || '-'}
</td>
<td className="text-center text-gray-400 w-8">&rarr;</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 || '-'}
</td>
</tr>
@ -1467,7 +1511,7 @@ const KeyAdminPage = () => {
{/* Status Banner */}
<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`}>
<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={`w-1.5 h-1.5 rounded-full ${statusConf.dotClass}`} />
{statusConf.label}
@ -1499,7 +1543,7 @@ const KeyAdminPage = () => {
</div>
<div className="min-w-0">
<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>
{/* EXPIRES AT */}

파일 보기

@ -4,6 +4,7 @@ import { getCatalog } from '../../services/apiHubService';
import { createKeyRequest } from '../../services/apiKeyService';
import type { ServiceCatalog } from '../../types/apihub';
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 ref = useRef<HTMLInputElement>(null);
@ -24,6 +25,8 @@ interface FlatDomainGroup {
apis: FlatApi[];
}
const PRESET_MONTHS = [3, 6, 9] as const;
const KeyRequestPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -40,6 +43,7 @@ const KeyRequestPage = () => {
const [dailyRequestEstimate, setDailyRequestEstimate] = useState('');
const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
const [isPermanent, setIsPermanent] = useState(false);
const [selectedPresetMonths, setSelectedPresetMonths] = useState<number | null>(null);
const [usageFromDate, setUsageFromDate] = useState('');
const [usageToDate, setUsageToDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
@ -208,9 +212,6 @@ const KeyRequestPage = () => {
}
};
const handleClearSelection = () => {
setSelectedApiIds(new Set());
};
const handlePresetPeriod = (months: number) => {
const from = new Date();
@ -220,12 +221,14 @@ const KeyRequestPage = () => {
setUsageToDate(to.toISOString().split('T')[0]);
setUsagePeriodMode('preset');
setIsPermanent(false);
setSelectedPresetMonths(months);
};
const handlePermanent = () => {
setIsPermanent(true);
setUsageFromDate('');
setUsageToDate('');
setSelectedPresetMonths(null);
};
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) {
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]"> ...</div>;
}
@ -291,185 +314,242 @@ const KeyRequestPage = () => {
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">API Key </h1>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<form onSubmit={handleSubmit}>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
Key Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
required
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"
/>
<form onSubmit={handleSubmit} className="max-w-7xl mx-auto flex flex-col h-full">
{/* 제목 행 */}
<div className="mb-3 shrink-0">
<div className="flex items-center gap-3">
<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" />
</svg>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1"> </label>
<textarea
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={2}
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"
/>
<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>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
<span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-2 mb-2">
<button type="button" onClick={() => handlePresetPeriod(3)}
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)]'}`}>
3
</button>
<button type="button" onClick={() => handlePresetPeriod(6)}
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)]'}`}>
6
</button>
<button type="button" onClick={() => handlePresetPeriod(9)}
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)]'}`}>
9
</button>
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
<Button type="button" size="sm" onClick={handlePermanent}
variant={isPermanent ? 'primary' : 'outline'}>
</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">
<button type="button"
onClick={() => {
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
setIsPermanent(false);
}}
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)]'}`}>
<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>
</label>
</div>
{isPermanent ? (
<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>
</div>
) : (
<div className="flex items-center gap-2">
<input type="date" value={usageFromDate}
onChange={(e) => setUsageFromDate(e.target.value)}
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)]'}`} />
<span className="text-[var(--color-text-secondary)]">~</span>
<input type="date" value={usageToDate}
onChange={(e) => setUsageToDate(e.target.value)}
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)]'}`} />
</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>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
IP <span className="text-red-500">*</span>
</label>
<input type="text" value={serviceIp}
onChange={(e) => setServiceIp(e.target.value)}
required placeholder="예: 192.168.1.100"
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" />
<p className="text-xs text-[var(--color-text-tertiary)] mt-1"> API Key로 IP</p>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Key Name */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
<span className="text-red-500">*</span>
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
Key Name <span className="text-red-500">*</span>
</label>
<select value={servicePurpose}
onChange={(e) => setServicePurpose(e.target.value)}
<input
type="text"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
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">
<option value=""></option>
<option value="로컬 환경"> </option>
<option value="개발 서버"> </option>
<option value="검증 서버"> </option>
<option value="운영 서버"> </option>
</select>
placeholder="API Key 이름을 입력하세요"
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>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
<span className="text-red-500">*</span>
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1"> </label>
<textarea
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={2}
placeholder="사용 목적을 입력하세요"
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>
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
<span className="text-red-500">*</span>
</label>
<select value={dailyRequestEstimate}
onChange={(e) => setDailyRequestEstimate(e.target.value)}
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">
<option value=""></option>
<option value="100">100 </option>
<option value="500">100~500</option>
<option value="1000">500~1,000</option>
<option value="5000">1,000~5,000</option>
<option value="10000">5,000~10,000</option>
<option value="50000">10,000 </option>
</select>
{/* 세그먼트 컨트롤 */}
<div className="flex items-center gap-2 mb-2">
<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'}
className={`px-2.5 py-1 text-xs rounded transition-colors ${
!isPermanent && usagePeriodMode === 'preset' && selectedPresetMonths === m
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
>
{m}
</button>
))}
</div>
<span className="text-[var(--color-text-tertiary)]">|</span>
<Button
type="button"
size="sm"
onClick={handlePermanent}
variant={isPermanent ? 'primary' : 'outline'}
>
</Button>
<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"
onClick={() => {
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
setIsPermanent(false);
setSelectedPresetMonths(null);
}}
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' : ''}`} />
</button>
</label>
</div>
{/* 날짜 입력 */}
{isPermanent ? (
<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-xs font-medium"> ( )</span>
</div>
) : (
<div className="flex items-center gap-2">
<input
type="date"
value={usageFromDate}
onChange={(e) => setUsageFromDate(e.target.value)}
readOnly={usagePeriodMode !== 'custom'}
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>
<input
type="date"
value={usageToDate}
onChange={(e) => setUsageToDate(e.target.value)}
readOnly={usagePeriodMode !== 'custom'}
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 className="flex flex-col gap-3">
{/* 서비스 IP */}
<div>
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
IP <span className="text-red-500">*</span>
</label>
<input
type="text"
value={serviceIp}
onChange={(e) => setServiceIp(e.target.value)}
required
placeholder="예: 192.168.1.100"
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>
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
<span className="text-red-500">*</span>
</label>
<select
value={servicePurpose}
onChange={(e) => setServicePurpose(e.target.value)}
required
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>
</select>
</div>
{/* 하루 예상 요청량 */}
<div>
<label className="block text-xs font-medium text-[var(--color-text-primary)] mb-1">
<span className="text-red-500">*</span>
</label>
<select
value={dailyRequestEstimate}
onChange={(e) => setDailyRequestEstimate(e.target.value)}
required
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="100">100 </option>
<option value="500">100~500</option>
<option value="1000">500~1,000</option>
<option value="5000">1,000~5,000</option>
<option value="10000">5,000~10,000</option>
<option value="50000">10,000 </option>
</select>
</div>
</div>
</div>
</div>
{/* API Selection Section */}
<div className="bg-[var(--color-bg-surface)] rounded-xl border border-[var(--color-border)] shadow mb-6 overflow-hidden">
{/* 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="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer" onClick={(e) => e.stopPropagation()}>
<IndeterminateCheckbox
checked={allApisSelected}
indeterminate={!allApisSelected && someApisSelected}
onChange={handleToggleAll}
className="rounded"
/>
</label>
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">API </h2>
{selectedApiIds.size > 0 && (
<span className="text-xs font-medium bg-blue-100 text-[var(--color-primary)] px-2.5 py-0.5 rounded-full">
{selectedApiIds.size}
</span>
)}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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"
{/* 우측: API 선택 카드 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex-1 flex flex-col">
{/* 헤더 행 */}
<div className="shrink-0 flex items-center gap-3 mb-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="M4 6h16M4 12h16M4 18h16" />
</svg>
API
</p>
<label className="flex items-center gap-1.5 cursor-pointer" onClick={(e) => e.stopPropagation()}>
<IndeterminateCheckbox
checked={allApisSelected}
indeterminate={!allApisSelected && someApisSelected}
onChange={handleToggleAll}
className="rounded"
/>
{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>
<span className="text-xs text-[var(--color-text-secondary)]"></span>
</label>
<Badge variant="info" size="sm">
{selectedApiIds.size} / {allApis.length}
</Badge>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="API 검색..."
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"
/>
</div>
{/* Domain cards */}
<div className="p-4 space-y-3">
{/* API 카테고리 목록 */}
<div className="flex-1 overflow-y-auto space-y-2">
{filteredDomainGroups.map((domainGroup) => {
const isDomainExpanded = expandedDomains.has(domainGroup.domain);
const domainApis = domainGroup.apis;
@ -481,15 +561,15 @@ const KeyRequestPage = () => {
return (
<div
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
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)}
>
<div className="flex items-center gap-3">
<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">
<div className="flex items-center gap-2.5">
<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" />
</svg>
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
@ -500,19 +580,21 @@ const KeyRequestPage = () => {
className="rounded"
/>
</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 && (
<span className="text-xs font-medium bg-blue-100 text-[var(--color-primary)] px-2 py-0.5 rounded-full">
{selectedCount} selected
<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}
</span>
)}
</div>
<span className="text-xs font-medium bg-[var(--color-bg-base)] text-[var(--color-text-secondary)] px-2 py-0.5 rounded-full">
{domainApis.length} API
<span className="text-xs text-[var(--color-text-tertiary)]">
{domainApis.length}
</span>
</div>
{/* API list */}
{/* API 목록 */}
{isDomainExpanded && (
<div className="divide-y divide-[var(--color-border)] bg-[var(--color-bg-surface)]">
{domainApis.map((api) => {
@ -520,21 +602,17 @@ const KeyRequestPage = () => {
return (
<div
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)}
>
<div className="flex items-center pt-0.5">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleApi(api.apiId)}
onClick={(e) => e.stopPropagation()}
className="rounded"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{api.apiName}</p>
</div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleApi(api.apiId)}
onClick={(e) => e.stopPropagation()}
className="rounded shrink-0"
/>
<p className="text-xs text-[var(--color-text-primary)] truncate">{api.apiName}</p>
</div>
);
})}
@ -544,40 +622,59 @@ const KeyRequestPage = () => {
);
})}
{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가 없습니다.'}
</div>
)}
</div>
</div>
</div>
{/* Bottom sticky summary bar */}
{selectedApiIds.size > 0 && (
<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">
<span className="text-sm font-medium">{selectedApiIds.size} API가 </span>
<button
type="button"
onClick={handleClearSelection}
className="text-sm text-blue-200 hover:text-white transition-colors"
>
</button>
</div>
</div>
)}
{/* 하단 요약 바 */}
<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="flex items-center gap-4 text-sm text-[var(--color-text-secondary)]">
<span>
<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 justify-end">
{/* 우측 버튼 */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate(-1)}
>
</Button>
<Button
type="submit"
disabled={isSubmitting}
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>
</div>
</form>
</div>
</div>
</form>
);
};

파일 보기

@ -76,10 +76,20 @@ const MyKeysPage = () => {
return (
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">My API Keys</h1>
<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}>
<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">
<Button onClick={() => navigate('/apikeys/request')}>
<Button onClick={() => navigate('/apikeys/request')} size="sm">
API Key
</Button>
</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="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<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-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium 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-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
<tr className="h-8">
<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-3 py-2 text-left text-xs font-medium 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)]">Status</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-3 py-2 text-left text-xs font-medium uppercase tracking-wider 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)]">Created At</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{keys.map((key) => (
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 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-4 py-3">
<Badge variant={STATUS_VARIANT[key.status] ?? 'default'}>
<tr key={key.apiKeyId} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1 text-[var(--color-text-primary)]">{key.keyName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{key.apiKeyPrefix}</td>
<td className="px-3 py-1">
<Badge variant={STATUS_VARIANT[key.status] ?? 'default'} size="sm">
{key.status}
</Badge>
</td>
<td className="px-4 py-3 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-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.createdAt)}</td>
<td className="px-4 py-3">
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(key.expiresAt)}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(key.lastUsedAt)}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{formatDateTime(key.createdAt)}</td>
<td className="px-3 py-1">
{key.status === 'ACTIVE' && (
<Button
onClick={() => handleRevoke(key)}
variant="danger"
size="sm"
size="xs"
>
</Button>
@ -130,7 +140,7 @@ const MyKeysPage = () => {
))}
{keys.length === 0 && (
<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가 .
</td>
</tr>

파일 보기

@ -102,7 +102,7 @@ const RequestLogDetailPage = () => {
</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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<div>
@ -175,7 +175,7 @@ const RequestLogDetailPage = () => {
{/* 요청 정보 */}
{(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>
{formattedHeaders && (
<div className="mb-4">
@ -198,7 +198,7 @@ const RequestLogDetailPage = () => {
{/* 에러 정보 */}
{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>
<p className="text-sm text-red-800">{log.errorMessage}</p>
</div>

파일 보기

@ -7,13 +7,7 @@ import { getServices } from '../../services/serviceService';
import Badge from '../../components/ui/Badge';
import type { BadgeVariant } from '../../components/ui/Badge';
import Button from '../../components/ui/Button';
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',
};
import { SERVICE_BADGE_VARIANTS } from '../../constants/chart';
const STATUS_VARIANT: Record<string, BadgeVariant> = {
SUCCESS: 'success',
@ -25,6 +19,13 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
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 HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
const DEFAULT_PAGE_SIZE = 20;
@ -47,17 +48,34 @@ const formatDateTime = (dateStr: string): string => {
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 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 [datePreset, setDatePreset] = useState('오늘');
const [datePreset, setDatePreset] = useState('최근 7일');
const [serviceId, setServiceId] = useState('');
const [requestStatus, setRequestStatus] = useState('');
const [requestMethod, setRequestMethod] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [services, setServices] = useState<ServiceInfo[]>([]);
const [serviceBadgeMap, setServiceBadgeMap] = useState<Record<string, BadgeVariant>>({}); // key: serviceName
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -68,6 +86,12 @@ const RequestLogsPage = () => {
const res = await getServices();
if (res.success && 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 {
// 서비스 목록 로딩 실패는 무시
@ -86,6 +110,7 @@ const RequestLogsPage = () => {
serviceId: serviceId ? Number(serviceId) : undefined,
requestStatus: requestStatus || undefined,
requestMethod: requestMethod || undefined,
keyword: searchKeyword || undefined,
page,
size: DEFAULT_PAGE_SIZE,
};
@ -103,12 +128,14 @@ const RequestLogsPage = () => {
};
const handleReset = () => {
setStartDate(getTodayString());
const d = new Date(); d.setDate(d.getDate() - 6);
setStartDate(formatDate(d));
setEndDate(getTodayString());
setDatePreset('오늘');
setDatePreset('최근 7일');
setServiceId('');
setRequestStatus('');
setRequestMethod('');
setSearchKeyword('');
setCurrentPage(0);
};
@ -120,13 +147,14 @@ const RequestLogsPage = () => {
const handleResetAndSearch = () => {
handleReset();
// 초기화 후 기본값으로 검색 (setState는 비동기이므로 직접 호출)
setLoading(true);
setError(null);
setCurrentPage(0);
const today = getTodayString();
const d = new Date(); d.setDate(d.getDate() - 6);
const weekAgo = formatDate(d);
const params: Record<string, string | number | undefined> = {
startDate: today,
startDate: weekAgo,
endDate: today,
page: 0,
size: DEFAULT_PAGE_SIZE,
@ -163,198 +191,438 @@ 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 (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Request Logs</h1>
{/* Search Form */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="md:col-span-3">
<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">
{([
{ 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
key={btn.label}
type="button"
onClick={btn.fn}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
datePreset === btn.label
? 'bg-blue-50 border-blue-300 text-[var(--color-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-base)]'
}`}
>
{btn.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => { setStartDate(e.target.value); setDatePreset('직접 선택'); }}
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"
/>
<span className="text-[var(--color-text-secondary)]">~</span>
<input
type="date"
value={endDate}
onChange={(e) => { setEndDate(e.target.value); setDatePreset('직접 선택'); }}
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"
/>
</div>
</div>
</div>
<div className="flex items-end gap-3 flex-wrap">
<div>
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1"></label>
<select
value={serviceId}
onChange={(e) => setServiceId(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>
{services.map((s) => (
<option key={s.serviceId} value={s.serviceId}>{s.serviceName}</option>
))}
</select>
{/* 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>
<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="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) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<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="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) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="flex items-end gap-2 ml-auto">
<Button onClick={() => handleSearch(0)} variant="primary">
</Button>
<Button onClick={handleResetAndSearch} variant="secondary">
</Button>
<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>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
{/* 2행: 필터 카드 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 mb-4 space-y-3">
{/* 1줄: 기간 프리셋 + 날짜 입력 */}
<div className="flex items-center gap-2 flex-wrap">
{/* 세그먼트 컨트롤 */}
<div className="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
{DATE_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={preset.fn}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
datePreset === preset.label
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
}`}
>
{preset.label}
</button>
))}
</div>
{/* Results Table */}
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
{loading ? (
<div className="text-center py-10 text-[var(--color-text-secondary)]"> ...</div>
) : (
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<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)]"></th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">URL</th>
<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 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)]">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{result && result.content.length > 0 ? (
result.content.map((log) => (
<tr
key={log.logId}
onClick={() => handleRowClick(log.logId)}
className="cursor-pointer hover:bg-[var(--color-bg-base)]"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-primary)]">
{formatDateTime(log.requestedAt)}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName || '-'}</td>
<td className="px-4 py-3">
<Badge className={METHOD_CLASS[log.requestMethod]}>
{log.requestMethod}
</Badge>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[250px]" title={log.requestUrl}>
{log.requestUrl}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{log.responseStatus != null ? log.responseStatus : '-'}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{log.responseTime != null ? log.responseTime : '-'}
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'}>
{log.requestStatus}
</Badge>
</td>
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-tertiary)]">{log.requestIp}</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
</td>
</tr>
)}
</tbody>
</table>
{/* 구분선 */}
<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
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setDatePreset('');
}}
className={selectClassName}
/>
<span className="text-xs text-[var(--color-text-tertiary)]">~</span>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setDatePreset('');
}}
className={selectClassName}
/>
</div>
</div>
{/* 2줄: 필터 셀렉트 + URL 검색 + 버튼 */}
<div className="flex items-center gap-2 flex-wrap">
{/* 필터 아이콘 */}
<svg
className="w-3.5 h-3.5 text-[var(--color-text-tertiary)] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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) => (
<option key={s.serviceId} value={s.serviceId}>
{s.serviceName}
</option>
))}
</select>
{/* 상태 */}
<select value={requestStatus} onChange={(e) => setRequestStatus(e.target.value)} className={selectClassName}>
<option value=""> </option>
{REQUEST_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
{/* Method */}
<select value={requestMethod} onChange={(e) => setRequestMethod(e.target.value)} className={selectClassName}>
<option value="">Method </option>
{HTTP_METHODS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</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>
{/* 버튼 */}
<Button onClick={() => handleSearch(0)} variant="primary" size="sm">
</Button>
<Button onClick={handleResetAndSearch} variant="outline" size="sm">
</Button>
</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>
{/* Pagination */}
{result && result.totalElements > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--color-text-secondary)]">
{result.totalElements} / {result.page + 1} / {result.totalPages}
</span>
<div className="flex gap-2">
<Button
onClick={handlePrev}
disabled={currentPage === 0}
variant="outline"
>
</Button>
<Button
onClick={handleNext}
disabled={!result || currentPage >= result.totalPages - 1}
variant="outline"
>
</Button>
</div>
{/* 에러 메시지 */}
{error && (
<div className="mb-4 p-3 rounded-lg text-sm bg-[var(--color-danger)]/10 text-[var(--color-danger)]">
{error}
</div>
)}
{/* 3행: 로그 테이블 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl overflow-hidden mb-4">
{loading ? (
<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">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<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">
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>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{result && result.content.length > 0 ? (
result.content.map((log) => (
<tr
key={log.logId}
onClick={() => handleRowClick(log.logId)}
className="cursor-pointer hover:bg-[var(--color-bg-base)] transition-colors"
>
{/* 시간 */}
<td className="px-3 py-1 whitespace-nowrap text-xs text-[var(--color-text-secondary)]">
{formatDateTime(log.requestedAt)}
</td>
{/* 서비스 — filled Badge (rounded-md, 고정 너비) */}
<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}
</Badge>
</td>
{/* URL */}
<td
className="px-3 py-1 text-xs text-[var(--color-text-secondary)] truncate max-w-[340px]"
title={log.requestUrl}
>
{log.requestUrl}
</td>
{/* 응답코드 — 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 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>
{/* 응답결과 — pill Badge */}
<td className="px-3 py-1 whitespace-nowrap">
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'} size="sm">
{log.requestStatus}
</Badge>
</td>
{/* IP */}
<td className="px-3 py-1 whitespace-nowrap text-xs text-[var(--color-text-tertiary)]">
{log.requestIp}
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-3 py-10 text-center text-sm text-[var(--color-text-tertiary)]">
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* 4행: 페이지네이션 (테이블 카드 내부 하단) */}
{result && result.totalElements > 0 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)]">
<span className="text-xs text-[var(--color-text-tertiary)]">
{' '}
<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 items-center gap-2">
<button
type="button"
onClick={handlePrev}
disabled={currentPage === 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">{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}
disabled={!result || currentPage >= result.totalPages - 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>
);
};

파일 보기

@ -70,7 +70,7 @@ const ServiceStatusDetailPage = () => {
</button>
{/* 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 gap-3">
<div className={`w-4 h-4 rounded-full ${STATUS_COLOR[detail.currentStatus] || 'bg-gray-400'}`} />
@ -89,7 +89,7 @@ const ServiceStatusDetailPage = () => {
</div>
{/* 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>
<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>
{/* 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)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]"> </h2>
</div>

파일 보기

@ -63,7 +63,17 @@ const ServiceStatusPage = () => {
return (
<div className="max-w-7xl mx-auto">
<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>
</div>
@ -80,7 +90,7 @@ const ServiceStatusPage = () => {
{/* Service List */}
<div className="space-y-6">
{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 */}
<div className="p-6 border-b border-[var(--color-border)]">
<div className="flex items-center justify-between">

파일 보기

@ -1,39 +1,46 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
Tooltip, Legend, ResponsiveContainer,
PieChart, Pie, Cell,
} from 'recharts';
import type { ApiStatsResponse } from '../../types/statistics';
import { getApiStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
import { CHART_COLORS_HEX } from '../../constants/chart';
import PeriodFilter from '../../components/PeriodFilter';
import { CHART_COLORS_HEX, SERVICE_BADGE_VARIANTS } from '../../constants/chart';
import { useTheme } from '../../hooks/useTheme';
import Badge from '../../components/ui/Badge';
import Badge, { type BadgeVariant } from '../../components/ui/Badge';
const SERVICE_TAG_CLASSES = [
'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400',
'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
'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 getRankBadgeClass = (rank: number) =>
rank <= 3
? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]';
const METHOD_CLASS: Record<string, string> = {
GET: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
POST: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
PUT: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400',
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 getSuccessRateClass = (rate: number) => {
if (rate >= 90) return 'text-[var(--color-success)]';
if (rate >= 70) return 'text-[var(--color-warning)]';
return 'text-[var(--color-danger)]';
};
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 { theme } = useTheme();
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<ApiStatsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -53,30 +60,31 @@ const ApiStatsPage = () => {
fetchData();
}, [fetchData]);
const handlePreset = (days: number) => {
const today = getToday();
if (days === 0) {
setStartDate(today);
} else {
const d = new Date();
d.setDate(d.getDate() - days);
setStartDate(d.toISOString().slice(0, 10));
}
setEndDate(today);
};
const serviceColorMap = useMemo(() => {
if (!data) return {};
const serviceNames = [...new Set(data.topApis.map((a) => a.serviceName))];
const map: Record<string, { tag: string; bar: string }> = {};
serviceNames.forEach((name, i) => {
map[name] = {
tag: SERVICE_TAG_CLASSES[i % SERVICE_TAG_CLASSES.length],
bar: chartColors[i % chartColors.length],
};
// 서비스명 알파벳순 정렬 후 고정 색상 매핑 (대시보드와 동일 기준)
const serviceVariantMap = useMemo(() => {
if (!data) return {} as Record<string, BadgeVariant>;
const allNames = new Set<string>();
data.topApis.forEach((a) => allNames.add(a.serviceName));
data.topErrorApis.forEach((a) => allNames.add(a.serviceName));
const map: Record<string, BadgeVariant> = {};
[...allNames].sort().forEach((name, i) => {
map[name] = SERVICE_BADGE_VARIANTS[i % SERVICE_BADGE_VARIANTS.length];
});
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(() => {
if (!data) return [];
@ -95,119 +103,197 @@ const ApiStatsPage = () => {
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">API </h1>
<DateRangeFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
<div className="max-w-7xl mx-auto flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<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="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}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onRefresh={fetchData}
/>
</div>
{!data ? (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
) : (
<>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: HTTP Method Distribution */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP </h3>
{data.methodDistribution.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.methodDistribution}
dataKey="count"
nameKey="method"
innerRadius={60}
outerRadius={100}
>
{data.methodDistribution.map((_, idx) => (
<Cell key={idx} fill={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>
)}
{/* 1행: KPI(좌) + 차트(우) */}
<div className="grid grid-cols-2 gap-3">
{/* 좌: KPI 4개 */}
<div className="grid grid-cols-2 grid-rows-2 gap-3">
{/* 총 호출 수 */}
<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>
{/* Chart 2: HTTP Status Code Distribution */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP </h3>
{statusChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statusChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="statusCode" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill={chartColors[0]} name="건수" />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
)}
{/* 우: 차트 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 ? (
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie
data={data.methodDistribution}
dataKey="count"
nameKey="method"
innerRadius={38}
outerRadius={60}
>
{data.methodDistribution.map((_, idx) => (
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
))}
</Pie>
<Tooltip contentStyle={{ fontSize: 10 }} labelStyle={{ fontSize: 10 }} />
<Legend layout="vertical" align="right" verticalAlign="middle" wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-12 text-xs"> </p>
)}
</div>
{/* HTTP 상태코드 분포 (가로 바) */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-3 flex flex-col">
<h3 className="text-xs font-semibold text-[var(--color-text-primary)] mb-2">HTTP </h3>
{statusChartData.length > 0 ? (
<div className="flex flex-col gap-1.5 flex-1 justify-center">
{(() => {
const maxCount = Math.max(...statusChartData.map((s) => s.count));
return statusChartData.map((s) => {
const code = Number(s.statusCode);
const barColor = code < 300 ? 'var(--color-success)' : code < 500 ? 'var(--color-warning)' : 'var(--color-danger)';
const pct = maxCount > 0 ? (s.count / maxCount) * 100 : 0;
return (
<div key={s.statusCode} className="flex items-center gap-2">
<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-12 text-xs"> </p>
)}
</div>
</div>
</div>
{/* Table 1: Top APIs */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API </h3>
{/* 2행: API 호출 순위 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between">
<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>
{data.topApis.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 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-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API</th>
<th className="px-4 py-3 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-4 py-3 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>
<tr className="h-8">
<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-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-20"></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-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-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-24"> </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>
</thead>
<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 pct = (api.callCount / maxCount) * 100;
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_CLASSES[0], bar: chartColors[0] };
return (
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
<td className="px-4 py-3">
<Badge className={colors.tag}>
<tr key={idx} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1 w-12">
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${getRankBadgeClass(idx + 1)}`}>
{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}
</Badge>
</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}
</td>
<td className="px-4 py-3">
<Badge className={METHOD_CLASS[api.requestMethod]}>
{api.requestMethod}
</Badge>
</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-24 bg-[var(--color-bg-base)] rounded-full h-4">
<div className="h-4 rounded-full" style={{ width: `${pct}%`, backgroundColor: colors.bar }} />
<div className="flex-1 bg-[var(--color-bg-base)] rounded-full h-2">
<div
className="h-2 rounded-full bg-[var(--color-primary)]"
style={{ width: `${pct}%` }}
/>
</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>
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.avgResponseTime.toFixed(0)}ms</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.successRate.toFixed(1)}%</td>
<td className={`px-3 py-1 text-xs font-medium text-right w-24 ${getResponseTimeClass(api.avgResponseTime)}`}>
{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>
);
})}
@ -215,51 +301,63 @@ const ApiStatsPage = () => {
</table>
</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>
{/* Table 2: Top Error APIs */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API </h3>
{/* 3행: 에러 순위 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between">
<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>
{data.topErrorApis.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 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-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API</th>
<th className="px-4 py-3 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-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<tr className="h-8">
<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-3 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-20"></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-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-24"> </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-3 py-2 text-right text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wider w-20"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{data.topErrorApis.slice(0, 10).map((api, idx) => {
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_CLASSES[0], bar: chartColors[0] };
return (
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
<td className="px-4 py-3">
<Badge className={colors.tag}>
{api.serviceName}
</Badge>
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]" title={api.apiName}>{api.apiName}</td>
<td className="px-4 py-3 text-red-600">{api.errorCount.toLocaleString()}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.totalCount.toLocaleString()}</td>
<td className="px-4 py-3 text-red-600">{api.errorRate.toFixed(1)}%</td>
</tr>
);
})}
{data.topErrorApis.slice(0, 3).map((api, idx) => (
<tr key={idx} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1 w-12">
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${getRankBadgeClass(idx + 1)}`}>
{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}
</Badge>
</td>
<td className="px-3 py-1 text-[var(--color-text-primary)] truncate" title={api.apiName}>
{api.apiName}
</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>
))}
</tbody>
</table>
</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>
</>

파일 보기

@ -1,21 +1,267 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
LineChart, Line, Cell,
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import type { ServiceStatsResponse } from '../../types/statistics';
import type { ServiceStatsResponse, ServiceRequestStats } from '../../types/statistics';
import { getServiceStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
import { CHART_COLORS_HEX } from '../../constants/chart';
import PeriodFilter from '../../components/PeriodFilter';
import Badge, { type BadgeVariant } from '../../components/ui/Badge';
import { CHART_COLORS_HEX, SERVICE_BADGE_VARIANTS } from '../../constants/chart';
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 { theme } = useTheme();
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 [isLoading, setIsLoading] = useState(true);
@ -35,17 +281,22 @@ const ServiceStatsPage = () => {
fetchData();
}, [fetchData]);
const handlePreset = (days: number) => {
const today = getToday();
if (days === 0) {
setStartDate(today);
} else {
const d = new Date();
d.setDate(d.getDate() - days);
setStartDate(d.toISOString().slice(0, 10));
}
setEndDate(today);
};
// 서비스별 색상 맵 (알파벳 순 정렬)
const serviceColorMap = useMemo(() => {
if (!data) return {};
const sorted = [...data.serviceStats].sort((a, b) =>
a.serviceName.localeCompare(b.serviceName)
);
const map: Record<number, { chartColor: string; borderColor: string; badgeVariant: BadgeVariant }> = {};
sorted.forEach((svc, i) => {
map[svc.serviceId] = {
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(() => {
if (!data) return { data: [], serviceNames: [] };
@ -60,14 +311,39 @@ const ServiceStatsPage = () => {
return { data: Object.values(byHour), serviceNames };
}, [data]);
const barChartData = useMemo(() => {
if (!data) return [];
return data.serviceStats.map((s) => ({
serviceName: s.serviceName,
totalRequests: s.totalRequests,
}));
// 서비스 이름 → serviceId 역방향 맵 (차트 색상용)
const nameToId = useMemo(() => {
if (!data) return {};
const map: Record<string, number> = {};
data.serviceStats.forEach((s) => { map[s.serviceName] = s.serviceId; });
return map;
}, [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) {
return (
<div className="flex items-center justify-center h-64">
@ -77,130 +353,151 @@ const ServiceStatsPage = () => {
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"> </h1>
<DateRangeFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
<div className="max-w-7xl mx-auto flex flex-col gap-4 h-full">
{/* 제목 + PeriodFilter (한 행) */}
<div className="flex items-center justify-between flex-wrap gap-2">
<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}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onRefresh={fetchData}
/>
</div>
{!data || data.serviceStats.length === 0 ? (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
) : (
<>
{/* Summary Cards */}
<div className="flex flex-wrap gap-4 mb-6">
{data.serviceStats.map((svc) => (
<div key={svc.serviceId} className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 flex-1 min-w-[200px]">
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{svc.serviceName}</p>
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
{svc.totalRequests.toLocaleString()}
</p>
<div className="flex items-center gap-3 mt-2 text-sm">
<span className="text-green-600"> {svc.successRate.toFixed(1)}%</span>
<span className="text-[var(--color-text-secondary)]">{svc.avgResponseTime.toFixed(0)}ms</span>
</div>
</div>
{/* 1행: KPI 카드 */}
<div className={`grid ${colsClass} gap-3`}>
{sortedServices.map((svc) => (
<KpiCard
key={svc.serviceId}
svc={svc}
borderColor={serviceColorMap[svc.serviceId]?.borderColor ?? CARD_BORDER_COLORS[0]}
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
/>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Service Request Count Bar */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barChartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="serviceName" type="category" width={120} />
<Tooltip />
<Legend />
<Bar dataKey="totalRequests" fill={chartColors[0]} name="요청 수" />
</BarChart>
</ResponsiveContainer>
{/* 2행: 요청 수 가로 바 + 시간별 추이 차트 */}
<div className="grid grid-cols-2 gap-3">
{/* 좌: 서비스별 요청 수 가로 바 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3"> </h3>
<div className="flex flex-col gap-2.5 justify-center flex-1">
{sortedServices.map((svc) => (
<RequestBar
key={svc.serviceId}
svc={svc}
maxRequests={maxRequests}
color={serviceColorMap[svc.serviceId]?.chartColor ?? chartColors[0]}
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
/>
))}
</div>
</div>
{/* Chart 2: Hourly Service Trend */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
{/* 우: 시간별 서비스 요청 추이 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3"> </h3>
{hourlyTrendPivoted.data.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={170}>
<LineChart data={hourlyTrendPivoted.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} />
<YAxis />
<Tooltip labelFormatter={(h) => `${h}`} />
<Legend />
{hourlyTrendPivoted.serviceNames.map((name, idx) => (
<XAxis
dataKey="hour"
tickFormatter={(h: number) => `${h}`}
tick={{ fontSize: 10 }}
/>
<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
key={name}
type="monotone"
dataKey={name}
stroke={chartColors[idx % chartColors.length]}
stroke={serviceColorMap[nameToId[name]]?.chartColor ?? chartColors[0]}
dot={false}
/>
))}
</LineChart>
</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>
{/* Charts Row 2: Error Rate + Response Time */}
<div className="grid grid-cols-2 gap-6">
{/* Chart: Error Rate Comparison */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={data.serviceStats.map((s) => ({
serviceName: s.serviceName,
successRate: Number(s.successRate.toFixed(1)),
errorRate: Number((100 - s.successRate).toFixed(1)),
}))}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} unit="%" />
<YAxis dataKey="serviceName" type="category" width={120} />
<Tooltip />
<Legend />
<Bar dataKey="successRate" stackId="a" fill="#10b981" name="성공률" />
<Bar dataKey="errorRate" stackId="a" fill="#ef4444" name="에러율" />
</BarChart>
</ResponsiveContainer>
{/* 3행: 성공률/에러율 스택 바 + 응답시간 가로 바 */}
<div className="grid grid-cols-2 gap-3">
{/* 좌: 성공률 스택 바 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]"> / </h3>
<div className="flex items-center gap-2 ml-auto text-[10px] text-[var(--color-text-secondary)]">
<span className="flex items-center gap-1">
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-[var(--color-success)]" />
<span className="text-[var(--color-success)]"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-[var(--color-danger)]" />
<span className="text-[var(--color-danger)]"></span>
</span>
</div>
</div>
<div className="flex flex-col gap-2.5 justify-center flex-1">
{sortedServices.map((svc) => (
<SuccessStackBar
key={svc.serviceId}
svc={svc}
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
/>
))}
</div>
</div>
{/* Chart: Avg Response Time Comparison */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={data.serviceStats.map((s) => ({
serviceName: s.serviceName,
avgResponseTime: Number(s.avgResponseTime.toFixed(0)),
}))}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" unit="ms" />
<YAxis dataKey="serviceName" type="category" width={120} />
<Tooltip />
<Bar dataKey="avgResponseTime" name="평균 응답시간 (ms)">
{data.serviceStats.map((s, idx) => {
const rt = s.avgResponseTime;
const color = rt < 100 ? '#10b981' : rt < 300 ? '#f59e0b' : '#ef4444';
return <Cell key={idx} fill={color} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
{/* 우: 응답시간 가로 바 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-4 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]"> </h3>
<div className="flex items-center gap-2 ml-auto text-[10px] text-[var(--color-text-secondary)]">
<span className="text-[var(--color-success)]">{"< 100ms"}</span>
<span></span>
<span className="text-[var(--color-warning)]">{"< 1000ms"}</span>
<span></span>
<span className="text-[var(--color-danger)]">{"≥ 1000ms"}</span>
</div>
</div>
<div className="flex flex-col gap-2.5 justify-center flex-1">
{sortedServices.map((svc) => (
<ResponseTimeBar
key={svc.serviceId}
svc={svc}
maxMs={maxResponseTime}
badgeVariant={serviceColorMap[svc.serviceId]?.badgeVariant ?? 'blue'}
/>
))}
</div>
</div>
</div>
</>

파일 보기

@ -1,21 +1,270 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
LineChart, Line,
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import type { TenantStatsResponse } from '../../types/statistics';
import type { TenantRequestStats, TenantStatsResponse } from '../../types/statistics';
import { getTenantStats } from '../../services/statisticsService';
import DateRangeFilter from '../../components/DateRangeFilter';
import { CHART_COLORS_HEX } from '../../constants/chart';
import PeriodFilter from '../../components/PeriodFilter';
import Badge, { type BadgeVariant } from '../../components/ui/Badge';
import { CHART_COLORS_HEX, SERVICE_BADGE_VARIANTS } from '../../constants/chart';
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 { theme } = useTheme();
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 [isLoading, setIsLoading] = useState(true);
@ -35,18 +284,44 @@ const TenantStatsPage = () => {
fetchData();
}, [fetchData]);
const handlePreset = (days: number) => {
const today = getToday();
if (days === 0) {
setStartDate(today);
} else {
const d = new Date();
d.setDate(d.getDate() - days);
setStartDate(d.toISOString().slice(0, 10));
}
setEndDate(today);
};
// 알파벳 순 정렬 + Unknown(null)은 항상 마지막
const sortedTenants = useMemo(() => {
if (!data) return [];
return [...data.tenantStats]
.map((t) => ({
...t,
tenantId: t.tenantId ?? -1,
tenantName: t.tenantName || 'Unknown',
}))
.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(() => {
if (!data) return { data: [], tenantNames: [] };
const tenantNames = [...new Set(data.dailyTrend.map((e) => e.tenantName))];
@ -60,6 +335,36 @@ const TenantStatsPage = () => {
return { data: Object.values(byDate), tenantNames };
}, [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) {
return (
<div className="flex items-center justify-center h-64">
@ -69,112 +374,156 @@ const TenantStatsPage = () => {
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"> </h1>
<DateRangeFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
<div className="max-w-7xl mx-auto flex flex-col gap-4 h-full">
{/* 제목 + DateRangeFilter (한 행) */}
<div className="flex items-center justify-between flex-wrap gap-2">
<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}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onRefresh={fetchData}
/>
</div>
{!data || data.tenantStats.length === 0 ? (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
) : (
<>
{/* Summary Cards */}
<div className="flex gap-4 mb-6">
{data.tenantStats.map((tenant) => (
<div key={tenant.tenantId} className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{tenant.tenantName || 'Unknown'}</p>
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
{tenant.totalRequests.toLocaleString()}
</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>
{/* 1행: 부서별 KPI 카드 */}
<div className={`grid ${colsClass} gap-3`}>
{sortedTenants.map((tenant) => (
<KpiCard
key={tenant.tenantId}
tenant={tenant}
borderColor={tenantColorMap[tenant.tenantId]?.borderColor ?? CARD_BORDER_COLORS[0]}
/>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Daily Tenant Trend */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
{/* 2행: 일별 추이(좌 60%) + API Key 현황(우 40%) */}
<div className="grid grid-cols-5 gap-3">
{/* 좌: 일별 부서별 요청 추이 */}
<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-sm font-semibold text-[var(--color-text-primary)] mb-3"> </h3>
{dailyTrendPivoted.data.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={dailyTrendPivoted.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
{dailyTrendPivoted.tenantNames.map((name, idx) => (
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} width={35} />
<Tooltip contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{dailyTrendPivoted.tenantNames.map((name) => (
<Line
key={name}
type="monotone"
dataKey={name}
stroke={chartColors[idx % chartColors.length]}
stroke={tenantColorMap[nameToId[name]]?.chartColor ?? chartColors[0]}
dot={false}
/>
))}
</LineChart>
</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>
{/* Chart 2: Tenant API Key Stats */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> API Key </h3>
{/* 우: API Key 현황 수직 그룹 바 차트 */}
<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-sm font-semibold text-[var(--color-text-primary)] mb-3"> API Key </h3>
{data.apiKeyStats.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.apiKeyStats}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="tenantName" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="totalKeys" fill={chartColors[0]} name="전체 키" />
<Bar dataKey="activeKeys" fill="#10b981" name="활성 키" />
</BarChart>
</ResponsiveContainer>
<ApiKeyGroupedBar
data={data.apiKeyStats}
tenantColorMap={tenantColorMap}
nameToId={nameToId}
/>
) : (
<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>
{/* Table: Tenant Details */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]"> </h3>
{/* 3행: 부서 상세 테이블 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<div className="flex items-center justify-between p-4 border-b border-[var(--color-border)]">
<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 className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 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-4 py-3 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-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"> </th>
<tr className="h-8">
<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 w-64"> </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-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>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{data.tenantStats.map((tenant) => (
<tr key={tenant.tenantId} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{tenant.tenantName || 'Unknown'}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.totalRequests.toLocaleString()}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.activeUsers}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.successRate.toFixed(1)}%</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.avgResponseTime.toFixed(0)}ms</td>
</tr>
))}
{sortedTenants
.slice()
.sort((a, b) => b.totalRequests - a.totalRequests)
.map((tenant) => {
const pct = maxRequests > 0 ? (tenant.totalRequests / maxRequests) * 100 : 0;
const colorMap = tenantColorMap[tenant.tenantId];
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>
);
})}
</tbody>
</table>
</div>

파일 보기

@ -1,12 +1,10 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Area, ComposedChart,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, ComposedChart, Line,
} from 'recharts';
import type { UsageTrendResponse } from '../../types/statistics';
import { getUsageTrend } from '../../services/statisticsService';
import { CHART_COLORS_HEX } from '../../constants/chart';
import { useTheme } from '../../hooks/useTheme';
type Period = 'daily' | 'weekly' | 'monthly';
@ -29,14 +27,18 @@ const formatLabel = (label: string, period: Period): string => {
};
const getSuccessRateColor = (rate: number): string => {
if (rate >= 99) return 'text-green-600';
if (rate >= 95) return 'text-yellow-600';
return 'text-red-600';
if (rate >= 90) return 'text-[var(--color-success)]';
if (rate >= 70) return 'text-[var(--color-warning)]';
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 { theme } = useTheme();
const chartColors = CHART_COLORS_HEX[theme];
const [period, setPeriod] = useState<Period>('daily');
const [data, setData] = useState<UsageTrendResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -57,30 +59,74 @@ const UsageTrendPage = () => {
fetchData();
}, [fetchData]);
const chartData = data?.items.map((item) => ({
...item,
formattedLabel: formatLabel(item.label, period),
})) ?? [];
const chartData = useMemo(
() =>
data?.items.map((item) => ({
...item,
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 (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"> </h1>
{/* Period Tabs */}
<div className="flex border-b border-[var(--color-border)] mb-6">
{PERIOD_OPTIONS.map((opt) => (
<div className="max-w-7xl mx-auto flex flex-col gap-3">
{/* 1행: 제목 + 필터 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<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="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) => (
<button
key={opt.key}
onClick={() => setPeriod(opt.key)}
className={`px-3.5 py-1 rounded text-xs font-medium transition-all duration-150 cursor-pointer ${
period === opt.key
? 'bg-[var(--color-primary-600)] text-white'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
{opt.label}
</button>
))}
</div>
<div className="w-px h-6 bg-[var(--color-border)]" />
<button
key={opt.key}
onClick={() => setPeriod(opt.key)}
className={`px-4 py-2 text-sm font-medium -mb-px ${
period === opt.key
? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
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="새로고침"
>
{opt.label}
<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 ? (
@ -91,138 +137,237 @@ const UsageTrendPage = () => {
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
) : (
<>
{/* Chart 1: 요청 수 추이 (full width) */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={chartData}>
<defs>
<linearGradient id="totalRequestsFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors[0]} stopOpacity={0.15} />
<stop offset="95%" stopColor={chartColors[0]} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis />
<Tooltip />
<Legend />
<Area
type="monotone"
dataKey="totalRequests"
stroke="none"
fill="url(#totalRequestsFill)"
name="총 요청"
/>
<Line
type="monotone"
dataKey="totalRequests"
stroke={chartColors[0]}
name="총 요청"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="failureCount"
stroke="#ef4444"
name="실패 수"
strokeWidth={2}
dot={false}
/>
</LineChart>
{/* 2행: KPI 카드 4개 */}
<div className="grid grid-cols-4 gap-3">
{/* 총 요청 */}
<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-info)" 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>
<span className="text-xs text-[var(--color-text-secondary)]"> </span>
</div>
<span className="text-2xl font-bold text-[var(--color-text-primary)]">
{kpiData?.totalRequests.toLocaleString() ?? '-'}
</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.successRate >= 90
? 'var(--color-success)'
: kpiData && kpiData.successRate >= 70
? 'var(--color-warning)'
: 'var(--color-danger)'
}
strokeWidth={2}
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span className="text-xs text-[var(--color-text-secondary)]"></span>
</div>
<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}
>
<circle cx="12" cy="12" r="10" />
<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>
</div>
{/* Charts 2 & 3: 2 column grid */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 2: 성공률 + 응답시간 추이 */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> + </h3>
<ResponsiveContainer width="100%" height={300}>
{/* 4행: 2-column */}
<div className="grid grid-cols-2 gap-3">
{/* : 성공률 + 응답시간 추이 */}
<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={150}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis yAxisId="left" unit="%" domain={[0, 100]} />
<YAxis yAxisId="right" orientation="right" unit="ms" />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="formattedLabel" tick={{ fontSize: 10 }} />
<YAxis yAxisId="left" unit="%" domain={[0, 100]} tick={{ fontSize: 10 }} width={35} />
<YAxis yAxisId="right" orientation="right" unit="ms" tick={{ fontSize: 10 }} width={40} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar
yAxisId="right"
dataKey="avgResponseTime"
fill="var(--color-info)"
name="평균 응답시간(ms)"
barSize={16}
opacity={0.4}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="successRate"
stroke="#10b981"
stroke="var(--color-success)"
name="성공률(%)"
strokeWidth={2}
dot={false}
/>
<Bar
yAxisId="right"
dataKey="avgResponseTime"
fill={chartColors[0]}
name="평균 응답시간(ms)"
barSize={20}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Chart 3: 활성 사용자 추이 */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
<ResponsiveContainer width="100%" height={300}>
{/* 우: 활성 사용자 추이 */}
<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={150}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formattedLabel" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="activeUsers" fill={chartColors[1]} name="활성 사용자" />
<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="activeUsers" fill="var(--color-primary)" name="활성 사용자" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Table: 상세 데이터 */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]"> </h3>
{/* 5행: 상세 데이터 테이블 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between">
<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 className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 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-4 py-3 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-4 py-3 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"> (ms)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"> </th>
<tr className="h-8">
<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"></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>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{data.items.map((item) => (
<tr key={item.label} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-primary)]">
<tr key={item.label} className="h-7 hover:bg-[var(--color-bg-base)] transition-colors">
<td className="px-3 py-1 text-[var(--color-text-primary)] text-xs">
{formatLabel(item.label, period)}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{item.totalRequests.toLocaleString()}
<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()}
</span>
</div>
</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()}
</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()}
</td>
<td className={`px-4 py-3 font-medium ${getSuccessRateColor(item.successRate)}`}>
{item.successRate.toFixed(1)}
<td className={`px-3 py-1 text-xs font-medium ${getSuccessRateColor(item.successRate)}`}>
{item.successRate.toFixed(1)}%
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{item.avgResponseTime.toFixed(0)}
<td className={`px-3 py-1 text-xs ${getResponseTimeColor(item.avgResponseTime)}`}>
{item.avgResponseTime.toFixed(0)}ms
</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()}
</td>
</tr>

파일 보기

@ -1,27 +1,178 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
PieChart, Pie, Cell,
} from 'recharts';
import type { UserStatsResponse } from '../../types/statistics';
import type { UserStatsResponse, UserRoleDistribution } from '../../types/statistics';
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 { useTheme } from '../../hooks/useTheme';
const ROLE_BADGE: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-blue-100 text-blue-800',
USER: 'bg-green-100 text-green-800',
// 역할별 Badge variant 매핑
const ROLE_BADGE_VARIANT: Record<string, 'rose' | 'blue' | 'teal' | 'lavender' | 'coral' | 'gold'> = {
ADMIN: 'rose',
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 { theme } = useTheme();
const chartColors = CHART_COLORS_HEX[theme];
const [startDate, setStartDate] = useState(getToday());
const [endDate, setEndDate] = useState(getToday());
const donutColors = ROLE_DONUT_COLORS_HEX[theme];
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 [isLoading, setIsLoading] = useState(true);
@ -41,17 +192,10 @@ const UserStatsPage = () => {
fetchData();
}, [fetchData]);
const handlePreset = (days: number) => {
const today = getToday();
if (days === 0) {
setStartDate(today);
} else {
const d = new Date();
d.setDate(d.getDate() - days);
setStartDate(d.toISOString().slice(0, 10));
}
setEndDate(today);
};
const maxRequests = useMemo(() => {
if (!data || data.topUsers.length === 0) return 1;
return Math.max(...data.topUsers.map((u) => u.requestCount), 1);
}, [data]);
if (isLoading) {
return (
@ -62,128 +206,185 @@ const UserStatsPage = () => {
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6"> </h1>
<DateRangeFilter
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPreset={handlePreset}
/>
<div className="max-w-7xl mx-auto flex flex-col gap-4">
{/* 헤더: 제목 + PeriodFilter */}
<div className="flex items-center justify-between flex-wrap gap-2">
<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}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onRefresh={fetchData}
/>
</div>
{!data ? (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
) : (
<>
{/* Summary Cards */}
<div className="flex gap-4 mb-6">
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalUsers}</p>
</div>
<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>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.usersWithActiveKey}</p>
</div>
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]">API </p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalActiveUsers}</p>
</div>
{/* 1행: KPI 카드 3개 */}
<div className="grid grid-cols-3 gap-3">
<KpiCard
icon="users"
label="전체 사용자"
value={data.totalUsers}
/>
<KpiCard
icon="key"
label="API Key 보유 사용자"
value={data.usersWithActiveKey}
total={data.totalUsers}
/>
<KpiCard
icon="activity"
label="API 요청 사용자"
value={data.totalActiveUsers}
total={data.totalUsers}
/>
</div>
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Daily Active Users */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> API </h3>
{/* 2행: 일별 추이(좌 60%) + 역할별 분포(우 40%) */}
<div className="grid grid-cols-5 gap-3">
{/* 좌: 일별 활성 사용자 추이 */}
<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-sm font-semibold text-[var(--color-text-primary)] mb-3"> API </h3>
{data.dailyActiveUsers.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={data.dailyActiveUsers}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} width={35} />
<Tooltip contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area
type="monotone"
dataKey="activeUsers"
stroke={chartColors[0]}
fill={chartColors[0]}
fillOpacity={0.3}
fillOpacity={0.2}
name="API 요청 사용자"
/>
</AreaChart>
</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>
{/* Chart 2: Role Distribution Donut */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4"> </h3>
{/* 우: 역할별 요청 분포 (CSS 도넛) */}
<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-sm font-semibold text-[var(--color-text-primary)] mb-3"> </h3>
{data.roleDistribution.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.roleDistribution}
dataKey="requestCount"
nameKey="role"
innerRadius={60}
outerRadius={100}
>
{data.roleDistribution.map((_, idx) => (
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
</PieChart>
</ResponsiveContainer>
<div className="flex items-center justify-center flex-1">
<DonutChart data={data.roleDistribution} colors={donutColors} />
</div>
) : (
<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>
{/* Table: Top Users */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]"> Top 10</h3>
{/* 3행: 상위 사용자 Top 5 테이블 */}
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<div className="px-4 py-3 border-b border-[var(--color-border)]">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]"> Top 5</h3>
</div>
{data.topUsers.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 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-4 py-3 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-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<tr className="h-8">
<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-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 w-28"></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 w-28"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{data.topUsers.slice(0, 10).map((user, idx) => (
<tr key={user.userId} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.userName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ROLE_BADGE[user.role] ?? 'bg-gray-100 text-gray-800'}`}>
{user.role}
</span>
</td>
<td className="px-4 py-3 text-[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>
))}
{data.topUsers.slice(0, 5).map((user, idx) => {
const rank = idx + 1;
const rankBg = rank <= 3
? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary-text)]'
: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]';
const reqPct = maxRequests > 0 ? (user.requestCount / maxRequests) * 100 : 0;
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>
</td>
</tr>
);
})}
</tbody>
</table>
</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>
</>