snp-connection-monitoring/frontend/src/pages/DashboardPage.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

435 lines
18 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
PieChart, Pie, Cell, AreaChart, Area,
} from 'recharts';
import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi, TenantRatio } from '../types/dashboard';
import type { RequestLog } from '../types/monitoring';
import type { HeartbeatStatus } from '../types/service';
import {
getSummary, getHourlyTrend, getServiceRatio,
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
getTenantRequestRatio, getTenantUserRatio,
} from '../services/dashboardService';
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const SERVICE_TAG_STYLES = [
'bg-blue-100 text-blue-700',
'bg-emerald-100 text-emerald-700',
'bg-amber-100 text-amber-700',
'bg-red-100 text-red-700',
'bg-violet-100 text-violet-700',
'bg-cyan-100 text-cyan-700',
];
const STATUS_BADGE: Record<string, string> = {
SUCCESS: 'bg-green-100 text-green-800',
FAIL: 'bg-red-100 text-red-800',
DENIED: 'bg-red-100 text-red-800',
EXPIRED: 'bg-orange-100 text-orange-800',
INVALID_KEY: 'bg-red-100 text-red-800',
ERROR: 'bg-orange-100 text-orange-800',
FAILED: 'bg-gray-100 text-gray-800',
};
const AUTO_REFRESH_MS = 30000;
const extractSettled = <T,>(result: PromiseSettledResult<{ data?: T }>, fallback: T): T => {
if (result.status === 'fulfilled' && result.value.data !== undefined) {
return result.value.data;
}
return fallback;
};
const truncate = (str: string, max: number): string => {
return str.length > max ? str.slice(0, max) + '...' : str;
};
const DashboardPage = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [heartbeat, setHeartbeat] = useState<HeartbeatStatus[]>([]);
const [hourlyTrend, setHourlyTrend] = useState<HourlyTrend[]>([]);
const [serviceRatio, setServiceRatio] = useState<ServiceRatio[]>([]);
const [errorTrend, setErrorTrend] = useState<ErrorTrend[]>([]);
const [topApis, setTopApis] = useState<TopApi[]>([]);
const [tenantRequestRatio, setTenantRequestRatio] = useState<TenantRatio[]>([]);
const [tenantUserRatio, setTenantUserRatio] = useState<TenantRatio[]>([]);
const [recentLogs, setRecentLogs] = useState<RequestLog[]>([]);
const [lastUpdated, setLastUpdated] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const fetchAll = useCallback(async () => {
try {
const [summaryRes, heartbeatRes, hourlyRes, serviceRes, errorRes, topRes, tenantReqRes, tenantUserRes, logsRes] =
await Promise.allSettled([
getSummary(),
getHeartbeat(),
getHourlyTrend(),
getServiceRatio(),
getErrorTrend(),
getTopApis(),
getTenantRequestRatio(),
getTenantUserRatio(),
getRecentLogs(),
]);
setStats(extractSettled<DashboardStats | null>(summaryRes, null));
setHeartbeat(extractSettled<HeartbeatStatus[]>(heartbeatRes, []));
setHourlyTrend(extractSettled<HourlyTrend[]>(hourlyRes, []));
setServiceRatio(extractSettled<ServiceRatio[]>(serviceRes, []));
setErrorTrend(extractSettled<ErrorTrend[]>(errorRes, []));
setTopApis(extractSettled<TopApi[]>(topRes, []));
setTenantRequestRatio(extractSettled<TenantRatio[]>(tenantReqRes, []));
setTenantUserRatio(extractSettled<TenantRatio[]>(tenantUserRes, []));
setRecentLogs(extractSettled<RequestLog[]>(logsRes, []));
setLastUpdated(new Date().toLocaleTimeString('ko-KR'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
const interval = setInterval(fetchAll, AUTO_REFRESH_MS);
return () => clearInterval(interval);
}, [fetchAll]);
const errorTrendPivoted = useMemo(() => {
const serviceNames = [...new Set(errorTrend.map((e) => e.serviceName))];
const byHour: Record<number, Record<string, number>> = {};
for (const item of errorTrend) {
if (!byHour[item.hour]) {
byHour[item.hour] = { hour: item.hour };
}
byHour[item.hour][item.serviceName] = item.errorRate;
}
return {
data: Object.values(byHour),
serviceNames,
};
}, [errorTrend]);
const topApiServiceColorMap = useMemo(() => {
const serviceNames = [...new Set(topApis.map((a) => a.serviceName))];
const map: Record<string, { tag: string; bar: string }> = {};
serviceNames.forEach((name, i) => {
map[name] = {
tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length],
bar: PIE_COLORS[i % PIE_COLORS.length],
};
});
return map;
}, [topApis]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
{lastUpdated && (
<span className="text-sm text-gray-500"> : {lastUpdated}</span>
)}
</div>
{/* Row 1: Summary Cards */}
{stats && (
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500"> </p>
<p className="text-3xl font-bold">{stats.totalRequests.toLocaleString()}</p>
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-gray-500'}`}>
{stats.changePercent > 0 ? '▲' : stats.changePercent < 0 ? '▼' : ''} {stats.changePercent}%
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500"></p>
<p className="text-3xl font-bold">{stats.successRate.toFixed(1)}%</p>
<p className="text-sm text-red-500"> {stats.failureCount}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500"> </p>
<p className="text-3xl font-bold">{stats.avgResponseTime.toFixed(0)}ms</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500"> </p>
<p className="text-3xl font-bold">{stats.activeUserCount}</p>
<p className="text-sm text-gray-500"></p>
</div>
</div>
)}
{/* Row 2: Heartbeat Status Bar */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
{heartbeat.length > 0 ? (
<div className="flex flex-row gap-6">
{heartbeat.map((svc) => (
<div
key={svc.serviceId}
className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 rounded-lg px-2 py-1 transition-colors"
onClick={() => navigate('/monitoring/service-status')}
>
<div
className={`w-3 h-3 rounded-full ${
svc.healthStatus === 'UP'
? 'bg-green-500'
: svc.healthStatus === 'DOWN'
? 'bg-red-500'
: 'bg-gray-400'
}`}
/>
<span className="font-medium">{svc.serviceName}</span>
{svc.healthResponseTime !== null && (
<span className="text-gray-500 text-sm">{svc.healthResponseTime}ms</span>
)}
{svc.healthCheckedAt && (
<span className="text-gray-400 text-xs">{svc.healthCheckedAt}</span>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm"> </p>
)}
</div>
{/* Row 3: Charts 2x2 */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Hourly Trend */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3>
{hourlyTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={hourlyTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} />
<YAxis />
<Tooltip labelFormatter={(h) => `${h}`} />
<Legend />
<Line type="monotone" dataKey="successCount" stroke="#3b82f6" name="성공" />
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 text-center py-20"> </p>
)}
</div>
{/* Chart 2: Service Ratio */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3>
{serviceRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={serviceRatio}
dataKey="count"
nameKey="serviceName"
innerRadius={60}
outerRadius={100}
>
{serviceRatio.map((_, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 text-center py-20"> </p>
)}
</div>
{/* Chart 3: Error Trend */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3>
{errorTrendPivoted.data.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={errorTrendPivoted.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}`} />
<YAxis unit="%" />
<Tooltip labelFormatter={(h) => `${h}`} />
<Legend />
{errorTrendPivoted.serviceNames.map((name, idx) => (
<Area
key={name}
type="monotone"
dataKey={name}
stackId="1"
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
fill={PIE_COLORS[idx % PIE_COLORS.length]}
fillOpacity={0.3}
/>
))}
</AreaChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 text-center py-20"> </p>
)}
</div>
{/* Chart 4: Top APIs */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> API</h3>
{topApis.length > 0 ? (
<div className="space-y-2">
{topApis.map((api, idx) => {
const maxCount = topApis[0]?.count || 1;
const pct = (api.count / maxCount) * 100;
const colors = topApiServiceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] };
return (
<div key={idx} className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-5 text-right">{idx + 1}</span>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
{api.serviceName}
</span>
<span className="shrink-0 text-sm text-gray-900 w-48 truncate" title={api.apiName}>
{api.apiName}
</span>
<div className="flex-1 bg-gray-100 rounded-full h-5 relative">
<div
className="h-5 rounded-full"
style={{ width: `${pct}%`, backgroundColor: colors.bar }}
/>
</div>
<span className="text-sm font-medium text-gray-700 w-12 text-right">{api.count}</span>
</div>
);
})}
</div>
) : (
<p className="text-gray-400 text-center py-20"> </p>
)}
</div>
</div>
{/* Row 4: Tenant Stats */}
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3>
{tenantRequestRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={tenantRequestRatio}
dataKey="count"
nameKey="tenantName"
innerRadius={60}
outerRadius={100}
>
{tenantRequestRatio.map((_, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 text-center py-20"> </p>
)}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3>
{tenantUserRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={tenantUserRatio}
dataKey="count"
nameKey="tenantName"
innerRadius={60}
outerRadius={100}
>
{tenantUserRatio.map((_, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-gray-400 text-center py-20"> </p>
)}
</div>
</div>
{/* Row 5: Recent Logs */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900"> </h3>
</div>
{recentLogs.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{recentLogs.slice(0, 5).map((log) => (
<tr
key={log.logId}
className="hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)}
>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{log.requestedAt}</td>
<td className="px-4 py-3 text-gray-900">{log.serviceName ?? '-'}</td>
<td className="px-4 py-3 text-gray-900">{log.userName ?? '-'}</td>
<td className="px-4 py-3 text-gray-900" title={log.requestUrl}>
{truncate(log.requestUrl, 40)}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_BADGE[log.requestStatus] ?? 'bg-gray-100 text-gray-800'}`}>
{log.requestStatus}
</span>
</td>
<td className="px-4 py-3 text-gray-600">
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-3 border-t text-center">
<button
onClick={() => navigate('/monitoring/request-logs')}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
</button>
</div>
</>
) : (
<p className="text-gray-400 text-center py-8"> </p>
)}
</div>
</div>
);
};
export default DashboardPage;