generated from gc/template-java-maven
- 디자인 시스템 가이드 문서 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>
385 lines
17 KiB
TypeScript
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;
|