snp-connection-monitoring/frontend/src/layouts/ApiHubLayout.tsx
HYOJIN 6f2627271f feat(api-hub): S&P API HUB SPA 구현 (#40)
- API Hub 대시보드 (배너, 인기 API, 최신 API, 서비스 카드)
- 서비스 트리 사이드바 레이아웃 (서비스 > 도메인 > API)
- 서비스별 API 목록 페이지 (도메인별 그룹)
- API 상세 명세 페이지
- 백엔드 카탈로그/최신 API 조회 엔드포인트
- 메인 사이드바에 API Hub 링크 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:28:55 +09:00

291 lines
13 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme';
import { getCatalog } from '../services/apiHubService';
import type { ServiceCatalog } from '../types/apihub';
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
const METHOD_BADGE_CLASS: 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',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
};
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<ServiceCatalog['healthStatus'], string> = {
UP: 'bg-green-500',
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400',
};
const ApiHubLayout = () => {
const { user, setRole } = useAuth();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const location = useLocation();
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
const [loading, setLoading] = useState(true);
const [openServices, setOpenServices] = useState<Record<number, boolean>>({});
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
useEffect(() => {
getCatalog()
.then((res) => {
const items = res.data ?? [];
setCatalog(items);
// Open all service groups and domain groups by default
const serviceState: Record<number, boolean> = {};
const domainState: Record<string, boolean> = {};
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] }));
};
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<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">
<svg
className="h-5 w-5 text-blue-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span className="text-base font-bold tracking-wide text-white">S&amp;P API HUB</span>
</div>
<div className="px-5 pb-3">
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-white transition-colors"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Dashboard
</button>
</div>
</div>
{/* Navigation tree */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-10">
<svg
className="h-6 w-6 animate-spin text-gray-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
) : (
catalog.map((service) => {
const serviceOpen = openServices[service.serviceId] ?? false;
const healthDot = HEALTH_DOT_CLASS[service.healthStatus];
return (
<div key={service.serviceId} className="mb-1">
{/* Service group header */}
<div className="flex items-center gap-1">
<button
onClick={() => navigate(`/api-hub/services/${service.serviceId}`)}
className="flex flex-1 min-w-0 items-center gap-2 rounded-l-lg px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors text-left"
>
<span
className={`h-2 w-2 flex-shrink-0 rounded-full ${healthDot}`}
title={service.healthStatus}
/>
<span className="truncate">{service.serviceName}</span>
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
{service.apiCount}
</span>
</button>
<button
onClick={() => toggleService(service.serviceId)}
className="flex-shrink-0 rounded-r-lg p-2 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
aria-label={serviceOpen ? 'Collapse' : 'Expand'}
>
<svg
className={`h-3.5 w-3.5 transition-transform ${serviceOpen ? '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>
</button>
</div>
{/* Domain groups */}
{serviceOpen && (
<div className="ml-3 mt-0.5 space-y-0.5">
{service.domains.map((dg) => {
const domainKey = `${service.serviceId}:${dg.domain}`;
const domainOpen = openDomains[domainKey] ?? false;
return (
<div key={dg.domain}>
{/* Domain header */}
<button
onClick={() => toggleDomain(domainKey)}
className="flex w-full items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors"
>
<svg
className={`h-3 w-3 flex-shrink-0 transition-transform ${domainOpen ? '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>
<span className="truncate uppercase tracking-wider">{dg.domain}</span>
<span className="ml-auto flex-shrink-0 text-gray-500">
{dg.apis.length}
</span>
</button>
{/* API items */}
{domainOpen && (
<div className="ml-3 mt-0.5 space-y-0.5">
{dg.apis.map((api) => {
const apiPath = `/api-hub/services/${service.serviceId}/apis/${api.apiId}`;
const isActive = location.pathname === apiPath;
return (
<NavLink
key={api.apiId}
to={apiPath}
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
<span
className={`flex-shrink-0 rounded px-1 py-0.5 text-[10px] font-bold leading-none ${getMethodBadgeClass(api.apiMethod)}`}
>
{api.apiMethod.toUpperCase()}
</span>
<span className="truncate">{api.apiName}</span>
</NavLink>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})
)}
</nav>
</aside>
{/* Main content */}
<div className="flex-1 ml-72">
{/* Header */}
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-6">
<div />
<div className="flex items-center gap-4">
<button
onClick={toggleTheme}
className="rounded-lg p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
>
{theme === 'light' ? (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
)}
</button>
<span className="text-sm text-gray-500 dark:text-gray-400">Role:</span>
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
{ROLES.map((role) => (
<button
key={role}
onClick={() => setRole(role)}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
user?.role === role
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
>
{role}
</button>
))}
</div>
</div>
</header>
{/* Content */}
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
<Outlet />
</main>
</div>
</div>
);
};
export default ApiHubLayout;