feat: initial infra-report project
CI/CD / build-and-push (push) Failing after 2m26s
Details
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:
parent
d2b8cd86a9
commit
4465be2032
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
charts/
|
||||||
|
.env.local
|
||||||
|
.gitea/
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.tgz
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
apiVersion: v2
|
||||||
|
name: infra-report
|
||||||
|
description: Infrastructure Weekly Monitoring Report
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "0.1.0"
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ .Values.serviceAccount.name }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "infra-report.labels" . | nindent 4 }}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** @type {import("next").NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["@kubernetes/client-node"],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -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/)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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 />;
|
||||||
|
}
|
||||||
|
|
@ -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 />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
|
@ -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 || '',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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]))',
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue