infra-report/plan.md

53 KiB

인프라 주간 모니터링 보고서 - 구현 계획서

프로젝트 정보


STEP 0. 인프라 사전 준비 (완료)

작업 상태
Gitea repo 생성 (soo-rnd/infra-report) 완료
Gitea Actions 활성화 완료
Gitea Secrets 등록 (REGISTRY_URL, USER, PASSWORD) 완료
Gitea Runner 연결 (soo-rnd 조직) 완료

STEP 1. 프로젝트 초기화 (임시 Pod 방식)

bastion에서 임시 Node.js Pod을 띄워 프로젝트를 스캐폴딩하고, 결과물을 bastion으로 꺼내 git에 커밋한다.

1-1. 임시 Pod 생성

kubectl run init-nextjs --image=node:20-alpine --restart=Never -n soo -- sleep 3600

1-2. Pod 접속 → 프로젝트 생성 + 패키지 설치 + next.config.js 덮어쓰기

kubectl exec -it init-nextjs -n soo -- sh
mkdir /app && cd /app

npx create-next-app@14 . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --no-git

npm install echarts echarts-for-react @tanstack/react-query axios @kubernetes/client-node date-fns zod jspdf html2canvas
npm install -D @types/node

cat > next.config.js << 'EOF'
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  serverExternalPackages: ['@kubernetes/client-node'],
};

module.exports = nextConfig;
EOF

exit

1-3. 결과물을 bastion으로 복사 + Pod 삭제

kubectl cp soo/init-nextjs:/app ./infra-report-src
kubectl delete pod init-nextjs -n soo

1-4. bastion에서 git에 커밋

cd infra-report-src
git init
git remote add origin https://gitea.inje-private.com/soo-rnd/infra-report.git
git add -A
git commit -m "chore: scaffold Next.js project with dependencies"
git push -u origin main

1-6. 생성 확인 파일 목록

  • package.json / package-lock.json
  • next.config.js (standalone + serverExternalPackages)
  • tsconfig.json
  • tailwind.config.ts
  • src/app/ (기본 page, layout, globals.css)
  • public/

STEP 2. Helm Chart

2-1. 디렉토리 구조

charts/infra-report/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── _helpers.tpl
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── serviceaccount.yaml
    ├── clusterrole.yaml
    ├── clusterrolebinding.yaml
    ├── configmap.yaml
    └── imagepullsecret.yaml

2-2. charts/infra-report/Chart.yaml

apiVersion: v2
name: infra-report
description: Infrastructure Weekly Monitoring Report
type: application
version: 0.1.0
appVersion: "0.1.0"

2-3. charts/infra-report/values.yaml

replicaCount: 1

image:
  repository: harbor.inje-private.com/infra/infra-report
  tag: "latest"
  pullPolicy: Always

imagePullSecrets:
  - name: harbor-pull-secret

serviceAccount:
  name: infra-report-sa

service:
  type: ClusterIP
  port: 80
  targetPort: 3000

ingress:
  enabled: true
  className: nginx
  host: infra-report.gpulive.cloud
  tls:
    secretName: gpulive-new-ssl
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/ssl-redirect: "true"

config:
  PROMETHEUS_URL: "http://prometheus-server.monitoring"
  REPORT_STEP: "5m"
  REPORT_RANGE_DAYS: "7"
  TZ: "Asia/Seoul"
  QUERY_CPU: '100 - (avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
  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]))'

resources:
  requests:
    cpu: "100m"
    memory: "256Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

probes:
  liveness:
    path: /api/health
    initialDelaySeconds: 10
    periodSeconds: 30
  readiness:
    path: /api/health
    initialDelaySeconds: 5
    periodSeconds: 10

2-4. charts/infra-report/templates/_helpers.tpl

{{- define "infra-report.name" -}}
{{- .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "infra-report.fullname" -}}
{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "infra-report.labels" -}}
app.kubernetes.io/name: {{ include "infra-report.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "infra-report.selectorLabels" -}}
app.kubernetes.io/name: {{ include "infra-report.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

2-5. charts/infra-report/templates/serviceaccount.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ .Values.serviceAccount.name }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}

2-6. charts/infra-report/templates/clusterrole.yaml

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: {{ include "infra-report.fullname" . }}-reader
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}
rules:
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["get", "list"]
  - apiGroups: ["metrics.k8s.io"]
    resources: ["nodes"]
    verbs: ["get", "list"]

2-7. charts/infra-report/templates/clusterrolebinding.yaml

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: {{ include "infra-report.fullname" . }}-reader-binding
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}
subjects:
  - kind: ServiceAccount
    name: {{ .Values.serviceAccount.name }}
    namespace: {{ .Release.Namespace }}
roleRef:
  kind: ClusterRole
  name: {{ include "infra-report.fullname" . }}-reader
  apiGroup: rbac.authorization.k8s.io

2-8. charts/infra-report/templates/configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "infra-report.fullname" . }}-config
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}
data:
  {{- range $key, $value := .Values.config }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}

2-9. charts/infra-report/templates/imagepullsecret.yaml

{{- if .Values.imagePullSecrets }}
apiVersion: v1
kind: Secret
metadata:
  name: harbor-pull-secret
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: {{ template "imagePullSecret" . }}
{{- end }}

실제 운영에서는 kubectl create secret docker-registry 로 별도 생성하거나, Helm values에 base64 인코딩된 dockerconfigjson을 주입한다. ArgoCD에서 관리할 경우 sealed-secret 또는 external-secrets 사용 권장.

수동 생성 방식:

kubectl create secret docker-registry harbor-pull-secret \
  --docker-server=harbor.inje-private.com \
  --docker-username=admin \
  --docker-password='Ijinc123!@#$' \
  -n soo

2-10. TLS 인증서 Secret 복사

monitoring 네임스페이스의 gpulive-new-ssl Secret을 soo 네임스페이스로 복사한다.

kubectl get secret gpulive-new-ssl -n monitoring -o json \
  | jq 'del(.metadata.creationTimestamp, .metadata.resourceVersion, .metadata.uid, .metadata.annotations, .metadata.managedFields) | .metadata.namespace = "soo"' \
  | kubectl apply -f -

2-12. charts/infra-report/templates/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "infra-report.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "infra-report.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "infra-report.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ .Values.serviceAccount.name }}
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.targetPort }}
          envFrom:
            - configMapRef:
                name: {{ include "infra-report.fullname" . }}-config
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          livenessProbe:
            httpGet:
              path: {{ .Values.probes.liveness.path }}
              port: {{ .Values.service.targetPort }}
            initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
            periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
          readinessProbe:
            httpGet:
              path: {{ .Values.probes.readiness.path }}
              port: {{ .Values.service.targetPort }}
            initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
            periodSeconds: {{ .Values.probes.readiness.periodSeconds }}

2-13. charts/infra-report/templates/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: {{ include "infra-report.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  selector:
    {{- include "infra-report.selectorLabels" . | nindent 4 }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.targetPort }}
      protocol: TCP

2-14. charts/infra-report/templates/ingress.yaml

{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "infra-report.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "infra-report.labels" . | nindent 4 }}
  {{- with .Values.ingress.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  {{- if .Values.ingress.tls }}
  tls:
    - hosts:
        - {{ .Values.ingress.host }}
      secretName: {{ .Values.ingress.tls.secretName }}
  {{- end }}
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ include "infra-report.fullname" . }}
                port:
                  number: {{ .Values.service.port }}
{{- end }}

STEP 3. Dockerfile

3-1. Dockerfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

3-2. .dockerignore

node_modules
.next
.git
*.md
charts/
.env.local
.gitea/

STEP 4. CI 워크플로우

4-1. .gitea/workflows/ci.yaml

name: CI/CD

on:
  push:
    branches: [main]
    tags: ['v*']

env:
  REGISTRY: ${{ secrets.REGISTRY_URL }}
  REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
  REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
  IMAGE_NAME: ${{ secrets.REGISTRY_URL }}/infra/infra-report
  CHART_NAME: infra-report

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set image tag
        id: tag
        run: |
          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
            echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
          else
            echo "TAG=sha-$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_ENV
          fi          

      - name: Login to Harbor
        run: |
          echo "${{ env.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin          

      - name: Build and push Docker image
        run: |
          docker build -t ${{ env.IMAGE_NAME }}:${{ env.TAG }} .
          docker tag ${{ env.IMAGE_NAME }}:${{ env.TAG }} ${{ env.IMAGE_NAME }}:latest
          docker push ${{ env.IMAGE_NAME }}:${{ env.TAG }}
          docker push ${{ env.IMAGE_NAME }}:latest          

      - name: Install Helm
        run: |
          curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash          

      - name: Package Helm chart
        run: |
          sed -i "s/appVersion: .*/appVersion: \"${{ env.TAG }}\"/" charts/infra-report/Chart.yaml
          helm package charts/infra-report --version 0.1.0 --app-version ${{ env.TAG }}          

      - name: Push Helm chart to Harbor (OCI)
        run: |
          echo "${{ env.REGISTRY_PASSWORD }}" | helm registry login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin
          helm push ${CHART_NAME}-0.1.0.tgz oci://${{ env.REGISTRY }}/infra          

STEP 5. ArgoCD 연동

5-1. Harbor OCI repo를 ArgoCD에 등록

argocd repo add oci://harbor.inje-private.com/infra \
  --type helm \
  --name harbor-infra \
  --enable-oci \
  --username admin \
  --password 'Ijinc123!@#$'

또는 ArgoCD UI에서: Settings → Repositories → Connect Repo

  • Type: Helm
  • Repository URL: harbor.inje-private.com/infra
  • Enable OCI: 체크
  • Username / Password 입력

5-2. ArgoCD Application 생성

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: infra-report
  namespace: argocd
spec:
  project: default
  source:
    chart: infra-report
    repoURL: harbor.inje-private.com/infra
    targetRevision: 0.1.0
    helm:
      values: |
        image:
          tag: "latest"
        ingress:
          host: infra-report.gpulive.cloud        
  destination:
    server: https://kubernetes.default.svc
    namespace: soo
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

CLI로 생성:

argocd app create infra-report \
  --repo harbor.inje-private.com/infra \
  --helm-chart infra-report \
  --revision 0.1.0 \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace soo \
  --sync-policy automated \
  --auto-prune \
  --self-heal \
  --helm-set image.tag=latest \
  --helm-set ingress.host=infra-report.gpulive.cloud

5-3. imagePullSecret 생성 (soo namespace)

kubectl create secret docker-registry harbor-pull-secret \
  --docker-server=harbor.inje-private.com \
  --docker-username=admin \
  --docker-password='Ijinc123!@#$' \
  -n soo

5-4. 전체 배포 흐름

코드 Push (main)
  → Gitea Actions (ci.yaml)
    → docker build + push (harbor.inje-private.com/infra/infra-report:sha-xxxx)
    → helm package + push (oci://harbor.inje-private.com/infra/infra-report:0.1.0)
  → ArgoCD 감지
    → helm install/upgrade → soo namespace 배포
    → Pod pull image (harbor-pull-secret 사용)

STEP 6. 백엔드 라이브러리 (src/lib/)

6-1. src/lib/prometheus.ts

Prometheus HTTP API 클라이언트.

import axios from 'axios';

const PROMETHEUS_URL = process.env.PROMETHEUS_URL || 'http://prometheus-server.monitoring';

interface PrometheusRangeResult {
  metric: Record<string, string>;
  values: [number, string][];
}

interface PrometheusResponse {
  status: string;
  data: {
    resultType: string;
    result: PrometheusRangeResult[];
  };
}

export async function queryRange(
  query: string,
  start: Date,
  end: Date,
  step: string = '5m'
): Promise<PrometheusRangeResult[]> {
  const res = await axios.get<PrometheusResponse>(`${PROMETHEUS_URL}/api/v1/query_range`, {
    params: {
      query,
      start: start.toISOString(),
      end: end.toISOString(),
      step,
    },
  });
  return res.data.data.result;
}

export async function queryInstant(query: string): Promise<PrometheusRangeResult[]> {
  const res = await axios.get<PrometheusResponse>(`${PROMETHEUS_URL}/api/v1/query`, {
    params: { query },
  });
  return res.data.data.result;
}

export const QUERIES = {
  cpuUsage: process.env.QUERY_CPU
    || '100 - (avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
  memoryUsage: process.env.QUERY_MEMORY
    || '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100',
  diskUsage: process.env.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)',
  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
    || 'sum(rate(container_network_receive_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))',
  networkTransmit: process.env.QUERY_NET_TX
    || 'sum(rate(container_network_transmit_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))',
};

6-2. src/lib/kubernetes.ts

K8s API 클라이언트. Pod 내부에서는 in-cluster config, 로컬에서는 kubeconfig 사용.

import * as k8s from '@kubernetes/client-node';

function getKubeConfig(): k8s.KubeConfig {
  const kc = new k8s.KubeConfig();
  try {
    kc.loadFromCluster();
  } catch {
    kc.loadFromDefault();
  }
  return kc;
}

export interface NodeInfo {
  name: string;
  role: string;
  status: string;
  cpuCapacity: string;
  memoryCapacity: string;
  osImage: string;
  kubeletVersion: string;
  conditions: { type: string; status: string }[];
}

export async function getNodes(): Promise<NodeInfo[]> {
  const kc = getKubeConfig();
  const coreApi = kc.makeApiClient(k8s.CoreV1Api);
  const res = await coreApi.listNode();

  return res.items.map((node) => {
    const labels = node.metadata?.labels || {};
    const role = Object.keys(labels)
      .find((k) => k.startsWith('node-role.kubernetes.io/'))
      ?.replace('node-role.kubernetes.io/', '') || 'worker';

    const readyCondition = node.status?.conditions?.find((c) => c.type === 'Ready');

    return {
      name: node.metadata?.name || '',
      role,
      status: readyCondition?.status === 'True' ? 'Ready' : 'NotReady',
      cpuCapacity: node.status?.capacity?.cpu || '',
      memoryCapacity: node.status?.capacity?.memory || '',
      osImage: node.status?.nodeInfo?.osImage || '',
      kubeletVersion: node.status?.nodeInfo?.kubeletVersion || '',
      conditions: (node.status?.conditions || []).map((c) => ({
        type: c.type || '',
        status: c.status || '',
      })),
    };
  });
}

6-3. src/lib/analytics.ts

통계 계산 + Peak 감지.

export interface DataPoint {
  timestamp: number;
  value: number;
}

export interface Stats {
  avg: number;
  max: number;
  min: number;
  p95: number;
  current: number;
}

export interface PeakInfo {
  startTime: number;
  endTime: number;
  maxValue: number;
  avgValue: number;
}

export function calculateStats(dataPoints: DataPoint[]): Stats {
  const values = dataPoints.map((d) => d.value);
  const sorted = [...values].sort((a, b) => a - b);

  return {
    avg: values.reduce((a, b) => a + b, 0) / values.length,
    max: Math.max(...values),
    min: Math.min(...values),
    p95: sorted[Math.floor(sorted.length * 0.95)],
    current: values[values.length - 1],
  };
}

export function detectPeaks(
  dataPoints: DataPoint[],
  threshold: number = 2
): { points: (DataPoint & { zScore: number; isPeak: boolean })[]; peaks: PeakInfo[] } {
  const values = dataPoints.map((d) => d.value);
  const mean = values.reduce((a, b) => a + b, 0) / values.length;
  const stddev = Math.sqrt(
    values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length
  );

  const points = dataPoints.map((d) => {
    const zScore = stddev === 0 ? 0 : (d.value - mean) / stddev;
    return { ...d, zScore, isPeak: Math.abs(zScore) > threshold };
  });

  const peaks: PeakInfo[] = [];
  let currentPeak: DataPoint[] | null = null;

  for (const point of points) {
    if (point.isPeak) {
      if (!currentPeak) currentPeak = [];
      currentPeak.push(point);
    } else if (currentPeak) {
      peaks.push({
        startTime: currentPeak[0].timestamp,
        endTime: currentPeak[currentPeak.length - 1].timestamp,
        maxValue: Math.max(...currentPeak.map((p) => p.value)),
        avgValue: currentPeak.reduce((a, b) => a + b.value, 0) / currentPeak.length,
      });
      currentPeak = null;
    }
  }

  if (currentPeak) {
    peaks.push({
      startTime: currentPeak[0].timestamp,
      endTime: currentPeak[currentPeak.length - 1].timestamp,
      maxValue: Math.max(...currentPeak.map((p) => p.value)),
      avgValue: currentPeak.reduce((a, b) => a + b.value, 0) / currentPeak.length,
    });
  }

  return { points, peaks };
}

6-4. src/lib/formatters.ts

단위 변환 유틸리티.

export function formatBytes(bytes: number): string {
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  let value = bytes;
  let unitIndex = 0;
  while (value >= 1024 && unitIndex < units.length - 1) {
    value /= 1024;
    unitIndex++;
  }
  return `${value.toFixed(1)} ${units[unitIndex]}`;
}

export function formatBytesPerSec(bytesPerSec: number): string {
  return `${formatBytes(bytesPerSec)}/s`;
}

export function formatPercent(value: number): string {
  return `${value.toFixed(1)}%`;
}

export function formatKiBytes(kiBytes: string): string {
  const bytes = parseInt(kiBytes.replace('Ki', '')) * 1024;
  return formatBytes(bytes);
}

export function formatTimestamp(ts: number): string {
  return new Date(ts * 1000).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
}

STEP 7. API Routes (src/app/api/)

7-1. src/app/api/health/route.ts

import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}

7-2. src/app/api/nodes/route.ts

import { NextResponse } from 'next/server';
import { getNodes } from '@/lib/kubernetes';

export async function GET() {
  try {
    const nodes = await getNodes();
    return NextResponse.json({ nodes });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Failed to fetch nodes' },
      { status: 500 }
    );
  }
}

7-3. src/app/api/metrics/[type]/route.ts

메트릭 타입별 통합 API Route. type은 cpu, memory, disk, nas, network 중 하나.

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[]> = {
  cpu: QUERIES.cpuUsage,
  memory: QUERIES.memoryUsage,
  disk: QUERIES.diskUsage,
  nas: QUERIES.nasUsage,
  network: [QUERIES.networkReceive, QUERIES.networkTransmit],
};

function parsePrometheusValues(values: [number, string][]): DataPoint[] {
  return values.map(([ts, val]) => ({ timestamp: ts, value: parseFloat(val) }));
}

export async function GET(
  request: NextRequest,
  { params }: { params: { type: string } }
) {
  const { type } = params;
  const searchParams = request.nextUrl.searchParams;
  const days = parseInt(searchParams.get('days') || process.env.REPORT_RANGE_DAYS || '7');
  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);

  const queries = QUERY_MAP[type];
  if (!queries) {
    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 instances = results.map((r) => {
      const dataPoints = parsePrometheusValues(r.values);
      return {
        instance: r.metric.instance,
        metric: r.metric,
        dataPoints,
        stats: calculateStats(dataPoints),
        peaks: detectPeaks(dataPoints),
      };
    });

    return NextResponse.json({ type, period: { start, end, step }, instances });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Failed to fetch metrics' },
      { status: 500 }
    );
  }
}

7-4. src/app/api/report/route.ts

모든 메트릭 + 노드 정보를 한 번에 조회하는 통합 API.

import { NextRequest, NextResponse } from 'next/server';
import { queryRange, QUERIES } from '@/lib/prometheus';
import { getNodes } from '@/lib/kubernetes';
import { calculateStats, detectPeaks, DataPoint } from '@/lib/analytics';

function parseValues(values: [number, string][]): DataPoint[] {
  return values.map(([ts, val]) => ({ timestamp: ts, value: parseFloat(val) }));
}

function processResults(results: { metric: Record<string, string>; values: [number, string][] }[]) {
  return results.map((r) => {
    const dataPoints = parseValues(r.values);
    return {
      instance: r.metric.instance,
      metric: r.metric,
      dataPoints,
      stats: calculateStats(dataPoints),
      peaks: detectPeaks(dataPoints),
    };
  });
}

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const days = parseInt(searchParams.get('days') || process.env.REPORT_RANGE_DAYS || '7');
  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);

  try {
    const [cpuData, memData, diskData, nasData, rxData, txData, 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),
      getNodes(),
    ]);

    return NextResponse.json({
      period: { start, end, days, step },
      nodes,
      metrics: {
        cpu: processResults(cpuData),
        memory: processResults(memData),
        disk: processResults(diskData),
        nas: processResults(nasData),
        network: {
          receive: processResults(rxData),
          transmit: processResults(txData),
        },
      },
    });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Failed to generate report' },
      { status: 500 }
    );
  }
}

STEP 8. 타입 정의 (src/types/)

8-1. src/types/metrics.ts

export interface DataPoint {
  timestamp: number;
  value: number;
}

export interface Stats {
  avg: number;
  max: number;
  min: number;
  p95: number;
  current: number;
}

export interface PeakInfo {
  startTime: number;
  endTime: number;
  maxValue: number;
  avgValue: number;
}

export interface MetricInstance {
  instance: string;
  metric: Record<string, string>;
  dataPoints: DataPoint[];
  stats: Stats;
  peaks: {
    points: (DataPoint & { zScore: number; isPeak: boolean })[];
    peaks: PeakInfo[];
  };
}

export interface NetworkMetrics {
  receive: MetricInstance[];
  transmit: MetricInstance[];
}

export interface NodeInfo {
  name: string;
  role: string;
  status: string;
  cpuCapacity: string;
  memoryCapacity: string;
  osImage: string;
  kubeletVersion: string;
  conditions: { type: string; status: string }[];
}

export interface ReportData {
  period: {
    start: string;
    end: string;
    days: number;
    step: string;
  };
  nodes: NodeInfo[];
  metrics: {
    cpu: MetricInstance[];
    memory: MetricInstance[];
    disk: MetricInstance[];
    nas: MetricInstance[];
    network: NetworkMetrics;
  };
}

STEP 9. 프론트엔드 컴포넌트

9-1. src/app/providers.tsx

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export default function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

9-2. src/app/layout.tsx

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Providers from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: '인프라 주간 모니터링 보고서',
  description: 'Infrastructure Weekly Monitoring Report',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

9-3. src/hooks/useReport.ts

'use client';

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import type { ReportData } from '@/types/metrics';

export function useReport(days: number = 7) {
  return useQuery<ReportData>({
    queryKey: ['report', days],
    queryFn: async () => {
      const res = await axios.get(`/api/report?days=${days}`);
      return res.data;
    },
  });
}

9-4. src/components/report/SummaryCards.tsx

'use client';

import { formatPercent, formatBytesPerSec } from '@/lib/formatters';
import type { MetricInstance, NetworkMetrics } from '@/types/metrics';

interface SummaryCardsProps {
  cpu: MetricInstance[];
  memory: MetricInstance[];
  disk: MetricInstance[];
  network: NetworkMetrics;
}

interface CardData {
  label: string;
  avg: string;
  max: string;
  min: string;
  color: string;
}

function getClusterStats(instances: MetricInstance[]): { avg: number; max: number; min: number } {
  if (instances.length === 0) return { avg: 0, max: 0, min: 0 };
  const avg = instances.reduce((sum, i) => sum + i.stats.avg, 0) / instances.length;
  const max = Math.max(...instances.map((i) => i.stats.max));
  const min = Math.min(...instances.map((i) => i.stats.min));
  return { avg, max, min };
}

export default function SummaryCards({ cpu, memory, disk, network }: 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 cards: CardData[] = [
    {
      label: 'CPU',
      avg: formatPercent(cpuStats.avg),
      max: formatPercent(cpuStats.max),
      min: formatPercent(cpuStats.min),
      color: cpuStats.max > 90 ? 'border-red-500' : cpuStats.max > 70 ? 'border-yellow-500' : 'border-green-500',
    },
    {
      label: 'Memory',
      avg: formatPercent(memStats.avg),
      max: formatPercent(memStats.max),
      min: formatPercent(memStats.min),
      color: memStats.max > 90 ? 'border-red-500' : memStats.max > 70 ? 'border-yellow-500' : 'border-green-500',
    },
    {
      label: 'Disk',
      avg: formatPercent(diskStats.avg),
      max: formatPercent(diskStats.max),
      min: formatPercent(diskStats.min),
      color: diskStats.max > 90 ? 'border-red-500' : diskStats.max > 70 ? 'border-yellow-500' : 'border-green-500',
    },
    {
      label: 'Network',
      avg: `RX ${formatBytesPerSec(netRxAvg)}`,
      max: `TX ${formatBytesPerSec(netTxAvg)}`,
      min: '',
      color: 'border-blue-500',
    },
  ];

  return (
    <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
      {cards.map((card) => (
        <div key={card.label} className={`bg-white rounded-lg shadow p-4 border-l-4 ${card.color}`}>
          <h3 className="text-sm font-medium text-gray-500">{card.label}</h3>
          <div className="mt-2 space-y-1">
            <p className="text-lg font-semibold">avg: {card.avg}</p>
            <p className="text-sm text-gray-600">max: {card.max}</p>
            {card.min && <p className="text-sm text-gray-600">min: {card.min}</p>}
          </div>
        </div>
      ))}
    </div>
  );
}

9-5. src/components/charts/TimeSeriesChart.tsx

'use client';

import ReactECharts from 'echarts-for-react';
import type { MetricInstance } from '@/types/metrics';
import { formatTimestamp } from '@/lib/formatters';

interface TimeSeriesChartProps {
  title: string;
  instances: MetricInstance[];
  unit?: string;
  height?: number;
}

export default function TimeSeriesChart({
  title,
  instances,
  unit = '%',
  height = 300,
}: TimeSeriesChartProps) {
  const series = instances.map((inst) => ({
    name: inst.instance,
    type: 'line' as const,
    smooth: true,
    symbol: 'none',
    data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value.toFixed(2)]),
    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 option = {
    title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
    tooltip: {
      trigger: 'axis',
      formatter: (params: any[]) => {
        const time = formatTimestamp(params[0].data[0] / 1000);
        const lines = params.map(
          (p: any) => `${p.marker} ${p.seriesName}: ${parseFloat(p.data[1]).toFixed(1)}${unit}`
        );
        return `${time}<br/>${lines.join('<br/>')}`;
      },
    },
    legend: { bottom: 0, type: 'scroll' },
    grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true },
    xAxis: {
      type: 'time',
      axisLabel: {
        formatter: (val: number) => {
          const d = new Date(val);
          return `${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getDate().toString().padStart(2, '0')}\n${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
        },
      },
    },
    yAxis: {
      type: 'value',
      axisLabel: { formatter: `{value}${unit}` },
    },
    dataZoom: [
      { type: 'inside', start: 0, end: 100 },
      { type: 'slider', start: 0, end: 100, height: 20, bottom: 30 },
    ],
    series,
  };

  return <ReactECharts option={option} style={{ height }} notMerge lazyUpdate />;
}

9-6. src/components/charts/NetworkChart.tsx

'use client';

import ReactECharts from 'echarts-for-react';
import type { NetworkMetrics } from '@/types/metrics';
import { formatTimestamp, formatBytes } from '@/lib/formatters';

interface NetworkChartProps {
  data: NetworkMetrics;
  height?: number;
}

export default function NetworkChart({ data, height = 300 }: NetworkChartProps) {
  const rxSeries = data.receive.map((inst) => ({
    name: `RX ${inst.instance}`,
    type: 'line' as const,
    smooth: true,
    symbol: 'none',
    areaStyle: { opacity: 0.3 },
    data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value]),
  }));

  const txSeries = data.transmit.map((inst) => ({
    name: `TX ${inst.instance}`,
    type: 'line' as const,
    smooth: true,
    symbol: 'none',
    lineStyle: { type: 'dashed' as const },
    data: inst.dataPoints.map((d) => [d.timestamp * 1000, -d.value]),
  }));

  const option = {
    title: { text: 'Network Traffic', left: 'center', textStyle: { fontSize: 14 } },
    tooltip: {
      trigger: 'axis',
      formatter: (params: any[]) => {
        const time = formatTimestamp(params[0].data[0] / 1000);
        const lines = params.map(
          (p: any) => `${p.marker} ${p.seriesName}: ${formatBytes(Math.abs(p.data[1]))}/s`
        );
        return `${time}<br/>${lines.join('<br/>')}`;
      },
    },
    legend: { bottom: 0, type: 'scroll' },
    grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true },
    xAxis: { type: 'time' },
    yAxis: {
      type: 'value',
      axisLabel: {
        formatter: (val: number) => `${formatBytes(Math.abs(val))}/s`,
      },
    },
    dataZoom: [
      { type: 'inside', start: 0, end: 100 },
      { type: 'slider', start: 0, end: 100, height: 20, bottom: 30 },
    ],
    series: [...rxSeries, ...txSeries],
  };

  return <ReactECharts option={option} style={{ height }} notMerge lazyUpdate />;
}

9-7. src/components/report/NodeTable.tsx

'use client';

import type { MetricInstance, NodeInfo } from '@/types/metrics';
import { formatPercent, formatKiBytes } from '@/lib/formatters';

interface NodeTableProps {
  nodes: NodeInfo[];
  cpu: MetricInstance[];
  memory: MetricInstance[];
  disk: MetricInstance[];
}

function findInstanceStats(instances: MetricInstance[], nodeName: string) {
  const inst = instances.find((i) => i.instance.includes(nodeName));
  return inst?.stats || null;
}

export default function NodeTable({ nodes, cpu, memory, disk }: NodeTableProps) {
  return (
    <div className="overflow-x-auto">
      <table className="min-w-full bg-white rounded-lg shadow">
        <thead>
          <tr className="bg-gray-50 border-b">
            <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Node</th>
            <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
            <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
            <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Spec</th>
            <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">CPU avg</th>
            <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">CPU max</th>
            <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">CPU min</th>
            <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">MEM avg</th>
            <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">MEM max</th>
            <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">MEM min</th>
            <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">DISK max</th>
          </tr>
        </thead>
        <tbody className="divide-y divide-gray-200">
          {nodes.map((node) => {
            const cpuStats = findInstanceStats(cpu, node.name);
            const memStats = findInstanceStats(memory, node.name);
            const diskStats = findInstanceStats(disk, node.name);

            return (
              <tr key={node.name} className="hover:bg-gray-50">
                <td className="px-4 py-3 text-sm font-medium">{node.name}</td>
                <td className="px-4 py-3 text-sm">{node.role}</td>
                <td className="px-4 py-3 text-sm">
                  <span
                    className={`inline-block px-2 py-1 rounded text-xs ${
                      node.status === 'Ready' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
                    }`}
                  >
                    {node.status}
                  </span>
                </td>
                <td className="px-4 py-3 text-sm text-gray-500">
                  {node.cpuCapacity} CPU / {formatKiBytes(node.memoryCapacity)}
                </td>
                <td className="px-4 py-3 text-sm text-right">{cpuStats ? formatPercent(cpuStats.avg) : '-'}</td>
                <td className="px-4 py-3 text-sm text-right font-medium">
                  {cpuStats ? formatPercent(cpuStats.max) : '-'}
                </td>
                <td className="px-4 py-3 text-sm text-right text-gray-400">
                  {cpuStats ? formatPercent(cpuStats.min) : '-'}
                </td>
                <td className="px-4 py-3 text-sm text-right">{memStats ? formatPercent(memStats.avg) : '-'}</td>
                <td className="px-4 py-3 text-sm text-right font-medium">
                  {memStats ? formatPercent(memStats.max) : '-'}
                </td>
                <td className="px-4 py-3 text-sm text-right text-gray-400">
                  {memStats ? formatPercent(memStats.min) : '-'}
                </td>
                <td className="px-4 py-3 text-sm text-right font-medium">
                  {diskStats ? formatPercent(diskStats.max) : '-'}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

9-8. src/components/report/AlertSection.tsx

'use client';

import type { MetricInstance } from '@/types/metrics';
import { formatTimestamp, formatPercent } from '@/lib/formatters';

interface AlertSectionProps {
  cpu: MetricInstance[];
  memory: MetricInstance[];
  disk: MetricInstance[];
}

interface Alert {
  level: 'warning' | 'critical';
  instance: string;
  metric: string;
  message: string;
}

export default function AlertSection({ cpu, memory, disk }: AlertSectionProps) {
  const alerts: Alert[] = [];

  cpu.forEach((inst) => {
    if (inst.stats.max > 90) {
      alerts.push({
        level: 'critical',
        instance: inst.instance,
        metric: 'CPU',
        message: `CPU 최대 ${formatPercent(inst.stats.max)} 도달`,
      });
    }
    inst.peaks.peaks.forEach((peak) => {
      alerts.push({
        level: 'warning',
        instance: inst.instance,
        metric: 'CPU',
        message: `${formatTimestamp(peak.startTime)} ~ ${formatTimestamp(peak.endTime)} 구간 Peak 감지 (max: ${formatPercent(peak.maxValue)})`,
      });
    });
  });

  memory.forEach((inst) => {
    if (inst.stats.max > 85) {
      alerts.push({
        level: inst.stats.max > 95 ? 'critical' : 'warning',
        instance: inst.instance,
        metric: 'Memory',
        message: `Memory 최대 ${formatPercent(inst.stats.max)} 도달`,
      });
    }
  });

  disk.forEach((inst) => {
    if (inst.stats.max > 80) {
      alerts.push({
        level: inst.stats.max > 90 ? 'critical' : 'warning',
        instance: inst.instance,
        metric: 'Disk',
        message: `Disk 사용률 ${formatPercent(inst.stats.max)} (증설 검토 필요)`,
      });
    }
  });

  if (alerts.length === 0) {
    return (
      <div className="bg-green-50 border border-green-200 rounded-lg p-4">
        <p className="text-green-800 text-sm">특이사항 없음</p>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-lg shadow">
      <div className="px-4 py-3 border-b">
        <h3 className="text-sm font-medium text-gray-700">주의 구간 ({alerts.length})</h3>
      </div>
      <ul className="divide-y divide-gray-200">
        {alerts.map((alert, idx) => (
          <li key={idx} className="px-4 py-3 flex items-start gap-3">
            <span
              className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${
                alert.level === 'critical' ? 'bg-red-500' : 'bg-yellow-500'
              }`}
            />
            <div className="text-sm">
              <span className="font-medium">[{alert.metric}] {alert.instance}</span>
              <p className="text-gray-600">{alert.message}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

9-9. src/components/report/ReportHeader.tsx

'use client';

import { format } from 'date-fns';
import { ko } from 'date-fns/locale';

interface ReportHeaderProps {
  start: string;
  end: string;
  onExportPdf: () => void;
}

export default function ReportHeader({ start, end, onExportPdf }: ReportHeaderProps) {
  const startDate = format(new Date(start), 'yyyy.MM.dd', { locale: ko });
  const endDate = format(new Date(end), 'yyyy.MM.dd', { locale: ko });

  return (
    <div className="flex items-center justify-between">
      <div>
        <h1 className="text-2xl font-bold text-gray-900">인프라 주간 모니터링 보고서</h1>
        <p className="text-sm text-gray-500 mt-1">{startDate} ~ {endDate}</p>
      </div>
      <button
        onClick={onExportPdf}
        className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
      >
        PDF 다운로드
      </button>
    </div>
  );
}

STEP 10. 메인 페이지

10-1. src/app/page.tsx

'use client';

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 NodeTable from '@/components/report/NodeTable';
import AlertSection from '@/components/report/AlertSection';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';

export default function Home() {
  const [days, setDays] = useState(7);
  const { data, isLoading, error } = useReport(days);
  const reportRef = useRef<HTMLDivElement>(null);

  const handleExportPdf = async () => {
    if (!reportRef.current) return;
    const canvas = await html2canvas(reportRef.current, { scale: 2, useCORS: true });
    const imgData = canvas.toDataURL('image/png');
    const pdf = new jsPDF('p', 'mm', 'a4');
    const pdfWidth = pdf.internal.pageSize.getWidth();
    const pdfHeight = (canvas.height * pdfWidth) / canvas.width;

    let heightLeft = pdfHeight;
    let position = 0;

    pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, pdfHeight);
    heightLeft -= pdf.internal.pageSize.getHeight();

    while (heightLeft > 0) {
      position -= pdf.internal.pageSize.getHeight();
      pdf.addPage();
      pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, pdfHeight);
      heightLeft -= pdf.internal.pageSize.getHeight();
    }

    pdf.save(`infra-report-${days}d.pdf`);
  };

  if (isLoading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-gray-500">데이터 로딩 ...</div>
      </div>
    );
  }

  if (error || !data) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-red-500">데이터 로딩 실패: {error?.message}</div>
      </div>
    );
  }

  return (
    <main className="min-h-screen bg-gray-100 p-6">
      <div ref={reportRef} className="max-w-7xl mx-auto space-y-6">
        <ReportHeader
          start={data.period.start}
          end={data.period.end}
          onExportPdf={handleExportPdf}
        />

        <div className="flex gap-2">
          {[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'
              }`}
            >
              {d}
            </button>
          ))}
        </div>

        <SummaryCards
          cpu={data.metrics.cpu}
          memory={data.metrics.memory}
          disk={data.metrics.disk}
          network={data.metrics.network}
        />

        <TimeSeriesChart title="CPU 사용률" instances={data.metrics.cpu} unit="%" />
        <TimeSeriesChart title="Memory 사용률" instances={data.metrics.memory} unit="%" />
        <TimeSeriesChart title="Disk 사용률" instances={data.metrics.disk} unit="%" />

        {data.metrics.nas.length > 0 && (
          <TimeSeriesChart title="NAS 사용률" instances={data.metrics.nas} unit="%" />
        )}

        <NetworkChart data={data.metrics.network} />

        <div>
          <h2 className="text-lg font-semibold text-gray-800 mb-3">노드별 상세</h2>
          <NodeTable
            nodes={data.nodes}
            cpu={data.metrics.cpu}
            memory={data.metrics.memory}
            disk={data.metrics.disk}
          />
        </div>

        <div>
          <h2 className="text-lg font-semibold text-gray-800 mb-3">주의 구간</h2>
          <AlertSection
            cpu={data.metrics.cpu}
            memory={data.metrics.memory}
            disk={data.metrics.disk}
          />
        </div>
      </div>
    </main>
  );
}

STEP 11. 빌드 및 배포

11-1. 로컬 개발

npm run dev

11-2. 수동 배포 (CI 없이 테스트 시)

docker build -t harbor.inje-private.com/infra/infra-report:v0.1.0 .
docker push harbor.inje-private.com/infra/infra-report:v0.1.0

kubectl create secret docker-registry harbor-pull-secret \
  --docker-server=harbor.inje-private.com \
  --docker-username=admin \
  --docker-password='Ijinc123!@#$' \
  -n soo

helm install infra-report charts/infra-report -n soo \
  --set image.tag=v0.1.0

11-3. CI/CD 배포 (정상 흐름)

git add .
git commit -m "feat: initial infra-report"
git remote add origin https://gitea.inje-private.com/soo-rnd/infra-report.git
git push -u origin main

이후 자동:

  1. Gitea Actions → Docker build + push + Helm push
  2. ArgoCD → Helm chart 변경 감지 → soo namespace 배포

11-4. 배포 확인

kubectl get pods -n soo -l app.kubernetes.io/name=infra-report
kubectl logs -n soo -l app.kubernetes.io/name=infra-report
curl https://infra-report.gpulive.cloud/api/health
argocd app get infra-report

파일 생성 순서 요약

순서 파일 역할
1 next.config.js standalone 빌드 설정
2 .env.local 로컬 환경변수
3 Dockerfile 컨테이너 빌드
4 .dockerignore 빌드 제외
5 charts/infra-report/Chart.yaml Helm 차트 정의
6 charts/infra-report/values.yaml Helm 기본값
7 charts/infra-report/templates/_helpers.tpl Helm 헬퍼
8 charts/infra-report/templates/serviceaccount.yaml SA
9 charts/infra-report/templates/clusterrole.yaml RBAC Role
10 charts/infra-report/templates/clusterrolebinding.yaml RBAC Binding
11 charts/infra-report/templates/configmap.yaml 환경변수
12 charts/infra-report/templates/deployment.yaml 워크로드
13 charts/infra-report/templates/service.yaml 서비스
14 charts/infra-report/templates/ingress.yaml 외부 접근
15 .gitea/workflows/ci.yaml CI/CD 파이프라인
16 src/types/metrics.ts 타입 정의
17 src/lib/prometheus.ts Prometheus 클라이언트
18 src/lib/kubernetes.ts K8s 클라이언트
19 src/lib/analytics.ts 통계/Peak 감지
20 src/lib/formatters.ts 단위 변환
21 src/app/api/health/route.ts 헬스체크
22 src/app/api/nodes/route.ts 노드 API
23 src/app/api/metrics/[type]/route.ts 메트릭 API
24 src/app/api/report/route.ts 통합 보고서 API
25 src/app/providers.tsx React Query Provider
26 src/app/layout.tsx 레이아웃
27 src/hooks/useReport.ts 데이터 조회 훅
28 src/components/report/ReportHeader.tsx 보고서 헤더
29 src/components/report/SummaryCards.tsx 요약 카드
30 src/components/charts/TimeSeriesChart.tsx 시계열 차트
31 src/components/charts/NetworkChart.tsx 네트워크 차트
32 src/components/report/NodeTable.tsx 노드 테이블
33 src/components/report/AlertSection.tsx 알림 섹션
34 src/app/page.tsx 메인 페이지

남은 작업 체크리스트

작업 상태
Gitea repo 생성 완료
Gitea Actions/Secrets/Runner 완료
Harbor infra 프로젝트 생성 확인 필요
Next.js 프로젝트 초기화 미완
Helm chart 작성 미완
CI 워크플로우 작성 미완
imagePullSecret 생성 (soo ns) 미완
ArgoCD Harbor OCI repo 등록 미완
ArgoCD Application 생성 미완
앱 코드 구현 (lib/api/components) 미완
첫 배포 + 동작 확인 미완