From 82635e033529a788ea6e96fe784d899711886924 Mon Sep 17 00:00:00 2001 From: Cloud User Date: Mon, 16 Mar 2026 13:16:54 +0900 Subject: [PATCH] feat: date range picker, split network into Ingress/Istio, fix NodeTable undefined instance --- charts/infra-report/values.yaml | 6 ++- plan.md | 2 +- record.md | 24 +++++++++ src/app/api/metrics/[type]/route.ts | 45 +++++++--------- src/app/api/report/route.ts | 43 +++++++++++---- src/app/page.tsx | 73 +++++++++++++++++++------- src/components/charts/NetworkChart.tsx | 17 ++++-- src/components/report/NodeTable.tsx | 2 +- src/components/report/SummaryCards.tsx | 10 ++-- src/hooks/useReport.ts | 6 +-- src/lib/prometheus.ts | 8 ++- src/types/metrics.ts | 3 +- 12 files changed, 166 insertions(+), 73 deletions(-) diff --git a/charts/infra-report/values.yaml b/charts/infra-report/values.yaml index 19b03e4..ecac528 100644 --- a/charts/infra-report/values.yaml +++ b/charts/infra-report/values.yaml @@ -35,8 +35,10 @@ config: QUERY_MEMORY: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100' QUERY_DISK: 'sum by (mountpoint,fstype)((1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"}) * 100)' QUERY_NAS: '((node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"} - node_filesystem_free_bytes{fstype=~"nfs", node=~".*(bastion).*"}) / node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"}) * 100' - QUERY_NET_RX: 'sum(rate(container_network_receive_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))' - QUERY_NET_TX: 'sum(rate(container_network_transmit_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))' + QUERY_INGRESS_RX: 'sum(rate(container_network_receive_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))' + QUERY_INGRESS_TX: 'sum(rate(container_network_transmit_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))' + QUERY_ISTIO_RX: 'sum(rate(istio_request_bytes_sum{reporter="destination"}[5m]))' + QUERY_ISTIO_TX: 'sum(rate(istio_response_bytes_sum{reporter="destination"}[5m]))' resources: requests: diff --git a/plan.md b/plan.md index 35246ae..b1afdf0 100644 --- a/plan.md +++ b/plan.md @@ -1776,7 +1776,7 @@ export default function Home() { --- -## STEP 11. 빌드 및 배포 +## STEP 11. 빌드 및 배포 ✅ ### 11-1. 로컬 개발 diff --git a/record.md b/record.md index 3668825..e364ea6 100644 --- a/record.md +++ b/record.md @@ -1,3 +1,27 @@ # 구현 기록 (plan.md 대비 변경사항) ## 변경 이력 + +### 2026-03-16 + +1. **Dockerfile 수정**: `COPY --from=builder /app/public ./public`에서 public 디렉토리가 존재하지 않아 빌드 실패. builder 단계에 `RUN mkdir -p public` 추가로 해결. + +2. **next.config.js 수정**: `serverExternalPackages`는 Next.js 15+ 옵션. Next.js 14에서는 `experimental.serverComponentsExternalPackages`로 변경. + +3. **ArgoCD 연동 방식 변경**: argocd CLI가 bastion에 미설치되어 있어, kubectl로 직접 Secret(repo 인증) + Application 리소스를 생성하는 방식으로 대체. + +4. **API Route params 수정**: Next.js 14 App Router에서 동적 route의 `params`를 `Promise<{ type: string }>`으로 처리 (async params pattern). + +5. **CI Harbor 로그인 실패 수정**: 비밀번호 `Ijinc123!@#$`의 특수문자(`!`, `$`)가 큰따옴표 안에서 shell에 의해 해석되는 문제. `echo "${{ env.REGISTRY_PASSWORD }}"` → `echo '${{ secrets.REGISTRY_PASSWORD }}'` (작은따옴표 + secrets 직접 참조)로 변경. + +6. **CI env 블록 구조 변경**: workflow 레벨 `env`에서 secrets를 매핑하던 방식을 제거하고, 각 step에서 `secrets.XXX`를 직접 참조하도록 변경. + +7. **SSR 클라이언트 에러 수정**: `echarts-for-react`, `html2canvas`, `jspdf`가 SSR 비호환 라이브러리라 클라이언트 에러 발생. `dynamic(() => import(...), { ssr: false })`로 변경하고, `html2canvas`/`jspdf`는 함수 내부에서 동적 import하도록 수정. + +8. **Helm chart 버전 전략 변경**: plan.md에서는 고정 버전 `0.1.0`이었으나, ArgoCD가 동일 버전에서 변경을 감지하지 못하는 문제 발생. semver pre-release(`0.1.0-sha`)도 정식 버전보다 낮게 취급되어 `>0.0.0` range에서 감지 안 됨. 최종적으로 타임스탬프 기반 patch 버전(`0.1.YYYYMMDDHHMMSS`)으로 변경하여 매 빌드마다 고유한 정식 semver 생성. + +9. **ArgoCD targetRevision 변경**: `0.1.0` 고정 → `>0.0.0` semver range로 변경하여 새 chart 버전 자동 감지. ArgoCD 기본 polling interval(~3분) 내에 자동 sync됨. + +10. **Deployment 이미지 태그 변경**: `image.tag` values 고정 대신 `{{ .Values.image.tag | default .Chart.AppVersion }}`으로 변경. ArgoCD helm values에서 `image.tag` override 제거하여 chart의 `appVersion`(CI에서 `sha-xxxx`로 설정)을 자동 사용. + +11. **.gitignore 추가**: plan.md에 없었으나 `.env`, `node_modules/`, `.next/` 등을 git에서 제외하기 위해 추가. diff --git a/src/app/api/metrics/[type]/route.ts b/src/app/api/metrics/[type]/route.ts index dae812b..ec5534d 100644 --- a/src/app/api/metrics/[type]/route.ts +++ b/src/app/api/metrics/[type]/route.ts @@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { queryRange, QUERIES } from '@/lib/prometheus'; import { calculateStats, detectPeaks, DataPoint } from '@/lib/analytics'; -const QUERY_MAP: Record = { +const QUERY_MAP: Record = { cpu: QUERIES.cpuUsage, memory: QUERIES.memoryUsage, disk: QUERIES.diskUsage, nas: QUERIES.nasUsage, - network: [QUERIES.networkReceive, QUERIES.networkTransmit], +}; + +const NETWORK_MAP: Record = { + ingress: { rx: QUERIES.ingressReceive, tx: QUERIES.ingressTransmit }, + istio: { rx: QUERIES.istioReceive, tx: QUERIES.istioTransmit }, }; function parsePrometheusValues(values: [number, string][]): DataPoint[] { @@ -26,46 +30,37 @@ export async function GET( const end = new Date(); const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); - const queries = QUERY_MAP[type]; - if (!queries) { - return NextResponse.json({ error: `Unknown metric type: ${type}` }, { status: 400 }); - } - try { - if (type === 'network') { + const netQuery = NETWORK_MAP[type]; + if (netQuery) { const [rxResults, txResults] = await Promise.all([ - queryRange(QUERIES.networkReceive, start, end, step), - queryRange(QUERIES.networkTransmit, start, end, step), + queryRange(netQuery.rx, start, end, step), + queryRange(netQuery.tx, start, end, step), ]); - const receive = rxResults.map((r) => { + const process = (results: typeof rxResults) => results.map((r) => { const dataPoints = parsePrometheusValues(r.values); return { - instance: r.metric.instance, + instance: r.metric.instance || '', dataPoints, stats: calculateStats(dataPoints), peaks: detectPeaks(dataPoints), }; }); - const transmit = txResults.map((r) => { - const dataPoints = parsePrometheusValues(r.values); - return { - instance: r.metric.instance, - dataPoints, - stats: calculateStats(dataPoints), - peaks: detectPeaks(dataPoints), - }; - }); - - return NextResponse.json({ type, period: { start, end, step }, receive, transmit }); + return NextResponse.json({ type, period: { start, end, step }, receive: process(rxResults), transmit: process(txResults) }); } - const results = await queryRange(queries as string, start, end, step); + const query = QUERY_MAP[type]; + if (!query) { + return NextResponse.json({ error: `Unknown metric type: ${type}` }, { status: 400 }); + } + + const results = await queryRange(query, start, end, step); const instances = results.map((r) => { const dataPoints = parsePrometheusValues(r.values); return { - instance: r.metric.instance, + instance: r.metric.instance || '', metric: r.metric, dataPoints, stats: calculateStats(dataPoints), diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts index 1d58ac6..b290c47 100644 --- a/src/app/api/report/route.ts +++ b/src/app/api/report/route.ts @@ -11,7 +11,7 @@ function processResults(results: { metric: Record; values: [numb return results.map((r) => { const dataPoints = parseValues(r.values); return { - instance: r.metric.instance, + instance: r.metric.instance || '', metric: r.metric, dataPoints, stats: calculateStats(dataPoints), @@ -22,34 +22,55 @@ function processResults(results: { metric: Record; values: [numb export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const days = parseInt(searchParams.get('days') || process.env.REPORT_RANGE_DAYS || '7'); + const startParam = searchParams.get('start'); + const endParam = searchParams.get('end'); const step = searchParams.get('step') || process.env.REPORT_STEP || '5m'; - const end = new Date(); - const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); + let end: Date; + let start: Date; + + if (startParam && endParam) { + start = new Date(startParam); + end = new Date(endParam); + } else { + const days = parseInt(searchParams.get('days') || process.env.REPORT_RANGE_DAYS || '7'); + end = new Date(); + start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); + } try { - const [cpuData, memData, diskData, nasData, rxData, txData, nodes] = await Promise.all([ + const [ + cpuData, memData, diskData, nasData, + ingressRxData, ingressTxData, + istioRxData, istioTxData, + nodes, + ] = await Promise.all([ queryRange(QUERIES.cpuUsage, start, end, step), queryRange(QUERIES.memoryUsage, start, end, step), queryRange(QUERIES.diskUsage, start, end, step), queryRange(QUERIES.nasUsage, start, end, step).catch(() => []), - queryRange(QUERIES.networkReceive, start, end, step), - queryRange(QUERIES.networkTransmit, start, end, step), + queryRange(QUERIES.ingressReceive, start, end, step).catch(() => []), + queryRange(QUERIES.ingressTransmit, start, end, step).catch(() => []), + queryRange(QUERIES.istioReceive, start, end, step).catch(() => []), + queryRange(QUERIES.istioTransmit, start, end, step).catch(() => []), getNodes(), ]); return NextResponse.json({ - period: { start, end, days, step }, + period: { start, end, step }, nodes, metrics: { cpu: processResults(cpuData), memory: processResults(memData), disk: processResults(diskData), nas: processResults(nasData), - network: { - receive: processResults(rxData), - transmit: processResults(txData), + ingress: { + receive: processResults(ingressRxData), + transmit: processResults(ingressTxData), + }, + istio: { + receive: processResults(istioRxData), + transmit: processResults(istioTxData), }, }, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 286d683..204eeba 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,9 +10,20 @@ import NodeTable from '@/components/report/NodeTable'; import AlertSection from '@/components/report/AlertSection'; import ErrorBoundary from '@/components/ErrorBoundary'; +function getDefaultRange() { + const end = new Date(); + const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000); + return { + start: start.toISOString().slice(0, 10), + end: end.toISOString().slice(0, 10), + }; +} + export default function Home() { - const [days, setDays] = useState(7); - const { data, isLoading, error } = useReport(days); + const defaultRange = getDefaultRange(); + const [startDate, setStartDate] = useState(defaultRange.start); + const [endDate, setEndDate] = useState(defaultRange.end); + const { data, isLoading, error } = useReport(startDate, endDate); const reportRef = useRef(null); const handleExportPdf = async () => { @@ -38,7 +49,14 @@ export default function Home() { heightLeft -= pdf.internal.pageSize.getHeight(); } - pdf.save(`infra-report-${days}d.pdf`); + pdf.save(`infra-report-${startDate}_${endDate}.pdf`); + }; + + const setPresetRange = (days: number) => { + const end = new Date(); + const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); + setStartDate(start.toISOString().slice(0, 10)); + setEndDate(end.toISOString().slice(0, 10)); }; if (isLoading) { @@ -68,18 +86,33 @@ export default function Home() { /> -
- {[1, 3, 7, 14, 30].map((d) => ( - - ))} +
+
+ setStartDate(e.target.value)} + className="px-3 py-1.5 text-sm border rounded bg-white" + /> + ~ + setEndDate(e.target.value)} + className="px-3 py-1.5 text-sm border rounded bg-white" + /> +
+
+ {[1, 3, 7, 14, 30].map((d) => ( + + ))} +
@@ -87,7 +120,7 @@ export default function Home() { cpu={data.metrics.cpu} memory={data.metrics.memory} disk={data.metrics.disk} - network={data.metrics.network} + ingress={data.metrics.ingress} /> @@ -107,8 +140,12 @@ export default function Home() { )} - - + + + + + + diff --git a/src/components/charts/NetworkChart.tsx b/src/components/charts/NetworkChart.tsx index 7a11a24..3fa0ed6 100644 --- a/src/components/charts/NetworkChart.tsx +++ b/src/components/charts/NetworkChart.tsx @@ -7,13 +7,22 @@ import { formatTimestamp, formatBytes } from '@/lib/formatters'; const ReactECharts = dynamic(() => import('echarts-for-react').then(mod => mod), { ssr: false, loading: () =>
}); interface NetworkChartProps { + title?: string; data: NetworkMetrics; height?: number; } -export default function NetworkChart({ data, height = 300 }: NetworkChartProps) { +export default function NetworkChart({ title = 'Network Traffic', data, height = 300 }: NetworkChartProps) { + if (!data.receive.length && !data.transmit.length) { + return ( +
+ {title} - 데이터 없음 +
+ ); + } + const rxSeries = data.receive.map((inst) => ({ - name: `RX ${inst.instance}`, + name: `RX ${inst.instance || 'total'}`, type: 'line' as const, smooth: true, symbol: 'none', @@ -22,7 +31,7 @@ export default function NetworkChart({ data, height = 300 }: NetworkChartProps) })); const txSeries = data.transmit.map((inst) => ({ - name: `TX ${inst.instance}`, + name: `TX ${inst.instance || 'total'}`, type: 'line' as const, smooth: true, symbol: 'none', @@ -31,7 +40,7 @@ export default function NetworkChart({ data, height = 300 }: NetworkChartProps) })); const option = { - title: { text: 'Network Traffic', left: 'center', textStyle: { fontSize: 14 } }, + title: { text: title, left: 'center', textStyle: { fontSize: 14 } }, tooltip: { trigger: 'axis', formatter: (params: any[]) => { diff --git a/src/components/report/NodeTable.tsx b/src/components/report/NodeTable.tsx index 219da64..fcc49a9 100644 --- a/src/components/report/NodeTable.tsx +++ b/src/components/report/NodeTable.tsx @@ -11,7 +11,7 @@ interface NodeTableProps { } function findInstanceStats(instances: MetricInstance[], nodeName: string) { - const inst = instances.find((i) => i.instance.includes(nodeName)); + const inst = instances.find((i) => i.instance && i.instance.includes(nodeName)); return inst?.stats || null; } diff --git a/src/components/report/SummaryCards.tsx b/src/components/report/SummaryCards.tsx index 78b7d28..f80aba2 100644 --- a/src/components/report/SummaryCards.tsx +++ b/src/components/report/SummaryCards.tsx @@ -7,7 +7,7 @@ interface SummaryCardsProps { cpu: MetricInstance[]; memory: MetricInstance[]; disk: MetricInstance[]; - network: NetworkMetrics; + ingress: NetworkMetrics; } interface CardData { @@ -26,13 +26,13 @@ function getClusterStats(instances: MetricInstance[]): { avg: number; max: numbe return { avg, max, min }; } -export default function SummaryCards({ cpu, memory, disk, network }: SummaryCardsProps) { +export default function SummaryCards({ cpu, memory, disk, ingress }: SummaryCardsProps) { const cpuStats = getClusterStats(cpu); const memStats = getClusterStats(memory); const diskStats = getClusterStats(disk); - const netRxAvg = network.receive.reduce((sum, i) => sum + i.stats.avg, 0); - const netTxAvg = network.transmit.reduce((sum, i) => sum + i.stats.avg, 0); + const netRxAvg = ingress.receive.reduce((sum, i) => sum + i.stats.avg, 0); + const netTxAvg = ingress.transmit.reduce((sum, i) => sum + i.stats.avg, 0); const cards: CardData[] = [ { @@ -57,7 +57,7 @@ export default function SummaryCards({ cpu, memory, disk, network }: SummaryCard color: diskStats.max > 90 ? 'border-red-500' : diskStats.max > 70 ? 'border-yellow-500' : 'border-green-500', }, { - label: 'Network', + label: 'Ingress', avg: `RX ${formatBytesPerSec(netRxAvg)}`, max: `TX ${formatBytesPerSec(netTxAvg)}`, min: '', diff --git a/src/hooks/useReport.ts b/src/hooks/useReport.ts index e738e4e..4a39927 100644 --- a/src/hooks/useReport.ts +++ b/src/hooks/useReport.ts @@ -4,11 +4,11 @@ import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import type { ReportData } from '@/types/metrics'; -export function useReport(days: number = 7) { +export function useReport(start: string, end: string) { return useQuery({ - queryKey: ['report', days], + queryKey: ['report', start, end], queryFn: async () => { - const res = await axios.get(`/api/report?days=${days}`); + const res = await axios.get(`/api/report?start=${start}&end=${end}`); return res.data; }, }); diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts index c48b50f..db9b967 100644 --- a/src/lib/prometheus.ts +++ b/src/lib/prometheus.ts @@ -48,8 +48,12 @@ export const QUERIES = { || 'sum by (mountpoint,fstype)((1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"}) * 100)', nasUsage: process.env.QUERY_NAS || '((node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"} - node_filesystem_free_bytes{fstype=~"nfs", node=~".*(bastion).*"}) / node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"}) * 100', - networkReceive: process.env.QUERY_NET_RX + ingressReceive: process.env.QUERY_INGRESS_RX || 'sum(rate(container_network_receive_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))', - networkTransmit: process.env.QUERY_NET_TX + ingressTransmit: process.env.QUERY_INGRESS_TX || 'sum(rate(container_network_transmit_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))', + istioReceive: process.env.QUERY_ISTIO_RX + || 'sum(rate(istio_request_bytes_sum{reporter="destination"}[5m]))', + istioTransmit: process.env.QUERY_ISTIO_TX + || 'sum(rate(istio_response_bytes_sum{reporter="destination"}[5m]))', }; diff --git a/src/types/metrics.ts b/src/types/metrics.ts index e9720e8..c6498c1 100644 --- a/src/types/metrics.ts +++ b/src/types/metrics.ts @@ -58,6 +58,7 @@ export interface ReportData { memory: MetricInstance[]; disk: MetricInstance[]; nas: MetricInstance[]; - network: NetworkMetrics; + ingress: NetworkMetrics; + istio: NetworkMetrics; }; }