generated from gc/template-java-maven
통계 메뉴 (5개 서브페이지): - 서비스 통계 (요약카드+에러율비교+응답시간분포+시간별추이) - 사용자 통계 (전체/API Key보유/API요청 사용자+역할분포+Top10) - API 통계 (호출순위+에러순위+메서드분포+상태코드분포) - 테넌트 통계 (요약카드+일별추이+API Key현황) - 사용량 추이 (일별/주별/월별 탭, 요청수+성공률+응답시간+활성사용자) 대시보드 피드백: - 요약카드 전일대비 소숫점 2자리 - 하트비트 카드형 (프로그레스바 제거, flex 균등분할) - 테넌트 차트 제거 - 상위 API URL 쿼리파라미터 정규화 (SPLIT_PART) - Gateway request_url 저장 시 쿼리스트링 제외 - "활성 사용자" → "API 요청 사용자" 라벨 변경 서비스 통계: 요약카드 flex 유동너비, 에러율+응답시간 차트 교체 사용자 통계: API Key 보유 사용자 카드 추가, flex 균등분할 API 통계: 타이틀 변경, 쿼리파라미터 제외 쿼리, 프로그레스바 분리 테넌트 통계: flex 균등분할, 빈 테넌트명 Unknown 처리 Closes #23 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { Outlet, NavLink } from 'react-router-dom';
|
|
import { useAuth } from '../hooks/useAuth';
|
|
import { useTheme } from '../hooks/useTheme';
|
|
|
|
interface NavGroup {
|
|
label: string;
|
|
items: { label: string; path: string }[];
|
|
adminOnly?: boolean;
|
|
}
|
|
|
|
const navGroups: NavGroup[] = [
|
|
{
|
|
label: 'Monitoring',
|
|
items: [
|
|
{ label: 'Request Logs', path: '/monitoring/request-logs' },
|
|
{ label: 'Service Status', path: '/monitoring/service-status' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Statistics',
|
|
items: [
|
|
{ label: '서비스 통계', path: '/statistics/services' },
|
|
{ label: '사용자 통계', path: '/statistics/users' },
|
|
{ label: 'API 통계', path: '/statistics/apis' },
|
|
{ label: '테넌트 통계', path: '/statistics/tenants' },
|
|
{ label: '사용량 추이', path: '/statistics/usage-trend' },
|
|
],
|
|
},
|
|
{
|
|
label: 'API Keys',
|
|
items: [
|
|
{ label: 'My Keys', path: '/apikeys/my-keys' },
|
|
{ label: 'Request', path: '/apikeys/request' },
|
|
{ label: 'Admin', path: '/apikeys/admin' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Admin',
|
|
adminOnly: true,
|
|
items: [
|
|
{ label: 'Services', path: '/admin/services' },
|
|
{ label: 'Users', path: '/admin/users' },
|
|
{ label: 'Tenants', path: '/admin/tenants' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const MainLayout = () => {
|
|
const { user, logout } = useAuth();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
|
Monitoring: true,
|
|
Statistics: true,
|
|
'API Keys': true,
|
|
Admin: true,
|
|
});
|
|
|
|
const toggleGroup = (label: string) => {
|
|
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
};
|
|
|
|
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
|
|
|
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>
|
|
</div>
|
|
|
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
|
{/* Dashboard */}
|
|
<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'
|
|
}`
|
|
}
|
|
>
|
|
<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>
|
|
|
|
{/* Nav Groups */}
|
|
{navGroups.map((group) => {
|
|
if (group.adminOnly && user?.role !== 'ADMIN') return null;
|
|
|
|
const isOpen = openGroups[group.label] ?? false;
|
|
|
|
return (
|
|
<div key={group.label} className="mt-4">
|
|
<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"
|
|
>
|
|
{group.label}
|
|
<svg
|
|
className={`h-4 w-4 transition-transform ${isOpen ? '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>
|
|
|
|
{isOpen && (
|
|
<div className="ml-2 space-y-1">
|
|
{group.items.map((item) => {
|
|
if (
|
|
group.label === 'API Keys' &&
|
|
item.label === 'Admin' &&
|
|
!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'
|
|
}`
|
|
}
|
|
>
|
|
{item.label}
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 ml-64">
|
|
{/* 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-700 dark:text-gray-300">{user?.userName}</span>
|
|
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
|
{user?.role}
|
|
</span>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
|
>
|
|
Logout
|
|
</button>
|
|
</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 MainLayout;
|