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 { useReport } from '@/hooks/useReport';
|
||||||
import ReportHeader from '@/components/report/ReportHeader';
|
import ReportHeader from '@/components/report/ReportHeader';
|
||||||
import SummaryCards from '@/components/report/SummaryCards';
|
import SummaryCards from '@/components/report/SummaryCards';
|
||||||
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
|
import TimeSeriesChart, { type ChartHandle } from '@/components/charts/TimeSeriesChart';
|
||||||
import NetworkChart from '@/components/charts/NetworkChart';
|
import NetworkChart, { type NetworkChartHandle } from '@/components/charts/NetworkChart';
|
||||||
import NodeTable from '@/components/report/NodeTable';
|
import NodeTable from '@/components/report/NodeTable';
|
||||||
import NasTable from '@/components/report/NasTable';
|
import NasTable from '@/components/report/NasTable';
|
||||||
import NetworkTable from '@/components/report/NetworkTable';
|
import NetworkTable from '@/components/report/NetworkTable';
|
||||||
|
|
@ -32,6 +32,13 @@ export default function Home() {
|
||||||
const reportRef = useRef<HTMLDivElement>(null);
|
const reportRef = useRef<HTMLDivElement>(null);
|
||||||
const isDirty = startDate !== appliedStart || endDate !== appliedEnd;
|
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 = () => {
|
const applyRange = () => {
|
||||||
setAppliedStart(startDate);
|
setAppliedStart(startDate);
|
||||||
setAppliedEnd(endDate);
|
setAppliedEnd(endDate);
|
||||||
|
|
@ -149,55 +156,55 @@ export default function Home() {
|
||||||
|
|
||||||
<div className="grid grid-cols-[1fr_280px] gap-4">
|
<div className="grid grid-cols-[1fr_280px] gap-4">
|
||||||
<ErrorBoundary fallback="CPU 차트 오류">
|
<ErrorBoundary fallback="CPU 차트 오류">
|
||||||
<TimeSeriesChart title="CPU 사용률" instances={data.metrics.cpu} unit="%" />
|
<TimeSeriesChart ref={cpuChartRef} title="CPU 사용률" instances={data.metrics.cpu} unit="%" />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ErrorBoundary fallback="CPU 주의 구간 오류">
|
<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>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[1fr_280px] gap-4">
|
<div className="grid grid-cols-[1fr_280px] gap-4">
|
||||||
<ErrorBoundary fallback="Memory 차트 오류">
|
<ErrorBoundary fallback="Memory 차트 오류">
|
||||||
<TimeSeriesChart title="Memory 사용률" instances={data.metrics.memory} unit="%" />
|
<TimeSeriesChart ref={memChartRef} title="Memory 사용률" instances={data.metrics.memory} unit="%" />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ErrorBoundary fallback="Memory 주의 구간 오류">
|
<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>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[1fr_280px] gap-4">
|
<div className="grid grid-cols-[1fr_280px] gap-4">
|
||||||
<ErrorBoundary fallback="Disk 차트 오류">
|
<ErrorBoundary fallback="Disk 차트 오류">
|
||||||
<TimeSeriesChart title="Disk 사용률" instances={data.metrics.disk} unit="%" />
|
<TimeSeriesChart ref={diskChartRef} title="Disk 사용률" instances={data.metrics.disk} unit="%" />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ErrorBoundary fallback="Disk 주의 구간 오류">
|
<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>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.metrics.nas && data.metrics.nas.length > 0 && (
|
{data.metrics.nas && data.metrics.nas.length > 0 && (
|
||||||
<div className="grid grid-cols-[1fr_280px] gap-4">
|
<div className="grid grid-cols-[1fr_280px] gap-4">
|
||||||
<ErrorBoundary fallback="NAS 차트 오류">
|
<ErrorBoundary fallback="NAS 차트 오류">
|
||||||
<TimeSeriesChart title="NAS 사용률" instances={data.metrics.nas} unit="%" />
|
<TimeSeriesChart ref={nasChartRef} title="NAS 사용률" instances={data.metrics.nas} unit="%" />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ErrorBoundary fallback="NAS 주의 구간 오류">
|
<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>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-[1fr_280px] gap-4">
|
<div className="grid grid-cols-[1fr_280px] gap-4">
|
||||||
<ErrorBoundary fallback="Ingress Network 차트 오류">
|
<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>
|
||||||
<ErrorBoundary fallback="Ingress 주의 구간 오류">
|
<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>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[1fr_280px] gap-4">
|
<div className="grid grid-cols-[1fr_280px] gap-4">
|
||||||
<ErrorBoundary fallback="Istio Network 차트 오류">
|
<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>
|
||||||
<ErrorBoundary fallback="Istio 주의 구간 오류">
|
<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>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useImperativeHandle, useRef, forwardRef } from 'react';
|
||||||
import type { NetworkMetrics } from '@/types/metrics';
|
import type { NetworkMetrics } from '@/types/metrics';
|
||||||
import { formatTimestamp, formatBytes } from '@/lib/formatters';
|
import { formatTimestamp, formatBytes } from '@/lib/formatters';
|
||||||
|
|
||||||
|
|
@ -13,10 +13,31 @@ interface NetworkChartProps {
|
||||||
height?: number;
|
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 chartInstance = useRef<any>(null);
|
||||||
const soloIndex = useRef<number | null>(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) {
|
if (!data.receive.length && !data.transmit.length) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 border rounded-lg p-4 text-center text-gray-400 text-sm" style={{ height }}>
|
<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 },
|
emphasis: { focus: 'series' as const },
|
||||||
areaStyle: { opacity: 0.3 },
|
areaStyle: { opacity: 0.3 },
|
||||||
data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value]),
|
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) => ({
|
const txSeries = data.transmit.map((inst) => ({
|
||||||
|
|
@ -43,6 +72,14 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
|
||||||
emphasis: { focus: 'series' as const },
|
emphasis: { focus: 'series' as const },
|
||||||
lineStyle: { type: 'dashed' as const },
|
lineStyle: { type: 'dashed' as const },
|
||||||
data: inst.dataPoints.map((d) => [d.timestamp * 1000, -d.value]),
|
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];
|
const allSeries = [...rxSeries, ...txSeries];
|
||||||
|
|
@ -125,4 +162,4 @@ export default function NetworkChart({ title = 'Network Traffic', data, height =
|
||||||
onEvents={onEvents()}
|
onEvents={onEvents()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useImperativeHandle, useRef, forwardRef } from 'react';
|
||||||
import type { MetricInstance } from '@/types/metrics';
|
import type { MetricInstance } from '@/types/metrics';
|
||||||
import { formatTimestamp } from '@/lib/formatters';
|
import { formatTimestamp } from '@/lib/formatters';
|
||||||
|
|
||||||
|
|
@ -14,15 +14,36 @@ interface TimeSeriesChartProps {
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeSeriesChart({
|
export interface ChartHandle {
|
||||||
|
zoomToRange: (startMs: number, endMs: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default forwardRef<ChartHandle, TimeSeriesChartProps>(function TimeSeriesChart({
|
||||||
title,
|
title,
|
||||||
instances,
|
instances,
|
||||||
unit = '%',
|
unit = '%',
|
||||||
height = 300,
|
height = 300,
|
||||||
}: TimeSeriesChartProps) {
|
}, ref) {
|
||||||
const chartInstance = useRef<any>(null);
|
const chartInstance = useRef<any>(null);
|
||||||
const soloIndex = useRef<number | null>(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) => ({
|
const series = instances.map((inst) => ({
|
||||||
name: inst.instance || 'total',
|
name: inst.instance || 'total',
|
||||||
type: 'line' as const,
|
type: 'line' as const,
|
||||||
|
|
@ -120,4 +141,4 @@ export default function TimeSeriesChart({
|
||||||
onEvents={onEvents()}
|
onEvents={onEvents()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@ interface AlertPanelProps {
|
||||||
instances: MetricInstance[];
|
instances: MetricInstance[];
|
||||||
type: 'cpu' | 'memory' | 'disk' | 'nas';
|
type: 'cpu' | 'memory' | 'disk' | 'nas';
|
||||||
height?: number;
|
height?: number;
|
||||||
|
onZoomRange?: (startMs: number, endMs: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Alert {
|
interface Alert {
|
||||||
level: 'warning' | 'critical';
|
level: 'warning' | 'critical';
|
||||||
|
category: 'threshold' | 'peak';
|
||||||
instance: string;
|
instance: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
startMs?: number;
|
||||||
|
endMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateAlerts(instances: MetricInstance[], type: string): Alert[] {
|
function generateAlerts(instances: MetricInstance[], type: string): Alert[] {
|
||||||
|
|
@ -23,47 +27,48 @@ function generateAlerts(instances: MetricInstance[], type: string): Alert[] {
|
||||||
if (inst.stats.max > 90) {
|
if (inst.stats.max > 90) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
level: 'critical',
|
level: 'critical',
|
||||||
|
category: 'threshold',
|
||||||
instance: inst.instance,
|
instance: inst.instance,
|
||||||
message: `최대 ${formatPercent(inst.stats.max)} 도달`,
|
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') {
|
} else if (type === 'memory') {
|
||||||
if (inst.stats.max > 80) {
|
if (inst.stats.max > 80) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
level: inst.stats.max > 90 ? 'critical' : 'warning',
|
level: inst.stats.max > 90 ? 'critical' : 'warning',
|
||||||
|
category: 'threshold',
|
||||||
instance: inst.instance,
|
instance: inst.instance,
|
||||||
message: `최대 ${formatPercent(inst.stats.max)} 도달`,
|
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') {
|
} else if (type === 'disk' || type === 'nas') {
|
||||||
if (inst.stats.max > 80) {
|
if (inst.stats.max > 80) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
level: inst.stats.max > 90 ? 'critical' : 'warning',
|
level: inst.stats.max > 90 ? 'critical' : 'warning',
|
||||||
|
category: 'threshold',
|
||||||
instance: inst.instance,
|
instance: inst.instance,
|
||||||
message: `사용률 ${formatPercent(inst.stats.max)} (증설 검토 필요)`,
|
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;
|
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);
|
const alerts = generateAlerts(instances, type);
|
||||||
|
|
||||||
if (alerts.length === 0) {
|
if (alerts.length === 0) {
|
||||||
|
|
@ -81,14 +86,33 @@ export default function AlertPanel({ instances, type, height = 300 }: AlertPanel
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-gray-100 overflow-y-auto flex-1">
|
<ul className="divide-y divide-gray-100 overflow-y-auto flex-1">
|
||||||
{alerts.map((alert, idx) => (
|
{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
|
<span
|
||||||
className={`mt-1 inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
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'
|
alert.level === 'critical' ? 'bg-red-500' : 'bg-yellow-500'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs">
|
<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="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>
|
<p className="text-gray-500">{alert.message}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,19 @@ import { formatTimestamp, formatBytesPerSec } from '@/lib/formatters';
|
||||||
interface NetworkAlertPanelProps {
|
interface NetworkAlertPanelProps {
|
||||||
data: NetworkMetrics;
|
data: NetworkMetrics;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
onZoomRange?: (startMs: number, endMs: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Alert {
|
interface Alert {
|
||||||
level: 'warning' | 'critical';
|
level: 'warning' | 'critical';
|
||||||
|
category: 'peak';
|
||||||
instance: string;
|
instance: string;
|
||||||
message: 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[] = [];
|
const alerts: Alert[] = [];
|
||||||
|
|
||||||
[...data.receive, ...data.transmit].forEach((inst) => {
|
[...data.receive, ...data.transmit].forEach((inst) => {
|
||||||
|
|
@ -24,8 +28,11 @@ export default function NetworkAlertPanel({ data, height = 300 }: NetworkAlertPa
|
||||||
inst.peaks.peaks.forEach((peak) => {
|
inst.peaks.peaks.forEach((peak) => {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
level: 'warning',
|
level: 'warning',
|
||||||
|
category: 'peak',
|
||||||
instance: label,
|
instance: label,
|
||||||
message: `${formatTimestamp(peak.startTime)} ~ ${formatTimestamp(peak.endTime)} 트래픽 급증 (max: ${formatBytesPerSec(peak.maxValue)})`,
|
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>
|
</div>
|
||||||
<ul className="divide-y divide-gray-100 overflow-y-auto flex-1">
|
<ul className="divide-y divide-gray-100 overflow-y-auto flex-1">
|
||||||
{alerts.map((alert, idx) => (
|
{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" />
|
<span className="mt-1 inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 bg-yellow-500" />
|
||||||
<div className="text-xs">
|
<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="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>
|
<p className="text-gray-500">{alert.message}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue