diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5daa5af..6dc6eec 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -13,6 +13,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). - 서비스별 API 목록 페이지 (도메인별 그룹) (#40) - API 상세 명세 페이지 (#40) - 백엔드 카탈로그/최신 API 조회 엔드포인트 (#40) +- API 관리 상세 화면 (Spec/Param CRUD, 출력결과 JSON 파싱) (#42) +- 시스템 공통 설정 관리 (SnpSystemConfig, 공통 샘플 코드) (#42) +- API HUB 상세 화면 개선 (아코디언, 샘플 URL, 출력결과 2열) (#42) +- Gateway API 인증: X-API-KEY 헤더 → authKey 쿼리 파라미터 변경 (#42) +- 일일 요청량 제한 기능 (daily_request_limit, HTTP 429) (#42) +- 에러 응답에 code 필드 추가, 인증/권한 거부 로그 DENIED 분리 (#42) +- API Key 검토 모달 예상 요청량 수정 기능 (#42) +- 도메인 관리 (SnpApiDomain CRUD, SVG 아이콘, 정렬순서) (#42) +- API HUB 사이드바: 서비스 기반 → 도메인 기반 플랫 메뉴 변경 (#42) +- 도메인 상세 페이지 (API 리스트 뷰, 검색) (#42) +- API 사용 신청 모달 (API HUB 상세 화면 내 도메인 기반 체크박스 선택) (#42) +- API 선택 UI: 서비스 기반 → 도메인 기반 변경 (Path/Method 제거) (#42) +- 대시보드 개선: 도메인 이미지 카드, 인기 API 주간 Top 3, 랭킹 뱃지 (#42) ## [2026-04-13] diff --git a/frontend/public/images/domains/ais.jpg b/frontend/public/images/domains/ais.jpg new file mode 100644 index 0000000..d0c98d4 Binary files /dev/null and b/frontend/public/images/domains/ais.jpg differ diff --git a/frontend/public/images/domains/company.jpg b/frontend/public/images/domains/company.jpg new file mode 100644 index 0000000..7a4b7d1 Binary files /dev/null and b/frontend/public/images/domains/company.jpg differ diff --git a/frontend/public/images/domains/compliance.jpg b/frontend/public/images/domains/compliance.jpg new file mode 100644 index 0000000..4233e40 Binary files /dev/null and b/frontend/public/images/domains/compliance.jpg differ diff --git a/frontend/public/images/domains/risk.jpg b/frontend/public/images/domains/risk.jpg new file mode 100644 index 0000000..d472c71 Binary files /dev/null and b/frontend/public/images/domains/risk.jpg differ diff --git a/frontend/public/images/domains/ship.jpg b/frontend/public/images/domains/ship.jpg new file mode 100644 index 0000000..0155ad5 Binary files /dev/null and b/frontend/public/images/domains/ship.jpg differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3fc7cd..ca2a11c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,10 @@ import MyKeysPage from './pages/apikeys/MyKeysPage'; import KeyRequestPage from './pages/apikeys/KeyRequestPage'; import KeyAdminPage from './pages/apikeys/KeyAdminPage'; import ServicesPage from './pages/admin/ServicesPage'; +import DomainsPage from './pages/admin/DomainsPage'; +import ApisPage from './pages/admin/ApisPage'; +import ApiEditPage from './pages/admin/ApiEditPage'; +import SampleCodePage from './pages/admin/SampleCodePage'; import UsersPage from './pages/admin/UsersPage'; import TenantsPage from './pages/admin/TenantsPage'; import ServiceStatsPage from './pages/statistics/ServiceStatsPage'; @@ -22,6 +26,7 @@ import ApiHubLayout from './layouts/ApiHubLayout'; import ApiHubDashboardPage from './pages/apihub/ApiHubDashboardPage'; import ApiHubServicePage from './pages/apihub/ApiHubServicePage'; import ApiHubApiDetailPage from './pages/apihub/ApiHubApiDetailPage'; +import ApiHubDomainPage from './pages/apihub/ApiHubDomainPage'; import NotFoundPage from './pages/NotFoundPage'; import RoleGuard from './components/RoleGuard'; @@ -49,12 +54,17 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> }> } /> + } /> } /> } /> diff --git a/frontend/src/layouts/ApiHubLayout.tsx b/frontend/src/layouts/ApiHubLayout.tsx index b47cd56..385d280 100644 --- a/frontend/src/layouts/ApiHubLayout.tsx +++ b/frontend/src/layouts/ApiHubLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; import { useTheme } from '../hooks/useTheme'; @@ -7,22 +7,28 @@ import type { ServiceCatalog } from '../types/apihub'; const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const; -const METHOD_BADGE_CLASS: Record = { - GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', - POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', - PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', - DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776']; + +/** iconPath 문자열에서 SVG path d 값 배열을 추출 */ +const parseIconPaths = (iconPath: string | null): string[] => { + if (!iconPath) return DEFAULT_ICON_PATHS; + // 형태에서 d 값 추출 + const pathRegex = /d="([^"]+)"/g; + const matches: string[] = []; + let m; + while ((m = pathRegex.exec(iconPath)) !== null) { + matches.push(m[1]); + } + // d 태그가 없으면 단일 path d 값으로 간주 + return matches.length > 0 ? matches : [iconPath]; }; -const getMethodBadgeClass = (method: string): string => - METHOD_BADGE_CLASS[method.toUpperCase()] ?? - 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; - -const HEALTH_DOT_CLASS: Record = { - UP: 'bg-green-500', - DOWN: 'bg-red-500', - UNKNOWN: 'bg-gray-400', -}; +interface FlatDomainGroup { + domain: string; + iconPath: string | null; + sortOrder: number; + apis: { serviceId: number; apiId: number; apiName: string; apiPath: string; apiMethod: string }[]; +} const ApiHubLayout = () => { const { user, setRole } = useAuth(); @@ -32,44 +38,78 @@ const ApiHubLayout = () => { const [catalog, setCatalog] = useState([]); const [loading, setLoading] = useState(true); - const [openServices, setOpenServices] = useState>({}); const [openDomains, setOpenDomains] = useState>({}); + const [searchQuery, setSearchQuery] = useState(''); useEffect(() => { getCatalog() .then((res) => { const items = res.data ?? []; setCatalog(items); - // Open all service groups and domain groups by default - const serviceState: Record = {}; - const domainState: Record = {}; - items.forEach((svc) => { - serviceState[svc.serviceId] = true; - svc.domains.forEach((dg) => { - domainState[`${svc.serviceId}:${dg.domain}`] = true; - }); - }); - setOpenServices(serviceState); - setOpenDomains(domainState); }) .finally(() => setLoading(false)); }, []); - const toggleService = (serviceId: number) => { - setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] })); - }; - const toggleDomain = (key: string) => { setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] })); }; + // 서비스 계층을 제거하고 도메인 기준으로 플랫하게 재그룹핑 + const domainGroups = useMemo(() => { + const map = new Map(); + for (const svc of catalog) { + for (const dg of svc.domains) { + const key = dg.domain.toUpperCase(); + const existing = map.get(key); + const apis = existing?.apis ?? []; + apis.push( + ...dg.apis.map((api) => ({ + serviceId: svc.serviceId, + apiId: api.apiId, + apiName: api.apiName, + apiPath: api.apiPath, + apiMethod: api.apiMethod, + })), + ); + // 첫 번째로 발견된 iconPath/sortOrder 사용 + const iconPath = existing?.iconPath !== undefined ? existing.iconPath : (dg.iconPath ?? null); + const sortOrder = existing?.sortOrder !== undefined ? existing.sortOrder : (dg.sortOrder ?? Number.MAX_SAFE_INTEGER); + map.set(key, { apis, iconPath, sortOrder }); + } + } + return Array.from(map.entries()) + .map(([domain, { apis, iconPath, sortOrder }]) => ({ domain, iconPath, sortOrder, apis })) + .sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain)); + }, [catalog]); + + const filteredDomainGroups = useMemo(() => { + if (!searchQuery.trim()) return domainGroups; + const q = searchQuery.trim().toLowerCase(); + return domainGroups + .map((dg) => { + const domainMatch = dg.domain.toLowerCase().includes(q); + const filteredApis = dg.apis.filter( + (api) => api.apiName.toLowerCase().includes(q) || api.apiPath.toLowerCase().includes(q), + ); + if (domainMatch) return dg; + if (filteredApis.length > 0) return { ...dg, apis: filteredApis }; + return null; + }) + .filter((dg): dg is NonNullable => dg !== null); + }, [domainGroups, searchQuery]); + + const isSearching = searchQuery.trim().length > 0; + return (
{/* Sidebar */}