snp-connection-monitoring/frontend/src/layouts/MainLayout.tsx
HYOJIN c330be5a52 feat(phase5): 대시보드 + 통계 + Service Status 페이지
백엔드:
- DashboardService/Controller (요약, 시간별/서비스별/테넌트별 통계, 에러율, 상위API, 최근로그)
- 헬스체크 1분 간격, 매 체크마다 로그 기록 (status page용)
- ServiceStatusDetail API (90일 일별 uptime, 최근 체크 60건)
- 통계 쿼리 최적화 인덱스 추가
- 테넌트별 요청/사용자 비율 API
- 상위 API에 serviceName + apiName 표시

프론트엔드:
- DashboardPage (요약 카드 4개, 하트비트 바, Recharts 차트 4개, 테넌트 차트 2개, 최근 로그 5건+더보기)
- ServiceStatusPage (status.claude.com 스타일, 90일 uptime 바, Overall banner)
- ServiceStatusDetailPage (서비스별 상세, 일별 uptime 바+툴팁, 최근 체크 테이블, 색상 범례)
- 30초 자동 갱신 (대시보드), 60초 자동 갱신 (status)
- Request Logs 배지 색상 대시보드와 통일

Closes #10

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

168 lines
5.8 KiB
TypeScript

import { useState } from 'react';
import { Outlet, NavLink } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
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: '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 [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: 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 py-5 border-b border-gray-700">
<svg className="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</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 border-b border-gray-200 flex items-center justify-between px-6">
<div />
<div className="flex items-center gap-4">
<span className="text-sm text-gray-700">{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 hover:bg-gray-100 hover:text-gray-900 transition-colors"
>
Logout
</button>
</div>
</header>
{/* Content */}
<main className="p-6">
<Outlet />
</main>
</div>
</div>
);
};
export default MainLayout;