feat: enhance alert panels with type labels, click-to-zoom, network markArea
CI/CD / build-and-push (push) Successful in 1m29s Details

- Add Z-score peak alerts for NAS (was missing, caused chart/panel mismatch)
- Add markArea (red zones) to NetworkChart for Ingress/Istio peaks
- Distinguish alert types: PEAK (purple), WARNING (orange), CRITICAL (red)
- Click alert item to zoom chart to that time range
- Charts expose zoomToRange via forwardRef/useImperativeHandle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cloud User 2026-03-17 14:10:49 +09:00
parent 139e758ad8
commit 282644dda5
5 changed files with 152 additions and 43 deletions

View File

@ -4,8 +4,8 @@ import { useRef, useState } from 'react';
import { useReport } from '@/hooks/useReport';
import ReportHeader from '@/components/report/ReportHeader';
import SummaryCards from '@/components/report/SummaryCards';
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
import NetworkChart from '@/components/charts/NetworkChart';
import TimeSeriesChart, { type ChartHandle } from '@/components/charts/TimeSeriesChart';
import NetworkChart, { type NetworkChartHandle } from '@/components/charts/NetworkChart';
import NodeTable from '@/components/report/NodeTable';
import NasTable from '@/components/report/NasTable';
import NetworkTable from '@/components/report/NetworkTable';
@ -32,6 +32,13 @@ export default function Home() {
const reportRef = useRef<HTMLDivElement>(null);
const isDirty = startDate !== appliedStart || endDate !== appliedEnd;
const cpuChartRef = useRef<ChartHandle>(null);
const memChartRef = useRef<ChartHandle>(null);
const diskChartRef = useRef<ChartHandle>(null);
const nasChartRef = useRef<ChartHandle>(null);
const ingressChartRef = useRef<NetworkChartHandle>(null);
const istioChartRef = useRef<NetworkChartHandle>(null);
const applyRange = () => {
setAppliedStart(startDate);
setAppliedEnd(endDate);
@ -149,55 +156,55 @@ export default function Home() {
<div className="grid grid-cols-[1fr_280px] gap-4">
<ErrorBoundary fallback="CPU 차트 오류">
<TimeSeriesChart title="CPU 사용률" instances={data.metrics.cpu} unit="%" />
<TimeSeriesChart ref={cpuChartRef} title="CPU 사용률" instances={data.metrics.cpu} unit="%" />
</ErrorBoundary>
<ErrorBoundary fallback="CPU 주의 구간 오류">
<AlertPanel instances={data.metrics.cpu} type="cpu" height={300} />
<AlertPanel instances={data.metrics.cpu} type="cpu" height={300} onZoomRange={(s, e) => cpuChartRef.current?.zoomToRange(s, e)} />
</ErrorBoundary>
</div>
<div className="grid grid-cols-[1fr_280px] gap-4">
<ErrorBoundary fallback="Memory 차트 오류">
<TimeSeriesChart title="Memory 사용률" instances={data.metrics.memory} unit="%" />
<TimeSeriesChart ref={memChartRef} title="Memory 사용률" instances={data.metrics.memory} unit="%" />
</ErrorBoundary>
<ErrorBoundary fallback="Memory 주의 구간 오류">
<AlertPanel instances={data.metrics.memory} type="memory" height={300} />
<AlertPanel instances={data.metrics.memory} type="memory" height={300} onZoomRange={(s, e) => memChartRef.current?.zoomToRange(s, e)} />
</ErrorBoundary>
</div>
<div className="grid grid-cols-[1fr_280px] gap-4">
<ErrorBoundary fallback="Disk 차트 오류">
<TimeSeriesChart title="Disk 사용률" instances={data.metrics.disk} unit="%" />
<TimeSeriesChart ref={diskChartRef} title="Disk 사용률" instances={data.metrics.disk} unit="%" />
</ErrorBoundary>
<ErrorBoundary fallback="Disk 주의 구간 오류">
<AlertPanel instances={data.metrics.disk} type="disk" height={300} />
<AlertPanel instances={data.metrics.disk} type="disk" height={300} onZoomRange={(s, e) => diskChartRef.current?.zoomToRange(s, e)} />
</ErrorBoundary>
</div>
{data.metrics.nas && data.metrics.nas.length > 0 && (
<div className="grid grid-cols-[1fr_280px] gap-4">
<ErrorBoundary fallback="NAS 차트 오류">
<TimeSeriesChart title="NAS 사용률" instances={data.metrics.nas} unit="%" />
<TimeSeriesChart ref={nasChartRef} title="NAS 사용률" instances={data.metrics.nas} unit="%" />
</ErrorBoundary>
<ErrorBoundary fallback="NAS 주의 구간 오류">
<AlertPanel instances={data.metrics.nas} type="nas" height={300} />
<AlertPanel instances={data.metrics.nas} type="nas" height={300} onZoomRange={(s, e) => nasChartRef.current?.zoomToRange(s, e)} />
</ErrorBoundary>
</div>
)}
<div className="grid grid-cols-[1fr_280px] gap-4">
<ErrorBoundary fallback="Ingress Network 차트 오류">
<NetworkChart title="Ingress Network Traffic" data={data.metrics.ingress} />
<NetworkChart ref={ingressChartRef} title="Ingress Network Traffic" data={data.metrics.ingress} />
</ErrorBoundary>
<ErrorBoundary fallback="Ingress 주의 구간 오류">
<NetworkAlertPanel data={data.metrics.ingress} height={300} />
<NetworkAlertPanel data={data.metrics.ingress} height={300} onZoomRange={(s, e) => ingressChartRef.current?.zoomToRange(s, e)} />
</ErrorBoundary>
</div>
<div className="grid grid-cols-[1fr_280px] gap-4">
<ErrorBoundary fallback="Istio Network 차트 오류">
<NetworkChart title="Istio Network Traffic" data={data.metrics.istio} />
<NetworkChart ref={istioChartRef} title="Istio Network Traffic" data={data.metrics.istio} />
</ErrorBoundary>
<ErrorBoundary fallback="Istio 주의 구간 오류">
<NetworkAlertPanel data={data.metrics.istio} height={300} />
<NetworkAlertPanel data={data.metrics.istio} height={300} onZoomRange={(s, e) => istioChartRef.current?.zoomToRange(s, e)} />
</ErrorBoundary>
</div>

View File

@ -1,7 +1,7 @@
'use client';
import dynamic from 'next/dynamic';
import { useCallback, useRef } from 'react';
import { useCallback, useImperativeHandle, useRef, forwardRef } from 'react';
import type { NetworkMetrics } from '@/types/metrics';
import { formatTimestamp, formatBytes } from '@/lib/formatters';
@ -13,10 +13,31 @@ interface NetworkChartProps {
height?: number;
}
export default function NetworkChart({ title = 'Network Traffic', data, height = 300 }: NetworkChartProps) {
export interface NetworkChartHandle {
zoomToRange: (startMs: number, endMs: number) => void;
}
export default forwardRef<NetworkChartHandle, NetworkChartProps>(function NetworkChart({ title = 'Network Traffic', data, height = 300 }, ref) {
const chartInstance = useRef<any>(null);
const soloIndex = useRef<number | null>(null);
useImperativeHandle(ref, () => ({
zoomToRange(startMs: number, endMs: number) {
const chart = chartInstance.current;
if (!chart) return;
const allTimes = [...data.receive, ...data.transmit].flatMap((inst) => inst.dataPoints.map((d) => d.timestamp * 1000));
if (allTimes.length === 0) return;
const minTime = Math.min(...allTimes);
const maxTime = Math.max(...allTimes);
const range = maxTime - minTime;
if (range === 0) return;
const padding = (endMs - startMs) * 0.5;
const s = Math.max(0, ((startMs - padding - minTime) / range) * 100);
const e = Math.min(100, ((endMs + padding - minTime) / range) * 100);
chart.dispatchAction({ type: 'dataZoom', start: s, end: e });
},
}), [data]);
if (!data.receive.length && !data.transmit.length) {
return (
<div className="bg-gray-50 border rounded-lg p-4 text-center text-gray-400 text-sm" style={{ height }}>
@ -33,6 +54,14 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
emphasis: { focus: 'series' as const },
areaStyle: { opacity: 0.3 },
data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value]),
markArea: {
silent: true,
itemStyle: { color: 'rgba(255, 0, 0, 0.08)' },
data: inst.peaks.peaks.map((peak) => [
{ xAxis: peak.startTime * 1000 },
{ xAxis: peak.endTime * 1000 },
]),
},
}));
const txSeries = data.transmit.map((inst) => ({
@ -43,6 +72,14 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
emphasis: { focus: 'series' as const },
lineStyle: { type: 'dashed' as const },
data: inst.dataPoints.map((d) => [d.timestamp * 1000, -d.value]),
markArea: {
silent: true,
itemStyle: { color: 'rgba(255, 0, 0, 0.08)' },
data: inst.peaks.peaks.map((peak) => [
{ xAxis: peak.startTime * 1000 },
{ xAxis: peak.endTime * 1000 },
]),
},
}));
const allSeries = [...rxSeries, ...txSeries];
@ -125,4 +162,4 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
onEvents={onEvents()}
/>
);
}
});

View File

@ -1,7 +1,7 @@
'use client';
import dynamic from 'next/dynamic';
import { useCallback, useRef } from 'react';
import { useCallback, useImperativeHandle, useRef, forwardRef } from 'react';
import type { MetricInstance } from '@/types/metrics';
import { formatTimestamp } from '@/lib/formatters';
@ -14,15 +14,36 @@ interface TimeSeriesChartProps {
height?: number;
}
export default function TimeSeriesChart({
export interface ChartHandle {
zoomToRange: (startMs: number, endMs: number) => void;
}
export default forwardRef<ChartHandle, TimeSeriesChartProps>(function TimeSeriesChart({
title,
instances,
unit = '%',
height = 300,
}: TimeSeriesChartProps) {
}, ref) {
const chartInstance = useRef<any>(null);
const soloIndex = useRef<number | null>(null);
useImperativeHandle(ref, () => ({
zoomToRange(startMs: number, endMs: number) {
const chart = chartInstance.current;
if (!chart) return;
const allTimes = instances.flatMap((inst) => inst.dataPoints.map((d) => d.timestamp * 1000));
if (allTimes.length === 0) return;
const minTime = Math.min(...allTimes);
const maxTime = Math.max(...allTimes);
const range = maxTime - minTime;
if (range === 0) return;
const padding = (endMs - startMs) * 0.5;
const s = Math.max(0, ((startMs - padding - minTime) / range) * 100);
const e = Math.min(100, ((endMs + padding - minTime) / range) * 100);
chart.dispatchAction({ type: 'dataZoom', start: s, end: e });
},
}), [instances]);
const series = instances.map((inst) => ({
name: inst.instance || 'total',
type: 'line' as const,
@ -120,4 +141,4 @@ export default function TimeSeriesChart({
onEvents={onEvents()}
/>
);
}
});

View File

@ -7,12 +7,16 @@ interface AlertPanelProps {
instances: MetricInstance[];
type: 'cpu' | 'memory' | 'disk' | 'nas';
height?: number;
onZoomRange?: (startMs: number, endMs: number) => void;
}
interface Alert {
level: 'warning' | 'critical';
category: 'threshold' | 'peak';
instance: string;
message: string;
startMs?: number;
endMs?: number;
}
function generateAlerts(instances: MetricInstance[], type: string): Alert[] {
@ -23,47 +27,48 @@ function generateAlerts(instances: MetricInstance[], type: string): Alert[] {
if (inst.stats.max > 90) {
alerts.push({
level: 'critical',
category: 'threshold',
instance: inst.instance,
message: `최대 ${formatPercent(inst.stats.max)} 도달`,
});
}
inst.peaks.peaks.forEach((peak) => {
alerts.push({
level: 'warning',
instance: inst.instance,
message: `${formatTimestamp(peak.startTime)} ~ ${formatTimestamp(peak.endTime)} Peak (max: ${formatPercent(peak.maxValue)})`,
});
});
} else if (type === 'memory') {
if (inst.stats.max > 80) {
alerts.push({
level: inst.stats.max > 90 ? 'critical' : 'warning',
category: 'threshold',
instance: inst.instance,
message: `최대 ${formatPercent(inst.stats.max)} 도달`,
});
}
inst.peaks.peaks.forEach((peak) => {
alerts.push({
level: 'warning',
instance: inst.instance,
message: `${formatTimestamp(peak.startTime)} ~ ${formatTimestamp(peak.endTime)} Peak (max: ${formatPercent(peak.maxValue)})`,
});
});
} else if (type === 'disk' || type === 'nas') {
if (inst.stats.max > 80) {
alerts.push({
level: inst.stats.max > 90 ? 'critical' : 'warning',
category: 'threshold',
instance: inst.instance,
message: `사용률 ${formatPercent(inst.stats.max)} (증설 검토 필요)`,
});
}
}
// Z-score peak alerts for all types
inst.peaks.peaks.forEach((peak) => {
alerts.push({
level: 'warning',
category: 'peak',
instance: inst.instance,
message: `${formatTimestamp(peak.startTime)} ~ ${formatTimestamp(peak.endTime)} Peak (max: ${formatPercent(peak.maxValue)})`,
startMs: peak.startTime * 1000,
endMs: peak.endTime * 1000,
});
});
});
return alerts;
}
export default function AlertPanel({ instances, type, height = 300 }: AlertPanelProps) {
export default function AlertPanel({ instances, type, height = 300, onZoomRange }: AlertPanelProps) {
const alerts = generateAlerts(instances, type);
if (alerts.length === 0) {
@ -81,14 +86,33 @@ export default function AlertPanel({ instances, type, height = 300 }: AlertPanel
</div>
<ul className="divide-y divide-gray-100 overflow-y-auto flex-1">
{alerts.map((alert, idx) => (
<li key={idx} className="px-3 py-2 flex items-start gap-2">
<li
key={idx}
className={`px-3 py-2 flex items-start gap-2 ${alert.startMs && onZoomRange ? 'cursor-pointer hover:bg-blue-50' : ''}`}
onClick={() => {
if (alert.startMs && alert.endMs && onZoomRange) {
onZoomRange(alert.startMs, alert.endMs);
}
}}
>
<span
className={`mt-1 inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${
alert.level === 'critical' ? 'bg-red-500' : 'bg-yellow-500'
}`}
/>
<div className="text-xs">
<span className="font-medium text-gray-800">{alert.instance}</span>
<div className="text-xs flex-1">
<div className="flex items-center gap-1.5">
<span className="font-medium text-gray-800">{alert.instance}</span>
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${
alert.category === 'peak'
? 'bg-purple-100 text-purple-700'
: alert.level === 'critical'
? 'bg-red-100 text-red-700'
: 'bg-orange-100 text-orange-700'
}`}>
{alert.category === 'peak' ? 'PEAK' : alert.level === 'critical' ? 'CRITICAL' : 'WARNING'}
</span>
</div>
<p className="text-gray-500">{alert.message}</p>
</div>
</li>

View File

@ -6,15 +6,19 @@ import { formatTimestamp, formatBytesPerSec } from '@/lib/formatters';
interface NetworkAlertPanelProps {
data: NetworkMetrics;
height?: number;
onZoomRange?: (startMs: number, endMs: number) => void;
}
interface Alert {
level: 'warning' | 'critical';
category: 'peak';
instance: string;
message: string;
startMs: number;
endMs: number;
}
export default function NetworkAlertPanel({ data, height = 300 }: NetworkAlertPanelProps) {
export default function NetworkAlertPanel({ data, height = 300, onZoomRange }: NetworkAlertPanelProps) {
const alerts: Alert[] = [];
[...data.receive, ...data.transmit].forEach((inst) => {
@ -24,8 +28,11 @@ export default function NetworkAlertPanel({ data, height = 300 }: NetworkAlertPa
inst.peaks.peaks.forEach((peak) => {
alerts.push({
level: 'warning',
category: 'peak',
instance: label,
message: `${formatTimestamp(peak.startTime)} ~ ${formatTimestamp(peak.endTime)} 트래픽 급증 (max: ${formatBytesPerSec(peak.maxValue)})`,
startMs: peak.startTime * 1000,
endMs: peak.endTime * 1000,
});
});
});
@ -45,10 +52,23 @@ export default function NetworkAlertPanel({ data, height = 300 }: NetworkAlertPa
</div>
<ul className="divide-y divide-gray-100 overflow-y-auto flex-1">
{alerts.map((alert, idx) => (
<li key={idx} className="px-3 py-2 flex items-start gap-2">
<li
key={idx}
className={`px-3 py-2 flex items-start gap-2 ${onZoomRange ? 'cursor-pointer hover:bg-blue-50' : ''}`}
onClick={() => {
if (onZoomRange) {
onZoomRange(alert.startMs, alert.endMs);
}
}}
>
<span className="mt-1 inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 bg-yellow-500" />
<div className="text-xs">
<span className="font-medium text-gray-800">{alert.instance}</span>
<div className="text-xs flex-1">
<div className="flex items-center gap-1.5">
<span className="font-medium text-gray-800">{alert.instance}</span>
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700">
PEAK
</span>
</div>
<p className="text-gray-500">{alert.message}</p>
</div>
</li>