release: 2026-04-15 (10건 커밋) #52

병합
HYOJIN develop 에서 main 로 10 commits 를 머지했습니다 2026-04-15 16:44:02 +09:00
10개의 변경된 파일365개의 추가작업 그리고 198개의 파일을 삭제
Showing only changes of commit 802d1ba464 - Show all commits

파일 보기

@ -8,7 +8,7 @@
<link rel="manifest" href="/snp-connection/site.webmanifest" />
<link rel="shortcut icon" href="/snp-connection/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SNP Connection Monitoring</title>
<title>KCG Connection Monitoring</title>
</head>
<body>
<div id="root"></div>

파일 보기

Before

Width:  |  Height:  |  크기: 4.0 MiB

After

Width:  |  Height:  |  크기: 4.0 MiB

파일 보기

Before

Width:  |  Height:  |  크기: 658 KiB

After

Width:  |  Height:  |  크기: 658 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 4.0 MiB

After

Width:  |  Height:  |  크기: 4.0 MiB

파일 보기

Before

Width:  |  Height:  |  크기: 2.7 MiB

After

Width:  |  Height:  |  크기: 2.7 MiB

파일 보기

Before

Width:  |  Height:  |  크기: 1.8 MiB

After

Width:  |  Height:  |  크기: 1.8 MiB

파일 보기

@ -26,6 +26,20 @@ const parseIconPaths = (iconPath: string | null): string[] => {
return matches.length > 0 ? matches : [iconPath];
};
/** 도메인 인덱스를 기반으로 색상 팔레트 반환 */
const DOMAIN_COLORS = [
{ bg: 'bg-indigo-500', dot: 'bg-indigo-400', badge: 'bg-indigo-900/60 text-indigo-300' },
{ bg: 'bg-violet-500', dot: 'bg-violet-400', badge: 'bg-violet-900/60 text-violet-300' },
{ bg: 'bg-sky-500', dot: 'bg-sky-400', badge: 'bg-sky-900/60 text-sky-300' },
{ bg: 'bg-emerald-500', dot: 'bg-emerald-400', badge: 'bg-emerald-900/60 text-emerald-300' },
{ bg: 'bg-amber-500', dot: 'bg-amber-400', badge: 'bg-amber-900/60 text-amber-300' },
{ bg: 'bg-rose-500', dot: 'bg-rose-400', badge: 'bg-rose-900/60 text-rose-300' },
{ bg: 'bg-teal-500', dot: 'bg-teal-400', badge: 'bg-teal-900/60 text-teal-300' },
{ bg: 'bg-orange-500', dot: 'bg-orange-400', badge: 'bg-orange-900/60 text-orange-300' },
] as const;
const getDomainColor = (index: number) => DOMAIN_COLORS[index % DOMAIN_COLORS.length];
interface FlatDomainGroup {
domain: string;
iconPath: string | null;
@ -103,49 +117,33 @@ const ApiHubLayoutInner = () => {
const isSearching = searchQuery.trim().length > 0;
const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]);
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">
<aside className="fixed left-0 top-0 h-screen w-[272px] bg-gray-900 flex flex-col">
{/* 로고 영역 */}
<div className="flex-shrink-0 border-b border-gray-700/70">
<button
onClick={() => navigate('/api-hub')}
className="flex items-center gap-2 px-5 h-16 w-full hover:bg-gray-800 transition-colors"
className="flex items-center gap-3 px-5 h-14 w-full hover:bg-gray-800/60 transition-colors"
>
<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>
</button>
<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" />
{/* Indigo 그라디언트 아이콘 배경 */}
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg">
<svg className="h-4.5 w-4.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path 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>
Back to Dashboard
</button>
</div>
</div>
<span className="text-sm font-bold tracking-widest text-white uppercase">KCG API HUB</span>
</button>
</div>
{/* Search */}
<div className="flex-shrink-0 px-3 pt-3 pb-1">
{/* 검색 영역 */}
<div className="flex-shrink-0 px-4 pt-4 pb-2">
<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"
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -157,12 +155,12 @@ const ApiHubLayoutInner = () => {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="API 검색..."
className="w-full bg-gray-800 border border-gray-700 text-gray-200 placeholder-gray-500 rounded-lg pl-8 pr-8 py-1.5 text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500 focus:outline-none"
className="w-full bg-gray-800 border border-gray-700 text-gray-200 placeholder-gray-600 rounded-lg pl-8 pr-7 py-2 text-xs focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-400 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="M6 18L18 6M6 6l12 12" />
@ -170,69 +168,67 @@ const ApiHubLayoutInner = () => {
</button>
)}
</div>
{/* API 수 요약 */}
{!loading && (
<p className="mt-2 text-[11px] text-gray-600">
{filteredDomainGroups.length} · {filteredDomainGroups.reduce((s, dg) => s + dg.apis.length, 0)} API
</p>
)}
</div>
{/* Navigation tree */}
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{/* 도메인 트리 */}
<nav className="flex-1 overflow-y-auto px-2 py-1 space-y-0.5">
{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 className="h-5 w-5 animate-spin text-gray-600" 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>
) : filteredDomainGroups.length === 0 ? (
<p className="text-xs text-gray-500 text-center py-6"> </p>
<p className="text-xs text-gray-600 text-center py-6"> </p>
) : (
filteredDomainGroups.map((dg) => {
filteredDomainGroups.map((dg, domainIndex) => {
const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
const color = getDomainColor(domainIndex);
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">
<div key={dg.domain} className="mb-0.5">
{/* 도메인 헤더 */}
<div className="flex w-full items-center rounded-lg hover:bg-gray-800/50 transition-colors group">
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
<button
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
className="flex flex-1 items-center gap-2 px-3 py-2 min-w-0"
className="flex flex-1 items-center gap-2.5 px-2 py-2 min-w-0"
>
<svg
className="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{parseIconPaths(dg.iconPath).map((d, i) => (
<path key={i} d={d} />
))}
</svg>
<span className="truncate tracking-wider">{dg.domain}</span>
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
{/* 도메인 색상 아이콘 배경 */}
<div className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md ${color.bg}`}>
<svg
className="h-3.5 w-3.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
{parseIconPaths(dg.iconPath).map((d, i) => (
<path key={i} d={d} />
))}
</svg>
</div>
<span className="flex-1 truncate text-xs font-semibold text-gray-300 group-hover:text-white transition-colors tracking-wide">
{dg.domain}
</span>
{/* API 카운트 뱃지 */}
<span className={`flex-shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${color.badge}`}>
{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"
className="flex-shrink-0 rounded-md p-1.5 mr-1 text-gray-600 hover:text-gray-400 transition-colors"
title={domainOpen ? '접기' : '펼치기'}
>
<svg
@ -246,9 +242,9 @@ const ApiHubLayoutInner = () => {
</button>
</div>
{/* API items */}
{/* API 항목 목록 */}
{domainOpen && (
<div className="ml-4 mt-0.5 space-y-0.5">
<div className="ml-3 mt-0.5 space-y-0.5 border-l border-gray-800 pl-3">
{dg.apis.map((api) => {
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
const isActive = location.pathname === apiPath;
@ -257,13 +253,17 @@ const ApiHubLayoutInner = () => {
<NavLink
key={`${api.serviceId}-${api.apiId}`}
to={apiPath}
className={`block rounded-lg px-2.5 py-1.5 text-xs truncate transition-colors ${
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs truncate transition-colors ${
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
? 'bg-gray-800 text-white font-medium'
: 'text-gray-500 hover:bg-gray-800/60 hover:text-gray-200'
}`}
>
{api.apiName}
{/* 활성 시 좌측 도트 인디케이터 */}
<span className={`flex-shrink-0 h-1.5 w-1.5 rounded-full transition-colors ${
isActive ? `${color.dot}` : 'bg-gray-700'
}`} />
<span className="truncate">{api.apiName}</span>
</NavLink>
);
})}
@ -274,49 +274,50 @@ const ApiHubLayoutInner = () => {
})
)}
</nav>
{/* 하단 정보 */}
{!loading && (
<div className="flex-shrink-0 border-t border-gray-700/70 px-4 py-3">
<div className="flex items-center justify-between text-[11px] text-gray-600">
<span> {totalApiCount} API</span>
<span>{domainGroups.length} </span>
</div>
</div>
)}
</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 className="flex-1 ml-[272px]">
{/* 헤더 */}
<header className="h-14 bg-gray-900 border-b border-gray-700/70 flex items-center justify-between px-6">
<div />
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
{/* 테마 토글 */}
<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"
className="rounded-lg p-2 text-gray-500 hover:bg-gray-800 hover:text-gray-300 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 className="h-4 w-4" 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 className="h-4 w-4" 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">
{/* 역할 스위처 (알약 형태) */}
<div className="flex rounded-full overflow-hidden border border-gray-700 bg-gray-800 p-0.5">
{ROLES.map((role) => (
<button
key={role}
onClick={() => setRole(role)}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
className={`rounded-full px-3 py-1 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'
? 'bg-indigo-600 text-white shadow-sm'
: 'text-gray-500 hover:text-gray-300'
}`}
>
{role}
@ -326,8 +327,8 @@ const ApiHubLayoutInner = () => {
</div>
</header>
{/* Content */}
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
{/* 콘텐츠 */}
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-3.5rem)]">
<Outlet />
<BasketFloatingPanel />
</main>

파일 보기

@ -1,50 +1,178 @@
import { useState } from 'react';
import { Outlet, NavLink } from 'react-router-dom';
import { Outlet, NavLink, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme';
interface NavItem {
label: string;
path: string;
icon: React.ReactNode;
adminManagerOnly?: boolean;
}
interface NavGroup {
label: string;
items: { label: string; path: string }[];
items: NavItem[];
adminOnly?: boolean;
}
const iconProps = {
fill: 'none' as const,
viewBox: '0 0 24 24',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
width: 18,
height: 18,
};
const IconDashboard = () => (
<svg {...iconProps}>
<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>
);
const IconRequestLog = () => (
<svg {...iconProps}>
<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>
);
const IconServiceStatus = () => (
<svg {...iconProps}>
<polyline points="22,12 18,12 15,21 9,3 6,12 2,12" />
</svg>
);
const IconServiceStats = () => (
<svg {...iconProps}>
<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>
);
const IconUserStats = () => (
<svg {...iconProps}>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
</svg>
);
const IconApiStats = () => (
<svg {...iconProps}>
<path d="M21 12a9 9 0 11-6.219-8.56" />
<path d="M21 3v6h-6" />
</svg>
);
const IconTenantStats = () => (
<svg {...iconProps}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18" />
<path d="M9 21V9" />
</svg>
);
const IconUsageTrend = () => (
<svg {...iconProps}>
<polyline points="23,6 13.5,15.5 8.5,10.5 1,18" />
<polyline points="17,6 23,6 23,12" />
</svg>
);
const IconMyKey = () => (
<svg {...iconProps}>
<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>
);
const IconKeyRequest = () => (
<svg {...iconProps}>
<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>
);
const IconKeyManage = () => (
<svg {...iconProps}>
<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>
);
const IconService = () => (
<svg {...iconProps}>
<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>
);
const IconDomain = () => (
<svg {...iconProps}>
<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>
);
const IconApiManage = () => (
<svg {...iconProps}>
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
);
const IconSampleCode = () => (
<svg {...iconProps}>
<polyline points="16,18 22,12 16,6" />
<polyline points="8,6 2,12 8,18" />
</svg>
);
const navGroups: NavGroup[] = [
{
label: 'Monitoring',
label: '모니터링',
items: [
{ label: 'Request Logs', path: '/monitoring/request-logs' },
{ label: 'Service Status', path: '/monitoring/service-status' },
{ label: '요청 로그', path: '/monitoring/request-logs', icon: <IconRequestLog /> },
{ label: '서비스 상태', path: '/monitoring/service-status', icon: <IconServiceStatus /> },
],
},
{
label: 'Statistics',
label: '통계',
items: [
{ label: '서비스 통계', path: '/statistics/services' },
{ label: '사용자 통계', path: '/statistics/users' },
{ label: 'API 통계', path: '/statistics/apis' },
{ label: '테넌트 통계', path: '/statistics/tenants' },
{ label: '사용량 추이', path: '/statistics/usage-trend' },
{ label: '서비스 통계', path: '/statistics/services', icon: <IconServiceStats /> },
{ label: '사용자 통계', path: '/statistics/users', icon: <IconUserStats /> },
{ label: 'API 통계', path: '/statistics/apis', icon: <IconApiStats /> },
{ label: '부서 통계', path: '/statistics/tenants', icon: <IconTenantStats /> },
{ label: '사용량 추이', path: '/statistics/usage-trend', icon: <IconUsageTrend /> },
],
},
{
label: 'API Keys',
label: 'API ',
items: [
{ label: 'My Keys', path: '/apikeys/my-keys' },
{ label: 'Request', path: '/apikeys/request' },
{ label: 'Admin', path: '/apikeys/admin' },
{ label: '내 키', path: '/apikeys/my-keys', icon: <IconMyKey /> },
{ label: '키 신청', path: '/apikeys/request', icon: <IconKeyRequest /> },
{ label: '키 관리', path: '/apikeys/admin', icon: <IconKeyManage />, adminManagerOnly: true },
],
},
{
label: 'Admin',
label: '관리자',
adminOnly: true,
items: [
{ label: 'Services', path: '/admin/services' },
{ label: 'Domains', path: '/admin/domains' },
{ label: 'API 관리', path: '/admin/apis' },
{ label: '공통 샘플 코드', path: '/admin/sample-code' },
{ label: 'Users', path: '/admin/users' },
{ label: 'Tenants', path: '/admin/tenants' },
{ label: '서비스', path: '/admin/services', icon: <IconService /> },
{ label: '도메인', path: '/admin/domains', icon: <IconDomain /> },
{ label: 'API 관리', path: '/admin/apis', icon: <IconApiManage /> },
{ label: '공통 샘플 코드', path: '/admin/sample-code', icon: <IconSampleCode /> },
{ label: '사용자', path: '/admin/users', icon: <IconUserStats /> },
{ label: '부서', path: '/admin/tenants', icon: <IconTenantStats /> },
],
},
];
@ -55,10 +183,10 @@ const MainLayout = () => {
const { user, setRole } = useAuth();
const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true,
Statistics: true,
'API Keys': true,
Admin: true,
'모니터링': true,
'통계': true,
'API ': true,
'관리자': true,
});
const toggleGroup = (label: string) => {
@ -70,43 +198,64 @@ const MainLayout = () => {
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
<div className="flex items-center gap-2 px-6 h-16 border-b border-gray-700">
<svg className="h-6 w-6" style={{ color: '#FF2E63' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="text-lg font-semibold">SNP Connection</span>
<aside className="fixed left-0 top-0 h-screen w-60 bg-gray-900 text-white flex flex-col z-40">
{/* 로고 영역 */}
<div className="flex items-center gap-3 px-4 h-14 border-b border-gray-700/60 flex-shrink-0">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-indigo-700 flex-shrink-0">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width={16} height={16} className="text-white">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
</svg>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-white leading-tight truncate">KCG Connection</div>
<div className="text-[10px] text-gray-400 leading-tight truncate">Monitoring Service</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
{/* Dashboard */}
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1 scrollbar-thin scrollbar-track-gray-900 scrollbar-thumb-gray-700">
{/* API Hub 바로가기 카드 */}
<Link
to="/api-hub"
className="flex items-center gap-3 mx-1 mb-3 px-3 py-2.5 rounded-lg border border-indigo-500/40 bg-indigo-500/10 hover:bg-indigo-500/20 transition-colors group"
>
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-indigo-600/30 flex-shrink-0">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={15} height={15} className="text-indigo-300">
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M9 9h6M9 13h4" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-indigo-300 leading-tight">API Hub</div>
<div className="text-[10px] text-indigo-400/70 leading-tight truncate">API </div>
</div>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width={14} height={14} className="text-indigo-400/60 flex-shrink-0 group-hover:text-indigo-300 transition-colors">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{/* 대시보드 */}
<NavLink
to="/dashboard"
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
}`
}
>
<svg className="h-5 w-5" 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-2V6zm10 0a2 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-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Dashboard
</NavLink>
{/* API Hub */}
<NavLink
to="/api-hub"
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 mt-4 text-sm font-medium transition-colors ${
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`
}
>
<svg className="h-5 w-5" 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>
API Hub
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-indigo-500 rounded-r-full" />
)}
<span className="flex-shrink-0"><IconDashboard /></span>
<span className="font-medium"></span>
</>
)}
</NavLink>
{/* Nav Groups */}
@ -116,44 +265,57 @@ const MainLayout = () => {
const isOpen = openGroups[group.label] ?? false;
return (
<div key={group.label} className="mt-4">
<div key={group.label}>
{/* 구분선 */}
<div className="my-2 mx-1 border-t border-gray-700/50" />
{/* 섹션 타이틀 */}
<button
onClick={() => toggleGroup(group.label)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-white"
className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-gray-500 hover:text-gray-300 transition-colors"
>
{group.label}
<span>{group.label}</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
width={12}
height={12}
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="ml-2 space-y-1">
<div className="mt-0.5 space-y-0.5">
{group.items.map((item) => {
if (
group.label === 'API Keys' &&
item.label === 'Admin' &&
!isAdminOrManager
) {
return null;
}
if (item.adminManagerOnly && !isAdminOrManager) return null;
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`block rounded-lg px-3 py-2 text-sm transition-colors ${
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
}`
}
>
{item.label}
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-indigo-500 rounded-r-full" />
)}
<span className="flex-shrink-0">{item.icon}</span>
<span>{item.label}</span>
</>
)}
</NavLink>
);
})}
@ -162,40 +324,45 @@ const MainLayout = () => {
</div>
);
})}
{/* 마지막 구분선 */}
<div className="my-2 mx-1 border-t border-gray-700/50" />
</nav>
</aside>
{/* Main Content */}
<div className="flex-1 ml-64">
<div className="flex-1 ml-60">
{/* 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">
<header className="h-14 bg-gray-900 border-b border-gray-700/60 flex items-center justify-between px-6 sticky top-0 z-30">
<div />
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
{/* 테마 토글 */}
<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"
className="flex items-center justify-center w-8 h-8 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 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 fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
<path 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 fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
<path 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">
{/* 역할 스위처 */}
<div className="flex items-center bg-gray-800 rounded-full p-0.5 gap-0.5">
{ROLES.map((role) => (
<button
key={role}
onClick={() => setRole(role)}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
className={`px-3 py-1 text-xs font-medium rounded-full 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'
? 'bg-indigo-600 text-white shadow-sm'
: 'text-gray-400 hover:text-gray-200'
}`}
>
{role}
@ -206,7 +373,7 @@ const MainLayout = () => {
</header>
{/* Content */}
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-3.5rem)]">
<Outlet />
</main>
</div>

파일 보기

@ -142,8 +142,8 @@ const ApiHubDashboardPage = () => {
<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&amp;P API HUB</h1>
<p className="text-indigo-200">S&amp;P / .</p>
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">KCG API HUB</h1>
<p className="text-indigo-200"> .</p>
</div>
{/* 인기 API 섹션 */}

파일 보기

@ -92,7 +92,6 @@ const ServiceStatusPage = () => {
>
{svc.serviceName}
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">{svc.serviceCode}</span>
</div>
<div className="flex items-center gap-4">
<span className={`text-sm font-medium ${svc.currentStatus === 'UP' ? 'text-green-600' : svc.currentStatus === 'DOWN' ? 'text-red-600' : 'text-gray-500'}`}>