1883 lines
53 KiB
Markdown
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) | 미완 |
|
|
| 첫 배포 + 동작 확인 | 미완 |
|