diff --git a/src/app/page.tsx b/src/app/page.tsx index 147bcd0..4cc4ce1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(null); const isDirty = startDate !== appliedStart || endDate !== appliedEnd; + const cpuChartRef = useRef(null); + const memChartRef = useRef(null); + const diskChartRef = useRef(null); + const nasChartRef = useRef(null); + const ingressChartRef = useRef(null); + const istioChartRef = useRef(null); + const applyRange = () => { setAppliedStart(startDate); setAppliedEnd(endDate); @@ -149,55 +156,55 @@ export default function Home() {
- + - + cpuChartRef.current?.zoomToRange(s, e)} />
- + - + memChartRef.current?.zoomToRange(s, e)} />
- + - + diskChartRef.current?.zoomToRange(s, e)} />
{data.metrics.nas && data.metrics.nas.length > 0 && (
- + - + nasChartRef.current?.zoomToRange(s, e)} />
)}
- + - + ingressChartRef.current?.zoomToRange(s, e)} />
- + - + istioChartRef.current?.zoomToRange(s, e)} />
diff --git a/src/components/charts/NetworkChart.tsx b/src/components/charts/NetworkChart.tsx index e7bc30b..e99314c 100644 --- a/src/components/charts/NetworkChart.tsx +++ b/src/components/charts/NetworkChart.tsx @@ -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(function NetworkChart({ title = 'Network Traffic', data, height = 300 }, ref) { const chartInstance = useRef(null); const soloIndex = useRef(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 (
@@ -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()} /> ); -} +}); diff --git a/src/components/charts/TimeSeriesChart.tsx b/src/components/charts/TimeSeriesChart.tsx index d496e2f..4ee565f 100644 --- a/src/components/charts/TimeSeriesChart.tsx +++ b/src/components/charts/TimeSeriesChart.tsx @@ -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(function TimeSeriesChart({ title, instances, unit = '%', height = 300, -}: TimeSeriesChartProps) { +}, ref) { const chartInstance = useRef(null); const soloIndex = useRef(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()} /> ); -} +}); diff --git a/src/components/report/AlertPanel.tsx b/src/components/report/AlertPanel.tsx index 5867319..79efcc7 100644 --- a/src/components/report/AlertPanel.tsx +++ b/src/components/report/AlertPanel.tsx @@ -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
    {alerts.map((alert, idx) => ( -
  • +
  • { + if (alert.startMs && alert.endMs && onZoomRange) { + onZoomRange(alert.startMs, alert.endMs); + } + }} + > -
    - {alert.instance} +
    +
    + {alert.instance} + + {alert.category === 'peak' ? 'PEAK' : alert.level === 'critical' ? 'CRITICAL' : 'WARNING'} + +

    {alert.message}

  • diff --git a/src/components/report/NetworkAlertPanel.tsx b/src/components/report/NetworkAlertPanel.tsx index 0d6b50d..0043828 100644 --- a/src/components/report/NetworkAlertPanel.tsx +++ b/src/components/report/NetworkAlertPanel.tsx @@ -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
      {alerts.map((alert, idx) => ( -
    • +
    • { + if (onZoomRange) { + onZoomRange(alert.startMs, alert.endMs); + } + }} + > -
      - {alert.instance} +
      +
      + {alert.instance} + + PEAK + +

      {alert.message}