snp-connection-monitoring/frontend/src/layouts/MainLayout.tsx
HYOJIN 8ebac1fa54 feat(stats): 통계 메뉴 + 대시보드 피드백 반영
통계 메뉴 (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>
2026-04-09 11:04:08 +09:00

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;