feat: date range picker, split network into Ingress/Istio, fix NodeTable undefined instance
CI/CD / build-and-push (push) Successful in 1m29s Details

This commit is contained in:
Cloud User 2026-03-16 13:16:54 +09:00
parent 7a0b1841f9
commit 82635e0335
12 changed files with 166 additions and 73 deletions

View File

@ -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:

View File

@ -1776,7 +1776,7 @@ export default function Home() {
---
## STEP 11. 빌드 및 배포
## STEP 11. 빌드 및 배포
### 11-1. 로컬 개발

View File

@ -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에서 제외하기 위해 추가.

View File

@ -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<string, string | string[]> = {
const QUERY_MAP: Record<string, string> = {
cpu: QUERIES.cpuUsage,
memory: QUERIES.memoryUsage,
disk: QUERIES.diskUsage,
nas: QUERIES.nasUsage,
network: [QUERIES.networkReceive, QUERIES.networkTransmit],
};
const NETWORK_MAP: Record<string, { rx: string; tx: string }> = {
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) {
try {
const netQuery = NETWORK_MAP[type];
if (netQuery) {
const [rxResults, txResults] = await Promise.all([
queryRange(netQuery.rx, start, end, step),
queryRange(netQuery.tx, start, end, step),
]);
const process = (results: typeof rxResults) => results.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: process(rxResults), transmit: process(txResults) });
}
const query = QUERY_MAP[type];
if (!query) {
return NextResponse.json({ error: `Unknown metric type: ${type}` }, { status: 400 });
}
try {
if (type === 'network') {
const [rxResults, txResults] = await Promise.all([
queryRange(QUERIES.networkReceive, start, end, step),
queryRange(QUERIES.networkTransmit, start, end, step),
]);
const receive = rxResults.map((r) => {
const dataPoints = parsePrometheusValues(r.values);
return {
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 });
}
const results = await queryRange(queries as string, start, end, step);
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),

View File

@ -11,7 +11,7 @@ function processResults(results: { metric: Record<string, string>; 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<string, string>; 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),
},
},
});

View File

@ -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<HTMLDivElement>(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,26 +86,41 @@ export default function Home() {
/>
</ErrorBoundary>
<div className="flex gap-2">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded bg-white"
/>
<span className="text-gray-500">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded bg-white"
/>
</div>
<div className="flex gap-1">
{[1, 3, 7, 14, 30].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1 text-sm rounded ${
days === d ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setPresetRange(d)}
className="px-3 py-1.5 text-sm rounded bg-white text-gray-700 hover:bg-gray-50 border"
>
{d}
</button>
))}
</div>
</div>
<ErrorBoundary fallback="SummaryCards 오류">
<SummaryCards
cpu={data.metrics.cpu}
memory={data.metrics.memory}
disk={data.metrics.disk}
network={data.metrics.network}
ingress={data.metrics.ingress}
/>
</ErrorBoundary>
@ -107,8 +140,12 @@ export default function Home() {
</ErrorBoundary>
)}
<ErrorBoundary fallback="Network 차트 오류">
<NetworkChart data={data.metrics.network} />
<ErrorBoundary fallback="Ingress Network 차트 오류">
<NetworkChart title="Ingress Network Traffic" data={data.metrics.ingress} />
</ErrorBoundary>
<ErrorBoundary fallback="Istio Network 차트 오류">
<NetworkChart title="Istio Network Traffic" data={data.metrics.istio} />
</ErrorBoundary>
<ErrorBoundary fallback="NodeTable 오류">

View File

@ -7,13 +7,22 @@ import { formatTimestamp, formatBytes } from '@/lib/formatters';
const ReactECharts = dynamic(() => import('echarts-for-react').then(mod => mod), { ssr: false, loading: () => <div style={{height: 300}} /> });
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 (
<div className="bg-gray-50 border rounded-lg p-4 text-center text-gray-400 text-sm" style={{ height }}>
{title} -
</div>
);
}
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[]) => {

View File

@ -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;
}

View File

@ -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: '',

View File

@ -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<ReportData>({
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;
},
});

View File

@ -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]))',
};

View File

@ -58,6 +58,7 @@ export interface ReportData {
memory: MetricInstance[];
disk: MetricInstance[];
nas: MetricInstance[];
network: NetworkMetrics;
ingress: NetworkMetrics;
istio: NetworkMetrics;
};
}