infra-report/plan.md

1883 lines
53 KiB
Markdown

# 인프라 주간 모니터링 보고서 - 구현 계획서
## 프로젝트 정보
- 프로젝트명: infra-report
- Gitea: https://gitea.inje-private.com/soo-rnd/infra-report
- 프레임워크: Next.js 14 (App Router) + TypeScript
- 이미지: harbor.inje-private.com/infra/infra-report
- Helm Chart: infra-report
- 배포: K8s Pod (namespace: soo)
- ArgoCD: https://argocd.gpulive.cloud (app: infra-report)
- Ingress: infra-report.gpulive.cloud
---
## 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 생성
```bash
kubectl run init-nextjs --image=node:20-alpine --restart=Never -n soo -- sleep 3600
```
### 1-2. Pod 접속 → 프로젝트 생성 + 패키지 설치 + next.config.js 덮어쓰기
```bash
kubectl exec -it init-nextjs -n soo -- sh
```
```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 삭제
```bash
kubectl cp soo/init-nextjs:/app ./infra-report-src
kubectl delete pod init-nextjs -n soo
```
### 1-4. bastion에서 git에 커밋
```bash
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
```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
```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
```yaml
{{- 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
```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
```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
```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
```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
```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 사용 권장.
**수동 생성 방식:**
```bash
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 네임스페이스로 복사한다.
```bash
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
```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
```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
```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
```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
```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에 등록
```bash
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 생성
```yaml
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로 생성:
```bash
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)
```bash
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 클라이언트.
```typescript
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 사용.
```typescript
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 감지.
```typescript
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
단위 변환 유틸리티.
```typescript
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
```typescript
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
```typescript
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 중 하나.
```typescript
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.
```typescript
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
```typescript
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
```typescript
'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
```typescript
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
```typescript
'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
```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
```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
```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
```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
```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
```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
```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. 로컬 개발
```bash
npm run dev
```
### 11-2. 수동 배포 (CI 없이 테스트 시)
```bash
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 배포 (정상 흐름)
```bash
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. 배포 확인
```bash
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) | 미완 |
| 첫 배포 + 동작 확인 | 미완 |