feat: enhance alert panels with type labels, click-to-zoom, network markArea
CI/CD / build-and-push (push) Successful in 1m29s
Details
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:
parent
139e758ad8
commit
282644dda5
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue