generated from gc/template-java-maven
feat(api-hub): 대시보드 개선 및 도메인 상세 페이지 구현
- 대시보드 레이아웃 개선 (히어로 배너, 도메인 카드 이미지, 인기/최신 API) - 인기 API: 최근 1주일 기준 Top 3 (PopularApiResponse 백엔드 추가) - 도메인 상세 페이지 (ApiHubDomainPage) 구현 + 리스트 뷰/검색 - 사이드바 도메인 클릭 시 도메인 상세 페이지 이동 - 브레드크럼: 서비스 제거, 도메인 기반으로 변경 - NoResourceFoundException 404 처리 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
01fe6e62f7
커밋
b37867b8ad
BIN
frontend/public/images/domains/ais.jpg
Normal file
BIN
frontend/public/images/domains/ais.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 658 KiB |
BIN
frontend/public/images/domains/company.jpg
Normal file
BIN
frontend/public/images/domains/company.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 1.8 MiB |
BIN
frontend/public/images/domains/compliance.jpg
Normal file
BIN
frontend/public/images/domains/compliance.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 4.0 MiB |
BIN
frontend/public/images/domains/risk.jpg
Normal file
BIN
frontend/public/images/domains/risk.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 2.7 MiB |
BIN
frontend/public/images/domains/ship.jpg
Normal file
BIN
frontend/public/images/domains/ship.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 4.0 MiB |
@ -26,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';
|
||||
|
||||
@ -63,6 +64,7 @@ const App = () => {
|
||||
</Route>
|
||||
<Route element={<ApiHubLayout />}>
|
||||
<Route path="/api-hub" element={<ApiHubDashboardPage />} />
|
||||
<Route path="/api-hub/domains/:domainName" element={<ApiHubDomainPage />} />
|
||||
<Route path="/api-hub/services/:serviceId" element={<ApiHubServicePage />} />
|
||||
<Route path="/api-hub/services/:serviceId/apis/:apiId" element={<ApiHubApiDetailPage />} />
|
||||
</Route>
|
||||
|
||||
@ -106,7 +106,10 @@ const ApiHubLayout = () => {
|
||||
<aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col">
|
||||
{/* Sidebar header */}
|
||||
<div className="flex-shrink-0 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2 px-5 h-16">
|
||||
<button
|
||||
onClick={() => navigate('/api-hub')}
|
||||
className="flex items-center gap-2 px-5 h-16 w-full hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-400 flex-shrink-0"
|
||||
fill="none"
|
||||
@ -121,7 +124,7 @@ const ApiHubLayout = () => {
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-base font-bold tracking-wide text-white">S&P API HUB</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="px-5 pb-3">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
@ -199,9 +202,11 @@ const ApiHubLayout = () => {
|
||||
return (
|
||||
<div key={dg.domain}>
|
||||
{/* Domain header */}
|
||||
<div className="flex w-full items-center gap-1 rounded-lg text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors">
|
||||
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
|
||||
<button
|
||||
onClick={() => toggleDomain(dg.domain)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
|
||||
className="flex flex-1 items-center gap-2 px-3 py-2 min-w-0"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||
@ -220,8 +225,15 @@ const ApiHubLayout = () => {
|
||||
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
|
||||
{dg.apis.length}
|
||||
</span>
|
||||
</button>
|
||||
{/* 화살표 버튼 → 펼침/접힘 토글 */}
|
||||
<button
|
||||
onClick={() => toggleDomain(dg.domain)}
|
||||
className="flex-shrink-0 rounded-md p-1.5 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title={domainOpen ? '접기' : '펼치기'}
|
||||
>
|
||||
<svg
|
||||
className={`h-3 w-3 flex-shrink-0 text-gray-500 transition-transform ${domainOpen ? 'rotate-90' : ''}`}
|
||||
className={`h-3 w-3 transition-transform ${domainOpen ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@ -229,6 +241,7 @@ const ApiHubLayout = () => {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API items */}
|
||||
{domainOpen && (
|
||||
|
||||
@ -252,14 +252,12 @@ const ApiHubApiDetailPage = () => {
|
||||
</button>
|
||||
<span>/</span>
|
||||
<button
|
||||
onClick={() => navigate(`/api-hub/services/${serviceId}`)}
|
||||
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(domainLabel)}`)}
|
||||
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{service?.serviceName ?? `서비스 ${serviceId}`}
|
||||
{domainLabel}
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{domainLabel}</span>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium truncate max-w-xs">{api.apiName}</span>
|
||||
</nav>
|
||||
|
||||
|
||||
@ -1,29 +1,60 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ServiceCatalog, RecentApi } from '../../types/apihub';
|
||||
import type { TopApi } from '../../types/dashboard';
|
||||
import { getCatalog, getRecentApis } from '../../services/apiHubService';
|
||||
import { getTopApis } from '../../services/dashboardService';
|
||||
import type { RecentApi, PopularApi, ServiceCatalog } from '../../types/apihub';
|
||||
import { getRecentApis, getPopularApis, getCatalog } from '../../services/apiHubService';
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
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',
|
||||
PATCH: '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 formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
|
||||
|
||||
// 도메인 컬러 팔레트 (해시 기반 매핑)
|
||||
const DOMAIN_COLOR_PALETTE = [
|
||||
{ color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
|
||||
{ color: 'text-rose-400', bg: 'bg-rose-500/10', border: 'border-rose-500/30', line: 'from-rose-500' },
|
||||
{ color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30', line: 'from-blue-500' },
|
||||
{ color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', line: 'from-amber-500' },
|
||||
{ color: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30', line: 'from-violet-500' },
|
||||
{ color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', line: 'from-cyan-500' },
|
||||
{ color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', line: 'from-orange-500' },
|
||||
{ color: 'text-pink-400', bg: 'bg-pink-500/10', border: 'border-pink-500/30', line: 'from-pink-500' },
|
||||
{ color: 'text-lime-400', bg: 'bg-lime-500/10', border: 'border-lime-500/30', line: 'from-lime-500' },
|
||||
{ color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', line: 'from-indigo-500' },
|
||||
{ color: 'text-teal-400', bg: 'bg-teal-500/10', border: 'border-teal-500/30', line: 'from-teal-500' },
|
||||
{ color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', border: 'border-fuchsia-500/30', line: 'from-fuchsia-500' },
|
||||
];
|
||||
|
||||
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;
|
||||
const pathRegex = /d="([^"]+)"/g;
|
||||
const matches: string[] = [];
|
||||
let m;
|
||||
while ((m = pathRegex.exec(iconPath)) !== null) {
|
||||
matches.push(m[1]);
|
||||
}
|
||||
return matches.length > 0 ? matches : [iconPath];
|
||||
};
|
||||
|
||||
const HEALTH_DOT: Record<string, string> = {
|
||||
UP: 'bg-green-500',
|
||||
DOWN: 'bg-red-500',
|
||||
UNKNOWN: 'bg-gray-400',
|
||||
const domainColorCache = new Map<string, (typeof DOMAIN_COLOR_PALETTE)[0]>();
|
||||
let nextColorIdx = 0;
|
||||
|
||||
const getDomainColorByHash = (domain: string) => {
|
||||
const key = domain.toUpperCase();
|
||||
const cached = domainColorCache.get(key);
|
||||
if (cached) return cached;
|
||||
const color = DOMAIN_COLOR_PALETTE[nextColorIdx % DOMAIN_COLOR_PALETTE.length];
|
||||
nextColorIdx++;
|
||||
domainColorCache.set(key, color);
|
||||
return color;
|
||||
};
|
||||
|
||||
const HEALTH_LABEL: Record<string, string> = {
|
||||
UP: '정상',
|
||||
DOWN: '중단',
|
||||
UNKNOWN: '알 수 없음',
|
||||
};
|
||||
const RANK_BADGE_STYLES = [
|
||||
'bg-gradient-to-br from-yellow-400 to-amber-500',
|
||||
'bg-gradient-to-br from-gray-300 to-gray-400',
|
||||
'bg-gradient-to-br from-amber-600 to-amber-700',
|
||||
];
|
||||
|
||||
const extractSettled = <T,>(result: PromiseSettledResult<{ data?: T }>, fallback: T): T => {
|
||||
if (result.status === 'fulfilled' && result.value.data !== undefined) {
|
||||
@ -40,24 +71,30 @@ const formatDate = (dateStr: string): string => {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
interface FlatDomain {
|
||||
domain: string;
|
||||
iconPath: string | null;
|
||||
sortOrder: number;
|
||||
apiCount: number;
|
||||
}
|
||||
|
||||
const ApiHubDashboardPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||
const [recentApis, setRecentApis] = useState<RecentApi[]>([]);
|
||||
const [topApis, setTopApis] = useState<TopApi[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [popularApis, setPopularApis] = useState<PopularApi[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const [catalogRes, recentRes, topRes] = await Promise.allSettled([
|
||||
const [catalogRes, recentRes, popularRes] = await Promise.allSettled([
|
||||
getCatalog(),
|
||||
getRecentApis(),
|
||||
getTopApis(5),
|
||||
getPopularApis(),
|
||||
]);
|
||||
setCatalog(extractSettled<ServiceCatalog[]>(catalogRes, []));
|
||||
setRecentApis(extractSettled<RecentApi[]>(recentRes, []));
|
||||
setTopApis(extractSettled<TopApi[]>(topRes, []));
|
||||
setPopularApis(extractSettled<PopularApi[]>(popularRes, []));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -67,25 +104,26 @@ const ApiHubDashboardPage = () => {
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
const filteredCatalog = catalog.filter((svc) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
svc.serviceName.toLowerCase().includes(q) ||
|
||||
svc.serviceCode.toLowerCase().includes(q) ||
|
||||
(svc.description ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
// 카탈로그에서 도메인 기준으로 플랫하게 집계
|
||||
const domainList = useMemo<FlatDomain[]>(() => {
|
||||
const map = new Map<string, { iconPath: string | null; sortOrder: number; apiCount: number }>();
|
||||
for (const svc of catalog) {
|
||||
for (const dg of svc.domains) {
|
||||
const key = dg.domain.toUpperCase();
|
||||
const existing = map.get(key);
|
||||
const apiCount = (existing?.apiCount ?? 0) + dg.apis.length;
|
||||
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, { iconPath, sortOrder, apiCount });
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([domain, { iconPath, sortOrder, apiCount }]) => ({ domain, iconPath, sortOrder, apiCount }))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain));
|
||||
}, [catalog]);
|
||||
|
||||
const filteredRecentApis = recentApis.filter((api) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
api.apiName.toLowerCase().includes(q) ||
|
||||
api.apiPath.toLowerCase().includes(q) ||
|
||||
api.serviceName.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
const recentTop3 = recentApis.slice(0, 3);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -96,145 +134,211 @@ const ApiHubDashboardPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero Banner */}
|
||||
<div className="rounded-xl bg-gradient-to-r from-blue-600 to-indigo-700 dark:from-blue-800 dark:to-indigo-900 p-8 mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">S&P API HUB</h1>
|
||||
<p className="text-blue-100 mb-6">서비스 API를 탐색하고, 명세를 확인하세요</p>
|
||||
<div className="max-w-xl">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="서비스명, API명, 경로 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg text-gray-900 bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-300 shadow"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* 히어로 배너 */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-950 via-indigo-800 to-indigo-600 p-8">
|
||||
{/* 장식 글로우 원 */}
|
||||
<div className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-indigo-400 opacity-20 blur-3xl" />
|
||||
<div className="pointer-events-none absolute right-32 -top-8 h-32 w-32 rounded-full bg-purple-400 opacity-10 blur-2xl" />
|
||||
|
||||
{/* 제목 */}
|
||||
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">S&P API HUB</h1>
|
||||
<p className="text-indigo-200">S&P 해양/선박 세계데이터를 직접 만나보세요.</p>
|
||||
</div>
|
||||
|
||||
{/* 인기 API 섹션 */}
|
||||
{topApis.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">인기 API</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{topApis.map((api, idx) => (
|
||||
{popularApis.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-500/20">
|
||||
<svg className="h-4 w-4 text-amber-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M12.963 2.286a.75.75 0 00-1.071-.136 9.742 9.742 0 00-3.539 6.176 7.547 7.547 0 01-1.705-1.715.75.75 0 00-1.152-.082A9 9 0 1015.68 4.534a7.46 7.46 0 01-2.717-2.248zM15.75 14.25a3.75 3.75 0 11-7.313-1.172c.628.465 1.35.81 2.133 1a5.99 5.99 0 011.925-3.546 3.75 3.75 0 013.255 3.718z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">인기 API</h2>
|
||||
<span className="ml-1 text-xs text-gray-400 dark:text-gray-500">최근 7일 기준</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{popularApis.map((api, idx) => {
|
||||
const palette = api.domain ? getDomainColorByHash(api.domain) : DOMAIN_COLOR_PALETTE[4];
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700"
|
||||
className="group flex flex-col rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-indigo-400/50 dark:hover:border-indigo-500/50"
|
||||
onClick={() =>
|
||||
api.serviceId && api.apiId
|
||||
? navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 dark:text-gray-500">#{idx + 1}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 font-medium truncate">
|
||||
{api.serviceName}
|
||||
</span>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{/* 랭킹 뱃지 */}
|
||||
<div
|
||||
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white shadow ${RANK_BADGE_STYLES[idx] ?? 'bg-gray-500'}`}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate mb-3" title={api.apiName}>
|
||||
{api.domain && (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium truncate ${palette.bg} ${palette.color}`}
|
||||
>
|
||||
{formatDomain(api.domain)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="flex-1 text-sm font-semibold text-gray-900 dark:text-gray-100 truncate mb-4"
|
||||
title={api.apiName}
|
||||
>
|
||||
{api.apiName}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{api.count.toLocaleString()} 호출</span>
|
||||
<div className="flex items-end justify-between border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-0.5">주간 호출</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{api.count.toLocaleString()}
|
||||
<span className="ml-0.5 text-xs font-normal text-gray-400 dark:text-gray-500">회</span>
|
||||
</p>
|
||||
</div>
|
||||
<svg className="h-7 w-10 text-indigo-400/60" fill="none" viewBox="0 0 40 28" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="2,22 8,16 14,20 22,8 28,14 36,4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 최신 등록 API 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">최신 등록 API</h2>
|
||||
{filteredRecentApis.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredRecentApis.map((api) => (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-cyan-500/20">
|
||||
<svg className="h-4 w-4 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최신 등록 API</h2>
|
||||
</div>
|
||||
{recentTop3.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{recentTop3.map((api) => {
|
||||
const palette = api.apiDomain ? getDomainColorByHash(api.apiDomain) : DOMAIN_COLOR_PALETTE[4];
|
||||
return (
|
||||
<div
|
||||
key={api.apiId}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-5 border border-gray-100 dark:border-gray-700 cursor-pointer hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
|
||||
className="group flex flex-col rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-indigo-400/50 dark:hover:border-indigo-500/50"
|
||||
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{api.apiDomain && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${METHOD_COLORS[api.apiMethod] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium truncate ${palette.bg} ${palette.color}`}
|
||||
>
|
||||
{api.apiMethod}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 font-medium">
|
||||
{api.serviceName}
|
||||
{formatDomain(api.apiDomain)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate" title={api.apiName}>
|
||||
<p
|
||||
className="flex-1 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate"
|
||||
title={api.apiName}
|
||||
>
|
||||
{api.apiName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono mb-2 truncate" title={api.apiPath}>
|
||||
{api.apiPath}
|
||||
</p>
|
||||
{api.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 line-clamp-2">
|
||||
<p className="mb-3 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{truncate(api.description, 80)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{formatDate(api.createdAt)} 등록</p>
|
||||
<div className="mt-auto flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
<svg className="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
{formatDate(api.createdAt)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
|
||||
{searchQuery ? '검색 결과가 없습니다' : '등록된 API가 없습니다'}
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
등록된 API가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 서비스 카드 섹션 */}
|
||||
{/* 서비스 도메인 섹션 */}
|
||||
{domainList.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스 목록</h2>
|
||||
{filteredCatalog.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredCatalog.map((svc) => (
|
||||
<div
|
||||
key={svc.serviceId}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700 cursor-pointer hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
|
||||
onClick={() => navigate(`/api-hub/services/${svc.serviceId}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{svc.serviceName}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">{svc.serviceCode}</p>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500/20">
|
||||
<svg className="h-4 w-4 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-3 shrink-0">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${HEALTH_DOT[svc.healthStatus] ?? 'bg-gray-400'}`} />
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
svc.healthStatus === 'UP'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: svc.healthStatus === 'DOWN'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">서비스 도메인</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{domainList.map((item) => {
|
||||
const palette = getDomainColorByHash(item.domain);
|
||||
const iconPaths = parseIconPaths(item.iconPath);
|
||||
const imgSrc = `${import.meta.env.BASE_URL}images/domains/${item.domain.toLowerCase()}.jpg`;
|
||||
return (
|
||||
<div
|
||||
key={item.domain}
|
||||
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(item.domain)}`)}
|
||||
className={`group relative overflow-hidden rounded-xl border bg-white dark:bg-gray-800 ${palette.border} cursor-pointer transition-all duration-200 hover:-translate-y-1 hover:shadow-xl`}
|
||||
>
|
||||
{HEALTH_LABEL[svc.healthStatus] ?? svc.healthStatus}
|
||||
<div className="relative h-[200px] overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={item.domain}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/20 backdrop-blur-sm px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg ${palette.bg}`}>
|
||||
<svg
|
||||
className={`h-4 w-4 ${palette.color}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{iconPaths.map((d, i) => (
|
||||
<path key={i} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white truncate">
|
||||
{formatDomain(item.domain)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-xs font-semibold text-white/80">
|
||||
{item.apiCount} APIs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{svc.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{svc.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>API {svc.apiCount}개</span>
|
||||
<span>도메인 {svc.domains.length}개</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
|
||||
{searchQuery ? '검색 결과가 없습니다' : '등록된 서비스가 없습니다'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
238
frontend/src/pages/apihub/ApiHubDomainPage.tsx
Normal file
238
frontend/src/pages/apihub/ApiHubDomainPage.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { ServiceCatalog } from '../../types/apihub';
|
||||
import { getCatalog } from '../../services/apiHubService';
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
const DOMAIN_COLOR_PALETTE = [
|
||||
{ color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
|
||||
{ color: 'text-rose-400', bg: 'bg-rose-500/10', border: 'border-rose-500/30', line: 'from-rose-500' },
|
||||
{ color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30', line: 'from-blue-500' },
|
||||
{ color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', line: 'from-amber-500' },
|
||||
{ color: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30', line: 'from-violet-500' },
|
||||
{ color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', line: 'from-cyan-500' },
|
||||
{ color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', line: 'from-orange-500' },
|
||||
{ color: 'text-pink-400', bg: 'bg-pink-500/10', border: 'border-pink-500/30', line: 'from-pink-500' },
|
||||
{ color: 'text-lime-400', bg: 'bg-lime-500/10', border: 'border-lime-500/30', line: 'from-lime-500' },
|
||||
{ color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', line: 'from-indigo-500' },
|
||||
{ color: 'text-teal-400', bg: 'bg-teal-500/10', border: 'border-teal-500/30', line: 'from-teal-500' },
|
||||
{ color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', border: 'border-fuchsia-500/30', line: 'from-fuchsia-500' },
|
||||
];
|
||||
|
||||
const domainColorCache = new Map<string, (typeof DOMAIN_COLOR_PALETTE)[0]>();
|
||||
let nextColorIdx = 0;
|
||||
|
||||
const getDomainColorByHash = (domain: string) => {
|
||||
const key = domain.toUpperCase();
|
||||
const cached = domainColorCache.get(key);
|
||||
if (cached) return cached;
|
||||
const color = DOMAIN_COLOR_PALETTE[nextColorIdx % DOMAIN_COLOR_PALETTE.length];
|
||||
nextColorIdx++;
|
||||
domainColorCache.set(key, color);
|
||||
return color;
|
||||
};
|
||||
|
||||
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
|
||||
const parseIconPaths = (iconPath: string | null): string[] => {
|
||||
if (!iconPath) return DEFAULT_ICON_PATHS;
|
||||
const pathRegex = /d="([^"]+)"/g;
|
||||
const matches: string[] = [];
|
||||
let m;
|
||||
while ((m = pathRegex.exec(iconPath)) !== null) {
|
||||
matches.push(m[1]);
|
||||
}
|
||||
return matches.length > 0 ? matches : [iconPath];
|
||||
};
|
||||
|
||||
const formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
|
||||
|
||||
interface FlatApi {
|
||||
serviceId: number;
|
||||
apiId: number;
|
||||
apiName: string;
|
||||
apiPath: string;
|
||||
apiMethod: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface DomainInfo {
|
||||
domain: string;
|
||||
iconPath: string | null;
|
||||
apis: FlatApi[];
|
||||
}
|
||||
|
||||
const ApiHubDomainPage = () => {
|
||||
const { domainName } = useParams<{ domainName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getCatalog()
|
||||
.then((res) => {
|
||||
setCatalog(res.data ?? []);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const domainInfo = useMemo<DomainInfo | null>(() => {
|
||||
if (!domainName) return null;
|
||||
const targetKey = decodeURIComponent(domainName).toUpperCase();
|
||||
const apis: FlatApi[] = [];
|
||||
let iconPath: string | null = null;
|
||||
let foundDomain = '';
|
||||
|
||||
for (const svc of catalog) {
|
||||
for (const dg of svc.domains) {
|
||||
if (dg.domain.toUpperCase() === targetKey) {
|
||||
if (!foundDomain) foundDomain = dg.domain;
|
||||
if (iconPath === null && dg.iconPath) iconPath = dg.iconPath;
|
||||
for (const api of dg.apis) {
|
||||
apis.push({
|
||||
serviceId: svc.serviceId,
|
||||
apiId: api.apiId,
|
||||
apiName: api.apiName,
|
||||
apiPath: api.apiPath,
|
||||
apiMethod: api.apiMethod,
|
||||
description: api.description ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundDomain) return null;
|
||||
return { domain: foundDomain, iconPath, apis };
|
||||
}, [catalog, domainName]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!domainInfo) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">도메인을 찾을 수 없습니다.</p>
|
||||
<button
|
||||
onClick={() => navigate('/api-hub')}
|
||||
className="mt-4 text-sm text-indigo-500 hover:underline"
|
||||
>
|
||||
API HUB 홈으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const palette = getDomainColorByHash(domainInfo.domain);
|
||||
const iconPaths = parseIconPaths(domainInfo.iconPath);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* 헤더 카드 */}
|
||||
<div className={`relative overflow-hidden rounded-2xl border bg-white dark:bg-gray-800 ${palette.border} p-6`}>
|
||||
{/* 상단 컬러 라인 */}
|
||||
<div className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${palette.line} to-transparent`} />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 도메인 아이콘 */}
|
||||
<div className={`flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl ${palette.bg}`}>
|
||||
<svg
|
||||
className={`h-7 w-7 ${palette.color}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{iconPaths.map((d, i) => (
|
||||
<path key={i} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 도메인명 + API 개수 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
|
||||
{formatDomain(domainInfo.domain)}
|
||||
</h1>
|
||||
<p className={`mt-1 text-sm font-medium ${palette.color}`}>
|
||||
{domainInfo.apis.length}개 API
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 목록 */}
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
||||
{/* 검색 헤더 */}
|
||||
<div className="flex items-center justify-between gap-4 border-b border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
API 목록
|
||||
<span className="ml-2 text-xs font-normal text-gray-400 dark:text-gray-500">{domainInfo.apis.length}건</span>
|
||||
</h2>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none" 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="API 검색..."
|
||||
className="w-52 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리스트 */}
|
||||
{(() => {
|
||||
const filtered = domainInfo.apis.filter((api) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return api.apiName.toLowerCase().includes(q) || (api.description ?? '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
{filtered.map((api) => (
|
||||
<div
|
||||
key={`${api.serviceId}-${api.apiId}`}
|
||||
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
||||
className="flex items-center gap-4 px-5 py-3.5 cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{api.apiName}</p>
|
||||
</div>
|
||||
<svg className="h-4 w-4 flex-shrink-0 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiHubDomainPage;
|
||||
@ -1,9 +1,10 @@
|
||||
import { get } from './apiClient';
|
||||
import type { ServiceCatalog, RecentApi } from '../types/apihub';
|
||||
import type { ServiceCatalog, RecentApi, PopularApi } from '../types/apihub';
|
||||
import type { ApiDetailInfo } from '../types/service';
|
||||
|
||||
export const getCatalog = () => get<ServiceCatalog[]>('/api-hub/catalog');
|
||||
export const getRecentApis = () => get<RecentApi[]>('/api-hub/recent-apis');
|
||||
export const getPopularApis = () => get<PopularApi[]>('/api-hub/popular-apis');
|
||||
export const getServiceCatalog = (serviceId: number) =>
|
||||
get<ServiceCatalog>(`/api-hub/services/${serviceId}`);
|
||||
export const getApiHubApiDetail = (serviceId: number, apiId: number) =>
|
||||
|
||||
@ -39,6 +39,14 @@ export interface ServiceCatalog {
|
||||
domains: DomainGroup[];
|
||||
}
|
||||
|
||||
export interface PopularApi {
|
||||
domain: string;
|
||||
apiName: string;
|
||||
apiId: number | null;
|
||||
serviceId: number | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RecentApi {
|
||||
apiId: number;
|
||||
apiName: string;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.gcsc.connection.apihub.controller;
|
||||
|
||||
import com.gcsc.connection.apihub.dto.PopularApiResponse;
|
||||
import com.gcsc.connection.apihub.dto.RecentApiResponse;
|
||||
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
|
||||
import com.gcsc.connection.apihub.service.ApiHubService;
|
||||
@ -44,6 +45,15 @@ public class ApiHubController {
|
||||
return ResponseEntity.ok(ApiResponse.ok(recentApis));
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기 API (최근 1주일 기준 호출 수 Top N)
|
||||
*/
|
||||
@GetMapping("/popular-apis")
|
||||
public ResponseEntity<ApiResponse<List<PopularApiResponse>>> getPopularApis() {
|
||||
List<PopularApiResponse> popularApis = apiHubService.getPopularApis(3);
|
||||
return ResponseEntity.ok(ApiResponse.ok(popularApis));
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 단건 카탈로그 조회
|
||||
*/
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
package com.gcsc.connection.apihub.dto;
|
||||
|
||||
public record PopularApiResponse(
|
||||
String domain,
|
||||
String apiName,
|
||||
Long apiId,
|
||||
Long serviceId,
|
||||
long count
|
||||
) {
|
||||
}
|
||||
@ -15,6 +15,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
@ -28,6 +31,7 @@ public class ApiHubService {
|
||||
private final SnpServiceRepository snpServiceRepository;
|
||||
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||
private final SnpApiDomainRepository snpApiDomainRepository;
|
||||
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
|
||||
|
||||
/**
|
||||
* 활성 서비스와 각 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
|
||||
@ -72,6 +76,23 @@ public class ApiHubService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 1주일 기준 인기 API 상위 N건
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<com.gcsc.connection.apihub.dto.PopularApiResponse> getPopularApis(int limit) {
|
||||
LocalDateTime since = LocalDateTime.now().minusDays(7);
|
||||
return snpApiRequestLogRepository.findTopApisForHub(since, limit).stream()
|
||||
.map(row -> new com.gcsc.connection.apihub.dto.PopularApiResponse(
|
||||
(String) row[0],
|
||||
(String) row[1],
|
||||
row[2] != null ? ((Number) row[2]).longValue() : null,
|
||||
row[3] != null ? ((Number) row[3]).longValue() : null,
|
||||
((Number) row[4]).longValue()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<String, SnpApiDomain> buildDomainMap() {
|
||||
return snpApiDomainRepository.findAllByOrderBySortOrderAscDomainNameAsc().stream()
|
||||
.collect(Collectors.toMap(SnpApiDomain::getDomainName, Function.identity()));
|
||||
|
||||
@ -87,6 +87,15 @@ public class GlobalExceptionHandler {
|
||||
.body(ApiResponse.error("요청 본문을 읽을 수 없습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 리소스 미발견 (이미지 등 404)
|
||||
*/
|
||||
@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
|
||||
public ResponseEntity<Void> handleNoResourceFound(
|
||||
org.springframework.web.servlet.resource.NoResourceFoundException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리되지 않은 예외 처리
|
||||
*/
|
||||
|
||||
@ -12,6 +12,20 @@ import java.util.List;
|
||||
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
|
||||
JpaSpecificationExecutor<SnpApiRequestLog> {
|
||||
|
||||
/** API HUB 인기 API (최근 1주일, 도메인 포함) */
|
||||
@Query(value = "SELECT COALESCE(a.api_domain, '') as domain, " +
|
||||
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)) as apiName, " +
|
||||
"a.api_id, a.service_id, COUNT(*) as cnt " +
|
||||
"FROM common.snp_api_request_log l " +
|
||||
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
|
||||
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
|
||||
"AND a.api_method = l.request_method " +
|
||||
"WHERE l.requested_at >= :since AND a.api_id IS NOT NULL " +
|
||||
"GROUP BY a.api_domain, a.api_name, a.api_id, a.service_id, SPLIT_PART(l.request_url, '?', 1) " +
|
||||
"ORDER BY cnt DESC LIMIT :limit", nativeQuery = true)
|
||||
List<Object[]> findTopApisForHub(@Param("since") LocalDateTime since, @Param("limit") int limit);
|
||||
|
||||
/** API Key별 일일 요청 건수 */
|
||||
long countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(Long apiKeyId, LocalDateTime startOfDay);
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user