From 88e25abe147cb2d29fcd5fba069f92411360a108 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Fri, 17 Apr 2026 14:45:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(frontend):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=84=EC=B2=B4=20UI=20=EA=B0=9C=EC=84=A0=20(#42?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 디자인 시스템 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 최적화 --- claude-code-design-system-prompt.md | 69 +- design-system-preview.html | 45 +- docs/design/colors.md | 8 +- docs/design/components.md | 40 +- frontend/src/components/PeriodFilter.tsx | 118 ++++ frontend/src/components/ui/Badge.tsx | 13 +- frontend/src/components/ui/Button.tsx | 3 +- frontend/src/constants/chart.ts | 6 +- frontend/src/index.css | 24 +- frontend/src/layouts/ApiHubLayout.tsx | 6 +- frontend/src/layouts/MainLayout.tsx | 6 +- frontend/src/pages/DashboardPage.tsx | 369 +++++----- frontend/src/pages/admin/ApiEditPage.tsx | 83 ++- frontend/src/pages/admin/ApisPage.tsx | 62 +- frontend/src/pages/admin/DomainsPage.tsx | 50 +- frontend/src/pages/admin/SampleCodePage.tsx | 20 +- frontend/src/pages/admin/ServicesPage.tsx | 104 +-- frontend/src/pages/admin/TenantsPage.tsx | 52 +- frontend/src/pages/admin/UsersPage.tsx | 66 +- frontend/src/pages/apikeys/KeyAdminPage.tsx | 234 ++++--- frontend/src/pages/apikeys/KeyRequestPage.tsx | 515 ++++++++------ frontend/src/pages/apikeys/MyKeysPage.tsx | 58 +- .../pages/monitoring/RequestLogDetailPage.tsx | 6 +- .../src/pages/monitoring/RequestLogsPage.tsx | 650 +++++++++++++----- .../monitoring/ServiceStatusDetailPage.tsx | 6 +- .../pages/monitoring/ServiceStatusPage.tsx | 14 +- .../src/pages/statistics/ApiStatsPage.tsx | 414 ++++++----- .../src/pages/statistics/ServiceStatsPage.tsx | 539 +++++++++++---- .../src/pages/statistics/TenantStatsPage.tsx | 529 +++++++++++--- .../src/pages/statistics/UsageTrendPage.tsx | 391 +++++++---- .../src/pages/statistics/UserStatsPage.tsx | 411 ++++++++--- 31 files changed, 3361 insertions(+), 1550 deletions(-) create mode 100644 frontend/src/components/PeriodFilter.tsx diff --git a/claude-code-design-system-prompt.md b/claude-code-design-system-prompt.md index de069b2..f997835 100644 --- a/claude-code-design-system-prompt.md +++ b/claude-code-design-system-prompt.md @@ -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; ``` diff --git a/design-system-preview.html b/design-system-preview.html index 91854da..7ad3176 100644 --- a/design-system-preview.html +++ b/design-system-preview.html @@ -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 @@
-
Badges
+
Badges — Pill (기본)
Default Primary @@ -556,6 +581,16 @@ Danger Info
+
Badges — Filled (버튼 스타일)
+
+ Default + Blue + Gold + Rose + Teal + Lavender + Coral +
diff --git a/docs/design/colors.md b/docs/design/colors.md index c2d431a..60c4c71 100644 --- a/docs/design/colors.md +++ b/docs/design/colors.md @@ -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`)를 사용한다. diff --git a/docs/design/components.md b/docs/design/components.md index 417e485..b819307 100644 --- a/docs/design/components.md +++ b/docs/design/components.md @@ -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) --- diff --git a/frontend/src/components/PeriodFilter.tsx b/frontend/src/components/PeriodFilter.tsx new file mode 100644 index 0000000..343a9ba --- /dev/null +++ b/frontend/src/components/PeriodFilter.tsx @@ -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 ( +
+ {/* 세그먼트 컨트롤 */} +
+ {PRESETS.map((p) => ( + + ))} +
+ + {/* 구분선 */} +
+ + {/* 달력 아이콘 + 날짜 입력 */} +
+ + + + + + + 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" + /> + ~ + 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" + /> +
+ + {/* 구분선 */} +
+ + {/* 새로고침 버튼 */} + +
+ ); +}; + +export default PeriodFilter; diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx index c485392..06f4e0e 100644 --- a/frontend/src/components/ui/Badge.tsx +++ b/frontend/src/components/ui/Badge.tsx @@ -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 = { diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index b7776ca..d1b7fc8 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -3,7 +3,7 @@ import { cn } from '../../utils/cn'; interface ButtonProps extends React.ButtonHTMLAttributes { 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', diff --git a/frontend/src/constants/chart.ts b/frontend/src/constants/chart.ts index d4f8795..16b3d01 100644 --- a/frontend/src/constants/chart.ts +++ b/frontend/src/constants/chart.ts @@ -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; diff --git a/frontend/src/index.css b/frontend/src/index.css index f4e0d06..a06a707 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } } diff --git a/frontend/src/layouts/ApiHubLayout.tsx b/frontend/src/layouts/ApiHubLayout.tsx index 344c74e..bf51266 100644 --- a/frontend/src/layouts/ApiHubLayout.tsx +++ b/frontend/src/layouts/ApiHubLayout.tsx @@ -120,7 +120,7 @@ const ApiHubLayoutInner = () => { const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]); return ( -
+
{/* Sidebar */} {/* Main content */} -
+
{/* 헤더 */}
@@ -328,7 +328,7 @@ const ApiHubLayoutInner = () => {
{/* 콘텐츠 */} -
+
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index ec7c80d..a727eb0 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -196,7 +196,7 @@ const MainLayout = () => { const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER'; return ( -
+
{/* Sidebar */} {/* Main Content */} -
+
{/* Header */}
@@ -373,7 +373,7 @@ const MainLayout = () => {
{/* Content */} -
+
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0114c41..e016b06 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 = { - 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 = { + 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(); + 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 = {}; + [...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> = {}; @@ -108,18 +122,6 @@ const DashboardPage = () => { }; }, [errorTrend]); - const topApiServiceColorMap = useMemo(() => { - const serviceNames = [...new Set(topApis.map((a) => a.serviceName))]; - const map: Record = {}; - 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 (
@@ -131,211 +133,238 @@ const DashboardPage = () => { return (
{/* Header */} -
-

Dashboard

+
+
+
+ + + + + + +
+
+

Dashboard

+

API Gateway 요약 및 실시간 현황을 확인합니다

+
+
{lastUpdated && ( 마지막 갱신: {lastUpdated} )}
- {/* Row 1: Summary Cards */} - {stats && ( -
-
-

오늘 총 요청

-

{stats.totalRequests.toLocaleString()}

-

0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}> - {stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {stats.changePercent.toFixed(2)}% -

-
-
-

성공률

-

{stats.successRate.toFixed(1)}%

-

실패 {stats.failureCount}건

-
-
-

평균 응답 시간

-

{stats.avgResponseTime.toFixed(0)}ms

-
-
-

API 요청 사용자

-

{stats.activeUserCount}

-

오늘

-
-
- )} - - {/* Row 2: Heartbeat Status Cards */} -
- {heartbeat.length > 0 ? ( -
- {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 ( -
navigate('/monitoring/service-status')} - > -
-
- {svc.serviceName} -
-
- - {isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'} - - {svc.healthCheckedAt && ( - {svc.healthCheckedAt} - )} -
-
- ); - })} -
- ) : ( -
-

등록된 서비스가 없습니다

+ {/* Row 1: Summary Cards (50%) + Heartbeat (50%) */} +
+ {/* 좌측: 요약 카드 1x4 */} + {stats && ( +
+
+

오늘 총 요청

+

{stats.totalRequests.toLocaleString()}

+

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)}% +

+
+
+

성공률

+

{stats.successRate.toFixed(1)}%

+

실패 {stats.failureCount}건

+
+
+

평균 응답

+

{stats.avgResponseTime.toFixed(0)}ms

+
+
+

요청 사용자

+

{stats.activeUserCount}

+
)} + + {/* 우측: 서비스 상태 카드 (필 그리드) */} +
+ {/* 헤더 */} +
+
+ 0 && heartbeat.every((s) => s.healthStatus === 'UP') ? 'text-green-500' : 'text-amber-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + + + 서비스 상태 +
+ 0 && heartbeat.every((s) => s.healthStatus === 'UP') ? 'text-green-500' : 'text-amber-500'}`}> + {heartbeat.filter((s) => s.healthStatus === 'UP').length}/{heartbeat.length} 정상 + +
+ + {/* 필 그리드 */} +
+ {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 ( +
navigate('/monitoring/service-status')} + > +
+ {isUp &&
} +
+
+ {svc.serviceName} + {svc.healthResponseTime !== null && ( + {svc.healthResponseTime}ms + )} +
+ ); + }) + ) : ( +

등록된 서비스가 없습니다

+ )} +
+
{/* Row 3: Charts 2x2 */} -
- {/* Chart 1: Hourly Trend */} -
-

시간별 요청 추이

+
+ {/* Chart 1: Hourly Trend (3/4) */} +
+

시간별 요청 추이

{hourlyTrend.length > 0 ? ( - + - `${h}시`} /> - - `${h}시`} /> - + `${h}시`} tick={{ fontSize: 10 }} /> + + `${h}시`} contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} /> + ) : ( -

데이터가 없습니다

+
+

데이터가 없습니다

+
)}
{/* Chart 2: Service Ratio */} -
-

서비스별 요청 비율

+
+

서비스별 요청 비율

{serviceRatio.length > 0 ? ( - + - {serviceRatio.map((_, idx) => ( - + {serviceRatio.map((entry, idx) => ( + ))} - - + + ) : ( -

데이터가 없습니다

+
+

데이터가 없습니다

+
)}
- {/* Chart 3: Error Trend */} -
-

에러율 추이

+ {/* Chart 3: Error Trend (1/4) */} +
+

에러율 추이

{errorTrendPivoted.data.length > 0 ? ( - + - `${h}시`} /> - - `${h}시`} /> - - {errorTrendPivoted.serviceNames.map((name, idx) => ( + `${h}시`} tick={{ fontSize: 10 }} /> + + `${h}시`} contentStyle={{ fontSize: 11 }} labelStyle={{ fontSize: 11 }} /> + + {errorTrendPivoted.serviceNames.map((name) => ( ))} ) : ( -

데이터가 없습니다

+
+

데이터가 없습니다

+
)}
- {/* Chart 4: Top APIs */} -
-

상위 호출 API

+ {/* Chart 4: Top APIs (3/4) */} +
+

상위 호출 API

{topApis.length > 0 ? ( -
- {topApis.map((api, idx) => { +
+ {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 ( -
- {idx + 1} - - {api.serviceName} - - +
+ {idx + 1} + {api.serviceName} + {api.apiName} -
+
- {api.count} + {api.count}
); })}
) : ( -

데이터가 없습니다

+
+

데이터가 없습니다

+
)}
{/* Row 4: Recent Logs */} -
-
-

최근 요청 로그

+
+
+

최근 요청 로그

{recentLogs.length > 0 ? ( <>
- +
- - - - - - + + + + + + @@ -345,26 +374,34 @@ const DashboardPage = () => { className="hover:bg-[var(--color-bg-base)] cursor-pointer" onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)} > - - - - - + - + + ))}
시간서비스사용자URL상태응답시간서비스사용자URL응답시간상태시간
{log.requestedAt}{log.serviceName ?? '-'}{log.userName ?? '-'} - {truncate(log.requestUrl, 40)} + + {log.serviceName ? ( + {log.serviceName} + ) : ( + - + )} - - {log.requestStatus} - + {log.userName ?? '-'} + {truncate(log.requestUrl, 70)} + {log.responseTime !== null ? `${log.responseTime}ms` : '-'} + + {log.requestStatus} + + + {log.requestedAt?.length > 23 ? log.requestedAt.substring(0, 23) : log.requestedAt} +
-
+
-
@@ -410,7 +417,7 @@ const ApiEditPage = () => { {/* Sections */}
{/* Section 1: 기본 정보 */} -
+

기본 정보

@@ -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} />
@@ -494,7 +501,7 @@ const ApiEditPage = () => {
{/* Section 2: API 명세 */} -
+

API 명세

@@ -584,7 +591,7 @@ const ApiEditPage = () => {
{/* Section 3: 요청인자 */} -
+

요청인자

- +
- - - - - - - - + + + + + + + @@ -616,11 +623,11 @@ const ApiEditPage = () => { ) : ( requestParams.map((param, idx) => ( - - + - - - - - -
#인자명의미설명필수입력유형 +
#인자명의미설명필수입력유형
+
{idx + 1} + { className={TABLE_INPUT_CLS} /> + { className={TABLE_INPUT_CLS} /> + { className={TABLE_INPUT_CLS} /> + { className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]" /> + +