feat: initial infra-report project
CI/CD / build-and-push (push) Failing after 2m26s Details

Next.js 14 기반 인프라 주간 모니터링 보고서 시스템
- Prometheus API 연동 (CPU, Memory, Disk, NAS, Network)
- K8s API 노드 정보 조회
- ECharts 시계열 차트 + Peak 감지
- Helm chart + Gitea CI/CD + ArgoCD 배포 구성
- PDF 다운로드 기능
This commit is contained in:
Cloud User 2026-03-16 10:53:02 +09:00
parent d2b8cd86a9
commit 4465be2032
47 changed files with 11043 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
.next
.git
*.md
charts/
.env.local
.gitea/

54
.gitea/workflows/ci.yaml Normal file
View File

@ -0,0 +1,54 @@
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

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
.next/
.env
.env.local
*.tgz

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
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"]

View File

@ -0,0 +1,6 @@
apiVersion: v2
name: infra-report
description: Infrastructure Weekly Monitoring Report
type: application
version: 0.1.0
appVersion: "0.1.0"

View File

@ -0,0 +1,19 @@
{{- 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 }}

View File

@ -0,0 +1,13 @@
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"]

View File

@ -0,0 +1,14 @@
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

View File

@ -0,0 +1,11 @@
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 }}

View File

@ -0,0 +1,45 @@
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 }}

View File

@ -0,0 +1,32 @@
{{- 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 }}

View File

@ -0,0 +1,15 @@
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

View File

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

View File

@ -0,0 +1,57 @@
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

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

7
next.config.js Normal file
View File

@ -0,0 +1,7 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["@kubernetes/client-node"],
};
module.exports = nextConfig;

6909
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@kubernetes/client-node": "^1.4.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20.19.37",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

1882
plan.md Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

3
record.md Normal file
View File

@ -0,0 +1,3 @@
# 구현 기록 (plan.md 대비 변경사항)
## 변경 이력

785
research.md Normal file
View File

@ -0,0 +1,785 @@
# 인프라 주간 모니터링 보고서 시스템 - 기술 리서치
## 1. 프로젝트 개요
**목표**: Prometheus API와 Kubernetes API에서 인프라 메트릭 데이터를 수집하여 주간 모니터링 보고서를 자동 생성하는 시스템
**배포 환경**: Kubernetes 클러스터 내 Pod으로 배포 (namespace: `soo`)
**핵심 모니터링 메트릭**:
- CPU 사용률
- Memory 사용률
- Disk 사용률
- NAS (NFS/CIFS) 스토리지
- Network Traffic (송/수신)
**핵심 분석 항목**:
- 평균(avg) / 최대(max) / 최소(min) 값
- Peak 구간 감지 (이상 탐지)
- 주의가 필요한 데이터 구간 하이라이트
---
## 2. 데이터 소스 분석
### 2.1 Prometheus HTTP API
Prometheus는 REST API를 통해 메트릭 데이터를 조회할 수 있다.
#### 주요 엔드포인트
| 엔드포인트 | 용도 | 설명 |
|---|---|---|
| `GET /api/v1/query` | 즉시 쿼리 | 현재 시점의 메트릭 조회 |
| `GET /api/v1/query_range` | 범위 쿼리 | **주간 보고서의 핵심** - 시작/종료 시간 + step 간격으로 시계열 데이터 조회 |
| `GET /api/v1/labels` | 레이블 목록 | 사용 가능한 메트릭 레이블 조회 |
| `GET /api/v1/label/<name>/values` | 레이블 값 | 특정 레이블의 값 조회 |
| `GET /api/v1/metadata` | 메트릭 메타데이터 | 메트릭 설명 및 타입 정보 |
#### query_range API 호출 예시
```
GET /api/v1/query_range?query=<PromQL>&start=<rfc3339>&end=<rfc3339>&step=<duration>
```
- **최대 조회 범위**: 32일 (주간 보고서에 충분)
- **step**: 데이터 해상도 (예: `5m`, `15m`, `1h`)
- 주간 보고서용 권장 step: `5m` ~ `15m` (세밀한 peak 감지를 위해)
#### 핵심 PromQL 쿼리
**CPU 사용률 (%):**
```promql
# 노드별 CPU 사용률
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# 또는
sum by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m])) * 100
```
**Memory 사용률 (%):**
```promql
# 노드별 메모리 사용률
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100
```
**Disk 사용률 (%):**
```promql
# 파일시스템별 디스크 사용률
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) * 100
```
**NAS/NFS 모니터링:**
```promql
# NFS 마운트 디스크 사용률
(1 - node_filesystem_avail_bytes{fstype="nfs"} / node_filesystem_size_bytes{fstype="nfs"}) * 100
# NFS 마운트 디스크 사용률 (nfs4 포함)
(1 - node_filesystem_avail_bytes{fstype=~"nfs|nfs4|cifs"} / node_filesystem_size_bytes{fstype=~"nfs|nfs4|cifs"}) * 100
```
> **참고**: NAS 모니터링은 node_exporter의 filesystem collector가 NFS/CIFS 마운트도 수집함. Synology 등 전용 NAS는 SNMP exporter(`snmp_exporter`)를 통해 더 상세한 메트릭 수집 가능.
**Network Traffic (bytes/sec):**
```promql
# 수신 트래픽 (bytes/sec)
sum by (instance) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))
# 송신 트래픽 (bytes/sec)
sum by (instance) (rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))
# bps 단위로 변환
sum by (instance) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m])) * 8
```
#### 통계 분석용 PromQL (avg / max / peak 감지)
**평균 (avg_over_time):**
```promql
# 주간 평균 CPU 사용률
avg_over_time(
(100 - avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)[7d:5m]
)
```
**최대 (max_over_time):**
```promql
# 주간 최대 CPU 사용률
max_over_time(
(100 - avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)[7d:5m]
)
```
**Peak/이상 탐지 (Z-Score 기반):**
```promql
# Z-Score = (현재값 - 평균) / 표준편차
# |Z| > 2 이면 이상 구간으로 판단
(
rate(node_cpu_seconds_total{mode!="idle"}[5m])
- avg_over_time(rate(node_cpu_seconds_total{mode!="idle"}[5m])[1d])
) / stddev_over_time(rate(node_cpu_seconds_total{mode!="idle"}[5m])[1d])
```
**표준편차 기반 이상 탐지:**
```promql
# 평균 + 2*표준편차를 초과하는 구간 감지
<metric> > (avg_over_time(<metric>[7d:5m]) + 2 * stddev_over_time(<metric>[7d:5m]))
```
---
### 2.2 Kubernetes API
#### 노드 정보 조회
| 엔드포인트 | 용도 |
|---|---|
| `GET /api/v1/nodes` | 전체 노드 목록 + 스펙(CPU, Memory, OS 등) |
| `GET /api/v1/nodes/<name>` | 특정 노드 상세 정보 |
| `GET /apis/metrics.k8s.io/v1beta1/nodes` | 노드별 실시간 리소스 사용량 (metrics-server 필요) |
| `GET /apis/metrics.k8s.io/v1beta1/nodes/<name>` | 특정 노드 실시간 사용량 |
#### 노드 정보에서 얻을 수 있는 데이터
```json
{
"metadata": { "name": "node-1", "labels": { "role": "worker" } },
"status": {
"capacity": { "cpu": "16", "memory": "65536Mi" },
"allocatable": { "cpu": "15800m", "memory": "63000Mi" },
"nodeInfo": {
"osImage": "Ubuntu 22.04",
"kubeletVersion": "v1.28.0",
"containerRuntimeVersion": "containerd://1.7.0"
},
"conditions": [
{ "type": "Ready", "status": "True" },
{ "type": "MemoryPressure", "status": "False" },
{ "type": "DiskPressure", "status": "False" }
]
}
}
```
#### 인증 방식 (Pod 내부 배포 기준)
Pod으로 배포되므로 **In-Cluster Config**를 사용한다. K8s가 자동으로 ServiceAccount 토큰을 Pod에 마운트해준다.
```
/var/run/secrets/kubernetes.io/serviceaccount/token ← Bearer Token
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt ← CA 인증서
KUBERNETES_SERVICE_HOST / KUBERNETES_SERVICE_PORT ← API 서버 주소 (자동 주입)
```
`@kubernetes/client-node` 라이브러리 사용 시:
```typescript
import * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();
kc.loadFromCluster(); // ← in-cluster config 자동 로드
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const nodes = await coreApi.listNode();
```
> **참고**: 외부 개발 시에는 `kc.loadFromDefault()`로 로컬 kubeconfig 사용 가능
---
## 3. 아키텍처 (K8s Pod 배포)
### 3.1 배포 아키텍처
K8s Pod으로 배포되므로 클러스터 내부 통신을 활용한 **Next.js 풀스택 (BFF 패턴)** 단일 구성이 최적이다.
```
┌─── K8s Cluster ──────────────────────────────────────────────┐
│ │
│ ┌─── Pod: infra-report (ns: soo) ────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Next.js (App Router) │ │ │
│ │ │ ┌───────────┐ ┌────────────┐ │ │ │
│ │ │ │ Frontend │ │ API Routes │──┼──→ Prometheus │ │
│ │ │ │ (React) │ │ (BFF) │──┼──→ K8s API Server │ │
│ │ │ └───────────┘ └────────────┘ │ │ │
│ │ └─────────────────────────────────┘ │ │
│ │ ServiceAccount: infra-report-sa │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌── Service ──┐ │
│ │ ClusterIP │ ← Ingress / NodePort로 외부 접근 │
│ │ :3000 │ │
│ └─────────────┘ │
│ │
│ Prometheus: http://prometheus-server.monitoring │
│ K8s API: https://kubernetes.default.svc (in-cluster) │
└──────────────────────────────────────────────────────────────┘
```
### 3.2 K8s Pod 배포의 이점
| 이점 | 설명 |
|---|---|
| **CORS 문제 없음** | API Routes가 서버 사이드에서 Prometheus 호출 (Pod 간 통신) |
| **인증 자동 처리** | ServiceAccount 토큰이 Pod에 자동 마운트 → K8s API 인증 |
| **Prometheus 내부 접근** | `prometheus-server.monitoring`으로 클러스터 내부 DNS 호출 |
| **설정 관리** | ConfigMap/Secret으로 환경변수 주입 |
| **헬스체크** | K8s liveness/readiness probe 활용 |
| **자동 복구** | Pod 장애 시 자동 재시작 |
| **수평 확장** | 필요시 replica 증가 가능 (보고서 용도라 보통 1개면 충분) |
### 3.3 프론트엔드 Only 가능 여부
K8s Pod으로 배포하는 이상 **BFF 패턴이 자연스러운 선택**이다.
Next.js 자체가 서버 런타임을 포함하므로 "프론트만 구현"해도 API Routes로 백엔드 로직을 가진 풀스택 앱이 된다.
즉, **프론트엔드 코드만 작성해도 사실상 백엔드가 포함된 구조**이므로 프론트 개발자 혼자서 충분히 구현 가능하다.
---
## 4. 기술 스택 상세
### 4.1 프론트엔드
| 카테고리 | 추천 기술 | 대안 | 선택 이유 |
|---|---|---|---|
| **프레임워크** | **Next.js (App Router)** | Vite + React | API Routes로 BFF 패턴, standalone 빌드로 K8s 최적화 |
| **언어** | **TypeScript** | JavaScript | 타입 안정성, API 응답 타입 정의 |
| **차트 라이브러리** | **Apache ECharts** (`echarts-for-react`) | Recharts, Chart.js | 대량 데이터 처리 성능 우수, 다양한 차트 타입, 인터랙티브 기능 |
| **UI 프레임워크** | **Tailwind CSS + shadcn/ui** | Ant Design, MUI | 경량, 커스터마이징 용이, 보고서 레이아웃에 적합 |
| **테이블** | **TanStack Table** | AG Grid | 정렬/필터링, 경량 |
| **상태관리** | **TanStack Query (React Query)** | SWR, Zustand | API 데이터 캐싱/재조회 최적화 |
| **날짜 처리** | **date-fns** 또는 **dayjs** | moment.js (deprecated) | 경량, 트리쉐이킹 지원 |
| **PDF 생성** | **html2canvas + jsPDF** (클라이언트) 또는 **Puppeteer** (서버) | react-pdf | 보고서 레이아웃 그대로 PDF 변환 |
#### 차트 라이브러리 비교 (보고서 용도 관점)
| 기준 | ECharts | Recharts | Chart.js |
|---|---|---|---|
| **대량 데이터 (1만+ 포인트)** | WebGL 렌더링으로 최고 | SVG 기반, 느려짐 | Canvas 기반, 양호 |
| **시계열 차트** | 내장 dataZoom, 브러시 선택 | 기본적 | 줌 플러그인 필요 |
| **보고서 스타일** | 풍부한 옵션 | 심플 | 심플 |
| **툴팁/인터랙션** | 매우 풍부 | 기본적 | 기본적 |
| **번들 사이즈** | ~1MB (트리쉐이킹 가능) | ~200KB | ~60KB |
| **React 통합** | echarts-for-react 래퍼 | 네이티브 React | react-chartjs-2 래퍼 |
> **권장**: 인프라 모니터링 보고서는 데이터 포인트가 많고 시계열 분석이 핵심이므로 **Apache ECharts** 추천. 데이터가 적고 단순한 보고서라면 **Recharts**도 충분.
### 4.2 백엔드 (경량)
| 카테고리 | 추천 기술 | 대안 | 선택 이유 |
|---|---|---|---|
| **런타임** | **Node.js** | Python (FastAPI) | 프론트와 언어 통일, Next.js API Routes 활용 가능 |
| **HTTP 클라이언트** | **axios** 또는 **fetch** (Node 18+) | got, node-fetch | Prometheus/K8s API 호출 |
| **K8s 클라이언트** | **@kubernetes/client-node** | kubectl exec | 공식 JS 클라이언트, in-cluster config 지원 |
| **스케줄링** | **node-cron** 또는 **K8s CronJob** | Bull Queue | Pod 내부 cron 또는 K8s 네이티브 CronJob |
| **PDF (서버)** | **Puppeteer** | Playwright | 헤드리스 브라우저로 pixel-perfect PDF |
### 4.3 Python 백엔드 대안 (데이터 분석 강점)
| 카테고리 | 기술 | 장점 |
|---|---|---|
| **프레임워크** | FastAPI | 비동기, 자동 API 문서, 타입 힌트 |
| **Prometheus 클라이언트** | `prometheus-api-client` | PromQL 결과를 Pandas DataFrame으로 변환 |
| **K8s 클라이언트** | `kubernetes` (공식) | 완성도 높은 공식 클라이언트 |
| **데이터 분석** | Pandas, NumPy | 통계 분석(평균/최대/표준편차/이상탐지) 강력 |
| **PDF 생성** | WeasyPrint, ReportLab | HTML → PDF 변환 |
> **참고**: 데이터 분석/통계에 중점을 둔다면 Python 백엔드가 더 강력하지만, 프론트엔드와의 통합성과 단일 프로젝트 관리를 고려하면 Next.js + API Routes가 더 실용적.
---
## 5. 보고서 데이터 가공 로직
### 5.1 데이터 수집 흐름
```
1. 보고 기간 설정 (예: 지난 7일)
├─ start: 2026-03-05T00:00:00Z
└─ end: 2026-03-12T00:00:00Z
2. Prometheus query_range API 호출
├─ CPU 사용률 쿼리 (step=5m → 2,016 데이터포인트/노드)
├─ Memory 사용률 쿼리
├─ Disk 사용률 쿼리
├─ NAS 사용률 쿼리
└─ Network 트래픽 쿼리
3. K8s API 호출
├─ 노드 목록 + 스펙 (CPU cores, RAM 총량)
└─ 노드 상태 (conditions)
4. 데이터 가공
├─ 노드별 통계 계산 (avg, max, min, p95, p99)
├─ Peak 구간 감지 (Z-Score 기반)
├─ 임계값 초과 구간 마킹
└─ 트렌드 분석 (전주 대비 증감)
5. 보고서 렌더링
├─ 요약 대시보드 (핵심 지표 카드)
├─ 시계열 차트 (peak 구간 하이라이트)
├─ 노드별 상세 테이블
└─ PDF 내보내기
```
### 5.2 Peak 감지 알고리즘
#### 방법 1: PromQL 서버 사이드 (Prometheus에서 계산)
```promql
# avg_over_time + stddev_over_time 조합
# 평균 + 2σ 초과 시 peak으로 판정
# CPU peak 감지
(100 - avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
>
(
avg_over_time((100 - avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)[7d:5m])
+ 2 * stddev_over_time((100 - avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)[7d:5m])
)
```
#### 방법 2: 클라이언트/백엔드 사이드 (JavaScript/Python)
```javascript
// Z-Score 기반 peak 감지
function detectPeaks(dataPoints, threshold = 2) {
const values = dataPoints.map(d => d.value);
const mean = values.reduce((a, b) => a + b) / values.length;
const stddev = Math.sqrt(
values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length
);
return dataPoints.map(d => ({
...d,
zScore: (d.value - mean) / stddev,
isPeak: Math.abs((d.value - mean) / stddev) > threshold,
}));
}
```
### 5.3 보고서 구성 요소
```
┌─────────────────────────────────────────────────┐
│ 인프라 주간 모니터링 보고서 │
│ 2026.03.05 ~ 2026.03.12 │
├─────────────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ CPU │ │ MEM │ │ DISK │ │ NET │ ← 요약 │
│ │ 평균 │ │ 평균 │ │ 평균 │ │ 평균 │ 카드 │
│ │ 최대 │ │ 최대 │ │ 최대 │ │ 최대 │ │
│ │ 최소 │ │ 최소 │ │ 최소 │ │ │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
├─────────────────────────────────────────────────┤
│ [CPU 사용률 시계열 차트] │
│ ████████░░░░████████████████░░░░████████ │
│ ↑ peak 구간 하이라이트 │
├─────────────────────────────────────────────────┤
│ [Memory 사용률 시계열 차트] │
├─────────────────────────────────────────────────┤
│ [Disk/NAS 사용률 차트] │
├─────────────────────────────────────────────────┤
│ [Network Traffic 차트 (In/Out)] │
├─────────────────────────────────────────────────┤
│ 노드별 상세 │
│ ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │
│ │ 노드 │ CPU │ CPU │ CPU │ MEM │ MEM │ MEM │ DISK │ │
│ │ │ avg │ max │ min │ avg │ max │ min │ max │ │
│ ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤ │
│ │node-1│ 45% │ 92% │ 12% │ 67% │ 85% │ 41% │ 72% │ │
│ │node-2│ 38% │ 78% │ 8% │ 55% │ 70% │ 35% │ 45% │ │
│ └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘ │
├─────────────────────────────────────────────────┤
│ ⚠ 주의 구간 │
│ - node-1: 03/07 14:00~16:30 CPU 92% 도달 │
│ - node-2: 03/09 02:00~03:00 Memory 85% 초과 │
│ - NAS-01: 디스크 사용률 85% 초과 (증설 검토 필요) │
└─────────────────────────────────────────────────┘
```
---
## 6. 기술 스택 최종 추천
### 6.1 추천 구성: Next.js 풀스택 + K8s 배포
```
infra-report/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── page.tsx # 메인 보고서 페이지
│ │ ├── api/ # API Routes (BFF)
│ │ │ ├── metrics/ # Prometheus 데이터 프록시 + 가공
│ │ │ │ ├── cpu/route.ts
│ │ │ │ ├── memory/route.ts
│ │ │ │ ├── disk/route.ts
│ │ │ │ ├── nas/route.ts
│ │ │ │ └── network/route.ts
│ │ │ ├── nodes/route.ts # K8s 노드 정보
│ │ │ └── report/route.ts # 보고서 데이터 통합
│ │ └── report/
│ │ └── [id]/page.tsx # 개별 보고서 상세
│ ├── components/
│ │ ├── charts/ # 차트 컴포넌트
│ │ │ ├── CpuChart.tsx
│ │ │ ├── MemoryChart.tsx
│ │ │ ├── DiskChart.tsx
│ │ │ ├── NetworkChart.tsx
│ │ │ └── PeakHighlight.tsx
│ │ ├── report/ # 보고서 레이아웃 컴포넌트
│ │ │ ├── SummaryCards.tsx
│ │ │ ├── NodeTable.tsx
│ │ │ └── AlertSection.tsx
│ │ └── ui/ # 공통 UI 컴포넌트
│ ├── lib/
│ │ ├── prometheus.ts # Prometheus API 클라이언트
│ │ ├── kubernetes.ts # K8s API 클라이언트 (in-cluster config)
│ │ ├── analytics.ts # 통계 계산 (avg, max, peak 감지)
│ │ └── formatters.ts # 데이터 포맷팅 (bytes→GB 등)
│ └── types/
│ ├── metrics.ts # 메트릭 데이터 타입
│ └── report.ts # 보고서 데이터 타입
├── k8s/ # K8s 매니페스트
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── serviceaccount.yaml
│ ├── rbac.yaml # ClusterRole + ClusterRoleBinding
│ └── configmap.yaml
├── Dockerfile
├── .dockerignore
├── package.json
├── tailwind.config.ts
├── tsconfig.json
└── .env.local # 로컬 개발용 (Pod에서는 ConfigMap/Secret 사용)
```
### 6.2 핵심 패키지 목록
```json
{
"dependencies": {
"next": "^14.x",
"react": "^18.x",
"typescript": "^5.x",
"echarts": "^5.x",
"echarts-for-react": "^3.x",
"@tanstack/react-query": "^5.x",
"@tanstack/react-table": "^8.x",
"tailwindcss": "^3.x",
"@radix-ui/react-*": "latest",
"axios": "^1.x",
"@kubernetes/client-node": "^0.20.x",
"date-fns": "^3.x",
"jspdf": "^2.x",
"html2canvas": "^1.x",
"zod": "^3.x"
}
}
```
---
## 7. K8s 배포 상세
### 7.1 Dockerfile
```dockerfile
# ── Build Stage ──
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Production Stage ──
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"]
```
> `next.config.js``output: 'standalone'` 설정 필요
### 7.2 K8s 매니페스트
#### ServiceAccount + RBAC (노드 조회 권한)
```yaml
# k8s/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: infra-report-sa
namespace: soo
---
# k8s/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: infra-report-reader
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list"]
- apiGroups: ["metrics.k8s.io"]
resources: ["nodes"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: infra-report-reader-binding
subjects:
- kind: ServiceAccount
name: infra-report-sa
namespace: soo
roleRef:
kind: ClusterRole
name: infra-report-reader
apiGroup: rbac.authorization.k8s.io
```
#### ConfigMap (환경변수)
```yaml
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: infra-report-config
namespace: soo
data:
PROMETHEUS_URL: "http://prometheus-server.monitoring"
REPORT_STEP: "5m"
REPORT_RANGE_DAYS: "7"
TZ: "Asia/Seoul"
```
#### Deployment
```yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: infra-report
namespace: soo
labels:
app: infra-report
spec:
replicas: 1
selector:
matchLabels:
app: infra-report
template:
metadata:
labels:
app: infra-report
spec:
serviceAccountName: infra-report-sa
containers:
- name: infra-report
image: harbor.inje-private.com/soo/infra-report:latest
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: infra-report-config
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
```
#### Service + Ingress
```yaml
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: infra-report
namespace: soo
spec:
type: ClusterIP
selector:
app: infra-report
ports:
- port: 80
targetPort: 3000
protocol: TCP
---
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: infra-report
namespace: soo
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: infra-report.gpulive.cloud # 실제 도메인으로 변경
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: infra-report
port:
number: 80
```
### 7.3 환경변수 관리
| 구분 | 로컬 개발 | K8s Pod |
|---|---|---|
| Prometheus URL | `.env.local` | ConfigMap |
| K8s 인증 | kubeconfig (자동) | ServiceAccount (자동) |
| 민감 정보 | `.env.local` | Secret |
| 타임존 | OS 설정 | ConfigMap (`TZ=Asia/Seoul`) |
### 7.4 빌드/배포 파이프라인
```
1. 코드 Push → Gitea (gitea.inje-private.com)
2. CI (Gitea Runner)
├─ npm ci && npm run build
├─ docker build → harbor.inje-private.com/soo/infra-report:${TAG}
└─ docker push
3. CD (ArgoCD: argocd.gpulive.cloud)
├─ k8s/ 매니페스트 변경 감지
└─ 자동 배포 (sync)
```
### 7.5 Next.js 설정 (standalone 빌드)
```javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // Docker 최적화 필수
experimental: {
// 서버 컴포넌트에서 K8s 클라이언트 사용 시
serverComponentsExternalPackages: ['@kubernetes/client-node'],
},
};
module.exports = nextConfig;
```
---
## 8. 구현 우선순위 로드맵
### Phase 1: 프로젝트 세팅 + K8s 배포 기반
- [ ] Next.js 프로젝트 초기 구성 (TypeScript, Tailwind, standalone 빌드)
- [ ] Dockerfile 작성 + 로컬 빌드 테스트
- [ ] K8s 매니페스트 작성 (ServiceAccount, RBAC, Deployment, Service)
- [ ] Harbor 이미지 Push + K8s 클러스터 배포 확인
- [ ] Health check API (`/api/health`) 구현
### Phase 2: 데이터 수집 MVP
- [ ] Prometheus API 클라이언트 구현 (lib/prometheus.ts)
- [ ] K8s 노드 정보 조회 (lib/kubernetes.ts, in-cluster config)
- [ ] CPU/Memory 메트릭 API Routes 구현
- [ ] 기본 시계열 차트 (ECharts)
- [ ] 요약 카드 컴포넌트
### Phase 3: 데이터 확장
- [ ] Disk/NAS/Network 메트릭 추가
- [ ] 노드별 상세 테이블
- [ ] 통계 계산 로직 (avg, max, p95)
### Phase 4: 분석 기능
- [ ] Peak 구간 감지 (Z-Score)
- [ ] 차트에 peak 구간 하이라이트
- [ ] 주의 구간 알림 섹션
- [ ] 전주 대비 트렌드
### Phase 5: 보고서 기능
- [ ] PDF 내보내기
- [ ] 보고서 기간 선택 UI
- [ ] Ingress 설정 + 외부 접근
### Phase 6: 자동화 (추후)
- [ ] 주간 자동 보고서 생성 (CronJob 또는 node-cron)
- [ ] Slack/Email 발송
- [ ] Gitea Runner CI/CD 파이프라인 연동
- [ ] ArgoCD 자동 배포
---
## 9. 참고 자료
### Prometheus
- [Prometheus Query Examples](https://prometheus.io/docs/prometheus/latest/querying/examples/)
- [Prometheus API Guide - Last9](https://last9.io/blog/prometheus-api/)
- [PromQL Cheat Sheet - SigNoz](https://signoz.io/guides/promql-cheat-sheet/)
- [PromQL Cheat Sheet - PromLabs](https://promlabs.com/promql-cheat-sheet/)
- [Anomaly Detection with PromQL](https://grigorkh.medium.com/practical-anomaly-detection-with-prometheus-and-promql-d3027b97da96)
- [Z-Score in PromQL](https://omarghader.github.io/prometheus-anomaly-detection-z-score-in-promql/)
- [Prometheus Over Time Analysis](https://oneuptime.com/blog/post/2025-12-17-prometheus-metrics-over-time-analysis/view)
- [PromQL Subqueries and Aggregations](https://oneuptime.com/blog/post/2026-02-02-promql-subqueries-aggregations/view)
### Kubernetes
- [K8s Resource Metrics Pipeline](https://kubernetes.io/docs/tasks/debug/debug-cluster/resource-metrics-pipeline/)
- [K8s Node Metrics Data](https://kubernetes.io/docs/reference/instrumentation/node-metrics/)
- [Metrics Server](https://kubernetes-sigs.github.io/metrics-server/)
- [kubectl proxy](https://kubernetes.io/docs/tasks/extend-kubernetes/http-proxy-access-api/)
- [@kubernetes/client-node](https://github.com/kubernetes-client/javascript)
- [K8s RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)
### 프론트엔드
- [React Chart Libraries 비교 2025 - LogRocket](https://blog.logrocket.com/best-react-chart-libraries-2025/)
- [Apache ECharts](https://echarts.apache.org/)
- [Chart Libraries for React 2026 - Weavelinx](https://weavelinx.com/best-chart-libraries-for-react-projects-in-2026/)
### PDF 생성
- [Next.js PDF Generation](https://dev.to/wonder2210/generating-pdf-files-using-next-js-24dm)
- [html2canvas + jsPDF in React](https://medium.com/@saidularefin8/generating-pdfs-from-html-in-a-react-application-with-html2canvas-and-jspdf-d46c5785eff2)
- [Puppeteer PDF in Next.js](https://medium.com/front-end-weekly/dynamic-html-to-pdf-generation-in-next-js-a-step-by-step-guide-with-puppeteer-dbcf276375d7)
### NAS/Storage
- [Node Exporter - GitHub](https://github.com/prometheus/node_exporter)
- [Synology NAS + Prometheus](https://github.com/ddiiwoong/synology-prometheus)
- [Network Interface Metrics - Robust Perception](https://www.robustperception.io/network-interface-metrics-from-the-node-exporter/)

View File

@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}

View File

@ -0,0 +1,83 @@
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: Promise<{ type: string }> }
) {
const { type } = await 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 }
);
}
}

View File

@ -0,0 +1,14 @@
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 }
);
}
}

View File

@ -0,0 +1,62 @@
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 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

BIN
src/app/fonts/GeistVF.woff Normal file

Binary file not shown.

27
src/app/globals.css Normal file
View File

@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

21
src/app/layout.tsx Normal file
View File

@ -0,0 +1,21 @@
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>
);
}

120
src/app/page.tsx Normal file
View File

@ -0,0 +1,120 @@
'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>
);
}

20
src/app/providers.tsx Normal file
View File

@ -0,0 +1,20 @@
'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>;
}

View File

@ -0,0 +1,60 @@
'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 />;
}

View File

@ -0,0 +1,71 @@
'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 />;
}

View File

@ -0,0 +1,93 @@
'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>
);
}

View File

@ -0,0 +1,83 @@
'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>
);
}

View File

@ -0,0 +1,30 @@
'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>
);
}

View File

@ -0,0 +1,82 @@
'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>
);
}

15
src/hooks/useReport.ts Normal file
View File

@ -0,0 +1,15 @@
'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;
},
});
}

77
src/lib/analytics.ts Normal file
View File

@ -0,0 +1,77 @@
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 };
}

27
src/lib/formatters.ts Normal file
View File

@ -0,0 +1,27 @@
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' });
}

51
src/lib/kubernetes.ts Normal file
View File

@ -0,0 +1,51 @@
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 || '',
})),
};
});
}

55
src/lib/prometheus.ts Normal file
View File

@ -0,0 +1,55 @@
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]))',
};

63
src/types/metrics.ts Normal file
View File

@ -0,0 +1,63 @@
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;
};
}

19
tailwind.config.ts Normal file
View File

@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
};
export default config;

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}