feat(ui): KCG 브랜딩 적용 및 레이아웃 디자인 개선 (#48)

- S&P/SNP 텍스트를 KCG로 변경 (타이틀, 사이드바, 대시보드)
- 사이드 메뉴 한글화 (모니터링, 통계, API 키, 관리자)
- 테넌트 → 부서 텍스트 변경
- MainLayout 헤더/사이드바 레퍼런스 디자인 적용 (아이콘, 인디케이터, 알약 역할 스위처)
- ApiHubLayout 헤더/사이드바 레퍼런스 디자인 적용 (도메인 색상 팔레트, 도트 인디케이터)
- 서비스 상태 카드 서비스 코드 제거
- 대시보드 배너 설명 텍스트 변경
- 도메인 이미지 파일명 한글 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-04-15 12:55:24 +09:00
부모 8113123f58
커밋 802d1ba464
10개의 변경된 파일365개의 추가작업 그리고 198개의 파일을 삭제

파일 보기

@ -8,7 +8,7 @@
<link rel="manifest" href="/snp-connection/site.webmanifest" /> <link rel="manifest" href="/snp-connection/site.webmanifest" />
<link rel="shortcut icon" href="/snp-connection/favicon.ico" /> <link rel="shortcut icon" href="/snp-connection/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SNP Connection Monitoring</title> <title>KCG Connection Monitoring</title>
</head> </head>
<body> <body>
<div id="root"></div> <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]; 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 { interface FlatDomainGroup {
domain: string; domain: string;
iconPath: string | null; iconPath: string | null;
@ -103,49 +117,33 @@ const ApiHubLayoutInner = () => {
const isSearching = searchQuery.trim().length > 0; const isSearching = searchQuery.trim().length > 0;
const totalApiCount = useMemo(() => domainGroups.reduce((sum, dg) => sum + dg.apis.length, 0), [domainGroups]);
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Sidebar */} {/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col"> <aside className="fixed left-0 top-0 h-screen w-[272px] bg-gray-900 flex flex-col">
{/* Sidebar header */} {/* 로고 영역 */}
<div className="flex-shrink-0 border-b border-gray-700"> <div className="flex-shrink-0 border-b border-gray-700/70">
<button <button
onClick={() => navigate('/api-hub')} 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 {/* Indigo 그라디언트 아이콘 배경 */}
className="h-5 w-5 text-blue-400 flex-shrink-0" <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">
fill="none" <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">
viewBox="0 0 24 24" <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" />
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> </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" />
</svg>
Back to Dashboard
</button>
</div> </div>
<span className="text-sm font-bold tracking-widest text-white uppercase">KCG API HUB</span>
</button>
</div> </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"> <div className="relative">
<svg <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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@ -157,12 +155,12 @@ const ApiHubLayoutInner = () => {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="API 검색..." 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 && ( {searchQuery && (
<button <button
onClick={() => setSearchQuery('')} 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -170,53 +168,47 @@ const ApiHubLayoutInner = () => {
</button> </button>
)} )}
</div> </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> </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 ? ( {loading ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<svg <svg className="h-5 w-5 animate-spin text-gray-600" fill="none" viewBox="0 0 24 24">
className="h-6 w-6 animate-spin text-gray-400" <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
fill="none" <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
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> </svg>
</div> </div>
) : filteredDomainGroups.length === 0 ? ( ) : 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 domainOpen = isSearching || (openDomains[dg.domain] ?? true);
const color = getDomainColor(domainIndex);
return ( return (
<div key={dg.domain}> <div key={dg.domain} className="mb-0.5">
{/* 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 className="flex w-full items-center rounded-lg hover:bg-gray-800/50 transition-colors group">
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */} {/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
<button <button
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)} 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"
> >
{/* 도메인 색상 아이콘 배경 */}
<div className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md ${color.bg}`}>
<svg <svg
className="h-4 w-4 flex-shrink-0 text-gray-400" className="h-3.5 w-3.5 text-white"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
@ -224,15 +216,19 @@ const ApiHubLayoutInner = () => {
<path key={i} d={d} /> <path key={i} d={d} />
))} ))}
</svg> </svg>
<span className="truncate tracking-wider">{dg.domain}</span> </div>
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300"> <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} {dg.apis.length}
</span> </span>
</button> </button>
{/* 화살표 버튼 → 펼침/접힘 토글 */} {/* 접힘/펼침 화살표 버튼 */}
<button <button
onClick={() => toggleDomain(dg.domain)} 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 ? '접기' : '펼치기'} title={domainOpen ? '접기' : '펼치기'}
> >
<svg <svg
@ -246,9 +242,9 @@ const ApiHubLayoutInner = () => {
</button> </button>
</div> </div>
{/* API items */} {/* API 항목 목록 */}
{domainOpen && ( {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) => { {dg.apis.map((api) => {
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`; const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
const isActive = location.pathname === apiPath; const isActive = location.pathname === apiPath;
@ -257,13 +253,17 @@ const ApiHubLayoutInner = () => {
<NavLink <NavLink
key={`${api.serviceId}-${api.apiId}`} key={`${api.serviceId}-${api.apiId}`}
to={apiPath} 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 isActive
? 'bg-gray-700 text-white' ? 'bg-gray-800 text-white font-medium'
: 'text-gray-300 hover:bg-gray-800 hover:text-white' : '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> </NavLink>
); );
})} })}
@ -274,49 +274,50 @@ const ApiHubLayoutInner = () => {
}) })
)} )}
</nav> </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> </aside>
{/* Main content */} {/* Main content */}
<div className="flex-1 ml-72"> <div className="flex-1 ml-[272px]">
{/* 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/70 flex items-center justify-between px-6">
<div /> <div />
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
{/* 테마 토글 */}
<button <button
onClick={toggleTheme} 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'} title={theme === 'light' ? 'Dark mode' : 'Light mode'}
> >
{theme === 'light' ? ( {theme === 'light' ? (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <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" />
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>
) : ( ) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <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" />
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> </svg>
)} )}
</button> </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) => ( {ROLES.map((role) => (
<button <button
key={role} key={role}
onClick={() => setRole(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 user?.role === role
? 'bg-blue-600 text-white' ? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600' : 'text-gray-500 hover:text-gray-300'
}`} }`}
> >
{role} {role}
@ -326,8 +327,8 @@ const ApiHubLayoutInner = () => {
</div> </div>
</header> </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 /> <Outlet />
<BasketFloatingPanel /> <BasketFloatingPanel />
</main> </main>

파일 보기

@ -1,50 +1,178 @@
import { useState } from 'react'; 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 { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
interface NavItem {
label: string;
path: string;
icon: React.ReactNode;
adminManagerOnly?: boolean;
}
interface NavGroup { interface NavGroup {
label: string; label: string;
items: { label: string; path: string }[]; items: NavItem[];
adminOnly?: boolean; 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[] = [ const navGroups: NavGroup[] = [
{ {
label: 'Monitoring', label: '모니터링',
items: [ items: [
{ label: 'Request Logs', path: '/monitoring/request-logs' }, { label: '요청 로그', path: '/monitoring/request-logs', icon: <IconRequestLog /> },
{ label: 'Service Status', path: '/monitoring/service-status' }, { label: '서비스 상태', path: '/monitoring/service-status', icon: <IconServiceStatus /> },
], ],
}, },
{ {
label: 'Statistics', label: '통계',
items: [ items: [
{ label: '서비스 통계', path: '/statistics/services' }, { label: '서비스 통계', path: '/statistics/services', icon: <IconServiceStats /> },
{ label: '사용자 통계', path: '/statistics/users' }, { label: '사용자 통계', path: '/statistics/users', icon: <IconUserStats /> },
{ label: 'API 통계', path: '/statistics/apis' }, { label: 'API 통계', path: '/statistics/apis', icon: <IconApiStats /> },
{ label: '테넌트 통계', path: '/statistics/tenants' }, { label: '부서 통계', path: '/statistics/tenants', icon: <IconTenantStats /> },
{ label: '사용량 추이', path: '/statistics/usage-trend' }, { label: '사용량 추이', path: '/statistics/usage-trend', icon: <IconUsageTrend /> },
], ],
}, },
{ {
label: 'API Keys', label: 'API ',
items: [ items: [
{ label: 'My Keys', path: '/apikeys/my-keys' }, { label: '내 키', path: '/apikeys/my-keys', icon: <IconMyKey /> },
{ label: 'Request', path: '/apikeys/request' }, { label: '키 신청', path: '/apikeys/request', icon: <IconKeyRequest /> },
{ label: 'Admin', path: '/apikeys/admin' }, { label: '키 관리', path: '/apikeys/admin', icon: <IconKeyManage />, adminManagerOnly: true },
], ],
}, },
{ {
label: 'Admin', label: '관리자',
adminOnly: true, adminOnly: true,
items: [ items: [
{ label: 'Services', path: '/admin/services' }, { label: '서비스', path: '/admin/services', icon: <IconService /> },
{ label: 'Domains', path: '/admin/domains' }, { label: '도메인', path: '/admin/domains', icon: <IconDomain /> },
{ label: 'API 관리', path: '/admin/apis' }, { label: 'API 관리', path: '/admin/apis', icon: <IconApiManage /> },
{ label: '공통 샘플 코드', path: '/admin/sample-code' }, { label: '공통 샘플 코드', path: '/admin/sample-code', icon: <IconSampleCode /> },
{ label: 'Users', path: '/admin/users' }, { label: '사용자', path: '/admin/users', icon: <IconUserStats /> },
{ label: 'Tenants', path: '/admin/tenants' }, { label: '부서', path: '/admin/tenants', icon: <IconTenantStats /> },
], ],
}, },
]; ];
@ -55,10 +183,10 @@ const MainLayout = () => {
const { user, setRole } = useAuth(); const { user, setRole } = useAuth();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true, '모니터링': true,
Statistics: true, '통계': true,
'API Keys': true, 'API ': true,
Admin: true, '관리자': true,
}); });
const toggleGroup = (label: string) => { const toggleGroup = (label: string) => {
@ -70,43 +198,64 @@ const MainLayout = () => {
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Sidebar */} {/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col"> <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-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" /> <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> </svg>
<span className="text-lg font-semibold">SNP Connection</span> </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> </div>
<nav className="flex-1 overflow-y-auto px-3 py-4"> <nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1 scrollbar-thin scrollbar-track-gray-900 scrollbar-thumb-gray-700">
{/* Dashboard */}
{/* 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 <NavLink
to="/dashboard" to="/dashboard"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ `relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white' 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"> {({ isActive }) => (
<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> {isActive && (
Dashboard <span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-indigo-500 rounded-r-full" />
</NavLink> )}
<span className="flex-shrink-0"><IconDashboard /></span>
{/* API Hub */} <span className="font-medium"></span>
<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
</NavLink> </NavLink>
{/* Nav Groups */} {/* Nav Groups */}
@ -116,44 +265,57 @@ const MainLayout = () => {
const isOpen = openGroups[group.label] ?? false; const isOpen = openGroups[group.label] ?? false;
return ( 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 <button
onClick={() => toggleGroup(group.label)} 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 <svg
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" 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> </svg>
</button> </button>
{isOpen && ( {isOpen && (
<div className="ml-2 space-y-1"> <div className="mt-0.5 space-y-0.5">
{group.items.map((item) => { {group.items.map((item) => {
if ( if (item.adminManagerOnly && !isAdminOrManager) return null;
group.label === 'API Keys' &&
item.label === 'Admin' &&
!isAdminOrManager
) {
return null;
}
return ( return (
<NavLink <NavLink
key={item.path} key={item.path}
to={item.path} to={item.path}
className={({ isActive }) => className={({ isActive }) =>
`block rounded-lg px-3 py-2 text-sm transition-colors ${ `relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white' 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> </NavLink>
); );
})} })}
@ -162,40 +324,45 @@ const MainLayout = () => {
</div> </div>
); );
})} })}
{/* 마지막 구분선 */}
<div className="my-2 mx-1 border-t border-gray-700/50" />
</nav> </nav>
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 ml-64"> <div className="flex-1 ml-60">
{/* Header */} {/* 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 />
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
{/* 테마 토글 */}
<button <button
onClick={toggleTheme} 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'} title={theme === 'light' ? 'Dark mode' : 'Light mode'}
> >
{theme === 'light' ? ( {theme === 'light' ? (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
<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" /> <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>
) : ( ) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
<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" /> <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> </svg>
)} )}
</button> </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) => ( {ROLES.map((role) => (
<button <button
key={role} key={role}
onClick={() => setRole(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 user?.role === role
? 'bg-blue-600 text-white' ? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600' : 'text-gray-400 hover:text-gray-200'
}`} }`}
> >
{role} {role}
@ -206,7 +373,7 @@ const MainLayout = () => {
</header> </header>
{/* Content */} {/* 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 /> <Outlet />
</main> </main>
</div> </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" /> <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> <h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">KCG API HUB</h1>
<p className="text-indigo-200">S&amp;P / .</p> <p className="text-indigo-200"> .</p>
</div> </div>
{/* 인기 API 섹션 */} {/* 인기 API 섹션 */}

파일 보기

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