generated from gc/template-java-maven
백엔드: - 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>
168 lines
5.8 KiB
TypeScript
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;
|