snp-connection-monitoring/frontend/src/pages/DashboardPage.tsx
HYOJIN c2a71c1b77 feat(design): 디자인 시스템 적용 (CSS 토큰, Button/Badge, 차트, 다크모드) (#48)
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/)
- CSS 변수 토큰 시스템 (@theme + :root/.dark 전환)
- cn() 유틸리티 (clsx + tailwind-merge)
- Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응)
- 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일)
- 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX)
- 버튼 다크모드 채도/대비 강화 (primary-600 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:38:00 +09:00

385 lines
17 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 } from '../types/dashboard';
import type { RequestLog } from '../types/monitoring';
import type { HeartbeatStatus } from '../types/service';
import {
getSummary, getHourlyTrend, getServiceRatio,
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
} from '../services/dashboardService';
import { CHART_COLORS_HEX } from '../constants/chart';
import { useTheme } from '../hooks/useTheme';
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 { theme } = useTheme();
const chartColors = CHART_COLORS_HEX[theme];
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 [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, logsRes] =
await Promise.allSettled([
getSummary(),
getHeartbeat(),
getHourlyTrend(),
getServiceRatio(),
getErrorTrend(),
getTopApis(),
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, []));
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: chartColors[i % chartColors.length],
};
});
return map;
}, [topApis, chartColors]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-[var(--color-text-secondary)]">Loading...</div>
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Dashboard</h1>
{lastUpdated && (
<span className="text-sm text-[var(--color-text-secondary)]"> : {lastUpdated}</span>
)}
</div>
{/* Row 1: Summary Cards */}
{stats && (
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.totalRequests.toLocaleString()}</p>
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} {stats.changePercent.toFixed(2)}%
</p>
</div>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]"></p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.successRate.toFixed(1)}%</p>
<p className="text-sm text-red-500"> {stats.failureCount}</p>
</div>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.avgResponseTime.toFixed(0)}ms</p>
</div>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<p className="text-sm text-[var(--color-text-secondary)]">API </p>
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.activeUserCount}</p>
<p className="text-sm text-[var(--color-text-secondary)]"></p>
</div>
</div>
)}
{/* Row 2: Heartbeat Status Cards */}
<div className="mb-6">
{heartbeat.length > 0 ? (
<div className="flex gap-4">
{heartbeat.map((svc) => {
const isUp = svc.healthStatus === 'UP';
const isDown = svc.healthStatus === 'DOWN';
const borderColor = isUp ? 'border-green-500' : isDown ? 'border-red-500' : 'border-gray-400';
return (
<div
key={svc.serviceId}
className={`flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-4 border-l-4 ${borderColor} cursor-pointer hover:shadow-md transition-shadow`}
onClick={() => navigate('/monitoring/service-status')}
>
<div className="flex items-center gap-2 mb-2">
<div
className={`w-3 h-3 rounded-full ${
isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400'
}`}
/>
<span className="font-medium text-[var(--color-text-primary)]">{svc.serviceName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className={`font-medium ${isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
{isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'}
</span>
{svc.healthCheckedAt && (
<span className="text-[var(--color-text-tertiary)] text-xs">{svc.healthCheckedAt}</span>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-4">
<p className="text-[var(--color-text-secondary)] text-sm"> </p>
</div>
)}
</div>
{/* Row 3: Charts 2x2 */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Hourly Trend */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] 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={chartColors[0]} name="성공" />
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
)}
</div>
{/* Chart 2: Service Ratio */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] 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={chartColors[idx % chartColors.length]} />
))}
</Pie>
<Tooltip />
<Legend layout="vertical" align="right" verticalAlign="middle" />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
)}
</div>
{/* Chart 3: Error Trend */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] 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={chartColors[idx % chartColors.length]}
fill={chartColors[idx % chartColors.length]}
fillOpacity={0.3}
/>
))}
</AreaChart>
</ResponsiveContainer>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
)}
</div>
{/* Chart 4: Top APIs */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] 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: chartColors[0] };
return (
<div key={idx} className="flex items-center gap-3">
<span className="text-xs text-[var(--color-text-tertiary)] 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-[var(--color-text-primary)] w-48 truncate" title={api.apiName}>
{api.apiName}
</span>
<div className="flex-1 bg-[var(--color-bg-base)] 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-[var(--color-text-primary)] w-12 text-right">{api.count}</span>
</div>
);
})}
</div>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-20"> </p>
)}
</div>
</div>
{/* Row 4: Recent Logs */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
<div className="p-6 border-b border-[var(--color-border)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]"> </h3>
</div>
{recentLogs.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">URL</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{recentLogs.slice(0, 5).map((log) => (
<tr
key={log.logId}
className="hover:bg-[var(--color-bg-base)] cursor-pointer"
onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)}
>
<td className="px-4 py-3 text-[var(--color-text-tertiary)] whitespace-nowrap">{log.requestedAt}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName ?? '-'}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.userName ?? '-'}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]" 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-[var(--color-text-tertiary)]">
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-3 border-t border-[var(--color-border)] text-center">
<button
onClick={() => navigate('/monitoring/request-logs')}
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] font-medium"
>
</button>
</div>
</>
) : (
<p className="text-[var(--color-text-tertiary)] text-center py-8"> </p>
)}
</div>
</div>
);
};
export default DashboardPage;