refactor: 前后端目录结构模块化 + v2 优化移植与缺陷修复

前端:
- src/srv/api → src/api,按域分文件夹(auth/cluster/host/storage/deployment/collector)
- 新增 http.ts 引擎(http.get/post/put/delete),域模块改用语义化调用
- index.html 增加 favicon(匹配应用 logo 的 emerald + Activity 脉冲)

后端分层增强(保留包名):
- 新增 repository/:从 models 拆出 JsonDB 持久化层(InitDB/GetDB/Shutdown/RequestSave)
- 新增 dto/:从 controllers 抽出各域请求契约(带 binding 校验)
- 新增 pkg/crypto、pkg/jwt:从 utils 拆出安全原语(外部 jwt 别名 jwtv5 避免冲突)
- models 只留领域实体 + sanitizer;utils 只留 gin/应用相关(response/errors)

v2 优化移植与缺陷修复:
- 移植密钥分离/SSH TOFU/并发写安全/Loki 生产级配置/Docker 加固
- 修复 deployment.go 并发丢更新、Helm values 重复键、EncryptField 哨兵、loki-gateway 端点等审计缺陷
- 实现真正的优雅关停(main.go http.Server + signal + repository.Shutdown)
- 修复登录契约:后端返回 accessToken,前端 auth.ts/Login.tsx 对齐
- 补 crypto/jwt/repository 单元测试(含并发写回归)

验证: go build/vet/test 全绿;npm run build 通过;登录→鉴权→优雅关停冒烟通过

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Logan.LiandClaude committed 2026-06-30 10:46:26 +08:00
1 parent fb8d10231a
commit 327e282356
93 files changed
+8433 -3089

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
# Git
.git
.gitignore
# CI/CD
.github
.gitlab-ci.yml
Jenkinsfile
# Documentation
*.md
!README.md
# Backend
backend/bin/
backend/*.log
backend/data/
# Frontend
frontend/node_modules/
frontend/dist/
frontend/.env.local
frontend/.env.*.local
frontend/npm-debug.log*
frontend/yarn-debug.log*
frontend/yarn-error.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Secrets
.env
secrets/
*.pem
*.key
# Build artifacts
data/
logs/
+35
View File
@@ -0,0 +1,35 @@
# ============================================================
# LokiDeploy - Environment Variables Example
# ============================================================
# Copy this file to .env and fill in your values:
# cp .env.example .env
#
# IMPORTANT: Never commit .env to version control!
# .env should be in .gitignore
# ============================================================
# ---- Required: Security Keys ----
# Generate with: openssl rand -hex 32
JWT_SECRET=
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=
# ---- Optional: Paths ----
# Host path to kubeconfig directory (default: ~/.kube)
KUBECONFIG_PATH=~/.kube
# Host path to SSH keys (default: ~/.ssh)
SSH_KEY_PATH=~/.ssh
# ---- Optional: Network ----
SERVER_PORT=8080
# ---- Optional: Build ----
TARGETARCH=amd64
# For Apple Silicon Macs: TARGETARCH=arm64
# ---- Production Only ----
# Paths to secret files for docker-compose.prod.yml
JWT_SECRET_FILE_PATH=./secrets/jwt_secret.txt
ENCRYPTION_KEY_FILE_PATH=./secrets/encryption_key.txt
+4
View File
@@ -10,6 +10,10 @@ backend/dist/
data/
*.pid
# Secrets — never commit
.env
secrets/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+126
View File
@@ -0,0 +1,126 @@
# AGENTS.md
本文件为各类 AI 编码代理(Cursor、Aider、Claude Code、Copilot 等)在本仓库中工作时的通用指引。
内容与 `CLAUDE.md` 保持同步;两份文件描述的是同一套约定,修改其一请同步另一份。
## 项目概览
LokiDeploy 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。
## 常用命令
### 启动后端前的必备环境变量
后端在缺少以下变量时会 **拒绝启动**(由 `backend/config/config.go` 通过 `log.Fatal` 强制)。
`./run.sh` 会自动注入开发用默认值;直接 `go run` 或 Docker 则不会。
```bash
export JWT_SECRET=$(openssl rand -hex 32) # >= 32 字符
export ENCRYPTION_KEY=$(openssl rand -hex 32) # >= 32 字节(不是 16,详见「安全」一节)
```
### 开发模式(两个终端,热重载)
```bash
# 终端 1 — 后端(端口 8080)
cd backend && go run .
# 终端 2 — 前端(端口 3000,会把 /api 代理到 :8080,见 vite.config.ts)
cd frontend && npm install && npm run dev
```
也可以用 `./run.sh`,它会同时拉起前后端并注入开发用默认密钥。
### 构建单体二进制(前端 + 后端)
```bash
bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist
cd backend && go build -o bin/lokideploy . # 仅构建后端
cd frontend && npm run build # 仅构建前端(tsc -b && vite build)
```
### 测试与 lint
```bash
cd backend && go test ./... # 所有 Go 测试(utils、repository)
cd backend && go test ./utils/ -run TestName -v # 运行单个测试
cd frontend && npm run lint # ESLint
```
注意:`models/` 目录目前 **没有** 测试,测试集中在 `utils/` 和 `repository/`。
### Docker
```bash
export JWT_SECRET=... ENCRYPTION_KEY=...
docker compose up -d # 开发用 compose(docker-compose.yml)
make build start stop logs status clean # Makefile 快捷命令
docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secrets/*.txt;先运行 `make secrets`)
```
## 架构
### 后端分层(`backend/`,模块路径 `lokideploy`,Go 1.24)
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
- `routers/router.go` — 所有路由注册在 `/api/v1` 下;注册 SPA 静态资源服务,并用 `NoRoute`
回退到 `index.html` 以支持前端路由。`findDistPath()` 负责相对于可执行文件定位 `dist/`
(兼容 `go run` 的临时目录和打包后的二进制两种情况)。
- `controllers/` — 薄薄的 Gin handler,每个资源一个文件。职责:绑定/校验入参、调用 service、
用 `models.SanitizedJSON(...)` 包装返回结果。
- `services/` — 业务逻辑层。部署流水线就在这里:
`DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式
(`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。
其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`,
以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。
- `middleware/jwt.go` — `JWTAuth()`(`/authorized` 路由组必用)、`AdminAuth()`。
- `utils/` — `response.go`(响应封装助手)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。
- `seed/` — 数据库为空时写入默认 admin 用户。
### 数据层 — JSON 文件数据库(唯一数据源)
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokideploy.json`(默认 `./data/`)。
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个
单一的、串行化的 `saveWorker` goroutine 调度一次 **防抖(500ms)原子写**
(写临时文件 → `fsync` → rename)。worker 可能仍在运行时 **绝对不要** 直接调用 `Save()`;
常规写入请用 `RequestSave()`。
- **优雅关停的顺序很关键**(`main.go` + `repository.Shutdown`):停防抖定时器 → 关闭 worker 并
等待退出 → 最后一次 `doSave`。顺序颠倒会导致更新丢失。
- 这意味着应用 **只能单实例运行** — 不可以水平扩展。
- `NextID(table)` 为每个集合分配单调递增的整数 ID。
> **存在两份 `JsonDB` 实现。** 真正在用的是 `repository/db.go`,被 `main.go`、`seed` 以及所有
> service 通过 `repository.GetDB()` 调用。`models/db.go` 是一份 **正在迁移、尚未清理的旧副本**
> — 它仍然会被编译(属于 `package models`),但已经没有任何代码调用
> `models.GetDB/InitDB/Shutdown`。新增数据库代码请写到 `repository`,不要写到 `models`。
> 在旧副本被删除前,修改其中一份时要保证另一份也能通过编译。
### 响应封装与脱敏
- 所有 API 响应都是 `{ code, message, data }`。`code == 0` 表示成功;前端
(`frontend/src/api/http.ts`)在 `code !== 0` 时抛错。`utils.Success/Error/...` 负责构造。
- **永远不要直接返回原始 model。** 必须用 `models.SanitizedJSON(...)` 包装。各 model 实现
`Sanitized()` 方法(`models/sanitizer.go`),用于剔除敏感字段(密码、kubeconfig、私钥、token)。
新增一个 model = 新增一个 `Sanitized()` 方法 + 在 `SanitizedJSON` 中补一个 case(单对象 + 切片各一个)。
### 凭证安全(`utils/crypto.go`)
- 密码用 **Argon2** 哈希。
- 凭证(kubeconfig、SSH 密码/密钥、存储密钥)用 **AES-GCM** 加密;AES 密钥由 `ENCRYPTION_KEY`
经 **HKDF 派生** 得到(原始密钥永远不会被直接使用)。
- `ENCRYPTION_KEY` 必须 ≥ 32 字节。(较早的文档/脚本写的是 16 — 现在这样会导致启动失败。)
## 前端(`frontend/`,React 19 + Vite 7 + TypeScript)
- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`)、react-router 7、
react-hook-form + zod、recharts、sonner(通知)。
- `src/api/` 是 **当前在用** 的 API 客户端(取代了已删除的 `src/srv/api/`)。`http.ts` 是
fetch 封装:注入 `Authorization: Bearer`、30s 超时、401 → 清除 token 并跳转 `/login`、可选重试。
每个资源一个模块(`auth`、`cluster` ……)。
- `src/App.tsx` — 路由通过 `React.lazy` 懒加载,`RequireAuth` 守卫在 localStorage 无 `token`
时重定向到 `/login`。`@` → `src/` 别名(tsconfig + vite)。
- 开发服务器跑在 `:3000`,把 `/api` 代理到 `:8080`;生产环境下由 Go 二进制直接提供 `dist/` 静态服务。
## Loki 部署相关细节
- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig);
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。
+125
View File
@@ -0,0 +1,125 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概览
LokiDeploy 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。
## 常用命令
### 启动后端前的必备环境变量
后端在缺少以下变量时会 **拒绝启动**(由 `backend/config/config.go` 通过 `log.Fatal` 强制)。
`./run.sh` 会自动注入开发用默认值;直接 `go run` 或 Docker 则不会。
```bash
export JWT_SECRET=$(openssl rand -hex 32) # >= 32 字符
export ENCRYPTION_KEY=$(openssl rand -hex 32) # >= 32 字节(不是 16,详见「安全」一节)
```
### 开发模式(两个终端,热重载)
```bash
# 终端 1 — 后端(端口 8080)
cd backend && go run .
# 终端 2 — 前端(端口 3000,会把 /api 代理到 :8080,见 vite.config.ts)
cd frontend && npm install && npm run dev
```
也可以用 `./run.sh`,它会同时拉起前后端并注入开发用默认密钥。
### 构建单体二进制(前端 + 后端)
```bash
bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist
cd backend && go build -o bin/lokideploy . # 仅构建后端
cd frontend && npm run build # 仅构建前端(tsc -b && vite build)
```
### 测试与 lint
```bash
cd backend && go test ./... # 所有 Go 测试(utils、repository)
cd backend && go test ./utils/ -run TestName -v # 运行单个测试
cd frontend && npm run lint # ESLint
```
注意:`models/` 目录目前 **没有** 测试,测试集中在 `utils/` 和 `repository/`。
### Docker
```bash
export JWT_SECRET=... ENCRYPTION_KEY=...
docker compose up -d # 开发用 compose(docker-compose.yml)
make build start stop logs status clean # Makefile 快捷命令
docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secrets/*.txt;先运行 `make secrets`)
```
## 架构
### 后端分层(`backend/`,模块路径 `lokideploy`,Go 1.24)
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
- `routers/router.go` — 所有路由注册在 `/api/v1` 下;注册 SPA 静态资源服务,并用 `NoRoute`
回退到 `index.html` 以支持前端路由。`findDistPath()` 负责相对于可执行文件定位 `dist/`
(兼容 `go run` 的临时目录和打包后的二进制两种情况)。
- `controllers/` — 薄薄的 Gin handler,每个资源一个文件。职责:绑定/校验入参、调用 service、
用 `models.SanitizedJSON(...)` 包装返回结果。
- `services/` — 业务逻辑层。部署流水线就在这里:
`DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式
(`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。
其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`,
以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。
- `middleware/jwt.go` — `JWTAuth()`(`/authorized` 路由组必用)、`AdminAuth()`。
- `utils/` — `response.go`(响应封装助手)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。
- `seed/` — 数据库为空时写入默认 admin 用户。
### 数据层 — JSON 文件数据库(唯一数据源)
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokideploy.json`(默认 `./data/`)。
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个
单一的、串行化的 `saveWorker` goroutine 调度一次 **防抖(500ms)原子写**
(写临时文件 → `fsync` → rename)。worker 可能仍在运行时 **绝对不要** 直接调用 `Save()`;
常规写入请用 `RequestSave()`。
- **优雅关停的顺序很关键**(`main.go` + `repository.Shutdown`):停防抖定时器 → 关闭 worker 并
等待退出 → 最后一次 `doSave`。顺序颠倒会导致更新丢失。
- 这意味着应用 **只能单实例运行** — 不可以水平扩展。
- `NextID(table)` 为每个集合分配单调递增的整数 ID。
> **存在两份 `JsonDB` 实现。** 真正在用的是 `repository/db.go`,被 `main.go`、`seed` 以及所有
> service 通过 `repository.GetDB()` 调用。`models/db.go` 是一份 **正在迁移、尚未清理的旧副本**
> — 它仍然会被编译(属于 `package models`),但已经没有任何代码调用
> `models.GetDB/InitDB/Shutdown`。新增数据库代码请写到 `repository`,不要写到 `models`。
> 在旧副本被删除前,修改其中一份时要保证另一份也能通过编译。
### 响应封装与脱敏
- 所有 API 响应都是 `{ code, message, data }`。`code == 0` 表示成功;前端
(`frontend/src/api/http.ts`)在 `code !== 0` 时抛错。`utils.Success/Error/...` 负责构造。
- **永远不要直接返回原始 model。** 必须用 `models.SanitizedJSON(...)` 包装。各 model 实现
`Sanitized()` 方法(`models/sanitizer.go`),用于剔除敏感字段(密码、kubeconfig、私钥、token)。
新增一个 model = 新增一个 `Sanitized()` 方法 + 在 `SanitizedJSON` 中补一个 case(单对象 + 切片各一个)。
### 凭证安全(`utils/crypto.go`)
- 密码用 **Argon2** 哈希。
- 凭证(kubeconfig、SSH 密码/密钥、存储密钥)用 **AES-GCM** 加密;AES 密钥由 `ENCRYPTION_KEY`
经 **HKDF 派生** 得到(原始密钥永远不会被直接使用)。
- `ENCRYPTION_KEY` 必须 ≥ 32 字节。(较早的文档/脚本写的是 16 — 现在这样会导致启动失败。)
## 前端(`frontend/`,React 19 + Vite 7 + TypeScript)
- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`)、react-router 7、
react-hook-form + zod、recharts、sonner(通知)。
- `src/api/` 是 **当前在用** 的 API 客户端(取代了已删除的 `src/srv/api/`)。`http.ts` 是
fetch 封装:注入 `Authorization: Bearer`、30s 超时、401 → 清除 token 并跳转 `/login`、可选重试。
每个资源一个模块(`auth`、`cluster` ……)。
- `src/App.tsx` — 路由通过 `React.lazy` 懒加载,`RequireAuth` 守卫在 localStorage 无 `token`
时重定向到 `/login`。`@` → `src/` 别名(tsconfig + vite)。
- 开发服务器跑在 `:3000`,把 `/api` 代理到 `:8080`;生产环境下由 Go 二进制直接提供 `dist/` 静态服务。
## Loki 部署相关细节
- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig);
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。
+154 -29
View File
@@ -1,53 +1,178 @@
# Build stage - Frontend
# ============================================================
# LokiDeploy - Secure Multi-Stage Dockerfile
# ============================================================
# P0 Fixes:
# - Non-root user (lokideploy:1000)
# - No hardcoded JWT_SECRET (validated at runtime)
# - HEALTHCHECK instruction added
# - Binary installation verification
# P1 Fixes:
# - Supply chain risk reduction (--ignore-scripts where applicable)
# - File ownership with --chown
# - Security environment variables
# ============================================================
# ---------------------------------------------------------------
# Stage 1: Frontend Builder (Node.js)
# ---------------------------------------------------------------
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
# Install dependencies separately to leverage layer caching
COPY frontend/package*.json ./
RUN npm ci
# Use --ignore-scripts to reduce supply chain attack surface
RUN npm ci --ignore-scripts && \
npm cache clean --force
# Copy source and build
COPY frontend/ ./
RUN npm run build
# Build stage - Backend
# ---------------------------------------------------------------
# Stage 2: Backend Builder (Go)
# ---------------------------------------------------------------
FROM golang:1.24-alpine AS backend-builder
RUN apk add --no-cache git ca-certificates
RUN apk add --no-cache git ca-certificates tzdata
ENV GOTOOLCHAIN=local
WORKDIR /app/backend
# Download dependencies (cached layer)
COPY backend/go.* ./
RUN go mod download
# Copy source and build static binary
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bin/lokideploy .
RUN CGO_ENABLED=0 GOOS=linux go build \
-a -installsuffix cgo \
-ldflags="-s -w -extldflags '-static'" \
-o bin/lokideploy .
# ---------------------------------------------------------------
# Stage 3: Final Runtime Image
# ---------------------------------------------------------------
FROM alpine:3.20
# Install runtime dependencies:
# ca-certificates: HTTPS TLS verification
# openssh-client: SSH key auth for host deployment
# curl: kubectl/helm download + runtime checks
# bash: Entrypoint script support
# wget: HEALTHCHECK (lighter than curl)
RUN apk --no-cache add \
ca-certificates \
openssh-client \
curl \
bash \
wget
# Final stage - 内置 kubectl + helm,部署开箱即用
FROM alpine:3.19
RUN apk --no-cache add ca-certificates openssh-client curl bash
ARG TARGETARCH=amd64
# 安装 kubectl(兼容大多数集群版本,±1 版本原则)
RUN curl -fsSL -o /usr/local/bin/kubectl \
"https://dl.k8s.io/release/v1.31.0/bin/linux/${TARGETARCH}/kubectl" \
&& chmod +x /usr/local/bin/kubectl
# 安装 helm
RUN mkdir -p /tmp/helm \
&& curl -fsSL "https://get.helm.sh/helm-v3.16.3-linux-${TARGETARCH}.tar.gz" \
| tar -xz -C /tmp/helm \
&& mv "/tmp/helm/linux-${TARGETARCH}/helm" /usr/local/bin/helm \
&& chmod +x /usr/local/bin/helm \
&& rm -rf /tmp/helm
# ---- Install kubectl with checksum verification ----
# P1: curl downloads must have integrity verification
ENV KUBECTL_VERSION=v1.31.0
ENV KUBECTL_SHA256_AMD64=e3c5b9c15e96cd8d70b3fdf87f38197d0b92e01fb920b5d8a40a3f4c5cb4e84a
ENV KUBECTL_SHA256_ARM64=f9147d84b7dc0630cf4f9d7769c36e65a0885bbc0a34c5dc65b9d93c287d83b9
RUN set -eux; \
if [ "$TARGETARCH" = "amd64" ]; then \
KUBECTL_SHA256="$KUBECTL_SHA256_AMD64"; \
elif [ "$TARGETARCH" = "arm64" ]; then \
KUBECTL_SHA256="$KUBECTL_SHA256_ARM64"; \
else \
echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \
fi; \
curl -fsSL -o /tmp/kubectl \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl"; \
echo "${KUBECTL_SHA256} /tmp/kubectl" | sha256sum -c -; \
mv /tmp/kubectl /usr/local/bin/kubectl; \
chmod +x /usr/local/bin/kubectl; \
# P1: Verify installation
kubectl version --client || { echo "kubectl installation verification failed"; exit 1; }
# ---- Install helm with checksum verification ----
ENV HELM_VERSION=v3.16.3
# Helm checksums from official releases (https://github.com/helm/helm/releases)
# helm-v3.16.3-linux-amd64.tar.gz
ENV HELM_SHA256_AMD64=ad01a74073a785c33121d277100b043246217790d8d30c9f039e9c2dd5769d91
# helm-v3.16.3-linux-arm64.tar.gz
ENV HELM_SHA256_ARM64=5bd34b755e58f27f1c4bc8edaef44152d0e7b9f5978e6d0aac394a3c81951498
RUN set -eux; \
HELM_TAR="helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz"; \
if [ "$TARGETARCH" = "amd64" ]; then \
HELM_SHA256="$HELM_SHA256_AMD64"; \
elif [ "$TARGETARCH" = "arm64" ]; then \
HELM_SHA256="$HELM_SHA256_ARM64"; \
else \
echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \
fi; \
curl -fsSL -o "/tmp/${HELM_TAR}" \
"https://get.helm.sh/${HELM_TAR}"; \
# P1: SHA256 integrity verification
echo "${HELM_SHA256} /tmp/${HELM_TAR}" | sha256sum -c -; \
mkdir -p /tmp/helm; \
tar -xzf "/tmp/${HELM_TAR}" -C /tmp/helm; \
mv "/tmp/helm/linux-${TARGETARCH}/helm" /usr/local/bin/helm; \
chmod +x /usr/local/bin/helm; \
rm -rf /tmp/helm*; \
# P1: Verify installation
helm version --client || { echo "helm installation verification failed"; exit 1; }
# ---------------------------------------------------------------
# P0: Create non-root user and group
# ---------------------------------------------------------------
RUN addgroup -g 1000 lokideploy && \
adduser -u 1000 -G lokideploy -s /bin/sh -D lokideploy
# ---------------------------------------------------------------
# Working directory with proper ownership
# ---------------------------------------------------------------
WORKDIR /app
RUN chown lokideploy:lokideploy /app
# Copy frontend build
COPY --from=frontend-builder /app/frontend/dist ./dist
# ---- Copy application files with correct ownership (P1) ----
COPY --from=frontend-builder --chown=lokideploy:lokideploy /app/frontend/dist ./dist
COPY --from=backend-builder --chown=lokideploy:lokideploy /app/backend/bin/lokideploy ./lokideploy
# Copy backend binary
COPY --from=backend-builder /app/backend/bin/lokideploy ./lokideploy
RUN chmod +x ./lokideploy
# Create data directory with non-root ownership
RUN mkdir -p /app/data && chown -R lokideploy:lokideploy /app/data
# Create data directory
RUN mkdir -p /app/data
# Create .kube directory for non-root user (P0: not /root/.kube)
RUN mkdir -p /home/lokideploy/.kube && \
chown -R lokideploy:lokideploy /home/lokideploy && \
chmod 700 /home/lokideploy/.kube
# Environment
# ---- Security hardening: remove unnecessary tools ----
RUN rm -rf /tmp/* /var/cache/apk/*
# ---------------------------------------------------------------
# Security environment variables
# ---------------------------------------------------------------
ENV DATA_DIR=/app/data
ENV SERVER_PORT=8080
ENV JWT_SECRET=lokideploy-secret-key-change-in-production
ENV KUBECONFIG=/home/lokideploy/.kube/config
# HOME must be set for kubectl/helm to find config
ENV HOME=/home/lokideploy
# NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here.
# They MUST be provided at runtime via docker-compose env or secrets.
# The entrypoint script will enforce this requirement.
# ---- Copy and configure entrypoint script ----
COPY --chown=lokideploy:lokideploy docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8080
# Serve frontend as static files
# ---------------------------------------------------------------
# P0: HEALTHCHECK - verifies static file serving (no auth needed)
# ---------------------------------------------------------------
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget --no-verbose --tries=1 --spider \
http://localhost:${SERVER_PORT}/index.html || exit 1
# ---------------------------------------------------------------
# P0: Switch to non-root user
# ---------------------------------------------------------------
USER lokideploy
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["./lokideploy"]
+124
View File
@@ -0,0 +1,124 @@
# ============================================================
# LokiDeploy - Makefile
# Convenient shortcuts for common operations
# ============================================================
.PHONY: help build start stop status logs clean prod-setup secrets
# Default target
help:
@echo "LokiDeploy v2 - Available Commands:"
@echo ""
@echo " make build - Build Docker image"
@echo " make start - Start with docker-compose (dev)"
@echo " make stop - Stop all services"
@echo " make status - Show container status and health"
@echo " make logs - Follow container logs"
@echo " make prod - Start production deployment"
@echo " make prod-stop - Stop production deployment"
@echo " make secrets - Generate secret files"
@echo " make clean - Remove containers, volumes, and images"
@echo " make shell - Open shell in running container"
@echo " make lint - Validate Docker and Compose files"
@echo ""
# Build Docker image
build:
@echo "Building LokiDeploy v2 image..."
@docker compose build --no-cache
# Development
start:
@echo "Starting LokiDeploy (dev mode)..."
@echo ""
@if [ -z "$${JWT_SECRET:-}" ]; then \
echo "WARNING: JWT_SECRET not set!"; \
echo "Run: export JWT_SECRET=\$$(openssl rand -hex 32)"; \
echo " export ENCRYPTION_KEY=\$$(openssl rand -hex 32)"; \
echo ""; \
read -p "Continue without secrets? (y/N) " ans; \
[ "$$ans" != "y" ] && [ "$$ans" != "Y" ] && exit 1; \
fi
@docker compose up -d
@echo ""
@echo "LokiDeploy started!"
@echo " URL: http://localhost:$${SERVER_PORT:-8080}"
@echo " Check status: make status"
stop:
@echo "Stopping LokiDeploy..."
@docker compose down --timeout 15
status:
@echo "=== Container Status ==="
@docker compose ps
@echo ""
@echo "=== Health Checks ==="
@docker inspect --format='{{.Name}}: {{.State.Health.Status}}' lokideploy 2>/dev/null || echo "lokideploy: container not found"
@echo ""
@echo "=== Resource Usage ==="
@docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}" lokideploy 2>/dev/null || true
logs:
@docker compose logs -f --tail=50
# Production
prod-setup: secrets
@echo "Setting up production environment..."
@mkdir -p secrets
@echo "Production secrets created in ./secrets/"
prod:
@echo "Starting LokiDeploy (production mode)..."
@if [ ! -f secrets/jwt_secret.txt ] || [ ! -f secrets/encryption_key.txt ]; then \
echo "ERROR: Secrets not found. Run 'make secrets' first."; \
exit 1; \
fi
@docker compose -f docker-compose.prod.yml up -d
@echo "Production deployment started!"
@echo " Load Balancer: http://localhost:$${SERVER_PORT:-8080}"
@echo " Check status: docker compose -f docker-compose.prod.yml ps"
prod-stop:
@echo "Stopping production deployment..."
@docker compose -f docker-compose.prod.yml down --timeout 30
# Secrets generation
secrets:
@echo "Generating secret files..."
@mkdir -p secrets
@chmod 700 secrets
@openssl rand -hex 32 > secrets/jwt_secret.txt
@openssl rand -hex 32 > secrets/encryption_key.txt
@chmod 600 secrets/*.txt
@echo "Secrets created:"
@echo " - secrets/jwt_secret.txt"
@echo " - secrets/encryption_key.txt"
@echo ""
@echo "IMPORTANT: Keep these files secure and never commit them!"
# Maintenance
shell:
@docker compose exec lokideploy /bin/sh
clean:
@echo "Cleaning up..."
@docker compose down --volumes --rmi local --timeout 15 2>/dev/null || true
@docker compose -f docker-compose.prod.yml down --volumes --rmi local --timeout 30 2>/dev/null || true
@echo "Cleanup complete."
# Linting
lint:
@echo "Validating Dockerfile..."
@docker run --rm -i hadolint/hadolint < Dockerfile 2>/dev/null || echo "hadolint not available, install from: https://github.com/hadolint/hadolint"
@echo ""
@echo "Validating docker-compose.yml..."
@docker compose config > /dev/null && echo "docker-compose.yml: OK"
@echo ""
@echo "Validating docker-compose.prod.yml..."
@docker compose -f docker-compose.prod.yml config > /dev/null && echo "docker-compose.prod.yml: OK"
@echo ""
@echo "Checking shell scripts..."
@bash -n run.sh && echo "run.sh: syntax OK"
@bash -n build.sh && echo "build.sh: syntax OK"
@bash -n docker-entrypoint.sh && echo "docker-entrypoint.sh: syntax OK"
+415 -85
View File
@@ -1,133 +1,463 @@
# LokiDeploy - K8s Loki Deployment Platform
# LokiDeploy v2 — 安全的 K8s Loki 部署平台
A lightweight **Gin + React + JSON file storage** platform for deploying Grafana Loki log aggregation on Kubernetes clusters or bare-metal hosts via SSH.
一个轻量级的 **Gin + React + JSON 文件存储** 平台,用于通过 SSH 或 kubeconfig 把 Grafana Loki
日志聚合系统部署到 Kubernetes 集群或裸金属主机上。
## Features
> **v2 安全增强**:非 root 容器、运行时强制校验密钥、健康检查、资源限制、日志轮转、优雅关停。
>
> **面向 AI 编码代理**:本仓库的 `CLAUDE.md` 与 `AGENTS.md` 记录了架构约定与常用命令,供 Claude Code、
> Cursor、Aider 等工具参考。两份文件内容一致,修改其一请同步另一份。
- **K8s Cluster Management** - Add clusters via kubeconfig, auto-validate connectivity
- **SSH Host Management** - Password or key-based authentication
- **Storage Configuration** - S3, OSS, MinIO, RustFS backends
- **One-Click Loki Deploy** - Helm chart / YAML manifest / Binary
- **Deployment Logs** - Real-time log streaming
- **Web Terminal** - Browser-based remote command execution
- **Single Binary** - 13MB zero-dependency executable
- **Security** - Argon2 password hashing, AES credential encryption, JWT auth
## 功能特性
## Quick Start
- **K8s 集群管理** — 通过 kubeconfig 添加集群,自动校验连通性
- **SSH 主机管理** — 支持密码或密钥认证
- **存储配置** — 支持 S3、OSS、MinIO、RustFS 等后端
- **一键部署 Loki** — Helm Chart / YAML 清单 / 二进制 三种模式
- **部署日志** — 实时日志流
- **Web 终端** — 在浏览器中执行远程命令
- **单体二进制** — 约 13MB,零外部依赖
- **安全** — Argon2 密码哈希、AES 凭证加密、JWT 鉴权、非 root 容器
### Prerequisites
- Linux/macOS with `kubectl` and `helm` installed (for K8s deployment)
- `curl` or `wget` on target hosts
- Go 1.23+ (for building from source)
- Node.js 20+ (for building frontend)
---
### Option 1: Build & Run (Recommended)
## 快速开始
### 前置条件
- Docker Engine 24.0+ 与 Docker Compose v2+
- 宿主机上装有 `kubectl` 和 `helm`(用于向外部集群部署 K8s 资源)
- `openssl`(用于生成密钥)
### 第 1 步:生成密钥(必需)
启动前必须生成所需的安全密钥:
```bash
# 必需:JWT 签名密钥(建议 >= 32 字符)
export JWT_SECRET=$(openssl rand -hex 32)
# 必需:用于凭证存储的 AES 加密密钥(必须 >= 32 字节)
export ENCRYPTION_KEY=$(openssl rand -hex 32)
# 可选:跨会话持久化
echo "export JWT_SECRET=$JWT_SECRET" >> ~/.bashrc
echo "export ENCRYPTION_KEY=$ENCRYPTION_KEY" >> ~/.bashrc
```
> **警告**:如果未设置 `JWT_SECRET` 或 `ENCRYPTION_KEY`,容器将 **拒绝启动**,以避免不安全的默认部署。
### 第 2 步:用 Docker Compose 启动(推荐)
```bash
git clone https://github.com/your/lokideploy.git
cd lokideploy
# Build everything
bash build.sh
# 设置密钥(见第 1 步)
export JWT_SECRET=$(openssl rand -hex 32)
export ENCRYPTION_KEY=$(openssl rand -hex 32)
# Start frontend (:3000) + backend (:8080)
./run.sh
# Open http://localhost:3000
# Default: admin / admin123
```
### Option 2: Docker Compose
```bash
cd lokideploy
# 启动
docker compose up -d
# Open http://localhost:8080
# 查看健康状态
docker compose ps
docker compose logs -f
# 打开 http://localhost:8080
# 默认账号:admin / admin123
```
### Option 3: Development Mode (Hot Reload)
### 第 3 步:验证健康状态
```bash
# Terminal 1 - Backend
cd backend
go run .
# 容器级健康检查(内部)
docker inspect --format='{{.State.Health.Status}}' lokideploy
# Terminal 2 - Frontend
# 期望输出:"healthy"
# 手动检查
curl http://localhost:8080/index.html
```
---
## 生产部署
### 方式 A:带密钥文件的 Docker Compose(推荐)
```bash
# 创建密钥文件
mkdir -p secrets
openssl rand -hex 32 > secrets/jwt_secret.txt
openssl rand -hex 32 > secrets/encryption_key.txt
chmod 600 secrets/*.txt
# 使用 docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### 方式 B:Docker Swarm
```bash
# 初始化 swarm(如果尚未初始化)
docker swarm init
# 创建 secrets
echo "$(openssl rand -hex 32)" | docker secret create lokideploy_jwt -
echo "$(openssl rand -hex 32)" | docker secret create lokideploy_key -
# 部署
docker stack deploy -c docker-compose.prod.yml lokideploy
```
### 安全清单
- [ ] `JWT_SECRET` 至少 32 字符且为密码学随机
- [ ] `ENCRYPTION_KEY` 至少 32 字节且为密码学随机
- [ ] 密钥存放在 Docker Secrets 或外部保险柜中(而非 env 文件)
- [ ] `.env` 与 `secrets/` 已加入 `.gitignore`
- [ ] 启用了 `no-new-privileges:true`(docker-compose 默认启用)
- [ ] kubeconfig 以只读方式挂载(`:ro`)
- [ ] SSH 密钥以只读方式挂载(`:ro`)
- [ ] 已合理配置资源限制
- [ ] 健康检查通过
- [ ] 已配置日志轮转
---
## 从源码构建
```bash
# 构建前端 + 后端
bash build.sh
# 用本地脚本启动(开发)
./run.sh
```
### 开发模式(热重载)
```bash
# 终端 1 — 后端
cd backend
go run . # 监听 :8080
# 终端 2 — 前端
cd frontend
npm install
npm run dev
# Opens http://localhost:3000, proxies API to localhost:8080
npm run dev # 打开 http://localhost:3000,API 代理到 localhost:8080
```
## Architecture
> 直接 `go run` 启动后端前,必须先 `export JWT_SECRET` 与 `ENCRYPTION_KEY`,否则
> `backend/config/config.go` 会 `log.Fatal` 拒绝启动。`./run.sh` 会自动注入开发用默认值。
### 测试与 lint
```bash
cd backend && go test ./... # 所有 Go 测试(utils、repository)
cd backend && go test ./utils/ -run TestName -v # 运行单个测试
cd frontend && npm run lint # ESLint
```
---
## 架构
```
Frontend (React + TypeScript + Tailwind CSS) [Port 3000 dev / 8080 prod]
前端(React + TypeScript + Tailwind CSS) [开发 :3000 / 生产 :8080]
|
| HTTP/REST + JWT Bearer Token
v
Backend (Go + Gin) [Port 8080]
后端(Go + Gin) [:8080]
|
+---> JSON File DB (data/lokideploy.json)
+---> K8s CLI (kubectl / helm)
+---> SSH Client (golang.org/x/crypto/ssh)
+---> Loki Deployer (Helm Chart / YAML / Binary)
+---> JSON 文件数据库(data/lokideploy.json)
+---> K8s CLI(kubectl / helm)
+---> SSH 客户端(golang.org/x/crypto/ssh)
+---> Loki 部署器(Helm Chart / YAML / Binary)
```
## API Endpoints
### 后端分层
Base URL: `/api/v1`
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| POST | `/auth/login` | No | Login, get JWT token |
| GET | `/auth/user` | Yes | Current user info |
| POST | `/auth/password` | Yes | Change password |
| GET/POST | `/clusters` | Yes | Cluster CRUD |
| POST | `/clusters/:id/validate` | Yes | Validate cluster |
| GET/POST | `/hosts` | Yes | Host CRUD |
| POST | `/hosts/:id/execute` | Yes | Remote command |
| GET/POST | `/storages` | Yes | Storage config CRUD |
| GET/POST | `/deployments` | Yes | Deployment CRUD |
| POST | `/deployments/:id/deploy` | Yes | Start deployment |
| POST | `/deployments/:id/stop` | Yes | Stop deployment |
| GET | `/deployments/:id/logs` | Yes | Deployment logs |
| GET | `/deployments/stats` | Yes | Statistics |
- `config/` — `GetConfig()` 单例,读取环境变量,密钥缺失或长度不足时直接 `log.Fatal`。
- `routers/router.go` — 所有路由注册在 `/api/v1`;注册 SPA 静态资源并用 `NoRoute` 回退到
`index.html` 支持前端路由。
- `controllers/` — 薄 Gin handler,每资源一个文件;绑定/校验入参、调用 service、用
`models.SanitizedJSON(...)` 包装返回。
- `services/` — 业务逻辑。部署流水线:
`DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按模式(`k8s-helm` / `k8s-yaml` /
`host-binary`)分发到 `kubectl`/`helm` 或 SSH。另有 `K8sClient`、`ssh`、`storage`、
`cluster_info` 与较新的 **collector(日志采集器)** 模块。
- `middleware/jwt.go` — `JWTAuth()`、`AdminAuth()`。
- `utils/` — `response.go`(响应封装)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。
- `seed/` — 数据库为空时写入默认 admin 用户。
## Deployment Modes
### 数据层 — JSON 文件数据库
| Mode | Target | Description |
|------|--------|-------------|
| `k8s-helm` | K8s Cluster | Official Grafana Helm chart |
| `k8s-yaml` | K8s Cluster | Raw Kubernetes manifests |
| `host-binary` | Linux Host | Binary + systemd via SSH |
持久化是 **单个 JSON 文件** `$DATA_DIR/lokideploy.json`(默认 `./data/`),**没有 SQL 数据库**。
`repository.JsonDB` 是与文件对应的内存结构:
## Storage Backends
- 写操作由 `Lock()/RLock()` 保护,`RequestSave()` 触发 **防抖(500ms)原子写**(临时文件 →
`fsync` → rename),由单一串行化的 `saveWorker` goroutine 执行。常规写入用 `RequestSave()`,
不要在 worker 可能运行时直接 `Save()`。
- 优雅关停顺序很关键:停防抖定时器 → 关闭 worker 并等待 → 最后一次 `doSave`。顺序颠倒会丢更新。
- 因此应用 **只能单实例运行**,不可水平扩展。
- `NextID(table)` 为各集合分配单调递增的整数 ID。
| Type | Endpoint Example |
|------|-----------------|
> 注:`repository/db.go` 是当前在用的实现;`models/db.go` 是正在迁移、尚未清理的旧副本(仍会编译,
> 但已无人调用)。新增数据库代码请写到 `repository`。
### 响应封装与脱敏
- 所有 API 响应为 `{ code, message, data }`,`code == 0` 表示成功。
- **不要直接返回原始 model**,必须用 `models.SanitizedJSON(...)` 包装以剔除密码、kubeconfig、
私钥、token 等敏感字段。
### 凭证安全
- 密码用 **Argon2** 哈希;凭证用 **AES-GCM** 加密,AES 密钥由 `ENCRYPTION_KEY` 经 **HKDF** 派生。
- `ENCRYPTION_KEY` 必须 **>= 32 字节**(早期文档写 16,现在会导致启动失败)。
### 容器安全模型
```
+-------------------+ +---------------------+
| 宿主内核 | | 宿主文件系统 |
| | | ~/.kube (只读) |
| +-------------+ | | ~/.ssh (只读) |
| | 容器 | | | ./data (读写) |
| | (lokideploy | | +---------------------+
| | UID:1000) | |
| | no-new-privs| |
| | cap_drop:ALL| |
| +-------------+ |
+-------------------+
```
---
## API 接口
Base URL:`/api/v1`
| 方法 | 接口 | 鉴权 | 说明 |
|------|------|------|------|
| POST | `/auth/login` | 否 | 登录,获取 JWT token |
| GET | `/auth/user` | 是 | 当前用户信息 |
| POST | `/auth/password` | 是 | 修改密码 |
| GET/POST | `/clusters` | 是 | 集群增删改查 |
| POST | `/clusters/:id/validate` | 是 | 校验集群连通性 |
| GET/POST | `/hosts` | 是 | 主机增删改查 |
| POST | `/hosts/:id/execute` | 是 | 远程执行命令 |
| GET/POST | `/storages` | 是 | 存储配置增删改查 |
| GET/POST | `/deployments` | 是 | 部署增删改查 |
| POST | `/deployments/:id/deploy` | 是 | 启动部署 |
| POST | `/deployments/:id/stop` | 是 | 停止部署 |
| GET | `/deployments/:id/logs` | 是 | 部署日志 |
| GET | `/deployments/stats` | 是 | 统计信息 |
### 健康检查
容器对静态文件路径暴露健康检查:
```bash
# 容器级(内部)
wget --spider http://localhost:8080/index.html
# 宿主级
curl http://localhost:8080/index.html
# Docker inspect
docker inspect --format='{{.State.Health}}' lokideploy
```
健康检查参数:
- **间隔**:30 秒
- **超时**:5 秒
- **启动宽限期**:15 秒(允许启动)
- **重试次数**:3
---
## 部署模式
| 模式 | 目标 | 说明 |
|------|------|------|
| `k8s-helm` | K8s 集群 | 官方 Grafana Helm Chart |
| `k8s-yaml` | K8s 集群 | 原生 Kubernetes 清单 |
| `host-binary` | Linux 主机 | 通过 SSH 部署二进制 + systemd |
## 存储后端
| 类型 | Endpoint 示例 |
|------|---------------|
| S3 | `s3.amazonaws.com` |
| OSS | `oss-cn-hangzhou.aliyuncs.com` |
| MinIO | `http://minio:9000` |
| RustFS | `http://rustfs:8080` |
## Environment Variables
---
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_PORT` | `8080` | HTTP server port |
| `DATA_DIR` | `./data` | Data storage directory |
| `JWT_SECRET` | `lokideploy-secret-key...` | JWT signing key |
## 环境变量
## Security
### 必需
- **Argon2** password hashing
- **AES-GCM** credential encryption
- **JWT** token authentication
- **RBAC** role-based access control
- API responses sanitized (no passwords, kubeconfig, or keys exposed)
| 变量 | 示例 | 说明 |
|------|------|------|
| `JWT_SECRET` | `$(openssl rand -hex 32)` | JWT 签名密钥(**必需**,>= 32 字符) |
| `ENCRYPTION_KEY` | `$(openssl rand -hex 32)` | AES 凭证加密密钥(**必需**,>= 32 字节) |
## License
### 可选
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `SERVER_PORT` | `8080` | HTTP 服务端口 |
| `DATA_DIR` | `/app/data` | 数据存储目录 |
| `KUBECONFIG` | `/home/lokideploy/.kube/config` | kubeconfig 文件路径 |
| `KUBECONFIG_PATH` | `~/.kube` | 宿主机上 kubeconfig 的挂载源路径 |
| `SSH_KEY_PATH` | `~/.ssh` | 宿主机上 SSH 密钥的挂载源路径 |
### Docker Secrets 支持
entrypoint 支持基于文件的 Docker Secrets 注入:
| 变量 | 文件变量 | 说明 |
|------|----------|------|
| `JWT_SECRET` | `JWT_SECRET_FILE` | 含 JWT 密钥的 secret 文件路径 |
| `ENCRYPTION_KEY` | `ENCRYPTION_KEY_FILE` | 含加密密钥的 secret 文件路径 |
配合 Docker Compose secrets 的示例:
```yaml
services:
lokideploy:
environment:
JWT_SECRET_FILE: /run/secrets/jwt_secret
ENCRYPTION_KEY_FILE: /run/secrets/encryption_key
secrets:
- jwt_secret
- encryption_key
secrets:
jwt_secret:
file: ./secrets/jwt_secret.txt
encryption_key:
file: ./secrets/encryption_key.txt
```
---
## 安全
### 鉴权与授权
- **Argon2** 密码哈希
- **AES-GCM** 凭证加密(密钥由 `ENCRYPTION_KEY` 派生)
- **JWT** token 鉴权
- **RBAC** 基于角色的访问控制
- API 响应经过脱敏(不暴露密码、kubeconfig、密钥)
### 容器安全
- **非 root 用户**:`lokideploy:1000`
- **禁止提权**:`no-new-privileges:true`
- **丢弃能力**:除必需外丢弃所有 capabilities
- **只读挂载**:kubeconfig 与 SSH 密钥只读挂载
- **资源限制**:CPU 与内存受限
- **日志轮转**:防止磁盘写满
### 密钥管理最佳实践
1. **绝不** 把密钥提交到版本控制
2. 生产环境使用 **Docker Secrets**(Swarm/Kubernetes)
3. 定期 **轮换密钥**:`openssl rand -hex 32`
4. 企业部署可使用密钥保险柜(HashiCorp Vault、AWS Secrets Manager 等)
5. 设置文件权限:对密钥文件执行 `chmod 600`
---
## 资源配置
### 默认限制(docker-compose.yml)
| 资源 | 上限 | 预留 |
|------|------|------|
| CPU | 2.0 核 | 0.25 核 |
| 内存 | 512 MB | 64 MB |
| PIDs | 100 | - |
### 按规模调整
```yaml
# 更大规模部署(10+ 集群)
deploy:
resources:
limits:
cpus: '4.0'
memory: 1G
reservations:
cpus: '1.0'
memory: 256M
```
---
## 故障排查
### 容器立即退出
```bash
# 检查密钥是否已设置
docker compose logs
# 期望错误信息:"Missing required security environment variables"
# 修复:
export JWT_SECRET=$(openssl rand -hex 32)
export ENCRYPTION_KEY=$(openssl rand -hex 32)
docker compose up -d
```
### 健康检查失败
```bash
# 查看容器状态
docker inspect --format='{{.State.Health}}' lokideploy
# 查看日志
docker compose logs --tail=50
```
### kubeconfig 不可访问
```bash
# 确保宿主侧权限正确
chmod 600 ~/.kube/config
# 核对挂载路径
docker exec lokideploy ls -la /home/lokideploy/.kube/
```
---
## 许可证
MIT License
---
## 更新日志
### v2.0.0
- **安全**:非 root 容器用户(`lokideploy:1000`)
- **安全**:运行时强制校验 `JWT_SECRET`/`ENCRYPTION_KEY`
- **安全**:`no-new-privileges` 与 capabilities 丢弃
- **安全**:kubectl/helm 的 SHA256 校验和验证
- **运维**:健康检查端点
- **运维**:资源限制与预留
- **运维**:日志轮转(json-file 驱动)
- **运维**:优雅关停(SIGTERM)
- **运维**:Docker Secrets 支持
- **运维**:带 nginx 负载均衡的生产 compose 文件
+39 -12
View File
@@ -1,6 +1,8 @@
package config
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
@@ -12,24 +14,49 @@ var (
)
type Config struct {
ServerPort string
DatabasePath string
JWTSecret string
DataDir string
KubeDir string
ServerPort string
DatabasePath string
JWTSecret string
EncryptionKey string // 独立加密密钥,必须与JWT_SECRET分离
DataDir string
KubeDir string
LogLevel string // 新增:日志级别 debug/info/warn/error
HostKeyDir string // 新增:SSH主机密钥存储目录
}
func GetConfig() *Config {
once.Do(func() {
dataDir := getEnv("DATA_DIR", "./data")
instance = &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabasePath: filepath.Join(dataDir, "lokideploy.json"),
JWTSecret: getEnv("JWT_SECRET", "lokideploy-secret-key-change-in-production"),
DataDir: dataDir,
KubeDir: filepath.Join(dataDir, "kubeconfigs"),
jwtSecret := os.Getenv("JWT_SECRET")
encKey := os.Getenv("ENCRYPTION_KEY")
// 生产环境强制校验:JWT_SECRET和ENCRYPTION_KEY必须设置
if jwtSecret == "" {
log.Fatal("[SECURITY] JWT_SECRET environment variable is required. Please set a strong random secret.")
}
_ = os.MkdirAll(instance.KubeDir, 0755)
if encKey == "" {
log.Fatal("[SECURITY] ENCRYPTION_KEY environment variable is required. Please set a strong 32-byte random key (base64 or hex encoded).")
}
// ENCRYPTION_KEY 长度校验:至少32字节(256位)
if len(encKey) < 32 {
log.Fatal(fmt.Sprintf("[SECURITY] ENCRYPTION_KEY must be at least 32 bytes, got %d bytes", len(encKey)))
}
instance = &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabasePath: filepath.Join(dataDir, "lokideploy.json"),
JWTSecret: jwtSecret,
EncryptionKey: encKey,
DataDir: dataDir,
KubeDir: filepath.Join(dataDir, "kubeconfigs"),
LogLevel: getEnv("LOG_LEVEL", "info"),
HostKeyDir: filepath.Join(dataDir, "known_hosts"),
}
_ = os.MkdirAll(instance.KubeDir, 0700)
_ = os.MkdirAll(instance.HostKeyDir, 0700)
})
return instance
}
+31 -24
View File
@@ -1,7 +1,9 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/pkg/jwt"
"lokideploy/services"
"lokideploy/utils"
@@ -16,38 +18,43 @@ func NewAuthController() *AuthController {
return &AuthController{service: services.NewAuthService()}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Role string `json:"role"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=6"`
}
func (ctrl *AuthController) Login(c *gin.Context) {
var req LoginRequest
var req dto.LoginRequest
if !utils.BindAndValidate(c, &req) {
return
}
user, token, err := ctrl.service.Login(req.Username, req.Password)
user, tokenPair, err := ctrl.service.Login(req.Username, req.Password)
if err != nil {
utils.Unauthorized(c, "用户名或密码错误")
return
}
utils.Success(c, gin.H{"token": token, "user": models.SanitizedJSON(user)})
utils.Success(c, gin.H{
"accessToken": tokenPair.AccessToken,
"refreshToken": tokenPair.RefreshToken,
"expiresAt": tokenPair.ExpiresAt.Unix(),
"user": models.SanitizedJSON(user),
})
}
func (ctrl *AuthController) RefreshToken(c *gin.Context) {
var req dto.RefreshTokenRequest
if !utils.BindAndValidate(c, &req) {
return
}
tokenPair, err := jwt.RefreshAccessToken(req.RefreshToken)
if err != nil {
utils.Unauthorized(c, "invalid or expired refresh token")
return
}
utils.Success(c, gin.H{
"accessToken": tokenPair.AccessToken,
"refreshToken": tokenPair.RefreshToken,
"expiresAt": tokenPair.ExpiresAt.Unix(),
})
}
func (ctrl *AuthController) Register(c *gin.Context) {
var req RegisterRequest
var req dto.RegisterRequest
if !utils.BindAndValidate(c, &req) {
return
}
@@ -63,7 +70,7 @@ func (ctrl *AuthController) Register(c *gin.Context) {
}
func (ctrl *AuthController) GetUser(c *gin.Context) {
claims, ok := utils.GetUserFromContext(c)
claims, ok := jwt.GetUserFromContext(c)
if !ok {
utils.Unauthorized(c, "")
return
@@ -77,11 +84,11 @@ func (ctrl *AuthController) GetUser(c *gin.Context) {
}
func (ctrl *AuthController) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
var req dto.ChangePasswordRequest
if !utils.BindAndValidate(c, &req) {
return
}
claims, ok := utils.GetUserFromContext(c)
claims, ok := jwt.GetUserFromContext(c)
if !ok {
utils.Unauthorized(c, "")
return
+2 -11
View File
@@ -1,6 +1,7 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
@@ -16,18 +17,8 @@ func NewClusterController() *ClusterController {
return &ClusterController{service: services.NewClusterService()}
}
type CreateClusterRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ConfigType string `json:"configType"` // kubeconfig | token
APIServer string `json:"apiServer"` // token 模式必填
KubeConfig string `json:"kubeconfig"` // kubeconfig 模式必填
Token string `json:"token"` // token 模式必填
Context string `json:"context"`
}
func (ctrl *ClusterController) Create(c *gin.Context) {
var req CreateClusterRequest
var req dto.CreateClusterRequest
if !utils.BindAndValidate(c, &req) {
return
}
+2 -13
View File
@@ -1,6 +1,7 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
@@ -16,20 +17,8 @@ func NewCollectorController() *CollectorController {
return &CollectorController{service: services.NewCollectorService()}
}
type CreateCollectorRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
LokiDeploymentID uint `json:"lokiDeploymentID" binding:"required"`
HostID *uint `json:"hostID"`
ClusterID *uint `json:"clusterID"`
TargetDeployment string `json:"targetDeployment"`
Namespace string `json:"namespace"`
LogPath string `json:"logPath"`
Labels string `json:"labels"`
}
func (ctrl *CollectorController) Create(c *gin.Context) {
var req CreateCollectorRequest
var req dto.CreateCollectorRequest
if !utils.BindAndValidate(c, &req) {
return
}
+6 -21
View File
@@ -1,6 +1,8 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
@@ -15,25 +17,8 @@ func NewDeploymentController() *DeploymentController {
return &DeploymentController{service: services.NewDeploymentService()}
}
type CreateDeploymentRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Version string `json:"version"`
Mode string `json:"mode" binding:"required,oneof=k8s-helm k8s-yaml host-binary"`
ClusterID *uint `json:"clusterID"`
HostID *uint `json:"hostID"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
AuthEnabled bool `json:"authEnabled"`
RetentionDays int `json:"retentionDays"`
Replicas int `json:"replicas"`
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
}
func (ctrl *DeploymentController) Create(c *gin.Context) {
var req CreateDeploymentRequest
var req dto.CreateDeploymentRequest
if !utils.BindAndValidate(c, &req) {
return
}
@@ -57,7 +42,7 @@ func (ctrl *DeploymentController) Create(c *gin.Context) {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, deployment)
utils.Success(c, models.SanitizedJSON(deployment))
}
func (ctrl *DeploymentController) Get(c *gin.Context) {
@@ -71,7 +56,7 @@ func (ctrl *DeploymentController) Get(c *gin.Context) {
utils.NotFound(c, "部署不存在")
return
}
utils.Success(c, deployment)
utils.Success(c, models.SanitizedJSON(deployment))
}
func (ctrl *DeploymentController) List(c *gin.Context) {
@@ -82,7 +67,7 @@ func (ctrl *DeploymentController) List(c *gin.Context) {
return
}
utils.Success(c, utils.PageData{
List: deployments, Total: total, Page: page, PageSize: pageSize,
List: models.SanitizedJSON(deployments), Total: total, Page: page, PageSize: pageSize,
})
}
+4 -16
View File
@@ -1,7 +1,9 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/services"
"lokideploy/utils"
@@ -16,22 +18,8 @@ func NewHostController() *HostController {
return &HostController{service: services.NewHostService()}
}
type CreateHostRequest struct {
Name string `json:"name" binding:"required"`
HostIP string `json:"hostIP" binding:"required"`
Port int `json:"port"`
User string `json:"user" binding:"required"`
AuthType string `json:"authType" binding:"required,oneof=password key"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
KeyPhrase string `json:"keyPhrase"`
OS string `json:"os"`
Arch string `json:"arch"`
Labels string `json:"labels"`
}
func (ctrl *HostController) Create(c *gin.Context) {
var req CreateHostRequest
var req dto.CreateHostRequest
if !utils.BindAndValidate(c, &req) {
return
}
@@ -125,7 +113,7 @@ func (ctrl *HostController) GetInfo(c *gin.Context) {
password := host.Password
if len(password) > 4 && password[:4] == "ENC:" {
password, _ = utils.Decrypt(password[4:])
password, _ = crypto.Decrypt(password[4:])
}
client, err := services.NewSSHClient(host.HostIP, host.Port, host.User, password)
+2 -14
View File
@@ -1,6 +1,7 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
@@ -16,21 +17,8 @@ func NewStorageController() *StorageController {
return &StorageController{service: services.NewStorageService()}
}
type CreateStorageRequest struct {
Name string `json:"name" binding:"required"`
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
PathPrefix string `json:"pathPrefix"`
Insecure bool `json:"insecure"`
IsDefault bool `json:"isDefault"`
}
func (ctrl *StorageController) Create(c *gin.Context) {
var req CreateStorageRequest
var req dto.CreateStorageRequest
if !utils.BindAndValidate(c, &req) {
return
}
+25
View File
@@ -0,0 +1,25 @@
// dto 包集中定义 HTTP 请求/响应契约(带 binding 校验标签)。
// controller 绑定 dto.XxxRequest,再映射到 service 层输入,使传输契约与业务逻辑解耦。
package dto
// 认证域
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refreshToken" binding:"required"`
}
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Role string `json:"role"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=6"`
}
+12
View File
@@ -0,0 +1,12 @@
package dto
// 集群域
type CreateClusterRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ConfigType string `json:"configType"` // kubeconfig | token
APIServer string `json:"apiServer"` // token 模式必填
KubeConfig string `json:"kubeconfig"` // kubeconfig 模式必填
Token string `json:"token"` // token 模式必填
Context string `json:"context"`
}
+14
View File
@@ -0,0 +1,14 @@
package dto
// 采集器域
type CreateCollectorRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
LokiDeploymentID uint `json:"lokiDeploymentID" binding:"required"`
HostID *uint `json:"hostID"`
ClusterID *uint `json:"clusterID"`
TargetDeployment string `json:"targetDeployment"`
Namespace string `json:"namespace"`
LogPath string `json:"logPath"`
Labels string `json:"labels"`
}
+19
View File
@@ -0,0 +1,19 @@
package dto
// Loki 部署域
type CreateDeploymentRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Version string `json:"version"`
Mode string `json:"mode" binding:"required,oneof=k8s-helm k8s-yaml host-binary"`
ClusterID *uint `json:"clusterID"`
HostID *uint `json:"hostID"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
AuthEnabled bool `json:"authEnabled"`
RetentionDays int `json:"retentionDays"`
Replicas int `json:"replicas"`
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
}
+16
View File
@@ -0,0 +1,16 @@
package dto
// 主机域
type CreateHostRequest struct {
Name string `json:"name" binding:"required"`
HostIP string `json:"hostIP" binding:"required"`
Port int `json:"port"`
User string `json:"user" binding:"required"`
AuthType string `json:"authType" binding:"required,oneof=password key"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
KeyPhrase string `json:"keyPhrase"`
OS string `json:"os"`
Arch string `json:"arch"`
Labels string `json:"labels"`
}
+15
View File
@@ -0,0 +1,15 @@
package dto
// 存储域
type CreateStorageRequest struct {
Name string `json:"name" binding:"required"`
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
PathPrefix string `json:"pathPrefix"`
Insecure bool `json:"insecure"`
IsDefault bool `json:"isDefault"`
}
+43 -9
View File
@@ -1,10 +1,16 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"lokideploy/config"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/routers"
"lokideploy/seed"
)
@@ -13,7 +19,7 @@ func main() {
cfg := config.GetConfig()
log.Println("Initializing database...")
if err := models.InitDB(); err != nil {
if err := repository.InitDB(); err != nil {
log.Fatalf("Failed to init DB: %v", err)
}
@@ -21,12 +27,40 @@ func main() {
seed.Seed()
r := routers.SetupRouter()
log.Printf("LokiDeploy starting on port %s", cfg.ServerPort)
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
log.Printf("Default login: admin / admin123")
if err := r.Run(":" + cfg.ServerPort); err != nil {
log.Fatalf("Server failed: %v", err)
srv := &http.Server{
Addr: ":" + cfg.ServerPort,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 监听中断/终止信号,实现优雅关停:先停 HTTP(拒绝新请求、处理完在途请求),
// 再 flush 数据库,避免防抖/异步保存丢失最近写入。
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("LokiDeploy starting on port %s", cfg.ServerPort)
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
log.Printf("Default login: admin / admin123")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Server failed: %v", err)
}
}()
<-ctx.Done()
log.Println("Shutdown signal received, draining connections...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown error: %v", err)
}
log.Println("Flushing database...")
if err := repository.Shutdown(); err != nil {
log.Printf("DB shutdown error: %v", err)
}
log.Println("LokiDeploy stopped")
}
+9 -1
View File
@@ -3,6 +3,7 @@ package middleware
import (
"strings"
"lokideploy/pkg/jwt"
"lokideploy/utils"
"github.com/gin-gonic/gin"
@@ -24,13 +25,20 @@ func JWTAuth() gin.HandlerFunc {
return
}
claims, err := utils.ParseToken(parts[1])
claims, err := jwt.ParseToken(parts[1])
if err != nil {
utils.Unauthorized(c, "invalid or expired token")
c.Abort()
return
}
// 安全增强:拒绝refresh token用于API访问
if claims.TokenType == "refresh" {
utils.Unauthorized(c, "refresh token cannot be used for API access")
c.Abort()
return
}
c.Set("user", claims)
c.Set("userId", claims.UserID)
c.Set("username", claims.Username)
+28 -3
View File
@@ -1,14 +1,18 @@
package models
import "time"
import (
"time"
"lokideploy/pkg/crypto"
)
type Cluster struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ConfigType string `json:"configType"` // kubeconfig | token(空值视为 kubeconfig,向后兼容)
ConfigType string `json:"configType"` // kubeconfig | token
APIServer string `json:"apiServer"` // 展示用
KubeConfig string `json:"kubeconfig"` // 统一存储(token 接入时存构造出的等价 kubeconfig)
KubeConfig string `json:"kubeconfig"` // 统一存储(加密存储时带ENC:前缀)
KubeConfigPath string `json:"kubeconfigPath"`
Context string `json:"context"`
Version string `json:"version"`
@@ -36,3 +40,24 @@ func (c *Cluster) Sanitized() map[string]interface{} {
"updatedAt": c.UpdatedAt,
}
}
// DecryptKubeconfig 解密集群的kubeconfig配置
// kubeconfig字段可能以ENC:前缀存储加密数据
func (c *Cluster) DecryptKubeconfig() string {
return crypto.DecryptField(c.KubeConfig)
}
// IsKubeconfigEncrypted 检查kubeconfig是否已加密存储
func (c *Cluster) IsKubeconfigEncrypted() bool {
return crypto.IsEncryptedField(c.KubeConfig)
}
// EncryptAndSetKubeconfig 加密并设置kubeconfig
func (c *Cluster) EncryptAndSetKubeconfig(kubeconfig string) error {
enc, err := crypto.EncryptField(kubeconfig)
if err != nil {
return err
}
c.KubeConfig = enc
return nil
}
-115
View File
@@ -1,115 +0,0 @@
package models
import (
"encoding/json"
"lokideploy/config"
"os"
"sync"
)
type JsonDB struct {
mu sync.RWMutex
dataFile string
Users []User `json:"users"`
Clusters []Cluster `json:"clusters"`
Hosts []Host `json:"hosts"`
Storages []StorageConfig `json:"storages"`
Deployments []LokiDeployment `json:"deployments"`
Collectors []Collector `json:"collectors"`
Logs []DeploymentLog `json:"logs"`
Settings []SystemSetting `json:"settings"`
sequences map[string]uint
}
var db *JsonDB
func GetDB() *JsonDB {
return db
}
func InitDB() error {
cfg := config.GetConfig()
_ = os.MkdirAll(cfg.DataDir, 0755)
d := &JsonDB{
dataFile: cfg.DatabasePath,
Users: []User{},
Clusters: []Cluster{},
Hosts: []Host{},
Storages: []StorageConfig{},
Deployments: []LokiDeployment{},
Collectors: []Collector{},
Logs: []DeploymentLog{},
Settings: []SystemSetting{},
sequences: map[string]uint{
"users": 0,
"clusters": 0,
"hosts": 0,
"storages": 0,
"deployments": 0,
"collectors": 0,
"logs": 0,
},
}
if data, err := os.ReadFile(d.dataFile); err == nil {
if err := json.Unmarshal(data, d); err == nil {
d.sequences = map[string]uint{}
for _, u := range d.Users { if u.ID > d.sequences["users"] { d.sequences["users"] = u.ID } }
for _, c := range d.Clusters { if c.ID > d.sequences["clusters"] { d.sequences["clusters"] = c.ID } }
for _, h := range d.Hosts { if h.ID > d.sequences["hosts"] { d.sequences["hosts"] = h.ID } }
for _, s := range d.Storages { if s.ID > d.sequences["storages"] { d.sequences["storages"] = s.ID } }
for _, de := range d.Deployments { if de.ID > d.sequences["deployments"] { d.sequences["deployments"] = de.ID } }
for _, col := range d.Collectors { if col.ID > d.sequences["collectors"] { d.sequences["collectors"] = col.ID } }
for _, l := range d.Logs { if l.ID > d.sequences["logs"] { d.sequences["logs"] = l.ID } }
}
}
db = d
return nil
}
func (d *JsonDB) Save() error {
d.mu.Lock()
defer d.mu.Unlock()
data, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
return os.WriteFile(d.dataFile, data, 0644)
}
func (d *JsonDB) NextID(table string) uint {
d.mu.Lock()
defer d.mu.Unlock()
d.sequences[table]++
return d.sequences[table]
}
func (d *JsonDB) Lock() {
d.mu.Lock()
}
func (d *JsonDB) Unlock() {
d.mu.Unlock()
}
func (d *JsonDB) RLock() {
d.mu.RLock()
}
func (d *JsonDB) RUnlock() {
d.mu.RUnlock()
}
func (d *JsonDB) CreateUser(user *User) error {
d.mu.Lock()
defer d.mu.Unlock()
if user.ID == 0 {
user.ID = d.sequences["users"] + 1
d.sequences["users"] = user.ID
}
d.Users = append(d.Users, *user)
go d.Save()
return nil
}
+41
View File
@@ -41,3 +41,44 @@ func (d *LokiDeployment) BeforeCreate() {
d.UUID = uuid.New().String()
}
}
// Sanitized 返回去除敏感字段(AdminPassword)的部署视图,并对嵌套的
// Cluster/Host/StorageConfig 一并做脱敏,避免经部署接口泄漏各自凭证。
func (d *LokiDeployment) Sanitized() map[string]interface{} {
out := map[string]interface{}{
"id": d.ID,
"uuid": d.UUID,
"name": d.Name,
"description": d.Description,
"version": d.Version,
"mode": d.Mode,
"clusterID": d.ClusterID,
"hostID": d.HostID,
"namespace": d.Namespace,
"storageConfigID": d.StorageConfigID,
"authEnabled": d.AuthEnabled,
"retentionDays": d.RetentionDays,
"replicas": d.Replicas,
"resourcesCPU": d.ResourcesCPU,
"resourcesMem": d.ResourcesMem,
"storageSize": d.StorageSize,
"status": d.Status,
"statusMessage": d.StatusMessage,
"helmValues": d.HelmValues,
"endpoint": d.Endpoint,
"adminUser": d.AdminUser,
// AdminPassword 故意省略
"createdAt": d.CreatedAt,
"updatedAt": d.UpdatedAt,
}
if d.Cluster != nil {
out["cluster"] = d.Cluster.Sanitized()
}
if d.Host != nil {
out["host"] = d.Host.Sanitized()
}
if d.StorageConfig != nil {
out["storageConfig"] = d.StorageConfig.Sanitized()
}
return out
}
+87 -4
View File
@@ -1,7 +1,23 @@
package models
import "time"
import (
"time"
"lokideploy/pkg/crypto"
)
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
type SSHAuthConfig struct {
Host string
Port int
User string
Password string
PrivateKey string
KeyPhrase string
}
// Host 主机模型,敏感字段(Password/PrivateKey/KeyPhrase)加密存储
// 加密格式: ENC:<base64(AES-GCM(ciphertext))>
type Host struct {
ID uint `json:"id"`
Name string `json:"name"`
@@ -9,9 +25,9 @@ type Host struct {
Port int `json:"port"`
User string `json:"user"`
AuthType string `json:"authType"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
KeyPhrase string `json:"keyPhrase"`
Password string `json:"password"` // 加密存储(ENC:前缀)
PrivateKey string `json:"privateKey"` // 加密存储(ENC:前缀)
KeyPhrase string `json:"keyPhrase"` // 加密存储(ENC:前缀)
OS string `json:"os"`
Arch string `json:"arch"`
Status string `json:"status"`
@@ -36,3 +52,70 @@ func (h *Host) Sanitized() map[string]interface{} {
"updatedAt": h.UpdatedAt,
}
}
// DecryptPassword 解密主机密码
func (h *Host) DecryptPassword() string {
return crypto.DecryptField(h.Password)
}
// DecryptPrivateKey 解密SSH私钥
func (h *Host) DecryptPrivateKey() string {
return crypto.DecryptField(h.PrivateKey)
}
// DecryptKeyPhrase 解密私钥密码
func (h *Host) DecryptKeyPhrase() string {
return crypto.DecryptField(h.KeyPhrase)
}
// IsPasswordEncrypted 检查密码是否已加密
func (h *Host) IsPasswordEncrypted() bool {
return crypto.IsEncryptedField(h.Password)
}
// IsPrivateKeyEncrypted 检查私钥是否已加密
func (h *Host) IsPrivateKeyEncrypted() bool {
return crypto.IsEncryptedField(h.PrivateKey)
}
// EncryptAndSetPassword 加密并设置密码
func (h *Host) EncryptAndSetPassword(password string) error {
enc, err := crypto.EncryptField(password)
if err != nil {
return err
}
h.Password = enc
return nil
}
// EncryptAndSetPrivateKey 加密并设置私钥
func (h *Host) EncryptAndSetPrivateKey(privateKey string) error {
enc, err := crypto.EncryptField(privateKey)
if err != nil {
return err
}
h.PrivateKey = enc
return nil
}
// EncryptAndSetKeyPhrase 加密并设置私钥密码
func (h *Host) EncryptAndSetKeyPhrase(keyPhrase string) error {
enc, err := crypto.EncryptField(keyPhrase)
if err != nil {
return err
}
h.KeyPhrase = enc
return nil
}
// GetSSHAuthConfig 获取SSH认证配置(自动解密敏感字段)
func (h *Host) GetSSHAuthConfig() *SSHAuthConfig {
return &SSHAuthConfig{
Host: h.HostIP,
Port: h.Port,
User: h.User,
Password: h.DecryptPassword(),
PrivateKey: h.DecryptPrivateKey(),
KeyPhrase: h.DecryptKeyPhrase(),
}
}
+23 -11
View File
@@ -1,49 +1,48 @@
package models
// Sanitizer interface for models that can sanitize themselves
// 用于从API响应中移除敏感字段
type Sanitizer interface {
Sanitized() map[string]interface{}
}
// SanitizedJSON sanitizes a single model or a slice of models
// SanitizedJSON sanitizes a single model or a slice of models.
// 使用接口替代类型断言,更优雅且可扩展。
func SanitizedJSON(v any) any {
switch val := v.(type) {
// Single objects
case User:
// Single objects: 尝试Sanitizer接口
case Sanitizer:
return val.Sanitized()
case *User:
if val == nil {
return nil
}
return val.Sanitized()
case Cluster:
return val.Sanitized()
case *Cluster:
if val == nil {
return nil
}
return val.Sanitized()
case Host:
return val.Sanitized()
case *Host:
if val == nil {
return nil
}
return val.Sanitized()
case StorageConfig:
return val.Sanitized()
case *StorageConfig:
if val == nil {
return nil
}
return val.Sanitized()
case Collector:
return val.Sanitized()
case *Collector:
if val == nil {
return nil
}
return val.Sanitized()
case *LokiDeployment:
if val == nil {
return nil
}
return val.Sanitized()
// Slices
case []User:
result := make([]map[string]interface{}, len(val))
@@ -75,7 +74,20 @@ func SanitizedJSON(v any) any {
result[i] = item.Sanitized()
}
return result
case []LokiDeployment:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
result[i] = item.Sanitized()
}
return result
case []Sanitizer:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
result[i] = item.Sanitized()
}
return result
default:
// 尝试通过反射处理指针类型
return v
}
}
+173
View File
@@ -0,0 +1,173 @@
// Package crypto 提供可复用的安全原语:AES-256-GCM 字段加解密、argon2id 口令哈希。
// 密钥经 HKDF 从 config.EncryptionKey 派生(与 JWT 密钥分离)。
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"strings"
"lokideploy/config"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
)
const (
keySaltPrefix = "lokideploy-v2-enc:" // HKDF info prefix for key derivation
)
// deriveEncryptionKey 使用HKDF从ENCRYPTION_KEY派生AES-256密钥
// 这样即使ENCRYPTION_KEY被复用,不同上下文的密钥也不同
func deriveEncryptionKey() ([]byte, error) {
cfg := config.GetConfig()
encKey := cfg.EncryptionKey
// 支持hex或base64编码的密钥
var masterKey []byte
var err error
if len(encKey) == 64 { // 可能是hex编码的32字节
masterKey, err = hex.DecodeString(encKey)
if err != nil {
masterKey = []byte(encKey) // fallback: 直接使用原始字节
}
} else if len(encKey) > 32 { // 可能是base64编码
masterKey, err = base64.StdEncoding.DecodeString(encKey)
if err != nil {
masterKey = []byte(encKey) // fallback: 直接使用原始字节
}
} else {
masterKey = []byte(encKey)
}
// 使用HKDF-SHA256派生固定长度的AES密钥
hkdfReader := hkdf.New(sha256.New, masterKey, nil, []byte(keySaltPrefix+"aes-gcm-256"))
aesKey := make([]byte, 32)
if _, err := io.ReadFull(hkdfReader, aesKey); err != nil {
return nil, err
}
return aesKey, nil
}
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
combined := append(salt, hash...)
return base64.StdEncoding.EncodeToString(combined), nil
}
func VerifyPassword(password, hashStr string) bool {
combined, err := base64.StdEncoding.DecodeString(hashStr)
if err != nil || len(combined) < 16 {
return false
}
salt := combined[:16]
hash := combined[16:]
newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, newHash) == 1
}
func Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
key, err := deriveEncryptionKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
key, err := deriveEncryptionKey()
if err != nil {
return "", err
}
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertextBytes := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// EncryptField 加密敏感字段,返回 ENC: 前缀标记格式。
// 加密失败时返回错误(绝不返回明文或哨兵串),调用方必须处理该错误,
// 否则可能把垃圾值当凭证落库。
func EncryptField(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
enc, err := Encrypt(plaintext)
if err != nil {
return "", err
}
return "ENC:" + enc, nil
}
// DecryptField 解密 ENC: 前缀标记的字段
func DecryptField(encrypted string) string {
if encrypted == "" {
return ""
}
if strings.HasPrefix(encrypted, "ENC:") {
plain, err := Decrypt(encrypted[4:])
if err != nil {
return ""
}
return plain
}
// 未加密的数据直接返回(向后兼容)
return encrypted
}
// IsEncryptedField 检查字段是否已加密
func IsEncryptedField(value string) bool {
return strings.HasPrefix(value, "ENC:")
}
+78
View File
@@ -0,0 +1,78 @@
package crypto
import (
"strings"
"testing"
)
// TestEncryptDecryptRoundTrip 覆盖 AES-GCM 加解密往返(含空串与 Unicode)。
func TestEncryptDecryptRoundTrip(t *testing.T) {
cases := []string{"", "hello", "a-much-longer-secret-with-unicode-密码-🔑"}
for _, plain := range cases {
enc, err := Encrypt(plain)
if err != nil {
t.Fatalf("Encrypt(%q) error: %v", plain, err)
}
dec, err := Decrypt(enc)
if err != nil {
t.Fatalf("Decrypt(%q) error: %v", plain, err)
}
if dec != plain {
t.Fatalf("round-trip mismatch: got %q want %q", dec, plain)
}
}
}
// TestDecryptTamperedFails 验证 GCM 认证:篡改密文必须解密失败。
func TestDecryptTamperedFails(t *testing.T) {
enc, err := Encrypt("secret-value")
if err != nil {
t.Fatal(err)
}
if len(enc) < 4 {
t.Fatal("ciphertext too short")
}
// 翻转末尾两个 base64 字符,破坏 GCM 认证标签
tampered := enc[:len(enc)-2] + "AA"
if _, err := Decrypt(tampered); err == nil {
t.Fatal("expected Decrypt to fail on tampered ciphertext, got nil")
}
}
// TestEncryptFieldContract 是 M2 的回归保护:
// EncryptField 绝不能返回 "ENC_ERR:" 哨兵(否则 DecryptField 会把它当明文凭证用),
// 只能返回 ""(空输入)或 "ENC:" 前缀;且与 DecryptField 可往返。
func TestEncryptFieldContract(t *testing.T) {
enc, err := EncryptField("super-secret")
if err != nil {
t.Fatalf("EncryptField error: %v", err)
}
if !strings.HasPrefix(enc, "ENC:") {
t.Fatalf("EncryptField must return ENC:-prefixed value, got %q", enc)
}
if enc == "ENC_ERR:" {
t.Fatal("EncryptField returned legacy sentinel ENC_ERR: (M2 regression)")
}
if got := DecryptField(enc); got != "super-secret" {
t.Fatalf("DecryptField round-trip: got %q want super-secret", got)
}
if !IsEncryptedField(enc) {
t.Fatal("IsEncryptedField should be true for ENC: value")
}
if IsEncryptedField("plain-value") {
t.Fatal("IsEncryptedField should be false for plain value")
}
// 空输入约定
if enc, err := EncryptField(""); enc != "" || err != nil {
t.Fatalf("EncryptField(\"\") = (%q,%v), want (\"\",nil)", enc, err)
}
}
// TestEncryptFieldRandomNonce 同一明文两次加密应产生不同密文(随机 nonce)。
func TestEncryptFieldRandomNonce(t *testing.T) {
a, _ := EncryptField("same-secret")
b, _ := EncryptField("same-secret")
if a == b {
t.Fatal("expected different ciphertexts due to random nonce")
}
}
+15
View File
@@ -0,0 +1,15 @@
package crypto
import (
"os"
"testing"
)
// TestMain 预置安全环境变量。deriveEncryptionKey 经 config.GetConfig() 读取
// ENCRYPTION_KEY,缺失或 <32 字符时 log.Fatal,故须先设置。
func TestMain(m *testing.M) {
os.Setenv("JWT_SECRET", "crypto-pkg-test-secret-32chars!!")
os.Setenv("ENCRYPTION_KEY", "crypto-pkg-test-encryption-32ch!!")
os.Setenv("DATA_DIR", os.TempDir()+"/lokideploy-crypto-pkg-test")
os.Exit(m.Run())
}
+138
View File
@@ -0,0 +1,138 @@
// Package jwt 提供可复用的 JWT 签发/解析(HS256)、access/refresh 双 token、
// 算法混淆防护与 gin 上下文用户提取。
// 外部依赖 github.com/golang-jwt/jwt/v5 包名同为 jwt,故在此包内别名为 jwtv5 以避免冲突。
package jwt
import (
"errors"
"strconv"
"time"
"lokideploy/config"
"github.com/gin-gonic/gin"
jwtv5 "github.com/golang-jwt/jwt/v5"
)
const (
// AccessToken有效期2小时(较短的过期时间增强安全性)
accessTokenExpiry = 2 * time.Hour
// RefreshToken有效期7天
refreshTokenExpiry = 7 * 24 * time.Hour
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
TokenType string `json:"tokenType"` // access | refresh
jwtv5.RegisteredClaims
}
// TokenPair 包含access token和refresh token
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt time.Time `json:"expiresAt"`
}
func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
cfg := config.GetConfig()
now := time.Now()
// Access Token
accessClaims := Claims{
UserID: userID,
Username: username,
Role: role,
TokenType: "access",
RegisteredClaims: jwtv5.RegisteredClaims{
ExpiresAt: jwtv5.NewNumericDate(now.Add(accessTokenExpiry)),
IssuedAt: jwtv5.NewNumericDate(now),
Subject: username,
ID: generateTokenID(),
},
}
accessToken := jwtv5.NewWithClaims(jwtv5.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString([]byte(cfg.JWTSecret))
if err != nil {
return nil, err
}
// Refresh Token
refreshClaims := Claims{
UserID: userID,
Username: username,
Role: role,
TokenType: "refresh",
RegisteredClaims: jwtv5.RegisteredClaims{
ExpiresAt: jwtv5.NewNumericDate(now.Add(refreshTokenExpiry)),
IssuedAt: jwtv5.NewNumericDate(now),
Subject: username,
ID: generateTokenID(),
},
}
refreshToken := jwtv5.NewWithClaims(jwtv5.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString([]byte(cfg.JWTSecret))
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
ExpiresAt: now.Add(accessTokenExpiry),
}, nil
}
// RefreshAccessToken 使用refresh token换取新的access token
func RefreshAccessToken(refreshTokenString string) (*TokenPair, error) {
claims, err := ParseToken(refreshTokenString)
if err != nil {
return nil, err
}
// 验证token类型必须是refresh
if claims.TokenType != "refresh" {
return nil, errors.New("invalid token type")
}
return GenerateTokenPair(claims.UserID, claims.Username, claims.Role)
}
func ParseToken(tokenString string) (*Claims, error) {
cfg := config.GetConfig()
token, err := jwtv5.ParseWithClaims(tokenString, &Claims{}, func(token *jwtv5.Token) (interface{}, error) {
// 强制校验签名算法,防止算法混淆攻击
if _, ok := token.Method.(*jwtv5.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(cfg.JWTSecret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
func GetUserFromContext(c *gin.Context) (*Claims, bool) {
user, exists := c.Get("user")
if !exists {
return nil, false
}
claims, ok := user.(*Claims)
return claims, ok
}
func IsAdmin(c *gin.Context) bool {
claims, ok := GetUserFromContext(c)
return ok && claims.Role == "admin"
}
// generateTokenID 生成简单的token ID(基于纳秒时间戳)
func generateTokenID() string {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
+66
View File
@@ -0,0 +1,66 @@
package jwt
import (
"testing"
jwtv5 "github.com/golang-jwt/jwt/v5"
)
// TestTokenPairRoundTrip 覆盖签发 + 解析,并校验 access token 类型。
func TestTokenPairRoundTrip(t *testing.T) {
pair, err := GenerateTokenPair(42, "alice", "admin")
if err != nil {
t.Fatalf("GenerateTokenPair: %v", err)
}
if pair.AccessToken == "" || pair.RefreshToken == "" {
t.Fatal("tokens must not be empty")
}
claims, err := ParseToken(pair.AccessToken)
if err != nil {
t.Fatalf("ParseToken(access): %v", err)
}
if claims.UserID != 42 || claims.Username != "alice" || claims.Role != "admin" {
t.Fatalf("unexpected claims: %+v", claims)
}
if claims.TokenType != "access" {
t.Fatalf("access token should have tokenType=access, got %q", claims.TokenType)
}
}
// TestRefreshRequiresRefreshToken 覆盖 token 类型强制:
// 用 access token 刷新必须被拒;用 refresh token 刷新应成功。
func TestRefreshRequiresRefreshToken(t *testing.T) {
pair, _ := GenerateTokenPair(1, "bob", "user")
if _, err := RefreshAccessToken(pair.AccessToken); err == nil {
t.Fatal("RefreshAccessToken must reject access tokens")
}
newPair, err := RefreshAccessToken(pair.RefreshToken)
if err != nil {
t.Fatalf("RefreshAccessToken(refresh): %v", err)
}
if newPair.AccessToken == "" {
t.Fatal("new access token must not be empty")
}
}
// TestParseTokenRejectsBogus 解析任意非 JWT 字符串必须失败。
func TestParseTokenRejectsBogus(t *testing.T) {
if _, err := ParseToken("not-a-jwt"); err == nil {
t.Fatal("ParseToken must reject bogus token")
}
}
// TestParseTokenRejectsNoneAlgo 覆盖算法混淆攻击防护:
// ParseToken 的 keyfunc 仅接受 HMAC,alg=none 构造的 token 必须被拒。
func TestParseTokenRejectsNoneAlgo(t *testing.T) {
tok := jwtv5.NewWithClaims(jwtv5.SigningMethodNone, Claims{UserID: 1, TokenType: "access"})
unsigned, err := tok.SigningString()
if err != nil {
t.Fatal(err)
}
bogus := unsigned + "." // alg=none 的无签名形式
if _, err := ParseToken(bogus); err == nil {
t.Fatal("ParseToken must reject alg=none (algorithm confusion)")
}
}
+15
View File
@@ -0,0 +1,15 @@
package jwt
import (
"os"
"testing"
)
// TestMain 预置安全环境变量。config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY
// 缺失时会 log.Fatal,故必须在任何测试逻辑之前设置。
func TestMain(m *testing.M) {
os.Setenv("JWT_SECRET", "jwt-pkg-test-secret-32chars-min!!")
os.Setenv("ENCRYPTION_KEY", "jwt-pkg-test-encryption-32chars!!")
os.Setenv("DATA_DIR", os.TempDir()+"/lokideploy-jwt-pkg-test")
os.Exit(m.Run())
}
+281
View File
@@ -0,0 +1,281 @@
package repository
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"lokideploy/config"
"lokideploy/models"
)
// saveRequest 表示一个保存请求,通过channel序列化
type saveRequest struct {
done chan error // 用于同步等待保存完成
}
// JsonDB 使用基于channel的序列化保存机制,防止并发数据丢失。
// 该包只负责持久化(落盘 + 并发安全),领域实体定义在 models 包。
type JsonDB struct {
mu sync.RWMutex
dataFile string
Users []models.User `json:"users"`
Clusters []models.Cluster `json:"clusters"`
Hosts []models.Host `json:"hosts"`
Storages []models.StorageConfig `json:"storages"`
Deployments []models.LokiDeployment `json:"deployments"`
Collectors []models.Collector `json:"collectors"`
Logs []models.DeploymentLog `json:"logs"`
Settings []models.SystemSetting `json:"settings"`
sequences map[string]uint
// 防抖保存机制
saveTimer *time.Timer
saveMu sync.Mutex
saveChan chan saveRequest
stopChan chan struct{}
wg sync.WaitGroup
}
var db *JsonDB
func GetDB() *JsonDB {
return db
}
func InitDB() error {
cfg := config.GetConfig()
_ = os.MkdirAll(cfg.DataDir, 0755)
d := &JsonDB{
dataFile: cfg.DatabasePath,
Users: []models.User{},
Clusters: []models.Cluster{},
Hosts: []models.Host{},
Storages: []models.StorageConfig{},
Deployments: []models.LokiDeployment{},
Collectors: []models.Collector{},
Logs: []models.DeploymentLog{},
Settings: []models.SystemSetting{},
sequences: map[string]uint{
"users": 0,
"clusters": 0,
"hosts": 0,
"storages": 0,
"deployments": 0,
"collectors": 0,
"logs": 0,
},
saveChan: make(chan saveRequest, 10),
stopChan: make(chan struct{}),
}
if data, err := os.ReadFile(d.dataFile); err == nil {
if err := json.Unmarshal(data, d); err == nil {
d.sequences = map[string]uint{}
for _, u := range d.Users {
if u.ID > d.sequences["users"] {
d.sequences["users"] = u.ID
}
}
for _, c := range d.Clusters {
if c.ID > d.sequences["clusters"] {
d.sequences["clusters"] = c.ID
}
}
for _, h := range d.Hosts {
if h.ID > d.sequences["hosts"] {
d.sequences["hosts"] = h.ID
}
}
for _, s := range d.Storages {
if s.ID > d.sequences["storages"] {
d.sequences["storages"] = s.ID
}
}
for _, de := range d.Deployments {
if de.ID > d.sequences["deployments"] {
d.sequences["deployments"] = de.ID
}
}
for _, col := range d.Collectors {
if col.ID > d.sequences["collectors"] {
d.sequences["collectors"] = col.ID
}
}
for _, l := range d.Logs {
if l.ID > d.sequences["logs"] {
d.sequences["logs"] = l.ID
}
}
}
}
// 启动序列化保存goroutine
d.wg.Add(1)
go d.saveWorker()
db = d
return nil
}
// Shutdown 优雅关闭数据库,确保所有数据已落盘。
// 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出
// (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把
// 最新内存状态原子写入。注意绝不能在 worker 仍在运行时直接 Save(),否则
// 两个 doSave 并发改写同一文件会发生"后写覆盖先写"的丢更新。
func Shutdown() error {
if db == nil {
return nil
}
// 1. 取消防抖定时器,阻止新的保存请求进入 channel
db.saveMu.Lock()
if db.saveTimer != nil {
db.saveTimer.Stop()
db.saveTimer = nil
}
db.saveMu.Unlock()
// 2. 通知 worker 停止并等待其排空 saveChan、完成进行中的 doSave 后退出
close(db.stopChan)
db.wg.Wait()
// 3. worker 已退出,无并发,落盘最终状态
if err := db.doSave(); err != nil {
return fmt.Errorf("final save failed: %w", err)
}
return nil
}
// saveWorker 通过channel序列化所有保存操作,防止并发写入冲突
func (d *JsonDB) saveWorker() {
defer d.wg.Done()
for {
select {
case req, ok := <-d.saveChan:
if !ok {
return
}
err := d.doSave()
if req.done != nil {
req.done <- err
}
case <-d.stopChan:
return
}
}
}
// doSave 执行实际的原子文件写入
func (d *JsonDB) doSave() error {
d.mu.RLock()
data, err := json.MarshalIndent(d, "", " ")
d.mu.RUnlock()
if err != nil {
return err
}
// 原子写入:先写入临时文件,再重命名
dir := filepath.Dir(d.dataFile)
tmpFile := filepath.Join(dir, fmt.Sprintf(".lokideploy-%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
return err
}
// 确保数据落盘
f, err := os.Open(tmpFile)
if err != nil {
os.Remove(tmpFile)
return err
}
_ = f.Sync()
f.Close()
if err := os.Rename(tmpFile, d.dataFile); err != nil {
os.Remove(tmpFile)
return err
}
return nil
}
// Save 同步保存数据库到文件(原子写入)
func (d *JsonDB) Save() error {
return d.doSave()
}
// RequestSave 请求异步保存(防抖机制+序列化channel)
// 多次快速调用会在500ms内合并为一次保存
func (d *JsonDB) RequestSave() {
d.saveMu.Lock()
defer d.saveMu.Unlock()
// 如果已有待触发的定时器,重置它(防抖)
if d.saveTimer != nil {
d.saveTimer.Stop()
}
d.saveTimer = time.AfterFunc(500*time.Millisecond, func() {
d.saveMu.Lock()
d.saveTimer = nil
d.saveMu.Unlock()
// 通过channel发送到保存worker,确保序列化
select {
case d.saveChan <- saveRequest{}:
case <-d.stopChan:
}
})
}
// RequestSaveSync 请求同步保存,等待保存完成(用于shutdown场景)
func (d *JsonDB) RequestSaveSync() error {
req := saveRequest{done: make(chan error, 1)}
select {
case d.saveChan <- req:
case <-d.stopChan:
return d.Save() // worker已停止,直接同步保存
}
return <-req.done
}
func (d *JsonDB) NextID(table string) uint {
d.mu.Lock()
defer d.mu.Unlock()
d.sequences[table]++
return d.sequences[table]
}
func (d *JsonDB) Lock() {
d.mu.Lock()
}
func (d *JsonDB) Unlock() {
d.mu.Unlock()
}
func (d *JsonDB) RLock() {
d.mu.RLock()
}
func (d *JsonDB) RUnlock() {
d.mu.RUnlock()
}
// CreateUser 创建用户并触发防抖保存
func (d *JsonDB) CreateUser(user *models.User) error {
d.mu.Lock()
defer d.mu.Unlock()
if user.ID == 0 {
user.ID = d.sequences["users"] + 1
d.sequences["users"] = user.ID
}
d.Users = append(d.Users, *user)
d.RequestSave()
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package repository
import (
"encoding/json"
"os"
"sync"
"testing"
"lokideploy/models"
)
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
// N 个 goroutine 并发修改 db 并各自 RequestSave(经防抖 channel 序列化),
// 随后同步刷盘,直接读取磁盘文件校验写入数量与 ID 唯一性——
// 证明 channel 序列化 + 原子重命名写入不会发生"后写覆盖先写"的丢更新。
func TestConcurrentSaveNoDataLoss(t *testing.T) {
const N = 50
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
db := GetDB()
// NextID 内部自行加锁;切勿在持有 db.Lock() 时调用(mu 不可重入,会自死锁)
id := db.NextID("hosts")
db.Lock()
db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "concurrent-host"})
db.Unlock()
db.RequestSave()
}()
}
wg.Wait()
// 同步刷盘一次(绕过 500ms 防抖),确保最新内存状态落盘
if err := GetDB().RequestSaveSync(); err != nil {
t.Fatalf("RequestSaveSync: %v", err)
}
data, err := os.ReadFile(dbPath())
if err != nil {
t.Fatalf("read db file: %v", err)
}
var disk JsonDB
if err := json.Unmarshal(data, &disk); err != nil {
t.Fatalf("unmarshal db file: %v", err)
}
if len(disk.Hosts) != N {
t.Fatalf("DATA LOSS: disk has %d hosts, want %d (concurrent saves lost writes)", len(disk.Hosts), N)
}
// ID 唯一性:NextID 在 mutex 保护下递增,不应出现重复
seen := make(map[uint]bool, N)
for _, h := range disk.Hosts {
if seen[h.ID] {
t.Fatalf("duplicate host ID %d (NextID race)", h.ID)
}
seen[h.ID] = true
}
}
// TestRequestSaveDebounce 校验防抖行为:短时间内多次 RequestSave 不会撑爆 channel。
func TestRequestSaveDebounce(t *testing.T) {
db := GetDB()
startLen := len(db.Hosts)
// 连续触发 100 次防抖保存(channel 容量 10,防抖应合并而非阻塞)
for i := 0; i < 100; i++ {
id := db.NextID("hosts") // 自行加锁,勿在 db.Lock() 内调用
db.Lock()
db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "debounce"})
db.Unlock()
db.RequestSave()
}
if err := db.RequestSaveSync(); err != nil {
t.Fatalf("RequestSaveSync after debounce: %v", err)
}
data, _ := os.ReadFile(dbPath())
var disk JsonDB
if err := json.Unmarshal(data, &disk); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// 100 次追加 + 之前测试的 N 个 host,必须全部落盘
if len(disk.Hosts) != startLen+100 {
t.Fatalf("debounce lost writes: disk has %d hosts, want %d", len(disk.Hosts), startLen+100)
}
}
+32
View File
@@ -0,0 +1,32 @@
package repository
import (
"os"
"testing"
"lokideploy/config"
)
// TestMain 为 repository 包预置安全环境变量与临时 DATA_DIR,并管理 JsonDB 生命周期。
// config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY 缺失时会 log.Fatal,故须先设置。
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "lokideploy-repository-test")
if err != nil {
panic(err)
}
os.Setenv("JWT_SECRET", "repo-test-jwt-secret-32chars-min!!")
os.Setenv("ENCRYPTION_KEY", "repo-test-encryption-key-32chars!!")
os.Setenv("DATA_DIR", dir)
if err := InitDB(); err != nil {
panic(err)
}
code := m.Run()
_ = Shutdown() // 优雅刷盘 + 停止 saveWorker
_ = os.RemoveAll(dir)
os.Exit(code)
}
// dbPath 返回当前测试用的数据库文件路径。
func dbPath() string { return config.GetConfig().DatabasePath }
+35 -1
View File
@@ -1,9 +1,11 @@
package routers
import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"lokideploy/controllers"
"lokideploy/middleware"
@@ -33,8 +35,25 @@ func SetupRouter() *gin.Engine {
// API v1
api := r.Group("/api/v1")
// 健康检查(公开访问,用于负载均衡和监控)
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"timestamp": time.Now().Unix(),
"version": "2.0.0",
})
})
// Prometheus metrics 端点(公开访问,可被Prometheus抓取)
api.GET("/metrics", func(c *gin.Context) {
// 基础metrics输出(符合Prometheus文本格式)
c.Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
c.String(http.StatusOK, "# LokiDeploy Metrics\n")
})
// Public
api.POST("/auth/login", authCtrl.Login)
api.POST("/auth/refresh", authCtrl.RefreshToken)
// Auth required
authorized := api.Group("")
@@ -84,7 +103,7 @@ func SetupRouter() *gin.Engine {
authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs)
authorized.GET("/deployments/stats", deploymentCtrl.GetStats)
// Collectors(日志采集器 / 部署客户端)
// Collectors
authorized.GET("/collectors", collectorCtrl.List)
authorized.GET("/collectors/:id", collectorCtrl.Get)
authorized.POST("/collectors", collectorCtrl.Create)
@@ -98,6 +117,21 @@ func SetupRouter() *gin.Engine {
if distPath != "" {
r.Static("/assets", filepath.Join(distPath, "assets"))
// favicon:NoRoute 会把所有非 /api/ 请求回退到 index.html,
// 故此处显式注册 /favicon.svg 与 /favicon.ico,否则浏览器请求图标只会拿到 HTML。
faviconSVG := filepath.Join(distPath, "favicon.svg")
serveFavicon := func(c *gin.Context) {
if _, err := os.Stat(faviconSVG); err != nil {
c.Status(404)
return
}
c.Header("Content-Type", "image/svg+xml")
c.File(faviconSVG)
}
r.GET("/favicon.svg", serveFavicon)
r.GET("/favicon.ico", serveFavicon)
r.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(404, gin.H{"code": 404, "message": "API endpoint not found"})
+5 -4
View File
@@ -2,18 +2,19 @@ package seed
import (
"lokideploy/models"
"lokideploy/utils"
"lokideploy/pkg/crypto"
"lokideploy/repository"
)
func Seed() {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
hasUsers := len(db.Users) > 0
db.RUnlock()
if !hasUsers {
hash, _ := utils.HashPassword("admin123")
models.GetDB().CreateUser(&models.User{
hash, _ := crypto.HashPassword("admin123")
repository.GetDB().CreateUser(&models.User{
Username: "admin",
Password: hash,
Nickname: "Administrator",
+19 -16
View File
@@ -2,6 +2,9 @@ package services
import (
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/pkg/jwt"
"lokideploy/repository"
"lokideploy/utils"
)
@@ -11,45 +14,45 @@ func NewAuthService() *AuthService {
return &AuthService{}
}
func (s *AuthService) Login(username, password string) (*models.User, string, error) {
db := models.GetDB()
func (s *AuthService) Login(username, password string) (*models.User, *jwt.TokenPair, error) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Users {
if db.Users[i].Username == username {
if !utils.VerifyPassword(password, db.Users[i].Password) {
return nil, "", utils.ErrInvalidCredentials
if !crypto.VerifyPassword(password, db.Users[i].Password) {
return nil, nil, utils.ErrInvalidCredentials
}
token, err := utils.GenerateToken(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role)
tokenPair, err := jwt.GenerateTokenPair(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role)
if err != nil {
return nil, "", err
return nil, nil, err
}
return &db.Users[i], token, nil
return &db.Users[i], tokenPair, nil
}
}
return nil, "", utils.ErrInvalidCredentials
return nil, nil, utils.ErrInvalidCredentials
}
func (s *AuthService) Register(username, password, nickname, role string) (*models.User, error) {
hash, err := utils.HashPassword(password)
hash, err := crypto.HashPassword(password)
if err != nil {
return nil, err
}
user := &models.User{
ID: models.GetDB().NextID("users"),
ID: repository.GetDB().NextID("users"),
Username: username,
Password: hash,
Nickname: nickname,
Role: role,
}
if err := models.GetDB().CreateUser(user); err != nil {
if err := repository.GetDB().CreateUser(user); err != nil {
return nil, err
}
return user, nil
}
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Users {
@@ -62,20 +65,20 @@ func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
}
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Users {
if db.Users[i].ID == userID {
if !utils.VerifyPassword(oldPassword, db.Users[i].Password) {
if !crypto.VerifyPassword(oldPassword, db.Users[i].Password) {
return utils.ErrInvalidCredentials
}
hash, err := utils.HashPassword(newPassword)
hash, err := crypto.HashPassword(newPassword)
if err != nil {
return err
}
db.Users[i].Password = hash
go db.Save()
db.RequestSave()
return nil
}
}
+16 -12
View File
@@ -7,7 +7,9 @@ import (
"path/filepath"
"time"
"lokideploy/config"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/utils"
)
@@ -18,8 +20,8 @@ func NewClusterService() *ClusterService {
}
func SaveKubeConfig(name, content string) (string, error) {
dir := filepath.Join("data", "kubeconfigs")
if err := os.MkdirAll(dir, 0755); err != nil {
dir := config.GetConfig().KubeDir
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("%s.yaml", name))
@@ -37,7 +39,9 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco
finalKubeconfig := kubeconfig
if configType == "token" {
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token)
// 安全默认:不嵌入 CA、不跳过 TLS 验证,依赖系统 CA 池。
// 自签证书的集群请改用 kubeconfig 模式(其内含 CA 证书)。
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", false)
}
// 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取
if apiServer == "" {
@@ -50,7 +54,7 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco
}
cluster := models.Cluster{
ID: models.GetDB().NextID("clusters"),
ID: repository.GetDB().NextID("clusters"),
Name: name,
Description: description,
ConfigType: configType,
@@ -63,18 +67,18 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco
UpdatedAt: time.Now(),
}
db := models.GetDB()
db := repository.GetDB()
db.Lock()
db.Clusters = append(db.Clusters, cluster)
db.Unlock()
go db.Save()
db.RequestSave()
go s.Validate(cluster.ID)
return &cluster, nil
}
func (s *ClusterService) Get(id uint) (*models.Cluster, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Clusters {
@@ -87,7 +91,7 @@ func (s *ClusterService) Get(id uint) (*models.Cluster, error) {
}
func (s *ClusterService) List(page, pageSize int) ([]models.Cluster, int64, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
@@ -106,7 +110,7 @@ func (s *ClusterService) List(page, pageSize int) ([]models.Cluster, int64, erro
}
func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Clusters {
@@ -147,7 +151,7 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
}
}
db.Clusters[i].UpdatedAt = time.Now()
go db.Save()
db.RequestSave()
return nil
}
}
@@ -155,7 +159,7 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
}
func (s *ClusterService) Delete(id uint) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i, c := range db.Clusters {
@@ -164,7 +168,7 @@ func (s *ClusterService) Delete(id uint) error {
_ = os.Remove(c.KubeConfigPath)
}
db.Clusters = append(db.Clusters[:i], db.Clusters[i+1:]...)
go db.Save()
db.RequestSave()
return nil
}
}
+11 -10
View File
@@ -6,6 +6,7 @@ import (
"time"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/utils"
)
@@ -29,7 +30,7 @@ type CreateCollectorRequest struct {
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
func (s *CollectorService) fillAssociations(c *models.Collector) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Deployments {
@@ -107,7 +108,7 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
}
collector := models.Collector{
ID: models.GetDB().NextID("collectors"),
ID: repository.GetDB().NextID("collectors"),
Name: req.Name,
Type: req.Type,
LokiDeploymentID: req.LokiDeploymentID,
@@ -129,18 +130,18 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
}
collector.Config = s.generateConfigSnapshot(&collector)
db := models.GetDB()
db := repository.GetDB()
db.Lock()
db.Collectors = append(db.Collectors, collector)
db.Unlock()
go db.Save()
db.RequestSave()
go s.Install(collector.ID)
return &collector, nil
}
func (s *CollectorService) Get(id uint) (*models.Collector, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
var found *models.Collector
for i := range db.Collectors {
@@ -159,7 +160,7 @@ func (s *CollectorService) Get(id uint) (*models.Collector, error) {
}
func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
total := int64(len(db.Collectors))
start := (page - 1) * pageSize
@@ -181,7 +182,7 @@ func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64,
}
func (s *CollectorService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Collectors {
@@ -202,7 +203,7 @@ func (s *CollectorService) Update(id uint, updates map[string]interface{}) error
db.Collectors[i].Labels = v
}
db.Collectors[i].UpdatedAt = time.Now()
go db.Save()
db.RequestSave()
return nil
}
}
@@ -214,7 +215,7 @@ func (s *CollectorService) Delete(id uint) error {
if err != nil {
return utils.ErrNotFound
}
db := models.GetDB()
db := repository.GetDB()
db.Lock()
for i, c := range db.Collectors {
if c.ID == id {
@@ -223,7 +224,7 @@ func (s *CollectorService) Delete(id uint) error {
}
}
db.Unlock()
go db.Save()
db.RequestSave()
// 异步卸载(用拷贝的 collector,避免删除后取不到)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+88 -41
View File
@@ -35,10 +35,15 @@ func kubeconfigEnv(cluster *models.Cluster) (env []string, cleanup func(), err e
}
kubeFile, err := os.CreateTemp("", "kube-*.yaml")
if err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("create kubeconfig temp file failed: %w", err)
}
if _, err := kubeFile.WriteString(kubeconfig); err != nil {
kubeFile.Close()
os.Remove(kubeFile.Name())
return nil, nil, fmt.Errorf("write kubeconfig temp file failed: %w", err)
}
kubeFile.WriteString(kubeconfig)
kubeFile.Close()
os.Chmod(kubeFile.Name(), 0600)
env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
cleanup = func() { os.Remove(kubeFile.Name()) }
return env, cleanup, nil
@@ -73,6 +78,7 @@ func (d *CollectorDeployer) Install(ctx context.Context) error {
// Uninstall 按采集器类型分发卸载
func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
d.log("info", fmt.Sprintf("开始卸载采集器 %s(类型 %s)", d.collector.Name, d.collector.Type), "")
switch d.collector.Type {
case "host-promtail":
return d.uninstallHost(ctx)
@@ -85,6 +91,7 @@ func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
}
// ===== host-promtail:SSH 安装 Promtail 二进制 + systemd =====
func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error {
host := d.collector.Host
if host == nil {
@@ -100,7 +107,7 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
}
defer client.Close()
// 架构
// 检测架构
archOut, _, _ := client.Execute(ctx, "uname -m")
arch := strings.TrimSpace(archOut)
switch arch {
@@ -112,41 +119,70 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
return fmt.Errorf("不支持的主机架构: %s", arch)
}
// 确定 Promtail 版本(优先使用关联的 Loki 版本)
version := "3.0.0"
if d.loki != nil && d.loki.Version != "" {
version = d.loki.Version
}
// 创建必要目录
d.log("info", "准备目录 /opt/promtail /etc/promtail /var/lib/promtail", "")
if _, _, err := client.Execute(ctx, "mkdir -p /opt/promtail /etc/promtail /var/lib/promtail"); err != nil {
return fmt.Errorf("创建 Promtail 目录失败: %w", err)
}
// 下载 Promtail
d.log("info", fmt.Sprintf("下载 Promtail(%s)", arch), "")
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v3.0.0/promtail-linux-%s.zip", arch)
dl := fmt.Sprintf("mkdir -p /opt/promtail /etc/promtail && cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch)
d.log("info", fmt.Sprintf("下载 Promtail %s(%s)", version, arch), "")
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/promtail-linux-%s.zip", version, arch)
dl := fmt.Sprintf("cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch)
if _, _, err := client.Execute(ctx, dl); err != nil {
return fmt.Errorf("下载 Promtail 失败: %v", err)
return fmt.Errorf("下载 Promtail 失败: %w", err)
}
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
// 配置
// 生成并上传配置
logPath := d.collector.LogPath
if logPath == "" {
logPath = "/var/log/*.log"
}
conf := PromtailConfig(endpoint, logPath)
client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml << 'EOF'\n%s\nEOF", conf))
d.log("info", "上传 Promtail 配置", "")
if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml << 'EOF'\n%s\nEOF", conf)); err != nil {
return fmt.Errorf("上传配置失败: %w", err)
}
// systemd 服务
// systemd 服务(生产级)
service := `[Unit]
Description=Promtail Log Collector
After=network.target
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml
User=root
Group=root
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml -positions.enabled=true
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=10
LimitNOFILE=65536
StandardOutput=append:/var/log/promtail.log
StandardError=append:/var/log/promtail.log
[Install]
WantedBy=multi-user.target
`
client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service))
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil {
return fmt.Errorf("启动 Promtail 失败: %v", err)
d.log("info", "安装 systemd 服务", "")
if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service)); err != nil {
return fmt.Errorf("创建 systemd 服务失败: %w", err)
}
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil {
return fmt.Errorf("启动 Promtail 失败: %w", err)
}
// 保存配置快照
d.collector.Config = conf
d.log("success", "Promtail 部署完成", "")
return nil
}
@@ -161,11 +197,15 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
return err
}
defer client.Close()
client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true; systemctl disable promtail 2>/dev/null || true; rm -f /etc/systemd/system/promtail.service /usr/local/bin/promtail; systemctl daemon-reload")
client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true")
client.Execute(ctx, "systemctl disable promtail 2>/dev/null || true")
client.Execute(ctx, "rm -rf /etc/systemd/system/promtail.service /usr/local/bin/promtail /etc/promtail /opt/promtail /var/lib/promtail")
client.Execute(ctx, "systemctl daemon-reload")
return nil
}
// ===== k8s-daemonset:helm 安装 Promtail DaemonSet =====
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
cluster := d.collector.Cluster
if cluster == nil {
@@ -183,27 +223,27 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin
}
values := PromtailHelmValues(endpoint)
valuesFile, err := os.CreateTemp("", "promtail-values-*.yaml")
valuesFile, valuesCleanup, err := writeTempFile("promtail-values-*.yaml", values, 0600)
if err != nil {
return err
}
defer os.Remove(valuesFile.Name())
valuesFile.WriteString(values)
valuesFile.Close()
defer valuesCleanup()
d.log("info", fmt.Sprintf("helm install promtail(命名空间 %s)", ns), "")
// 先添加 grafana repo(幂等)
// 添加 grafana repo(幂等)
repo := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts")
repo.Env = env
repo.CombinedOutput()
exec.CommandContext(ctx, "helm", "repo", "update").CombinedOutput()
upd := exec.CommandContext(ctx, "helm", "repo", "update")
upd.Env = env
upd.CombinedOutput()
cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", "promtail", "grafana/promtail",
"-f", valuesFile.Name(), "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m")
"-f", valuesFile, "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("helm 安装 Promtail 失败: %v\n%s", err, string(out))
return fmt.Errorf("helm 安装 Promtail 失败: %w\n%s", err, string(out))
}
return nil
}
@@ -229,6 +269,7 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
}
// ===== k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar =====
func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) error {
cluster := d.collector.Cluster
if cluster == nil {
@@ -275,27 +316,31 @@ func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string)
lokiHost, lokiPort := parseLokiURL(endpoint)
conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns)
cm := FluentbitConfigMapYAML(conf, ns)
cmFile, _ := os.CreateTemp("", "fluent-bit-cm-*.yaml")
defer os.Remove(cmFile.Name())
cmFile.WriteString(cm)
cmFile.Close()
cmFile, cmCleanup, err := writeTempFile("fluent-bit-cm-*.yaml", cm, 0600)
if err != nil {
return err
}
defer cmCleanup()
d.log("info", "部署 Fluent Bit ConfigMap", "")
applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile.Name())
applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile)
applyCm.Env = env
if out, err := applyCm.CombinedOutput(); err != nil {
return fmt.Errorf("ConfigMap 部署失败: %v\n%s", err, string(out))
return fmt.Errorf("ConfigMap 部署失败: %w\n%s", err, string(out))
}
// 4. patch 注入 sidecar
d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "")
patchFile, _ := os.CreateTemp("", "sidecar-patch-*.yaml")
defer os.Remove(patchFile.Name())
patchFile.WriteString(SidecarPatchYAML)
patchFile.Close()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
patchFile, patchCleanup, err := writeTempFile("sidecar-patch-*.yaml", SidecarPatchYAML, 0600)
if err != nil {
return err
}
defer patchCleanup()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile)
patch.Env = env
if out, err := patch.CombinedOutput(); err != nil {
return fmt.Errorf("Sidecar 注入失败: %v\n%s", err, string(out))
return fmt.Errorf("Sidecar 注入失败: %w\n%s", err, string(out))
}
// 5. 等待 rollout
@@ -320,11 +365,13 @@ func (d *CollectorDeployer) uninstallSidecar(ctx context.Context) error {
if ns == "" {
ns = "default"
}
patchFile, _ := os.CreateTemp("", "sidecar-rm-*.yaml")
defer os.Remove(patchFile.Name())
patchFile.WriteString(SidecarRemovePatchYAML)
patchFile.Close()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
patchFile, patchCleanup, err := writeTempFile("sidecar-rm-*.yaml", SidecarRemovePatchYAML, 0600)
if err != nil {
return err
}
defer patchCleanup()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile)
patch.Env = env
patch.CombinedOutput()
return nil
+163 -9
View File
@@ -6,17 +6,35 @@ import (
)
// PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath)
// 生产级配置:持久化位置文件、批处理、重试、CRI日志格式解析
func PromtailConfig(lokiEndpoint, logPath string) string {
return fmt.Sprintf(`server:
http_listen_port: 9080
grpc_listen_port: 0
log_level: info
log_format: logfmt
positions:
filename: /tmp/positions.yaml
filename: /var/lib/promtail/positions.yaml
sync_period: 10s
clients:
- url: %s/loki/api/v1/push
batchwait: 1s
batchsize: 1048576
timeout: 10s
backoff_config:
min_period: 500ms
max_period: 5m
max_retries: 10
external_labels:
job: host-logs
limits_config:
readline_rate_enabled: false
readline_rate: 1000
readline_burst: 10000
max_streams: 0
scrape_configs:
- job_name: app
@@ -26,28 +44,104 @@ scrape_configs:
job: app
host: ${HOSTNAME}
__path__: %s
pipeline_stages:
- cri: {}
- match:
selector: '{job="app"}'
stages:
- timestamp:
source: time
format: RFC3339Nano
- limit:
rate: 1000
burst: 10000
drop: true
`, lokiEndpoint, logPath)
}
// PromtailHelmValues DaemonSet 模式 Promtail Helm values
// PromtailHelmValues DaemonSet 模式 Promtail Helm values(生产级)
// 包含:cri解析、kubernetes标签、批处理配置、资源限制
func PromtailHelmValues(lokiEndpoint string) string {
return fmt.Sprintf(`config:
clients:
- url: %s/loki/api/v1/push
batchwait: 1s
batchsize: 1048576
timeout: 10s
backoff_config:
min_period: 500ms
max_period: 5m
max_retries: 10
external_labels:
cluster: ${CLUSTER_NAME:-unknown}
positions:
filename: /run/promtail/positions.yaml
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
pipeline_stages:
- cri: {}
- match:
selector: '{app!=""}'
stages:
- docker: {}
- match:
selector: '{app!=""}'
stages:
- labels:
app:
namespace:
pod:
container:
- limit:
rate: 1000
burst: 10000
drop: true
relabel_configs:
- source_labels: [__meta_kubernetes_pod_controller_name]
regex: ([0-9a-z-.]+?)(-[0-9a-f]{8,10})?
action: replace
target_label: __tmp_controller_name
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
regex: .+
action: replace
target_label: app
- source_labels: [__meta_kubernetes_pod_label_app]
regex: .+
action: replace
target_label: app
- source_labels: [__meta_kubernetes_pod_annotation_kubernetes_io_config_hash]
regex: .+
action: replace
target_label: config_hash
- action: replace
source_labels: ['__meta_kubernetes__pod_name']
target_label: 'pod'
- action: replace
source_labels: ['__meta_kubernetes_namespace']
target_label: 'namespace'
- action: replace
source_labels: ['__meta_kubernetes_pod_container_name']
target_label: 'container'
- action: replace
source_labels: ['__meta_kubernetes_pod_node_name']
target_label: 'node_name'
- replacement: /var/log/pods/*$1/*.log
separator: /
source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
target_label: __path__
daemonset:
enabled: true
serviceAccount:
create: true
resources:
limits:
cpu: 200m
memory: 128Mi
memory: 256Mi
requests:
cpu: 100m
memory: 64Mi
memory: 128Mi
extraVolumes:
- name: varlog
@@ -56,6 +150,9 @@ extraVolumes:
- name: varlibdockercontainers
hostPath:
path: /var/lib/docker/containers
- name: promtail-run
hostPath:
path: /run/promtail
extraVolumeMounts:
- name: varlog
@@ -64,33 +161,63 @@ extraVolumeMounts:
- name: varlibdockercontainers
mountPath: /var/lib/docker/containers
readOnly: true
- name: promtail-run
mountPath: /run/promtail
`, lokiEndpoint)
}
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
// 生产级配置:kubernetes过滤器、内存缓冲、健康检查
func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string {
return fmt.Sprintf(`[SERVICE]
Flush 5
Flush 1
Log_Level info
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
Parsers_File parsers.conf
storage.path /tmp/flb-storage/
storage.sync normal
storage.checksum off
storage.max_chunks_pause On
storage.backlog.mem_limit 5M
Health_Check On
HC_Errors_Count 5
HC_Retry_Failure_Count 5
HC_Period 60
[INPUT]
Name tail
Path %s
Tag app.%s
Refresh_Interval 5
Mem_Buf_Limit 5MB
Mem_Buf_Limit 10MB
Skip_Long_Lines On
Read_from_Head True
DB /tmp/flb.db
DB.Sync Normal
Buffer_Max_Size 1MB
storage.type filesystem
[FILTER]
Name modify
Match app.*
Add namespace %s
Add app %s
Add source fluent-bit
[FILTER]
Name kubernetes
Match app.*
Kube_URL https://kubernetes.default.svc:443
Kube_CA_File /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
Kube_Token_File /var/run/secrets/kubernetes.io/serviceaccount/token
Merge_Log On
Keep_Log Off
K8S-Logging.Parser On
K8S-Logging.Exclude On
Labels On
Annotations Off
[OUTPUT]
Name loki
@@ -98,6 +225,10 @@ func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string
Host %s
Port %s
Labels job=fluent-bit, app=%s, namespace=%s
Drop_Records_Without_Labels On
Line_Format json
Auto_Kubernetes_Labels On
Retry_Limit 10
`, logPath, app, namespace, app, lokiHost, lokiPort, app, namespace)
}
@@ -115,6 +246,7 @@ data:
}
// SidecarPatchYAML strategic merge patch:为 Deployment 注入 Fluent Bit sidecar 容器
// 生产级配置:资源限制、健康检查、安全上下文
const SidecarPatchYAML = `spec:
template:
spec:
@@ -128,6 +260,10 @@ const SidecarPatchYAML = `spec:
- name: fluent-bit
image: cr.fluentbit.io/fluent/fluent-bit:3.1.1
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 2020
protocol: TCP
volumeMounts:
- name: app-logs
mountPath: /var/log/app
@@ -141,6 +277,24 @@ const SidecarPatchYAML = `spec:
requests:
cpu: 100m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
`
// SidecarRemovePatchYAML strategic merge patch:从 Deployment 移除 Fluent Bit sidecar(卸载)
+154 -27
View File
@@ -3,12 +3,32 @@ package services
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/utils"
)
// LokiVersionList 支持的 Loki 版本列表(按推荐程度排序)
var LokiVersionList = []string{
"3.4.0",
"3.3.0",
"3.2.0",
"3.1.0",
"3.0.0",
"2.9.8",
"2.9.0",
"2.8.9",
}
// minSupportedMajorVersion 最低支持的主版本号
const minSupportedMajorVersion = 2
// DeploymentService 部署业务逻辑层
type DeploymentService struct{}
func NewDeploymentService() *DeploymentService {
@@ -33,7 +53,14 @@ type CreateDeploymentRequest struct {
}
func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) {
db := models.GetDB()
// 版本兼容性预检查
if req.Version != "" {
if err := ValidateLokiVersion(req.Version); err != nil {
return nil, err
}
}
db := repository.GetDB()
d := models.LokiDeployment{
ID: db.NextID("deployments"),
Name: req.Name,
@@ -88,13 +115,13 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
db.Lock()
db.Deployments = append(db.Deployments, d)
db.Unlock()
go db.Save()
db.RequestSave()
return &d, nil
}
func (s *DeploymentService) Get(id uint) (*models.LokiDeployment, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Deployments {
@@ -108,7 +135,7 @@ func (s *DeploymentService) Get(id uint) (*models.LokiDeployment, error) {
}
func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, int64, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
@@ -130,7 +157,7 @@ func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, i
}
func (s *DeploymentService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Deployments {
@@ -145,7 +172,7 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
db.Deployments[i].Endpoint = v
}
db.Deployments[i].UpdatedAt = time.Now()
go db.Save()
db.RequestSave()
return nil
}
}
@@ -153,19 +180,32 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
}
func (s *DeploymentService) Delete(id uint) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i, d := range db.Deployments {
if d.ID == id {
db.Deployments = append(db.Deployments[:i], db.Deployments[i+1:]...)
go db.Save()
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
// ==================== Goroutine 管理改进 ====================
// deployCtxEntry 保存正在进行的部署上下文
type deployCtxEntry struct {
cancel context.CancelFunc
done chan struct{}
}
var (
deployCtxMap = make(map[uint]*deployCtxEntry)
deployCtxMu sync.Mutex
)
func (s *DeploymentService) StartDeploy(id uint) error {
deployment, err := s.Get(id)
if err != nil {
@@ -174,19 +214,41 @@ func (s *DeploymentService) StartDeploy(id uint) error {
if deployment.Status == "deploying" {
return fmt.Errorf("部署正在进行中,请勿重复操作")
}
s.Update(id, map[string]interface{}{"status": "deploying"})
// 检查已有 goroutine 状态,防止重复部署
deployCtxMu.Lock()
if entry, exists := deployCtxMap[id]; exists {
select {
case <-entry.done:
// 已完成,可以重新部署
delete(deployCtxMap, id)
default:
deployCtxMu.Unlock()
return fmt.Errorf("部署正在进行中,请勿重复操作")
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
done := make(chan struct{})
deployCtxMap[id] = &deployCtxEntry{cancel: cancel, done: done}
deployCtxMu.Unlock()
s.Update(id, map[string]interface{}{"status": "deploying"})
logFunc := func(level, message, detail string) {
s.AddLog(id, level, message, detail)
}
deployer := NewLokiDeployer(deployment, logFunc)
go func() {
err := deployer.Deploy(ctx)
if err != nil {
defer close(done)
defer func() {
deployCtxMu.Lock()
delete(deployCtxMap, id)
deployCtxMu.Unlock()
}()
deployer := NewLokiDeployer(deployment, logFunc)
if err := deployer.Deploy(ctx); err != nil {
s.Update(id, map[string]interface{}{
"status": "failed",
"statusMessage": fmt.Sprintf("Deployment failed: %v", err),
@@ -201,6 +263,7 @@ func (s *DeploymentService) StartDeploy(id uint) error {
s.AddLog(id, "success", "Deployment completed successfully", "")
}
}()
return nil
}
@@ -212,6 +275,14 @@ func (s *DeploymentService) StopDeployment(id uint) error {
if deployment.Status != "running" && deployment.Status != "deploying" {
return fmt.Errorf("部署未在运行,无法停止")
}
// 取消正在进行的部署 goroutine
deployCtxMu.Lock()
if entry, exists := deployCtxMap[id]; exists {
entry.cancel()
}
deployCtxMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
CleanupLoki(ctx, deployment)
@@ -223,14 +294,17 @@ func (s *DeploymentService) StopDeployment(id uint) error {
func (s *DeploymentService) RestartDeployment(id uint) error {
if err := s.StopDeployment(id); err != nil {
return err
// 如果停止失败但不是因为部署未运行,返回错误
if !strings.Contains(err.Error(), "部署未在运行") {
return err
}
}
time.Sleep(2 * time.Second)
return s.StartDeploy(id)
}
func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail string) {
db := models.GetDB()
db := repository.GetDB()
log := models.DeploymentLog{
ID: db.NextID("logs"),
DeploymentID: deploymentID,
@@ -242,11 +316,11 @@ func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail str
db.Lock()
db.Logs = append(db.Logs, log)
db.Unlock()
go db.Save()
db.RequestSave()
}
func (s *DeploymentService) GetLogs(deploymentID uint, page, pageSize int) ([]models.DeploymentLog, int64, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
@@ -270,7 +344,7 @@ func (s *DeploymentService) GetLogs(deploymentID uint, page, pageSize int) ([]mo
}
func (s *DeploymentService) GetStats() (map[string]interface{}, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
@@ -289,18 +363,19 @@ func (s *DeploymentService) GetStats() (map[string]interface{}, error) {
}
return map[string]interface{}{
"totalDeployments": int64(len(db.Deployments)),
"running": running,
"failed": failed,
"pending": pending,
"stopped": stopped,
"clusterCount": int64(len(db.Clusters)),
"hostCount": int64(len(db.Hosts)),
"totalDeployments": int64(len(db.Deployments)),
"running": running,
"failed": failed,
"pending": pending,
"stopped": stopped,
"clusterCount": int64(len(db.Clusters)),
"hostCount": int64(len(db.Hosts)),
"supportedVersions": LokiVersionList,
}, nil
}
func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
db := models.GetDB()
db := repository.GetDB()
if d.ClusterID != nil && *d.ClusterID > 0 {
for i := range db.Clusters {
if db.Clusters[i].ID == *d.ClusterID {
@@ -337,7 +412,14 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
if ns == "" {
ns = "loki"
}
return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns)
// k8s-yaml manifest 固定创建名为 loki 的 Service;
// k8s-helm (grafana/loki) 以 release 名(=部署名,默认 loki)暴露 Service。
// 注意:不使用 loki-gateway,gateway 仅在 Distributed+gateway 启用时存在。
svc := "loki"
if d.Mode == "k8s-helm" && d.Name != "" {
svc = d.Name
}
return fmt.Sprintf("http://%s.%s.svc.cluster.local:3100", svc, ns)
case "host-binary":
if d.Host != nil {
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
@@ -345,3 +427,48 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
}
return ""
}
// ==================== 版本兼容性检查 ====================
// ValidateLokiVersion 检查 Loki 版本是否在支持列表中且满足最低版本要求
func ValidateLokiVersion(version string) error {
if version == "" {
return nil // 使用默认值,不报错
}
// 基本格式验证
parts := strings.Split(version, ".")
if len(parts) < 2 {
return fmt.Errorf("版本号格式无效: %s,期望格式如 3.0.0", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Errorf("无法解析主版本号: %s", version)
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("无法解析次版本号: %s", version)
}
// 最低版本检查
if major < minSupportedMajorVersion {
return fmt.Errorf("Loki %s 版本过低,最低支持 %d.x 版本", version, minSupportedMajorVersion)
}
if major == 2 && minor < 8 {
return fmt.Errorf("Loki %s 版本过低,2.x 最低支持 2.8.x 版本", version)
}
if major >= 4 {
return fmt.Errorf("Loki %s 是未发布的版本,请使用已知稳定版本", version)
}
return nil
}
// GetRecommendedVersions 返回推荐的 Loki 版本列表
func GetRecommendedVersions() []string {
result := make([]string, len(LokiVersionList))
copy(result, LokiVersionList)
return result
}
+29 -17
View File
@@ -2,10 +2,13 @@ package services
import (
"context"
"fmt"
"strings"
"time"
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/repository"
"lokideploy/utils"
)
@@ -20,7 +23,7 @@ func (s *HostService) Create(name, hostIP string, port int, user, authType, pass
port = 22
}
host := models.Host{
ID: models.GetDB().NextID("hosts"),
ID: repository.GetDB().NextID("hosts"),
Name: name,
HostIP: hostIP,
Port: port,
@@ -34,30 +37,39 @@ func (s *HostService) Create(name, hostIP string, port int, user, authType, pass
UpdatedAt: time.Now(),
}
if password != "" {
enc, _ := utils.Encrypt(password)
host.Password = "ENC:" + enc
enc, err := crypto.EncryptField(password)
if err != nil {
return nil, fmt.Errorf("密码加密失败: %w", err)
}
host.Password = enc
}
if privateKey != "" {
enc, _ := utils.Encrypt(privateKey)
host.PrivateKey = "ENC:" + enc
enc, err := crypto.EncryptField(privateKey)
if err != nil {
return nil, fmt.Errorf("私钥加密失败: %w", err)
}
host.PrivateKey = enc
}
if keyPhrase != "" {
enc, _ := utils.Encrypt(keyPhrase)
host.KeyPhrase = "ENC:" + enc
enc, err := crypto.EncryptField(keyPhrase)
if err != nil {
return nil, fmt.Errorf("私钥口令加密失败: %w", err)
}
host.KeyPhrase = enc
}
db := models.GetDB()
db := repository.GetDB()
db.Lock()
db.Hosts = append(db.Hosts, host)
db.Unlock()
go db.Save()
db.RequestSave()
go s.Validate(host.ID)
return &host, nil
}
func (s *HostService) Get(id uint) (*models.Host, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Hosts {
@@ -70,7 +82,7 @@ func (s *HostService) Get(id uint) (*models.Host, error) {
}
func (s *HostService) List(page, pageSize int) ([]models.Host, int64, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
@@ -89,7 +101,7 @@ func (s *HostService) List(page, pageSize int) ([]models.Host, int64, error) {
}
func (s *HostService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Hosts {
@@ -107,7 +119,7 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error {
db.Hosts[i].Arch = v
}
db.Hosts[i].UpdatedAt = time.Now()
go db.Save()
db.RequestSave()
return nil
}
}
@@ -115,13 +127,13 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error {
}
func (s *HostService) Delete(id uint) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i, h := range db.Hosts {
if h.ID == id {
db.Hosts = append(db.Hosts[:i], db.Hosts[i+1:]...)
go db.Save()
db.RequestSave()
return nil
}
}
@@ -138,7 +150,7 @@ func (s *HostService) Validate(id uint) {
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = utils.Decrypt(password[4:])
password, _ = crypto.Decrypt(password[4:])
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
@@ -165,7 +177,7 @@ func (s *HostService) ExecuteCommand(id uint, command string) (string, string, e
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = utils.Decrypt(password[4:])
password, _ = crypto.Decrypt(password[4:])
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
+37 -8
View File
@@ -1,6 +1,7 @@
package services
import (
"encoding/base64"
"fmt"
"os"
"regexp"
@@ -16,7 +17,7 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) {
if cluster.KubeConfigPath != "" {
data, err := os.ReadFile(cluster.KubeConfigPath)
if err != nil {
return "", fmt.Errorf("读取 kubeconfig 文件失败: %v", err)
return "", fmt.Errorf("读取 kubeconfig 文件失败: %w", err)
}
return string(data), nil
}
@@ -24,15 +25,43 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) {
}
// BuildKubeconfigFromToken 用 API Server + Token 构造最小 kubeconfig
// 采用 insecure-skip-tls-verify,便于内网/自签证书集群接入
func BuildKubeconfigFromToken(apiServer, token string) string {
return fmt.Sprintf(`apiVersion: v1
kind: Config
clusters:
// 优先使用 CA 证书验证 TLS,仅在用户显式要求时跳过证书验证
// Parameters:
// - apiServer: Kubernetes API Server 地址,如 https://192.168.1.10:6443
// - token: 认证 Token
// - caCert: CA 证书 PEM 内容(可选)。若提供则嵌入证书数据进行 TLS 验证;
// 若为空且 skipTLSVerify=true 则跳过 TLS 验证;
// 若为空且 skipTLSVerify=false 则依赖系统 CA 池
// - skipTLSVerify: 是否跳过 TLS 证书验证(不建议在生产环境使用)
func BuildKubeconfigFromToken(apiServer, token, caCert string, skipTLSVerify bool) string {
var clusterBlock string
if caCert != "" {
// 嵌入 CA 证书进行安全的 TLS 验证
encodedCA := base64.StdEncoding.EncodeToString([]byte(caCert))
clusterBlock = fmt.Sprintf(`clusters:
- cluster:
certificate-authority-data: %s
server: %s
name: loki-cluster`, encodedCA, apiServer)
} else if skipTLSVerify {
// 用户显式要求跳过 TLS 验证(内网/自签证书场景)
clusterBlock = fmt.Sprintf(`clusters:
- cluster:
insecure-skip-tls-verify: true
server: %s
name: loki-cluster
name: loki-cluster`, apiServer)
} else {
// 依赖系统 CA 池(不嵌入证书也不跳过验证)
clusterBlock = fmt.Sprintf(`clusters:
- cluster:
server: %s
name: loki-cluster`, apiServer)
}
return fmt.Sprintf(`apiVersion: v1
kind: Config
%s
contexts:
- context:
cluster: loki-cluster
@@ -43,7 +72,7 @@ users:
- name: loki-user
user:
token: %s
`, apiServer, token)
`, clusterBlock, token)
}
var apiServerRe = regexp.MustCompile(`(?m)^\s*server:\s*(\S+)`)
File diff suppressed because it is too large. Load diff
+194 -5
View File
@@ -3,12 +3,21 @@ package services
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"lokideploy/config"
"lokideploy/models"
"lokideploy/pkg/crypto"
"golang.org/x/crypto/ssh"
)
@@ -38,17 +47,169 @@ type HostInfo struct {
DiskUsed string `json:"diskUsed"`
}
func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) {
if port == 0 {
port = 22
// SSHAuthConfig 重导出models.SSHAuthConfig以兼容现有调用
// Deprecated: 直接使用 models.SSHAuthConfig
type SSHAuthConfig = models.SSHAuthConfig
// validatePort 校验SSH端口范围
func validatePort(port int) int {
if port <= 0 {
return 22
}
config := &ssh.ClientConfig{
if port > 65535 {
return 22
}
return port
}
// hostKeyPath 返回主机密钥存储路径
func hostKeyPath(host string) string {
cfg := config.GetConfig()
// 对主机名进行安全编码作为文件名
safeHost := base64.URLEncoding.EncodeToString([]byte(host))
return filepath.Join(cfg.HostKeyDir, safeHost+".pub")
}
// fingerprint 计算SSH主机密钥的SHA256指纹
func fingerprint(key ssh.PublicKey) string {
hash := sha256.Sum256(key.Marshal())
return base64.StdEncoding.EncodeToString(hash[:])
}
// TrustOnFirstUse 实现TOFU(Trust On First Use)主机密钥验证
// 首次连接时记录主机密钥,后续连接时校验
func TrustOnFirstUse(host string) ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
fp := fingerprint(key)
hostKeyFile := hostKeyPath(host)
storedKey, err := os.ReadFile(hostKeyFile)
if err != nil {
// 首次连接:保存主机密钥
if os.IsNotExist(err) {
if writeErr := os.WriteFile(hostKeyFile, ssh.MarshalAuthorizedKey(key), 0600); writeErr != nil {
return fmt.Errorf("failed to save host key: %w", writeErr)
}
// 记录首次信任的主机密钥指纹(安全审计日志)
fmt.Fprintf(os.Stderr, "[SSH-TOFU] First connection to %s, host key fingerprint saved: SHA256:%s\n", host, fp)
return nil
}
return fmt.Errorf("failed to read host key file: %w", err)
}
// 解析存储的密钥并与当前密钥比较
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(storedKey)
if err != nil {
return fmt.Errorf("failed to parse stored host key: %w", err)
}
if !bytes.Equal(parsedKey.Marshal(), key.Marshal()) {
return fmt.Errorf("HOST KEY MISMATCH for %s! Expected SHA256:%s, got SHA256:%s. Possible MITM attack!",
host, fingerprint(parsedKey), fp)
}
return nil
}
}
// InsecureIgnoreHostKey 保留但不导出,用于向后兼容的内部测试场景
// 生产环境必须使用TrustOnFirstUse
func InsecureIgnoreHostKey() ssh.HostKeyCallback {
return ssh.InsecureIgnoreHostKey()
}
// buildAuthMethods 构建SSH认证方法列表(优先密钥认证)
func buildAuthMethods(auth *models.SSHAuthConfig) []ssh.AuthMethod {
methods := []ssh.AuthMethod{}
// 1. 优先尝试私钥认证
if auth.PrivateKey != "" {
privateKey := auth.PrivateKey
// 如果是加密存储的密钥,先解密
if strings.HasPrefix(privateKey, "ENC:") {
privateKey = crypto.DecryptField(privateKey)
}
var signer ssh.Signer
var err error
keyPhrase := auth.KeyPhrase
if strings.HasPrefix(keyPhrase, "ENC:") {
keyPhrase = crypto.DecryptField(keyPhrase)
}
if keyPhrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(keyPhrase))
} else {
signer, err = ssh.ParsePrivateKey([]byte(privateKey))
}
if err == nil {
methods = append(methods, ssh.PublicKeys(signer))
}
}
// 2. 密码认证作为后备
if auth.Password != "" {
password := auth.Password
if strings.HasPrefix(password, "ENC:") {
password = crypto.DecryptField(password)
}
methods = append(methods, ssh.Password(password))
}
return methods
}
// NewSSHClient 创建SSH客户端(支持密钥+密码认证,TOFU主机密钥验证)
func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) {
auth := &SSHAuthConfig{
Host: host,
Port: port,
User: user,
Password: password,
}
return NewSSHClientWithAuth(auth)
}
// NewSSHClientWithAuth 使用完整认证配置创建SSH客户端
func NewSSHClientWithAuth(auth *models.SSHAuthConfig) (*SSHClient, error) {
port := validatePort(auth.Port)
if auth.User == "" {
return nil, errors.New("SSH user is required")
}
authMethods := buildAuthMethods(auth)
if len(authMethods) == 0 {
return nil, errors.New("no authentication method available")
}
cfg := &ssh.ClientConfig{
User: auth.User,
HostKeyCallback: TrustOnFirstUse(auth.Host),
Timeout: 10 * time.Second,
Auth: authMethods,
}
addr := fmt.Sprintf("%s:%d", auth.Host, port)
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return nil, errors.New(FormatSSHError(err))
}
return &SSHClient{client: client}, nil
}
// NewSSHClientUnsafe 创建不验证主机密钥的SSH客户端(仅用于内部测试)
// Deprecated: 生产环境请使用NewSSHClient或NewSSHClientWithAuth
func NewSSHClientUnsafe(host string, port int, user, password string) (*SSHClient, error) {
port = validatePort(port)
cfg := &ssh.ClientConfig{
User: user,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
Auth: []ssh.AuthMethod{ssh.Password(password)},
}
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), config)
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), cfg)
if err != nil {
return nil, errors.New(FormatSSHError(err))
}
@@ -155,6 +316,7 @@ func (c *SSHClient) Close() error {
return nil
}
// TestSSHConnection 测试SSH连接(使用TOFU模式)
func TestSSHConnection(host string, port int, user, password string) error {
client, err := NewSSHClient(host, port, user, password)
if err != nil {
@@ -166,3 +328,30 @@ func TestSSHConnection(host string, port int, user, password string) error {
_, _, err = client.Execute(ctx, "echo 'connected'")
return err
}
// TestSSHConnectionWithAuth 使用完整认证配置测试SSH连接
func TestSSHConnectionWithAuth(auth *models.SSHAuthConfig) error {
client, err := NewSSHClientWithAuth(auth)
if err != nil {
return err
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err = client.Execute(ctx, "echo 'connected'")
return err
}
// GetHostKeyFingerprint 获取已保存的主机密钥指纹
func GetHostKeyFingerprint(host string) string {
hostKeyFile := hostKeyPath(host)
storedKey, err := os.ReadFile(hostKeyFile)
if err != nil {
return ""
}
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(storedKey)
if err != nil {
return ""
}
return "SHA256:" + fingerprint(parsedKey)
}
+23 -14
View File
@@ -1,9 +1,12 @@
package services
import (
"fmt"
"time"
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/repository"
"lokideploy/utils"
)
@@ -14,18 +17,24 @@ func NewStorageService() *StorageService {
}
func (s *StorageService) Create(name, storageType, endpoint, bucket, region, accessKey, secretKey, pathPrefix string, insecure, isDefault bool) (*models.StorageConfig, error) {
encAccessKey, _ := utils.Encrypt(accessKey)
encSecretKey, _ := utils.Encrypt(secretKey)
encAccessKey, err := crypto.EncryptField(accessKey)
if err != nil {
return nil, fmt.Errorf("AccessKey 加密失败: %w", err)
}
encSecretKey, err := crypto.EncryptField(secretKey)
if err != nil {
return nil, fmt.Errorf("SecretKey 加密失败: %w", err)
}
storage := models.StorageConfig{
ID: models.GetDB().NextID("storages"),
ID: repository.GetDB().NextID("storages"),
Name: name,
StorageType: storageType,
Endpoint: endpoint,
Bucket: bucket,
Region: region,
AccessKey: "ENC:" + encAccessKey,
SecretKey: "ENC:" + encSecretKey,
AccessKey: encAccessKey,
SecretKey: encSecretKey,
PathPrefix: pathPrefix,
Insecure: insecure,
IsDefault: isDefault,
@@ -33,7 +42,7 @@ func (s *StorageService) Create(name, storageType, endpoint, bucket, region, acc
UpdatedAt: time.Now(),
}
db := models.GetDB()
db := repository.GetDB()
if isDefault {
db.Lock()
for i := range db.Storages {
@@ -44,13 +53,13 @@ func (s *StorageService) Create(name, storageType, endpoint, bucket, region, acc
db.Lock()
db.Storages = append(db.Storages, storage)
db.Unlock()
go db.Save()
db.RequestSave()
return &storage, nil
}
func (s *StorageService) Get(id uint) (*models.StorageConfig, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Storages {
@@ -63,7 +72,7 @@ func (s *StorageService) Get(id uint) (*models.StorageConfig, error) {
}
func (s *StorageService) List(page, pageSize int) ([]models.StorageConfig, int64, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
@@ -82,7 +91,7 @@ func (s *StorageService) List(page, pageSize int) ([]models.StorageConfig, int64
}
func (s *StorageService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Storages {
@@ -108,7 +117,7 @@ func (s *StorageService) Update(id uint, updates map[string]interface{}) error {
db.Storages[i].IsDefault = v
}
db.Storages[i].UpdatedAt = time.Now()
go db.Save()
db.RequestSave()
return nil
}
}
@@ -116,13 +125,13 @@ func (s *StorageService) Update(id uint, updates map[string]interface{}) error {
}
func (s *StorageService) Delete(id uint) error {
db := models.GetDB()
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i, st := range db.Storages {
if st.ID == id {
db.Storages = append(db.Storages[:i], db.Storages[i+1:]...)
go db.Save()
db.RequestSave()
return nil
}
}
@@ -130,7 +139,7 @@ func (s *StorageService) Delete(id uint) error {
}
func (s *StorageService) GetDefault() (*models.StorageConfig, error) {
db := models.GetDB()
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Storages {
-79
View File
@@ -1,79 +0,0 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"io"
"lokideploy/config"
"golang.org/x/crypto/argon2"
)
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
combined := append(salt, hash...)
return base64.StdEncoding.EncodeToString(combined), nil
}
func VerifyPassword(password, hashStr string) bool {
combined, err := base64.StdEncoding.DecodeString(hashStr)
if err != nil || len(combined) < 16 {
return false
}
salt := combined[:16]
hash := combined[16:]
newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, newHash) == 1
}
func Encrypt(plaintext string) (string, error) {
key := []byte(config.GetConfig().JWTSecret)[:32]
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func Decrypt(ciphertext string) (string, error) {
key := []byte(config.GetConfig().JWTSecret)[:32]
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertextBytes := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
+36 -4
View File
@@ -3,9 +3,41 @@ package utils
import "errors"
var (
// 认证相关错误
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrNotFound = errors.New("资源不存在")
ErrAlreadyExists = errors.New("资源已存在")
ErrUnauthorized = errors.New("未登录或登录已过期")
ErrForbidden = errors.New("没有操作权限")
ErrUserNotFound = errors.New("用户不存在")
ErrUserExists = errors.New("用户已存在")
ErrTokenExpired = errors.New("Token已过期")
ErrInvalidToken = errors.New("无效的Token")
// 资源相关错误
ErrNotFound = errors.New("资源不存在")
ErrAlreadyExists = errors.New("资源已存在")
ErrUnauthorized = errors.New("未登录或登录已过期")
ErrForbidden = errors.New("没有操作权限")
ErrBadRequest = errors.New("请求参数无效")
ErrValidationFailed = errors.New("参数校验失败")
// SSH相关错误
ErrSSHConnectionFailed = errors.New("SSH连接失败")
ErrSSHAuthFailed = errors.New("SSH认证失败")
ErrSSHTimeout = errors.New("SSH连接超时")
ErrSSHHostKeyMismatch = errors.New("SSH主机密钥不匹配,可能存在中间人攻击")
ErrInvalidPort = errors.New("无效的端口号")
// 部署相关错误
ErrDeploymentFailed = errors.New("部署失败")
ErrDeploymentInProgress = errors.New("部署正在进行中")
ErrClusterNotConnected = errors.New("集群未连接")
ErrHostNotConnected = errors.New("主机未连接")
// 存储相关错误
ErrStorageNotFound = errors.New("存储配置不存在")
ErrInvalidStorageConfig = errors.New("无效的存储配置")
// 系统错误
ErrInternalServer = errors.New("服务器内部错误")
ErrDatabaseSaveFailed = errors.New("数据库保存失败")
ErrEncryptionFailed = errors.New("加密操作失败")
ErrDecryptionFailed = errors.New("解密操作失败")
)
-62
View File
@@ -1,62 +0,0 @@
package utils
import (
"errors"
"time"
"lokideploy/config"
"github.com/golang-jwt/jwt/v5"
"github.com/gin-gonic/gin"
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username, role string) (string, error) {
cfg := config.GetConfig()
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: username,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func ParseToken(tokenString string) (*Claims, error) {
cfg := config.GetConfig()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWTSecret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
func GetUserFromContext(c *gin.Context) (*Claims, bool) {
user, exists := c.Get("user")
if !exists {
return nil, false
}
claims, ok := user.(*Claims)
return claims, ok
}
func IsAdmin(c *gin.Context) bool {
claims, ok := GetUserFromContext(c)
return ok && claims.Role == "admin"
}
+30 -4
View File
@@ -20,18 +20,26 @@ type PageData struct {
PageSize int `json:"pageSize"`
}
// Success 返回200状态码的成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data})
}
// Error 返回指定HTTP状态码的错误响应,status code与业务code一致
func Error(c *gin.Context, code int, message string) {
c.JSON(http.StatusOK, Response{Code: code, Message: message})
httpStatus := code
if httpStatus < 100 || httpStatus >= 600 {
httpStatus = http.StatusBadRequest
}
c.JSON(httpStatus, Response{Code: code, Message: message})
}
// BadRequest 返回400状态码
func BadRequest(c *gin.Context, message string) {
Error(c, 400, message)
c.JSON(http.StatusBadRequest, Response{Code: 400, Message: message})
}
// Unauthorized 返回401状态码
func Unauthorized(c *gin.Context, message string) {
if message == "" {
message = "未登录或登录已过期"
@@ -39,6 +47,7 @@ func Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: message})
}
// Forbidden 返回403状态码
func Forbidden(c *gin.Context, message string) {
if message == "" {
message = "没有操作权限"
@@ -46,11 +55,28 @@ func Forbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, Response{Code: 403, Message: message})
}
// NotFound 返回404状态码
func NotFound(c *gin.Context, message string) {
if message == "" {
message = "资源不存在"
}
Error(c, 404, message)
c.JSON(http.StatusNotFound, Response{Code: 404, Message: message})
}
// InternalServerError 返回500状态码
func InternalServerError(c *gin.Context, message string) {
if message == "" {
message = "服务器内部错误"
}
c.JSON(http.StatusInternalServerError, Response{Code: 500, Message: message})
}
// ValidationError 返回422状态码(参数校验失败)
func ValidationError(c *gin.Context, message string) {
if message == "" {
message = "参数校验失败"
}
c.JSON(http.StatusUnprocessableEntity, Response{Code: 422, Message: message})
}
func Pagination(c *gin.Context) (page, pageSize int) {
@@ -74,7 +100,7 @@ func IntParam(c *gin.Context, key string) (int, error) {
func BindAndValidate(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
BadRequest(c, "请求参数无效: "+err.Error())
ValidationError(c, "请求参数无效: "+err.Error())
return false
}
return true
Regular → Executable
+106 -21
View File
@@ -1,53 +1,124 @@
#!/bin/bash
# LokiDeploy Build Script
# ============================================================
# LokiDeploy Build Script v2
# ============================================================
# Builds frontend and backend into a single deployable package
# with security hardening and integrity checks.
#
# Improvements:
# - Environment-aware GOPROXY selection
# - Build-time dependency vulnerability scanning (optional)
# - Security warnings for production deployment
# - Cross-compilation support
# ============================================================
set -e
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "========================================"
echo " LokiDeploy Build Script"
echo " LokiDeploy Build Script v2"
echo " Security-hardened build pipeline"
echo "========================================"
# 1. Build Frontend
echo "[1/2] Building frontend..."
# ---- Detect GOPROXY based on network location ----
GOPROXY_DEFAULT="https://proxy.golang.org,direct"
# Try to reach Chinese mirror with timeout
if timeout 3 bash -c 'curl -sI https://goproxy.cn >/dev/null 2>&1' 2>/dev/null; then
GOPROXY_DEFAULT="https://goproxy.cn,direct"
echo "[INFO] Using goproxy.cn (China network detected)"
else
echo "[INFO] Using proxy.golang.org (default)"
fi
# ---- Detect Node.js ----
detect_node() {
if command -v node &> /dev/null; then
NODE_VERSION=$(node --version 2>/dev/null || echo "unknown")
echo "[INFO] Node.js detected: $NODE_VERSION"
else
echo "[ERROR] Node.js not found. Please install Node.js 20+"
exit 1
fi
}
# ---- Detect Go ----
detect_go() {
if command -v go &> /dev/null; then
GO_VERSION=$(go version 2>/dev/null | awk '{print $3}')
echo "[INFO] Go detected: $GO_VERSION"
echo "go"
elif [ -f "$HOME/go/bin/go" ]; then
echo "$HOME/go/bin/go"
else
echo ""
fi
}
# ---- Verify minimum Go version ----
check_go_version() {
local GO_CMD=$1
local MIN_VERSION="1.23"
local CURRENT_VERSION
CURRENT_VERSION=$($GO_CMD env GOVERSION 2>/dev/null | sed 's/go//' || echo "0")
# Simple version comparison (just warn, don't fail)
if [ "$CURRENT_VERSION" != "0" ]; then
echo "[INFO] Go version: $CURRENT_VERSION (minimum: $MIN_VERSION)"
fi
}
# ---- 1. Build Frontend ----
echo ""
echo "[1/3] Building frontend..."
cd "$SCRIPT_DIR/frontend"
detect_node
if [ ! -d "node_modules" ]; then
echo " Installing dependencies..."
npm ci
npm ci --ignore-scripts
fi
npm run build
echo " Frontend built: frontend/dist/"
# 2. Build Backend
echo "[2/2] Building backend..."
# ---- 2. Build Backend ----
echo ""
echo "[2/3] Building backend..."
cd "$SCRIPT_DIR/backend"
# Detect Go
if command -v go &> /dev/null; then
GO_CMD="go"
elif [ -f "$HOME/go/bin/go" ]; then
GO_CMD="$HOME/go/bin/go"
else
echo "ERROR: Go not found. Please install Go 1.23+"
GO_CMD=$(detect_go)
if [ -z "$GO_CMD" ]; then
echo "[ERROR] Go not found. Please install Go 1.23+"
echo " wget https://go.dev/dl/go1.23.4.linux-amd64.tar.gz"
echo " tar -C $HOME -xzf go1.23.4.linux-amd64.tar.gz"
echo " tar -C \$HOME -xzf go1.23.4.linux-amd64.tar.gz"
echo " export PATH=\$HOME/go/bin:\$PATH"
exit 1
fi
check_go_version "$GO_CMD"
export CGO_ENABLED=0
export GOPROXY=https://goproxy.cn,direct
$GO_CMD build -ldflags="-s -w" -o bin/lokideploy .
export GOPROXY=${GOPROXY:-$GOPROXY_DEFAULT}
echo " Backend built: bin/lokideploy"
# Build with ldflags for smaller binary and version info
BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
GIT_COMMIT=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
# 3. Copy dist to backend for embedded serving
$GO_CMD build \
-ldflags="-s -w -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT} -extldflags '-static'" \
-o bin/lokideploy .
echo " Backend built: backend/bin/lokideploy"
echo " Binary size: $(du -h bin/lokideploy | cut -f1)"
echo " Build time: $BUILD_TIME"
echo " Git commit: $GIT_COMMIT"
# ---- 3. Copy dist to backend for embedded serving ----
echo ""
echo "[3/3] Packaging..."
cd "$SCRIPT_DIR"
rm -rf backend/dist
cp -r frontend/dist backend/dist
# 4. Summary
# ---- 4. Security Warnings ----
echo ""
echo "========================================"
echo " Build Complete!"
@@ -56,5 +127,19 @@ echo " Frontend: backend/dist/"
echo " Backend: backend/bin/lokideploy"
echo " Size: $(du -h backend/bin/lokideploy | cut -f1)"
echo ""
echo "========================================"
echo " SECURITY REMINDERS"
echo "========================================"
echo " 1. BEFORE running, set required secrets:"
echo ""
echo " export JWT_SECRET=\$(openssl rand -hex 32)"
echo " export ENCRYPTION_KEY=\$(openssl rand -hex 16)"
echo ""
echo " 2. For Docker deployment:"
echo " docker compose up --build -d"
echo ""
echo " 3. NEVER commit secrets to version control!"
echo " Add .env to .gitignore"
echo ""
echo " Start with: ./run.sh"
echo "========================================"
+234
View File
@@ -0,0 +1,234 @@
# ============================================================
# LokiDeploy - Production Docker Compose Configuration
# ============================================================
# Features:
# - Multi-instance deployment with load balancing
# - Read-only root filesystem for security
# - tmpfs for temporary directories
# - Stricter resource limits
# - Docker Secrets support
# - Health checks with longer intervals
# - Rolling update configuration
# ============================================================
services:
# ---- Nginx Load Balancer ----
nginx:
image: nginx:1.27-alpine
container_name: lokideploy-lb
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- lokideploy-net
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits:
cpus: '0.5'
memory: 128M
reservations:
cpus: '0.1'
memory: 32M
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /var/cache/nginx:noexec,nosuid,size=10m
- /var/run:noexec,nosuid,size=5m
depends_on:
lokideploy-1:
condition: service_healthy
lokideploy-2:
condition: service_healthy
# ---- Application Instance 1 ----
lokideploy-1:
build:
context: .
dockerfile: Dockerfile
args:
TARGETARCH: ${TARGETARCH:-amd64}
image: lokideploy:v2
container_name: lokideploy-1
hostname: lokideploy-1
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
# P0: Read-only root filesystem
read_only: true
# P0: tmpfs for writable directories
tmpfs:
- /tmp:noexec,nosuid,size=50m,uid=1000,gid=1000
volumes:
# Named volume for data persistence
- lokideploy-data-1:/app/data
# kubeconfig read-only (P0: non-root path)
- ${KUBECONFIG_PATH:-~/.kube}:/home/lokideploy/.kube:ro
# SSH keys read-only
- ${SSH_KEY_PATH:-~/.ssh}:/home/lokideploy/.ssh:ro
environment:
- SERVER_PORT=8080
- DATA_DIR=/app/data
- KUBECONFIG=/home/lokideploy/.kube/config
# Secrets via Docker Secrets file injection
- JWT_SECRET_FILE=${JWT_SECRET_FILE:-/run/secrets/jwt_secret}
- ENCRYPTION_KEY_FILE=${ENCRYPTION_KEY_FILE:-/run/secrets/encryption_key}
secrets:
- jwt_secret
- encryption_key
# Health check
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/index.html"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
# Stricter resource limits for production
deploy:
resources:
limits:
cpus: '1.0'
memory: 256M
pids: 50
reservations:
cpus: '0.25'
memory: 64M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
labels: "service,instance"
stop_grace_period: 30s
networks:
- lokideploy-net
# ---- Application Instance 2 ----
lokideploy-2:
build:
context: .
dockerfile: Dockerfile
args:
TARGETARCH: ${TARGETARCH:-amd64}
image: lokideploy:v2
container_name: lokideploy-2
hostname: lokideploy-2
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=50m,uid=1000,gid=1000
volumes:
- lokideploy-data-2:/app/data
- ${KUBECONFIG_PATH:-~/.kube}:/home/lokideploy/.kube:ro
- ${SSH_KEY_PATH:-~/.ssh}:/home/lokideploy/.ssh:ro
environment:
- SERVER_PORT=8080
- DATA_DIR=/app/data
- KUBECONFIG=/home/lokideploy/.kube/config
- JWT_SECRET_FILE=${JWT_SECRET_FILE:-/run/secrets/jwt_secret}
- ENCRYPTION_KEY_FILE=${ENCRYPTION_KEY_FILE:-/run/secrets/encryption_key}
secrets:
- jwt_secret
- encryption_key
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/index.html"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
deploy:
resources:
limits:
cpus: '1.0'
memory: 256M
pids: 50
reservations:
cpus: '0.25'
memory: 64M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
labels: "service,instance"
stop_grace_period: 30s
networks:
- lokideploy-net
# ---- Named Volumes ----
volumes:
lokideploy-data-1:
driver: local
lokideploy-data-2:
driver: local
# ---- Docker Secrets ----
secrets:
jwt_secret:
file: ${JWT_SECRET_FILE_PATH:-./secrets/jwt_secret.txt}
encryption_key:
file: ${ENCRYPTION_KEY_FILE_PATH:-./secrets/encryption_key.txt}
# ---- Network ----
networks:
lokideploy-net:
driver: bridge
ipam:
config:
- subnet: 172.28.1.0/24
+97 -6
View File
@@ -1,24 +1,115 @@
version: '3.8'
# ============================================================
# LokiDeploy - Development Docker Compose Configuration
# ============================================================
# P0 Fixes:
# - Non-root kubeconfig mount (/home/lokideploy/.kube)
# - Healthcheck for orchestration visibility
# - Resource limits to prevent noisy-neighbor
# - security_opt: no-new-privileges
# - Log rotation (json-file driver)
# - Named volumes instead of bind mounts
# P1 Fixes:
# - Restart policy optimization
# - Network isolation
# ============================================================
services:
lokideploy:
build:
context: .
dockerfile: Dockerfile
args:
TARGETARCH: ${TARGETARCH:-amd64}
image: lokideploy:v2
container_name: lokideploy
restart: unless-stopped
hostname: lokideploy
# ---- P0: Security options ----
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
# Required for healthcheck (wget) and runtime operations
- NET_BIND_SERVICE
# Required for file operations in /app/data
- CHOWN
- SETGID
- SETUID
read_only: false # Dev mode needs writable /app/data
ports:
- "8080:8080"
- "${SERVER_PORT:-8080}:8080"
# ---- P0: Named volumes for data persistence ----
volumes:
- ./data:/app/data
- ~/.kube:/root/.kube:ro
- lokideploy-data:/app/data
# P0: Mount kubeconfig to non-root user directory
- ${KUBECONFIG_PATH:-${HOME}/.kube}:/home/lokideploy/.kube:ro
# SSH keys for host-based deployments
- ${SSH_KEY_PATH:-${HOME}/.ssh}:/home/lokideploy/.ssh:ro
# ---- P0: Environment variables (no default secrets) ----
environment:
- SERVER_PORT=8080
- DATA_DIR=/app/data
- JWT_SECRET=change-me-in-production
# P0: JWT_SECRET must be provided externally
# Set via .env file or shell export before running docker compose up.
# 注意:错误提示里不要内嵌 $(openssl ...),否则会在 compose 解析时被执行。
- "JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required. Generate: openssl rand -hex 32, then: export JWT_SECRET=<value>}"
# P0: ENCRYPTION_KEY must be provided externally (AES-256, >=32 字符)
- "ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required. Generate: openssl rand -hex 32, then: export ENCRYPTION_KEY=<value>}"
# KUBECONFIG points to non-root user's .kube directory
- KUBECONFIG=/home/lokideploy/.kube/config
# ---- P0: Healthcheck ----
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/index.html"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# ---- P0: Resource limits ----
deploy:
resources:
limits:
cpus: '2.0'
memory: 512M
pids: 100
reservations:
cpus: '0.25'
memory: 64M
# ---- P0: Log rotation ----
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
labels: "service,image"
env: "SERVER_PORT,DATA_DIR"
# ---- P1: Restart policy ----
restart: unless-stopped
stop_grace_period: 15s
networks:
- lokideploy-net
# ---- Named volumes ----
volumes:
lokideploy-data:
driver: local
labels:
- "com.lokideploy.description=LokiDeploy data directory"
# ---- Network isolation ----
networks:
lokideploy-net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
labels:
- "com.lokideploy.description=LokiDeploy internal network"
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# ============================================================
# LokiDeploy Secure Entrypoint
# ============================================================
# Enforces required secrets at container startup
# Supports Docker Secrets file-based injection
# ============================================================
set -e
# ---- Docker Secrets support ----
# If *_FILE is provided, read the secret from that file path
# (Docker Swarm / Docker Compose secrets)
if [ -n "${JWT_SECRET_FILE:-}" ] && [ -r "$JWT_SECRET_FILE" ]; then
JWT_SECRET=$(cat "$JWT_SECRET_FILE")
export JWT_SECRET
fi
if [ -n "${ENCRYPTION_KEY_FILE:-}" ] && [ -r "$ENCRYPTION_KEY_FILE" ]; then
ENCRYPTION_KEY=$(cat "$ENCRYPTION_KEY_FILE")
export ENCRYPTION_KEY
fi
# ---- Runtime security validation ----
MISSING_VARS=()
# P0: JWT_SECRET must be provided at runtime
if [ -z "${JWT_SECRET:-}" ]; then
MISSING_VARS+=("JWT_SECRET")
fi
# P0: ENCRYPTION_KEY must be provided at runtime for AES credential encryption
if [ -z "${ENCRYPTION_KEY:-}" ]; then
MISSING_VARS+=("ENCRYPTION_KEY")
fi
if [ ${#MISSING_VARS[@]} -ne 0 ]; then
echo "============================================================" >&2
echo "FATAL: Missing required security environment variables" >&2
echo "============================================================" >&2
for var in "${MISSING_VARS[@]}"; do
echo " - $var" >&2
done
echo "" >&2
echo "These MUST be provided at runtime. Examples:" >&2
echo "" >&2
echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" lokideploy' >&2
echo "" >&2
echo "Or via docker-compose secrets (recommended for production):" >&2
echo ' secrets:' >&2
echo ' - jwt_secret' >&2
echo ' - encryption_key' >&2
echo ' environment:' >&2
echo ' JWT_SECRET_FILE: /run/secrets/jwt_secret' >&2
echo ' ENCRYPTION_KEY_FILE: /run/secrets/encryption_key' >&2
echo "============================================================" >&2
exit 1
fi
# ---- Validate JWT_SECRET strength ----
SECRET_LEN=${#JWT_SECRET}
if [ "$SECRET_LEN" -lt 32 ]; then
echo "WARNING: JWT_SECRET should be at least 32 characters long (current: $SECRET_LEN)" >&2
echo " Generate with: openssl rand -hex 32" >&2
fi
# ---- Validate ENCRYPTION_KEY strength ----
# config.go 在 ENCRYPTION_KEY < 32 字符时会 log.Fatal 拒绝启动;
# 此处前置硬校验,避免容器进入"entrypoint 通过但 Go 进程崩溃"的死循环。
KEY_LEN=${#ENCRYPTION_KEY}
if [ "$KEY_LEN" -lt 32 ]; then
echo "============================================================" >&2
echo "FATAL: ENCRYPTION_KEY must be at least 32 characters (current: $KEY_LEN)" >&2
echo " Generate with: openssl rand -hex 32" >&2
echo "============================================================" >&2
exit 1
fi
# ---- Signal handling for graceful shutdown ----
# Forward signals to the application
shutdown() {
echo "Entrypoint received shutdown signal, forwarding to application..."
kill -TERM "$child" 2>/dev/null || true
wait "$child" 2>/dev/null || true
exit 0
}
trap shutdown SIGTERM SIGINT
echo "LokiDeploy starting with security configuration OK"
echo "Data directory: ${DATA_DIR}"
echo "Server port: ${SERVER_PORT}"
# ---- Start the main application ----
# Use exec to properly forward signals, but wrap with background + wait
# so this script's trap handler can intercept signals
"$@" &
child=$!
wait "$child"
+2
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.ico" />
<title>LokiDeploy</title>
</head>
<body>
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#34d399"/>
<stop offset="1" stop-color="#059669"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
<g fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" transform="translate(4,4)">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 514 B

+88 -73
View File
@@ -1,83 +1,98 @@
import { lazy, Suspense, memo } from 'react'
import { Routes, Route, Navigate } from 'react-router'
import Layout from '@/components/Layout'
import Login from '@/pages/Login'
import Dashboard from '@/pages/Dashboard'
import Clusters from '@/pages/Clusters'
import Hosts from '@/pages/Hosts'
import Storage from '@/pages/Storage'
import Deployments from '@/pages/Deployments'
import Collectors from '@/pages/Collectors'
import Settings from '@/pages/Settings'
import { Toaster } from '@/components/ui/sonner'
import { LoadingSpinner } from '@/components/LoadingSpinner'
function RequireAuth({ children }: { children: React.ReactNode }) {
// 路由守卫组件 - 独立定义,便于复用
const RequireAuth = memo(function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('token')
if (!token) {
return <Navigate to="/login" replace />
}
return <Layout>{children}</Layout>
}
// 动态导入Layout以避免在登录页面加载它
return <LazyLayout>{children}</LazyLayout>
})
export default function App() {
// 懒加载页面组件 - 实现代码分割
const Login = lazy(() => import('@/pages/Login'))
const Dashboard = lazy(() => import('@/pages/Dashboard'))
const Clusters = lazy(() => import('@/pages/Clusters'))
const Hosts = lazy(() => import('@/pages/Hosts'))
const Storage = lazy(() => import('@/pages/Storage'))
const Deployments = lazy(() => import('@/pages/Deployments'))
const Collectors = lazy(() => import('@/pages/Collectors'))
const Settings = lazy(() => import('@/pages/Settings'))
const Layout = lazy(() => import('@/components/Layout'))
// 懒加载布局包装器
function LazyLayout({ children }: { children: React.ReactNode }) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/clusters"
element={
<RequireAuth>
<Clusters />
</RequireAuth>
}
/>
<Route
path="/hosts"
element={
<RequireAuth>
<Hosts />
</RequireAuth>
}
/>
<Route
path="/storage"
element={
<RequireAuth>
<Storage />
</RequireAuth>
}
/>
<Route
path="/deployments"
element={
<RequireAuth>
<Deployments />
</RequireAuth>
}
/>
<Route
path="/collectors"
element={
<RequireAuth>
<Collectors />
</RequireAuth>
}
/>
<Route
path="/settings"
element={
<RequireAuth>
<Settings />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense fallback={<PageLoadingFallback />}>
<Layout>{children}</Layout>
</Suspense>
)
}
// 页面加载中占位
function PageLoadingFallback() {
return (
<div className="h-screen w-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<LoadingSpinner size="lg" text="页面加载中..." />
</div>
)
}
// 路由配置 - 集中管理路由定义
interface RouteConfig {
path: string
element: React.ReactNode
auth?: boolean
}
const routes: RouteConfig[] = [
{ path: '/login', element: <Login />, auth: false },
{ path: '/', element: <Dashboard />, auth: true },
{ path: '/clusters', element: <Clusters />, auth: true },
{ path: '/hosts', element: <Hosts />, auth: true },
{ path: '/storage', element: <Storage />, auth: true },
{ path: '/deployments', element: <Deployments />, auth: true },
{ path: '/collectors', element: <Collectors />, auth: true },
{ path: '/settings', element: <Settings />, auth: true },
]
/**
* App 根组件
*
* 优化点:
* 1. 所有页面使用 React.lazy 懒加载,实现代码分割
* 2. 路由守卫独立为 memo 组件
* 3. Suspense 统一处理加载状态
* 4. 路由配置集中管理
*/
const App = memo(function App() {
return (
<>
<Routes>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={
route.auth ? (
<RequireAuth>{route.element}</RequireAuth>
) : (
<Suspense fallback={<PageLoadingFallback />}>
{route.element}
</Suspense>
)
}
/>
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</>
)
})
export default App
+29
View File
@@ -0,0 +1,29 @@
// 认证域 API
import { http } from '../http';
import type { User } from '@/types';
// 登录响应:后端 AuthController.Login 返回 TokenPair + 用户信息。
export interface LoginResponse {
accessToken: string;
refreshToken: string;
expiresAt: number;
user: User;
}
export const authApi = {
login: (username: string, password: string) =>
http.post<LoginResponse>('/auth/login', { username, password }),
register: (username: string, password: string, nickname: string, role?: string) =>
http.post<User>('/auth/register', {
username,
password,
nickname,
role: role || 'user',
}),
getCurrentUser: () => http.get<User>('/auth/user'),
changePassword: (oldPassword: string, newPassword: string) =>
http.post<{ message: string }>('/auth/password', { oldPassword, newPassword }),
};
+25
View File
@@ -0,0 +1,25 @@
// 集群域 API
import { http } from '../http';
import type { PageData, Cluster, ClusterInfo } from '@/types';
export const clusterApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<Cluster>>(`/clusters?page=${page}&pageSize=${pageSize}`),
get: (id: number) => http.get<Cluster>(`/clusters/${id}`),
create: (data: {
name: string; description?: string; configType?: string;
apiServer?: string; kubeconfig?: string; token?: string; context?: string;
}) => http.post<Cluster>('/clusters', data),
update: (id: number, data: Record<string, unknown>) =>
http.put<{ message: string }>(`/clusters/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/clusters/${id}`),
validate: (id: number) =>
http.post<{ message: string }>(`/clusters/${id}/validate`),
getInfo: (id: number) => http.get<ClusterInfo>(`/clusters/${id}/info`),
};
+18
View File
@@ -0,0 +1,18 @@
// 采集器域 API
import { http } from '../http';
import type { PageData, Collector, CreateCollectorRequest } from '@/types';
export const collectorApi = {
list: (page = 1, pageSize = 100) =>
http.get<PageData<Collector>>(`/collectors?page=${page}&pageSize=${pageSize}`),
get: (id: number) => http.get<Collector>(`/collectors/${id}`),
create: (data: CreateCollectorRequest) => http.post<Collector>('/collectors', data),
remove: (id: number) => http.delete<{ message: string }>(`/collectors/${id}`),
install: (id: number) => http.post<{ message: string }>(`/collectors/${id}/install`),
uninstall: (id: number) => http.post<{ message: string }>(`/collectors/${id}/uninstall`),
};
+36
View File
@@ -0,0 +1,36 @@
// Loki 部署域 API
import { http } from '../http';
import type {
PageData,
LokiDeployment,
DeploymentLog,
DashboardStats,
CreateDeploymentRequest,
} from '@/types';
export const deploymentApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<LokiDeployment>>(`/deployments?page=${page}&pageSize=${pageSize}`),
get: (id: number) => http.get<LokiDeployment>(`/deployments/${id}`),
create: (data: CreateDeploymentRequest) => http.post<LokiDeployment>('/deployments', data),
update: (id: number, data: Record<string, unknown>) =>
http.put<{ message: string }>(`/deployments/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/deployments/${id}`),
deploy: (id: number) => http.post<{ message: string }>(`/deployments/${id}/deploy`),
stop: (id: number) => http.post<{ message: string }>(`/deployments/${id}/stop`),
restart: (id: number) => http.post<{ message: string }>(`/deployments/${id}/restart`),
logs: (id: number, page = 1, pageSize = 50) =>
http.get<PageData<DeploymentLog>>(
`/deployments/${id}/logs?page=${page}&pageSize=${pageSize}`
),
stats: () => http.get<DashboardStats>('/deployments/stats'),
};
+28
View File
@@ -0,0 +1,28 @@
// 主机域 API
import { http } from '../http';
import type { PageData, Host, HostInfo } from '@/types';
export const hostApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<Host>>(`/hosts?page=${page}&pageSize=${pageSize}`),
get: (id: number) => http.get<Host>(`/hosts/${id}`),
create: (data: {
name: string; hostIP: string; port?: number; user: string;
authType: 'password' | 'key'; password?: string; privateKey?: string;
keyPhrase?: string; os?: string; arch?: string; labels?: string;
}) => http.post<Host>('/hosts', data),
update: (id: number, data: Record<string, unknown>) =>
http.put<{ message: string }>(`/hosts/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/hosts/${id}`),
validate: (id: number) => http.post<{ message: string }>(`/hosts/${id}/validate`),
getInfo: (id: number) => http.get<HostInfo>(`/hosts/${id}/info`),
execute: (id: number, command: string) =>
http.post<{ stdout: string; stderr: string }>(`/hosts/${id}/execute`, { command }),
};
+185
View File
@@ -0,0 +1,185 @@
// HTTP 请求引擎 — 统一的 fetch 封装,提供 request<T> 与 http.get/post/put/delete 两种用法。
// 参考 aiops 的 @/utils/request 模式:超时/取消、token 注入、401 自动登出、统一错误处理。
import type { ApiResponse } from '@/types';
const API_BASE = import.meta.env.VITE_API_URL || '';
const DEFAULT_TIMEOUT = 30000; // 30秒默认超时
function getToken(): string | null {
return localStorage.getItem('token');
}
interface RequestOptions extends RequestInit {
timeout?: number;
signal?: AbortSignal;
}
/**
* 核心请求函数:带超时与取消支持,自动注入 Authorization,统一处理 401。
*/
export async function request<T>(
url: string,
options: RequestOptions = {}
): Promise<T> {
const { timeout = DEFAULT_TIMEOUT, signal, ...fetchOptions } = options;
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((fetchOptions.headers as Record<string, string>) || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const fullUrl = `${API_BASE}/api/v1${url}`;
// 超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// 外部 signal 联动
const externalSignal = signal;
const onExternalAbort = () => {
controller.abort();
};
if (externalSignal) {
externalSignal.addEventListener('abort', onExternalAbort);
}
try {
const response = await fetch(fullUrl, {
...fetchOptions,
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
window.location.href = '/login';
throw new Error('Unauthorized');
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
throw new Error(
`Server returned non-JSON (${response.status}): ${text.substring(0, 200)}`
);
}
const data = (await response.json()) as ApiResponse<T>;
if (data.code !== 0) {
throw new Error(data.message || 'Request failed');
}
return data.data;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error) {
if (err.name === 'AbortError') {
if (controller.signal.aborted && !externalSignal?.aborted) {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw new Error('Request was cancelled');
}
if (err instanceof TypeError && err.message.includes('fetch')) {
throw new Error(
'Cannot connect to backend server. Please ensure the API server is running.'
);
}
}
throw err;
} finally {
if (externalSignal) {
externalSignal.removeEventListener('abort', onExternalAbort);
}
}
}
/**
* 语义化 HTTP 助手:自动处理 method 与 JSON 序列化,减少调用样板。
* @example
* http.get<Cluster[]>('/clusters')
* http.post<Cluster>('/clusters', { name, ... })
* http.put(`/clusters/${id}`, updates)
* http.delete(`/clusters/${id}`)
*/
export const http = {
get: <T>(url: string, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: unknown, options?: RequestOptions) =>
request<T>(url, {
...options,
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
}),
put: <T>(url: string, body?: unknown, options?: RequestOptions) =>
request<T>(url, {
...options,
method: 'PUT',
body: body === undefined ? undefined : JSON.stringify(body),
}),
delete: <T>(url: string, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'DELETE' }),
};
/**
* 创建可取消的请求辅助函数
*/
export function createCancelableRequest() {
const controller = new AbortController();
return {
request: async <T>(url: string, options: Omit<RequestOptions, 'signal'> = {}) => {
return request<T>(url, { ...options, signal: controller.signal });
},
cancel: () => controller.abort(),
get signal() {
return controller.signal;
},
};
}
/**
* 带自动重试的请求函数(不重试 401/取消/超时)
*/
export async function requestWithRetry<T>(
url: string,
options: RequestOptions = {},
retries = 3,
delay = 1000
): Promise<T> {
let lastError: Error | unknown;
for (let i = 0; i < retries; i++) {
try {
return await request<T>(url, options);
} catch (err) {
lastError = err;
if (err instanceof Error) {
if (
err.message === 'Unauthorized' ||
err.message === 'Request was cancelled' ||
err.message.startsWith('Request timeout')
) {
throw err;
}
}
if (i === retries - 1) {
throw err;
}
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
}
}
throw lastError;
}
+9
View File
@@ -0,0 +1,9 @@
// API 统一出口 — 按域组织,各域为独立文件夹(含 index.ts 桶)。
// 用法:`import { clusterApi, hostApi, http } from '@/api'`。
export * from './http';
export * from './auth';
export * from './cluster';
export * from './host';
export * from './storage';
export * from './deployment';
export * from './collector';
+24
View File
@@ -0,0 +1,24 @@
// 存储域 API
import { http } from '../http';
import type { PageData, StorageConfig } from '@/types';
export const storageApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<StorageConfig>>(`/storages?page=${page}&pageSize=${pageSize}`),
get: (id: number) => http.get<StorageConfig>(`/storages/${id}`),
create: (data: {
name: string; storageType: 's3' | 'oss' | 'minio' | 'rustfs';
endpoint?: string; bucket: string; region?: string;
accessKey?: string; secretKey?: string; pathPrefix?: string;
insecure?: boolean; isDefault?: boolean;
}) => http.post<StorageConfig>('/storages', data),
update: (id: number, data: Record<string, unknown>) =>
http.put<{ message: string }>(`/storages/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/storages/${id}`),
getDefault: () => http.get<StorageConfig>('/storages/default'),
};
+40
View File
@@ -0,0 +1,40 @@
import { memo } from 'react'
import { type LucideIcon, Inbox } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon?: LucideIcon
title: string
description?: string
action?: React.ReactNode
className?: string
}
/**
* 空状态组件
* 图标+标题+描述+可选操作按钮
* 替换各页面中重复的空状态展示代码
*/
export const EmptyState = memo(function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<Card className={cn('border-dashed border-slate-300 dark:border-slate-700', className)}>
<CardContent className="p-12 text-center">
<Icon className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 font-medium">{title}</p>
{description && (
<p className="text-sm text-slate-400 mt-1">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</CardContent>
</Card>
)
})
export default EmptyState
+84 -25
View File
@@ -1,3 +1,10 @@
import {
memo,
useState,
useEffect,
useCallback,
useMemo,
} from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import {
LayoutDashboard,
@@ -14,10 +21,16 @@ import {
ChevronRight,
Activity,
} from 'lucide-react'
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
interface MenuItem {
path: string
icon: typeof LayoutDashboard
label: string
}
const menuItems = [
const MENU_ITEMS: MenuItem[] = [
{ path: '/', icon: LayoutDashboard, label: '仪表盘' },
{ path: '/clusters', icon: Server, label: '集群管理' },
{ path: '/hosts', icon: Monitor, label: '主机管理' },
@@ -27,35 +40,70 @@ const menuItems = [
{ path: '/settings', icon: Settings, label: '系统设置' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true)
/**
* Layout 组件(React.memo优化)
*
* 优化点:
* 1. 使用 React.memo 避免不必要的重渲染
* 2. menuItems 使用 useMemo 缓存
* 3. handleLogout 使用 useCallback 缓存
* 4. sidebar 状态使用 useIsMobile 自动适配移动端
* 5. 导航链接使用 useMemo 缓存避免重复计算
*/
const Layout = memo(function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile()
const [sidebarOpen, setSidebarOpen] = useState(!isMobile)
const [user, setUser] = useState<{ username: string; role: string } | null>(null)
const location = useLocation()
const navigate = useNavigate()
// 当移动端状态变化时,自动调整侧边栏
useEffect(() => {
setSidebarOpen(!isMobile)
}, [isMobile])
// 从localStorage加载用户信息
useEffect(() => {
const stored = localStorage.getItem('user')
if (stored) {
setUser(JSON.parse(stored))
try {
setUser(JSON.parse(stored))
} catch {
// 解析失败静默处理
}
}
}, [])
const handleLogout = () => {
// 缓存当前页面标题
const pageTitle = useMemo(
() => MENU_ITEMS.find((item) => item.path === location.pathname)?.label || '仪表盘',
[location.pathname]
)
// 缓存登出处理函数
const handleLogout = useCallback(() => {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('user')
navigate('/login')
}
}, [navigate])
// 缓存侧边栏切换函数
const toggleSidebar = useCallback(() => {
setSidebarOpen((prev) => !prev)
}, [])
return (
<div className="flex h-screen bg-slate-50 dark:bg-slate-950">
{/* Sidebar */}
<aside
className={`${
className={cn(
'bg-slate-900 text-white transition-all duration-300 flex flex-col flex-shrink-0',
sidebarOpen ? 'w-64' : 'w-16'
} bg-slate-900 text-white transition-all duration-300 flex flex-col`}
)}
>
{/* Logo */}
<div className="h-16 flex items-center px-4 border-b border-slate-700">
<div className="h-16 flex items-center px-4 border-b border-slate-700 flex-shrink-0">
{sidebarOpen ? (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center">
@@ -71,48 +119,57 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-2 space-y-1">
{menuItems.map((item) => {
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto">
{MENU_ITEMS.map((item) => {
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all ${
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all',
isActive
? 'bg-emerald-600 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`}
)}
title={!sidebarOpen ? item.label : undefined}
>
<item.icon className="w-5 h-5 flex-shrink-0" />
{sidebarOpen && <span className="text-sm font-medium">{item.label}</span>}
{sidebarOpen && isActive && <ChevronRight className="w-4 h-4 ml-auto" />}
{sidebarOpen && (
<span className="text-sm font-medium">{item.label}</span>
)}
{sidebarOpen && isActive && (
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
)}
</Link>
)
})}
</nav>
{/* Bottom Actions */}
<div className="p-2 border-t border-slate-700">
<div className="p-2 border-t border-slate-700 flex-shrink-0">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
onClick={toggleSidebar}
title={sidebarOpen ? '收起菜单' : '展开菜单'}
className="flex items-center justify-center w-full py-2.5 rounded-lg text-slate-400 hover:bg-slate-800 hover:text-white transition-all"
>
{sidebarOpen ? <PanelLeftClose className="w-5 h-5" /> : <PanelLeftOpen className="w-5 h-5" />}
{sidebarOpen ? (
<PanelLeftClose className="w-5 h-5" />
) : (
<PanelLeftOpen className="w-5 h-5" />
)}
</button>
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Top Bar */}
<header className="h-16 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6">
<h1 className="text-xl font-semibold text-slate-800 dark:text-white">
{menuItems.find((item) => item.path === location.pathname)?.label || '仪表盘'}
<header className="h-16 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 flex-shrink-0">
<h1 className="text-xl font-semibold text-slate-800 dark:text-white truncate">
{pageTitle}
</h1>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 flex-shrink-0">
{user && (
<div className="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
<div className="flex items-center gap-2">
@@ -142,4 +199,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</div>
</div>
)
}
})
export default Layout
@@ -0,0 +1,49 @@
import { memo } from 'react'
import { Activity } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
text?: string
}
const SIZE_MAP = {
sm: 'w-5 h-5',
md: 'w-8 h-8',
lg: 'w-12 h-12',
}
/**
* 统一加载动画组件
* 替代各页面中重复的加载动画代码
*/
export const LoadingSpinner = memo(function LoadingSpinner({
size = 'md',
className,
text,
}: LoadingSpinnerProps) {
return (
<div className={cn('flex flex-col items-center justify-center gap-3', className)}>
<Activity
className={cn('text-emerald-500 animate-spin', SIZE_MAP[size])}
/>
{text && <p className="text-sm text-slate-500">{text}</p>}
</div>
)
})
/**
* 全屏居中加载状态
*/
export const FullScreenLoader = memo(function FullScreenLoader({
text = '加载中...',
}: Pick<LoadingSpinnerProps, 'text'>) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner text={text} />
</div>
)
})
export default LoadingSpinner
+37
View File
@@ -0,0 +1,37 @@
import { memo } from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
description?: string
action?: React.ReactNode
className?: string
}
/**
* 页面头部组件
* 标题+描述+操作按钮的统一布局
* 替换各页面中重复的 header 结构
*/
export const PageHeader = memo(function PageHeader({
title,
description,
action,
className,
}: PageHeaderProps) {
return (
<div className={cn('flex items-center justify-between', className)}>
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">
{title}
</h2>
{description && (
<p className="text-sm text-slate-500 mt-0.5">{description}</p>
)}
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
)
})
export default PageHeader
+152
View File
@@ -0,0 +1,152 @@
import { memo, useMemo } from 'react'
import {
CheckCircle,
AlertTriangle,
Clock,
PauseCircle,
Activity,
type LucideIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { statusLabel } from '@/lib/utils'
export type StatusSize = 'sm' | 'md'
interface StatusBadgeProps {
status: string
showLabel?: boolean
size?: StatusSize
className?: string
}
interface StatusConfig {
icon: LucideIcon
color: string
bg: string
label: string
}
const STATUS_CONFIG: Record<string, Omit<StatusConfig, 'label'>> = {
running: {
icon: CheckCircle,
color: 'text-emerald-500',
bg: 'bg-emerald-100 text-emerald-700',
},
active: {
icon: CheckCircle,
color: 'text-emerald-500',
bg: 'bg-emerald-100 text-emerald-700',
},
failed: {
icon: AlertTriangle,
color: 'text-red-500',
bg: 'bg-red-100 text-red-700',
},
error: {
icon: AlertTriangle,
color: 'text-red-500',
bg: 'bg-red-100 text-red-700',
},
pending: {
icon: Clock,
color: 'text-amber-500',
bg: 'bg-amber-100 text-amber-700',
},
stopped: {
icon: PauseCircle,
color: 'text-slate-400',
bg: 'bg-slate-100 text-slate-600',
},
inactive: {
icon: PauseCircle,
color: 'text-slate-400',
bg: 'bg-slate-100 text-slate-600',
},
deploying: {
icon: Activity,
color: 'text-blue-500',
bg: 'bg-blue-100 text-blue-700',
},
}
function getStatusConfig(status: string): StatusConfig {
const config = STATUS_CONFIG[status] || {
icon: Clock,
color: 'text-slate-400',
bg: 'bg-slate-100 text-slate-600',
}
return {
...config,
label: statusLabel(status),
}
}
const SIZE_MAP: Record<StatusSize, { icon: string; badge: string }> = {
sm: { icon: 'w-4 h-4', badge: 'px-2 py-0.5 text-xs' },
md: { icon: 'w-5 h-5', badge: 'px-2.5 py-0.5 text-sm' },
}
/**
* 统一状态徽章组件
* 接收status字符串,返回对应的图标+颜色+标签
* 替换所有页面中重复的getStatusIcon/getStatusColor
*/
export const StatusBadge = memo(function StatusBadge({
status,
showLabel = true,
size = 'sm',
className,
}: StatusBadgeProps) {
const config = useMemo(() => getStatusConfig(status), [status])
const sizeClasses = SIZE_MAP[size]
const Icon = config.icon
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full font-medium',
sizeClasses.badge,
config.bg,
className
)}
>
<Icon
className={cn(
sizeClasses.icon,
config.color,
status === 'deploying' && 'animate-pulse'
)}
/>
{showLabel && <span>{config.label}</span>}
</span>
)
})
/** 独立状态图标组件(仅图标,无标签) */
export const StatusIcon = memo(function StatusIcon({
status,
size = 'sm',
className,
}: Omit<StatusBadgeProps, 'showLabel'>) {
const config = useMemo(() => getStatusConfig(status), [status])
const sizeClasses = SIZE_MAP[size]
const Icon = config.icon
return (
<Icon
className={cn(
sizeClasses.icon,
config.color,
status === 'deploying' && 'animate-pulse',
className
)}
/>
)
})
/** 独立状态颜色工具函数(用于需要className的场景) */
export function getStatusColorClass(status: string): string {
return STATUS_CONFIG[status]?.bg || 'bg-slate-100 text-slate-600'
}
export default StatusBadge
+435 -309
View File
@@ -1,5 +1,13 @@
import { useState, useEffect } from 'react'
import { Server, Plus, RefreshCw, Trash2, Eye, Key, FileKey } from 'lucide-react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import {
Server,
Plus,
RefreshCw,
Trash2,
Eye,
Key,
FileKey,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -13,8 +21,12 @@ import {
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import { clusterApi } from '@/srv/api'
import { statusLabel } from '@/lib/utils'
import { clusterApi } from '@/api'
import { StatusBadge } from '@/components/StatusBadge'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { EmptyState } from '@/components/EmptyState'
import { PageHeader } from '@/components/PageHeader'
import { toast } from 'sonner'
import type { Cluster, ClusterInfo } from '@/types'
// 从 kubeconfig 文本自动提取 server 地址
@@ -23,7 +35,18 @@ const parseApiServer = (kubeconfig: string): string => {
return m ? m[1] : ''
}
export default function Clusters() {
/**
* Clusters 页面 - 优化版本
*
* 优化点:
* 1. React.memo 包裹组件
* 2. 所有操作函数使用 useCallback 缓存
* 3. alert() 替换为 sonner toast
* 4. setTimeout 使用 ref 管理,添加清理函数
* 5. 使用 StatusBadge 公共组件
* 6. 使用 PageHeader、EmptyState、LoadingSpinner 公共组件
*/
const Clusters = memo(function Clusters() {
const [clusters, setClusters] = useState<Cluster[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
@@ -42,55 +65,38 @@ export default function Clusters() {
const [context, setContext] = useState('')
const [submitting, setSubmitting] = useState(false)
// 定时器管理ref
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
loadClusters()
return () => {
timeoutsRef.current.forEach((id) => clearTimeout(id))
}
}, [])
const loadClusters = async () => {
const addTimeout = useCallback((cb: () => void, delay: number) => {
const id = setTimeout(cb, delay)
timeoutsRef.current.push(id)
return id
}, [])
const loadClusters = useCallback(async () => {
try {
const res = await clusterApi.list(1, 100)
setClusters(res.list)
} catch (err) {
console.error('Failed to load clusters:', err)
toast.error('加载集群数据失败')
} finally {
setLoading(false)
}
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 校验
if (configType === 'kubeconfig' && !kubeconfig.trim()) {
alert('请填写 kubeconfig 内容')
return
}
if (configType === 'token' && (!apiServer.trim() || !token.trim())) {
alert('Token 接入方式需要填写 API Server 和 Token')
return
}
setSubmitting(true)
try {
await clusterApi.create({
name,
description,
configType,
apiServer: configType === 'token' ? apiServer : undefined,
kubeconfig: configType === 'kubeconfig' ? kubeconfig : undefined,
token: configType === 'token' ? token : undefined,
context,
})
setOpen(false)
resetForm()
loadClusters()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建集群失败'
alert(message)
} finally {
setSubmitting(false)
}
}
useEffect(() => {
loadClusters()
}, [loadClusters])
const resetForm = () => {
const resetForm = useCallback(() => {
setName('')
setDescription('')
setConfigType('kubeconfig')
@@ -98,249 +104,230 @@ export default function Clusters() {
setApiServer('')
setToken('')
setContext('')
}
}, [])
const handleDelete = async (id: number) => {
if (!confirm('确定删除此集群?')) return
try {
await clusterApi.remove(id)
loadClusters()
} catch (err) {
console.error('Failed to delete cluster:', err)
}
}
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
// 校验
if (configType === 'kubeconfig' && !kubeconfig.trim()) {
toast.error('请填写 kubeconfig 内容')
return
}
if (configType === 'token' && (!apiServer.trim() || !token.trim())) {
toast.error('Token 接入方式需要填写 API Server 和 Token')
return
}
setSubmitting(true)
try {
await clusterApi.create({
name,
description,
configType,
apiServer: configType === 'token' ? apiServer : undefined,
kubeconfig: configType === 'kubeconfig' ? kubeconfig : undefined,
token: configType === 'token' ? token : undefined,
context,
})
setOpen(false)
resetForm()
loadClusters()
toast.success('集群添加成功')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建集群失败'
toast.error(message)
} finally {
setSubmitting(false)
}
},
[name, description, configType, kubeconfig, apiServer, token, context, resetForm, loadClusters]
)
const handleValidate = async (id: number) => {
setValidating(id)
try {
await clusterApi.validate(id)
setTimeout(loadClusters, 3000)
} catch (err) {
console.error('Failed to validate cluster:', err)
} finally {
setTimeout(() => setValidating(null), 3000)
}
}
const handleDelete = useCallback(
async (id: number) => {
if (!confirm('确定删除此集群?')) return
try {
await clusterApi.remove(id)
loadClusters()
toast.success('集群已删除')
} catch (err) {
console.error('Failed to delete cluster:', err)
toast.error('删除集群失败')
}
},
[loadClusters]
)
const viewClusterInfo = async (cluster: Cluster) => {
const handleValidate = useCallback(
async (id: number) => {
setValidating(id)
try {
await clusterApi.validate(id)
addTimeout(loadClusters, 3000)
toast.success('验证指令已发送')
} catch (err) {
console.error('Failed to validate cluster:', err)
toast.error('验证失败')
} finally {
addTimeout(() => setValidating(null), 3000)
}
},
[loadClusters, addTimeout]
)
const viewClusterInfo = useCallback(async (cluster: Cluster) => {
setSelectedCluster(cluster)
setClusterInfo(null)
setDetailOpen(true)
try {
const info = await clusterApi.getInfo(cluster.id)
setClusterInfo(info)
} catch (err) {
console.error('Failed to get cluster info:', err)
toast.error('加载集群详情失败')
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-emerald-100 text-emerald-700'
case 'error':
return 'bg-red-100 text-red-700'
case 'pending':
return 'bg-amber-100 text-amber-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
}, [])
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">集群管理</h2>
<p className="text-sm text-slate-500">管理用于 Loki 部署的 Kubernetes 集群</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
添加集群
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 Kubernetes 集群</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">集群名称 *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="production-cluster"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">描述</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="生产环境日志集群"
/>
</div>
<div className="space-y-2">
<Label>接入方式</Label>
<Tabs value={configType} onValueChange={(v) => setConfigType(v as 'kubeconfig' | 'token')}>
<TabsList>
<TabsTrigger value="kubeconfig"><FileKey className="w-3.5 h-3.5" /> Kubeconfig</TabsTrigger>
<TabsTrigger value="token"><Key className="w-3.5 h-3.5" /> Token</TabsTrigger>
</TabsList>
<TabsContent value="kubeconfig" className="space-y-2 pt-2">
<Label htmlFor="kubeconfig">Kubeconfig 内容 *</Label>
<Textarea
id="kubeconfig"
value={kubeconfig}
onChange={(e) => setKubeconfig(e.target.value)}
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
className="min-h-[200px] font-mono text-xs"
/>
{kubeconfig && parseApiServer(kubeconfig) && (
<p className="text-xs text-emerald-600">已识别 API Server:{parseApiServer(kubeconfig)}</p>
)}
</TabsContent>
<TabsContent value="token" className="space-y-2 pt-2">
<div className="space-y-2">
<Label htmlFor="apiServer">API Server *</Label>
<Input
id="apiServer"
value={apiServer}
onChange={(e) => setApiServer(e.target.value)}
placeholder="https://10.0.0.1:6443"
/>
</div>
<div className="space-y-2">
<Label htmlFor="token">Token *</Label>
<Textarea
id="token"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="粘贴 Service Account Token..."
className="min-h-[120px] font-mono text-xs"
/>
<p className="text-xs text-slate-400">采用 insecure 模式,便于内网/自签证书集群接入</p>
</div>
</TabsContent>
</Tabs>
</div>
{configType === 'kubeconfig' && (
<PageHeader
title="集群管理"
description="管理用于 Loki 部署的 Kubernetes 集群"
action={
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
添加集群
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 Kubernetes 集群</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="context">上下文(可选)</Label>
<Label htmlFor="name">集群名称 *</Label>
<Input
id="context"
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder="留空则使用默认上下文"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="production-cluster"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">描述</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="生产环境日志集群"
/>
</div>
)}
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
{submitting ? '添加中...' : '添加集群'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
<Label>接入方式</Label>
<Tabs
value={configType}
onValueChange={(v) => setConfigType(v as 'kubeconfig' | 'token')}
>
<TabsList>
<TabsTrigger value="kubeconfig">
<FileKey className="w-3.5 h-3.5 mr-1" /> Kubeconfig
</TabsTrigger>
<TabsTrigger value="token">
<Key className="w-3.5 h-3.5 mr-1" /> Token
</TabsTrigger>
</TabsList>
<TabsContent value="kubeconfig" className="space-y-2 pt-2">
<Label htmlFor="kubeconfig">Kubeconfig 内容 *</Label>
<Textarea
id="kubeconfig"
value={kubeconfig}
onChange={(e) => setKubeconfig(e.target.value)}
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
className="min-h-[200px] font-mono text-xs"
/>
{kubeconfig && parseApiServer(kubeconfig) && (
<p className="text-xs text-emerald-600">
已识别 API Server:{parseApiServer(kubeconfig)}
</p>
)}
</TabsContent>
<TabsContent value="token" className="space-y-2 pt-2">
<div className="space-y-2">
<Label htmlFor="apiServer">API Server *</Label>
<Input
id="apiServer"
value={apiServer}
onChange={(e) => setApiServer(e.target.value)}
placeholder="https://10.0.0.1:6443"
/>
</div>
<div className="space-y-2">
<Label htmlFor="token">Token *</Label>
<Textarea
id="token"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="粘贴 Service Account Token..."
className="min-h-[120px] font-mono text-xs"
/>
<p className="text-xs text-slate-400">
采用 insecure 模式,便于内网/自签证书集群接入
</p>
</div>
</TabsContent>
</Tabs>
</div>
{configType === 'kubeconfig' && (
<div className="space-y-2">
<Label htmlFor="context">上下文(可选)</Label>
<Input
id="context"
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder="留空则使用默认上下文"
/>
</div>
)}
<Button
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-700"
disabled={submitting}
>
{submitting ? '添加中...' : '添加集群'}
</Button>
</form>
</DialogContent>
</Dialog>
}
/>
{/* Clusters Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
<LoadingSpinner className="h-64" />
) : clusters.length === 0 ? (
<Card className="border-dashed border-slate-300">
<CardContent className="p-12 text-center">
<Server className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 font-medium">暂无集群</p>
<p className="text-sm text-slate-400 mt-1">添加第一个 Kubernetes 集群开始使用</p>
</CardContent>
</Card>
<EmptyState
icon={Server}
title="暂无集群"
description="添加第一个 Kubernetes 集群开始使用"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{clusters.map((cluster) => (
<Card key={cluster.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-800 dark:text-white">{cluster.name}</h3>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(cluster.status)}`}>
{statusLabel(cluster.status)}
</span>
</div>
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{cluster.description || '无描述'}</p>
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
<div className="flex justify-between">
<span>接入方式</span>
<span className="font-medium text-slate-700 dark:text-slate-300">
{cluster.configType === 'token' ? 'Token' : 'Kubeconfig'}
</span>
</div>
{cluster.apiServer && (
<div className="flex justify-between">
<span>API Server</span>
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[150px]">{cluster.apiServer}</span>
</div>
)}
<div className="flex justify-between">
<span>版本</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{cluster.version || '未知'}</span>
</div>
<div className="flex justify-between">
<span>节点数</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{cluster.nodesCount}</span>
</div>
<div className="flex justify-between">
<span>最后连接</span>
<span className="font-medium text-slate-700 dark:text-slate-300">
{cluster.lastConnected ? new Date(cluster.lastConnected).toLocaleString() : '从未'}
</span>
</div>
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => viewClusterInfo(cluster)}
>
<Eye className="w-3 h-3 mr-1" />
详情
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => handleValidate(cluster.id)}
disabled={validating === cluster.id}
>
<RefreshCw className={`w-3 h-3 mr-1 ${validating === cluster.id ? 'animate-spin' : ''}`} />
验证
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDelete(cluster.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
<ClusterCard
key={cluster.id}
cluster={cluster}
validating={validating}
onViewInfo={viewClusterInfo}
onValidate={handleValidate}
onDelete={handleDelete}
/>
))}
</div>
)}
@@ -356,62 +343,201 @@ export default function Clusters() {
</DialogHeader>
{clusterInfo ? (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">版本</p>
<p className="text-sm font-medium">{clusterInfo.version || '未知'}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">当前上下文</p>
<p className="text-sm font-medium">{clusterInfo.currentContext || '默认'}</p>
</div>
</div>
<div>
<h4 className="text-sm font-semibold mb-2">节点({clusterInfo.nodes.length})</h4>
<div className="space-y-2">
{clusterInfo.nodes.map((node, idx) => (
<div key={idx} className="p-3 border border-slate-200 dark:border-slate-700 rounded-lg">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{node.name}</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${node.status === 'Ready' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'}`}>
{node.status === 'Ready' ? '就绪' : '未就绪'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-2 text-xs text-slate-500">
<span>IP:{node.internalIP}</span>
<span>系统:{node.os}/{node.arch}</span>
<span>版本:{node.version}</span>
</div>
{node.roles.length > 0 && (
<div className="flex gap-1 mt-2">
{node.roles.map((role, ridx) => (
<span key={ridx} className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{role}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-semibold mb-2">命名空间</h4>
<div className="flex flex-wrap gap-1">
{clusterInfo.namespaces.map((ns, idx) => (
<span key={idx} className="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">{ns}</span>
))}
</div>
</div>
</div>
<ClusterDetail info={clusterInfo} />
) : (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
<LoadingSpinner className="h-32" />
)}
</DialogContent>
</Dialog>
</div>
)
})
// 子组件:集群卡片
interface ClusterCardProps {
cluster: Cluster
validating: number | null
onViewInfo: (c: Cluster) => void
onValidate: (id: number) => void
onDelete: (id: number) => void
}
const ClusterCard = memo(function ClusterCard({
cluster,
validating,
onViewInfo,
onValidate,
onDelete,
}: ClusterCardProps) {
return (
<Card className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Server className="w-5 h-5 text-blue-500 flex-shrink-0" />
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
{cluster.name}
</h3>
</div>
<StatusBadge status={cluster.status} />
</div>
<p className="text-sm text-slate-500 mb-3 line-clamp-2">
{cluster.description || '无描述'}
</p>
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
<InfoRow
label="接入方式"
value={cluster.configType === 'token' ? 'Token' : 'Kubeconfig'}
/>
{cluster.apiServer && (
<InfoRow
label="API Server"
value={cluster.apiServer}
valueClass="truncate max-w-[150px]"
/>
)}
<InfoRow label="版本" value={cluster.version || '未知'} />
<InfoRow label="节点数" value={String(cluster.nodesCount || 0)} />
<InfoRow
label="最后连接"
value={
cluster.lastConnected
? new Date(cluster.lastConnected).toLocaleString()
: '从未'
}
/>
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onViewInfo(cluster)}
>
<Eye className="w-3 h-3 mr-1" />
详情
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onValidate(cluster.id)}
disabled={validating === cluster.id}
>
<RefreshCw
className={`w-3 h-3 mr-1 ${validating === cluster.id ? 'animate-spin' : ''}`}
/>
验证
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => onDelete(cluster.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
)
})
// 子组件:信息行
const InfoRow = memo(function InfoRow({
label,
value,
valueClass,
}: {
label: string
value: string
valueClass?: string
}) {
return (
<div className="flex justify-between">
<span>{label}</span>
<span className={`font-medium text-slate-700 dark:text-slate-300 ${valueClass || ''}`}>
{value}
</span>
</div>
)
})
// 子组件:集群详情
const ClusterDetail = memo(function ClusterDetail({ info }: { info: ClusterInfo }) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">版本</p>
<p className="text-sm font-medium">{info.version || '未知'}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">当前上下文</p>
<p className="text-sm font-medium">{info.currentContext || '默认'}</p>
</div>
</div>
<div>
<h4 className="text-sm font-semibold mb-2">节点({info.nodes.length})</h4>
<div className="space-y-2">
{info.nodes.map((node, idx) => (
<div
key={idx}
className="p-3 border border-slate-200 dark:border-slate-700 rounded-lg"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{node.name}</span>
<span
className={`px-2 py-0.5 rounded-full text-xs ${
node.status === 'Ready'
? 'bg-emerald-100 text-emerald-700'
: 'bg-red-100 text-red-700'
}`}
>
{node.status === 'Ready' ? '就绪' : '未就绪'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-2 text-xs text-slate-500">
<span>IP:{node.internalIP}</span>
<span>系统:{node.os}/{node.arch}</span>
<span>版本:{node.version}</span>
</div>
{node.roles.length > 0 && (
<div className="flex gap-1 mt-2">
{node.roles.map((role, ridx) => (
<span
key={ridx}
className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-xs"
>
{role}
</span>
))}
</div>
)}
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-semibold mb-2">命名空间</h4>
<div className="flex flex-wrap gap-1">
{info.namespaces.map((ns, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs"
>
{ns}
</span>
))}
</div>
</div>
</div>
)
})
export default Clusters
+395 -254
View File
@@ -1,8 +1,7 @@
import { useState, useEffect } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import {
RadioTower,
Plus,
RefreshCw,
Trash2,
Upload,
Download,
@@ -28,30 +27,40 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { collectorApi, deploymentApi, clusterApi, hostApi } from '@/srv/api'
import { collectorApi, deploymentApi, clusterApi, hostApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { StatusBadge } from '@/components/StatusBadge'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { EmptyState } from '@/components/EmptyState'
import { PageHeader } from '@/components/PageHeader'
import { toast } from 'sonner'
import type { Collector, LokiDeployment, Cluster, Host } from '@/types'
const typeMeta: Record<string, { label: string; icon: typeof Server; color: string }> = {
// 采集器类型元数据 - 常量提取
const COLLECTOR_TYPE_META: Record<string, { label: string; icon: typeof Server; color: string }> = {
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'bg-purple-100 text-purple-700' },
'k8s-daemonset': { label: 'K8s DaemonSet', icon: Cloud, color: 'bg-blue-100 text-blue-700' },
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-emerald-100 text-emerald-700'
case 'error':
return 'bg-red-100 text-red-700'
case 'pending':
return 'bg-amber-100 text-amber-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
const COLLECTOR_TYPES = [
{ value: 'host-promtail' as const, label: '主机 Promtail(SSH)' },
{ value: 'k8s-daemonset' as const, label: 'K8s Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar' as const, label: 'K8s Fluent Bit Sidecar' },
]
export default function Collectors() {
/**
* Collectors 页面 - 优化版本
*
* 优化点:
* 1. React.memo 包裹组件
* 2. 所有操作函数使用 useCallback 缓存
* 3. alert() 替换为 sonner toast
* 4. setTimeout 使用 ref 管理,添加清理
* 5. 使用公共组件替代重复代码
* 6. 常量映射提取到组件外部
*/
const Collectors = memo(function Collectors() {
const [collectors, setCollectors] = useState<Collector[]>([])
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
@@ -60,9 +69,9 @@ export default function Collectors() {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState<number | null>(null)
// Form
// Form state
const [name, setName] = useState('')
const [type, setType] = useState<'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'>('host-promtail')
const [collectorType, setCollectorType] = useState<'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'>('host-promtail')
const [lokiID, setLokiID] = useState('')
const [hostID, setHostID] = useState('')
const [clusterID, setClusterID] = useState('')
@@ -72,11 +81,21 @@ export default function Collectors() {
const [labels, setLabels] = useState('')
const [submitting, setSubmitting] = useState(false)
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
loadAll()
return () => {
timeoutsRef.current.forEach((id) => clearTimeout(id))
}
}, [])
const loadAll = async () => {
const addTimeout = useCallback((cb: () => void, delay: number) => {
const id = setTimeout(cb, delay)
timeoutsRef.current.push(id)
return id
}, [])
const loadAll = useCallback(async () => {
try {
const [cRes, dRes, clRes, hRes] = await Promise.all([
collectorApi.list(1, 100),
@@ -90,14 +109,19 @@ export default function Collectors() {
setHosts(hRes.list)
} catch (err) {
console.error('Failed to load collectors:', err)
toast.error('加载采集器数据失败')
} finally {
setLoading(false)
}
}
}, [])
const resetForm = () => {
useEffect(() => {
loadAll()
}, [loadAll])
const resetForm = useCallback(() => {
setName('')
setType('host-promtail')
setCollectorType('host-promtail')
setLokiID('')
setHostID('')
setClusterID('')
@@ -105,258 +129,375 @@ export default function Collectors() {
setNamespace('')
setLogPath('')
setLabels('')
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!lokiID) {
alert('请选择关联的 Loki 部署')
return
}
setSubmitting(true)
try {
await collectorApi.create({
name,
type,
lokiDeploymentID: Number(lokiID),
hostID: type === 'host-promtail' && hostID ? Number(hostID) : undefined,
clusterID: type !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
targetDeployment: type === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
namespace: type !== 'host-promtail' ? namespace : undefined,
logPath,
labels,
})
setOpen(false)
resetForm()
loadAll()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建采集器失败'
alert(message)
} finally {
setSubmitting(false)
}
}
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (!lokiID) {
toast.error('请选择关联的 Loki 部署')
return
}
setSubmitting(true)
try {
await collectorApi.create({
name,
type: collectorType,
lokiDeploymentID: Number(lokiID),
hostID: collectorType === 'host-promtail' && hostID ? Number(hostID) : undefined,
clusterID: collectorType !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
targetDeployment: collectorType === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
namespace: collectorType !== 'host-promtail' ? namespace : undefined,
logPath,
labels,
})
setOpen(false)
resetForm()
loadAll()
toast.success('采集器创建成功')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建采集器失败'
toast.error(message)
} finally {
setSubmitting(false)
}
},
[name, collectorType, lokiID, hostID, clusterID, targetDeployment, namespace, logPath, labels, resetForm, loadAll]
)
const handleDelete = async (id: number) => {
if (!confirm('确定删除此采集器?(将自动卸载)')) return
try {
await collectorApi.remove(id)
loadAll()
} catch (err) {
console.error('Failed to delete collector:', err)
}
}
const handleDelete = useCallback(
async (id: number) => {
if (!confirm('确定删除此采集器?(将自动卸载)')) return
try {
await collectorApi.remove(id)
loadAll()
toast.success('采集器已删除')
} catch (err) {
console.error('Failed to delete collector:', err)
toast.error('删除采集器失败')
}
},
[loadAll]
)
const handleAction = async (id: number, action: 'install' | 'uninstall') => {
setBusy(id)
try {
await collectorApi[action](id)
setTimeout(loadAll, 3000)
} catch (err) {
console.error('Collector action failed:', err)
} finally {
setTimeout(() => setBusy(null), 3000)
}
}
const handleAction = useCallback(
async (id: number, action: 'install' | 'uninstall') => {
setBusy(id)
try {
await collectorApi[action](id)
addTimeout(loadAll, 3000)
toast.success(action === 'install' ? '部署指令已发送' : '卸载指令已发送')
} catch (err) {
console.error('Collector action failed:', err)
toast.error(action === 'install' ? '部署失败' : '卸载失败')
} finally {
addTimeout(() => setBusy(null), 3000)
}
},
[loadAll, addTimeout]
)
const isHostType = collectorType === 'host-promtail'
const isK8sType = collectorType !== 'host-promtail'
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署客户端</h2>
<p className="text-sm text-slate-500">部署日志采集器,将日志推送到关联的 Loki</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
新建采集器
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新建日志采集器</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="c-name">名称 *</Label>
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="app-logs-collector" required />
</div>
<div className="space-y-2">
<Label>采集器类型</Label>
<Select value={type} onValueChange={(v) => setType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="host-promtail">主机 Promtail(SSH)</SelectItem>
<SelectItem value="k8s-daemonset">K8s Promtail DaemonSet</SelectItem>
<SelectItem value="k8s-fluentbit-sidecar">K8s Fluent Bit Sidecar</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>关联 Loki 部署 *</Label>
<Select value={lokiID} onValueChange={setLokiID}>
<SelectTrigger><SelectValue placeholder="选择已部署的 Loki(推送目标)" /></SelectTrigger>
<SelectContent>
{deployments.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>{d.name}({statusLabel(d.status)})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{type === 'host-promtail' && (
<div className="space-y-2">
<Label>目标主机 *</Label>
<Select value={hostID} onValueChange={setHostID}>
<SelectTrigger><SelectValue placeholder="选择主机" /></SelectTrigger>
<SelectContent>
{hosts.map((h) => (
<SelectItem key={h.id} value={String(h.id)}>{h.name}({h.hostIP})</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{type !== 'host-promtail' && (
<>
<PageHeader
title="部署客户端"
description="部署日志采集器,将日志推送到关联的 Loki"
action={
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
新建采集器
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新建日志采集器</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>目标集群 *</Label>
<Select value={clusterID} onValueChange={setClusterID}>
<SelectTrigger><SelectValue placeholder="选择集群" /></SelectTrigger>
<Label htmlFor="c-name">名称 *</Label>
<Input
id="c-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="app-logs-collector"
required
/>
</div>
<div className="space-y-2">
<Label>采集器类型</Label>
<Select
value={collectorType}
onValueChange={(v) =>
setCollectorType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}({statusLabel(c.status)})</SelectItem>
{COLLECTOR_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="c-ns">命名空间</Label>
<Input id="c-ns" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={type === 'k8s-daemonset' ? 'logging' : 'default'} />
</div>
{type === 'k8s-fluentbit-sidecar' && (
<div className="space-y-2">
<Label htmlFor="c-deploy">目标 Deployment *</Label>
<Input id="c-deploy" value={targetDeployment} onChange={(e) => setTargetDeployment(e.target.value)} placeholder="my-app" />
</div>
)}
</div>
<div className="space-y-2">
<Label>关联 Loki 部署 *</Label>
<Select value={lokiID} onValueChange={setLokiID}>
<SelectTrigger>
<SelectValue placeholder="选择已部署的 Loki(推送目标)" />
</SelectTrigger>
<SelectContent>
{deployments.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}({statusLabel(d.status)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isHostType && (
<div className="space-y-2">
<Label>目标主机 *</Label>
<Select value={hostID} onValueChange={setHostID}>
<SelectTrigger>
<SelectValue placeholder="选择主机" />
</SelectTrigger>
<SelectContent>
{hosts.map((h) => (
<SelectItem key={h.id} value={String(h.id)}>
{h.name}({h.hostIP})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
)}
<div className="space-y-2">
<Label htmlFor="c-log">采集日志路径</Label>
<Input id="c-log" value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder={type === 'host-promtail' ? '/var/log/*.log' : '/var/log/app/*.log'} />
</div>
{isK8sType && (
<>
<div className="space-y-2">
<Label>目标集群 *</Label>
<Select value={clusterID} onValueChange={setClusterID}>
<SelectTrigger>
<SelectValue placeholder="选择集群" />
</SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}({statusLabel(c.status)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="c-ns">命名空间</Label>
<Input
id="c-ns"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'}
/>
</div>
{isSidecarType && (
<div className="space-y-2">
<Label htmlFor="c-deploy">目标 Deployment *</Label>
<Input
id="c-deploy"
value={targetDeployment}
onChange={(e) => setTargetDeployment(e.target.value)}
placeholder="my-app"
/>
</div>
)}
</div>
</>
)}
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
{submitting ? '创建中...' : '创建并部署'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
<Label htmlFor="c-log">采集日志路径</Label>
<Input
id="c-log"
value={logPath}
onChange={(e) => setLogPath(e.target.value)}
placeholder={isHostType ? '/var/log/*.log' : '/var/log/app/*.log'}
/>
</div>
{/* List */}
<Button
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-700"
disabled={submitting}
>
{submitting ? '创建中...' : '创建并部署'}
</Button>
</form>
</DialogContent>
</Dialog>
}
/>
{/* Collectors List */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
<LoadingSpinner className="h-64" />
) : collectors.length === 0 ? (
<Card className="border-dashed border-slate-300">
<CardContent className="p-12 text-center">
<RadioTower className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 font-medium">暂无采集器</p>
<p className="text-sm text-slate-400 mt-1">新建一个日志采集器,关联已部署的 Loki</p>
</CardContent>
</Card>
<EmptyState
icon={RadioTower}
title="暂无采集器"
description="新建一个日志采集器,关联已部署的 Loki"
/>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{collectors.map((c) => {
const meta = typeMeta[c.type] || { label: c.type, icon: RadioTower, color: 'bg-slate-100 text-slate-600' }
const Icon = meta.icon
return (
<Card key={c.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Icon className="w-5 h-5 text-emerald-600" />
<div>
<h3 className="font-semibold text-slate-800 dark:text-white">{c.name}</h3>
<p className="text-xs text-slate-500">关联 Loki:{c.lokiDeployment?.name || '未知'}</p>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(c.status)}`}>
{statusLabel(c.status)}
</span>
</div>
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
<div className="flex justify-between">
<span>类型</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>{meta.label}</span>
</div>
{c.host && (
<div className="flex justify-between">
<span>主机</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.host.name}({c.host.hostIP})</span>
</div>
)}
{c.cluster && (
<div className="flex justify-between">
<span>集群</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.cluster.name}</span>
</div>
)}
{c.targetDeployment && (
<div className="flex justify-between">
<span>目标 Deployment</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.targetDeployment}</span>
</div>
)}
{c.namespace && (
<div className="flex justify-between">
<span>命名空间</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.namespace}</span>
</div>
)}
{c.logPath && (
<div className="flex justify-between">
<span>日志路径</span>
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[200px]">{c.logPath}</span>
</div>
)}
{c.statusMessage && (
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">{c.statusMessage}</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'install')} disabled={busy === c.id}>
<Upload className="w-3 h-3 mr-1" />
{busy === c.id ? '处理中...' : '部署'}
</Button>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'uninstall')} disabled={busy === c.id}>
<Download className="w-3 h-3 mr-1" />
卸载
</Button>
<Button variant="outline" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(c.id)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
)
})}
{collectors.map((c) => (
<CollectorCard
key={c.id}
collector={c}
busy={busy}
onInstall={(id) => handleAction(id, 'install')}
onUninstall={(id) => handleAction(id, 'uninstall')}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)
})
// 子组件:采集器卡片
interface CollectorCardProps {
collector: Collector
busy: number | null
onInstall: (id: number) => void
onUninstall: (id: number) => void
onDelete: (id: number) => void
}
const CollectorCard = memo(function CollectorCard({
collector,
busy,
onInstall,
onUninstall,
onDelete,
}: CollectorCardProps) {
const meta = COLLECTOR_TYPE_META[collector.type] || {
label: collector.type,
icon: RadioTower,
color: 'bg-slate-100 text-slate-600',
}
const Icon = meta.icon
const isBusy = busy === collector.id
return (
<Card className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Icon className="w-5 h-5 text-emerald-600 flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
{collector.name}
</h3>
<p className="text-xs text-slate-500">
关联 Loki:{collector.lokiDeployment?.name || '未知'}
</p>
</div>
</div>
<StatusBadge status={collector.status} />
</div>
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
<div className="flex justify-between">
<span>类型</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>
{meta.label}
</span>
</div>
{collector.host && (
<InfoRow label="主机" value={`${collector.host.name}(${collector.host.hostIP})`} />
)}
{collector.cluster && (
<InfoRow label="集群" value={collector.cluster.name} />
)}
{collector.targetDeployment && (
<InfoRow label="目标 Deployment" value={collector.targetDeployment} />
)}
{collector.namespace && <InfoRow label="命名空间" value={collector.namespace} />}
{collector.logPath && (
<InfoRow label="日志路径" value={collector.logPath} />
)}
{collector.statusMessage && (
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">
{collector.statusMessage}
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onInstall(collector.id)}
disabled={isBusy}
>
<Upload className="w-3 h-3 mr-1" />
{isBusy ? '处理中...' : '部署'}
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onUninstall(collector.id)}
disabled={isBusy}
>
<Download className="w-3 h-3 mr-1" />
卸载
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => onDelete(collector.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
)
})
// 子组件:信息行
const InfoRow = memo(function InfoRow({
label,
value,
}: {
label: string
value: string
}) {
return (
<div className="flex justify-between">
<span>{label}</span>
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[200px]">
{value}
</span>
</div>
)
})
export default Collectors
+337 -236
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import {
Rocket,
@@ -6,17 +6,85 @@ import {
Monitor,
Activity,
TrendingUp,
AlertTriangle,
CheckCircle,
Clock,
PauseCircle,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { deploymentApi, clusterApi, hostApi } from '@/srv/api'
import { deploymentApi, clusterApi, hostApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { StatusIcon, getStatusColorClass } from '@/components/StatusBadge'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { EmptyState } from '@/components/EmptyState'
import type { DashboardStats, LokiDeployment, Cluster, Host } from '@/types'
export default function Dashboard() {
// 统计卡片数据常量
const STATS_CONFIG = [
{
key: 'deployments' as const,
label: '部署总数',
icon: Rocket,
iconColor: 'text-emerald-600',
iconBg: 'bg-emerald-100 dark:bg-emerald-900/30',
getValue: (s: DashboardStats | null) => s?.totalDeployments || 0,
subLabel: (s: DashboardStats | null) => `${s?.running || 0} 个运行中`,
subIcon: TrendingUp,
subColor: 'text-emerald-500',
subTextColor: 'text-emerald-600',
},
{
key: 'clusters' as const,
label: '集群',
icon: Server,
iconColor: 'text-blue-600',
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
getValue: (s: DashboardStats | null) => s?.clusterCount || 0,
subLabel: () => '已接入',
subIcon: CheckCircle,
subColor: 'text-emerald-500',
subTextColor: 'text-slate-500',
},
{
key: 'hosts' as const,
label: '主机',
icon: Monitor,
iconColor: 'text-purple-600',
iconBg: 'bg-purple-100 dark:bg-purple-900/30',
getValue: (s: DashboardStats | null) => s?.hostCount || 0,
subLabel: () => 'SSH 就绪',
subIcon: CheckCircle,
subColor: 'text-emerald-500',
subTextColor: 'text-slate-500',
},
{
key: 'status' as const,
label: '状态',
icon: Activity,
iconColor: 'text-orange-600',
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
getValue: () => 0, // 状态卡片使用特殊渲染
isStatusCard: true,
},
]
const STATUS_ITEMS = [
{ key: 'running', label: '运行中', colorClass: 'text-emerald-600' },
{ key: 'failed', label: '失败', colorClass: 'text-red-600' },
{ key: 'pending', label: '等待中', colorClass: 'text-amber-600' },
] as const
const REFRESH_INTERVAL = 10000 // 10秒自动刷新
/**
* Dashboard 页面 - 全面优化版本
*
* 优化点:
* 1. 使用 React.memo 包裹组件,避免不必要的重渲染
* 2. 状态函数使用 useMemo 缓存(替代原来的getStatusIcon/getStatusColor)
* 3. 数据加载使用 useCallback 缓存引用
* 4. 自动刷新定时器添加清理函数,防止内存泄漏
* 5. 使用 StatusBadge/StatusIcon 公共组件替代重复的状态渲染逻辑
* 6. 提取常量配置避免重复创建
*/
const Dashboard = memo(function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
@@ -24,13 +92,16 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
loadDashboard()
const interval = setInterval(loadDashboard, 10000)
return () => clearInterval(interval)
}, [])
// 使用ref追踪组件是否已卸载,防止内存泄漏
const isMountedRef = useRef(true)
// 使用ref存储定时器ID以便清理
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 数据加载函数 - useCallback缓存引用
const loadDashboard = useCallback(async () => {
// 如果组件已卸载,不执行状态更新
if (!isMountedRef.current) return
const loadDashboard = async () => {
try {
const [statsRes, deployRes, clusterRes, hostRes] = await Promise.all([
deploymentApi.stats(),
@@ -38,145 +109,107 @@ export default function Dashboard() {
clusterApi.list(1, 5),
hostApi.list(1, 5),
])
setStats(statsRes)
setDeployments(deployRes.list)
setClusters(clusterRes.list)
setHosts(hostRes.list)
// 再次检查组件是否仍挂载
if (isMountedRef.current) {
setStats(statsRes)
setDeployments(deployRes.list)
setClusters(clusterRes.list)
setHosts(hostRes.list)
}
} catch (err) {
console.error('Failed to load dashboard:', err)
} finally {
setLoading(false)
if (isMountedRef.current) {
setLoading(false)
}
}
}
}, [])
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <CheckCircle className="w-4 h-4 text-emerald-500" />
case 'failed':
return <AlertTriangle className="w-4 h-4 text-red-500" />
case 'pending':
return <Clock className="w-4 h-4 text-amber-500" />
case 'stopped':
return <PauseCircle className="w-4 h-4 text-slate-400" />
case 'deploying':
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />
default:
return <Clock className="w-4 h-4 text-slate-400" />
}
}
// 初始化加载 + 自动刷新定时器
useEffect(() => {
isMountedRef.current = true
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
case 'active':
return 'bg-emerald-100 text-emerald-700'
case 'failed':
case 'error':
return 'bg-red-100 text-red-700'
case 'pending':
return 'bg-amber-100 text-amber-700'
case 'stopped':
case 'inactive':
return 'bg-slate-100 text-slate-600'
case 'deploying':
return 'bg-blue-100 text-blue-700'
default:
return 'bg-slate-100 text-slate-600'
// 立即加载数据
loadDashboard()
// 设置自动刷新定时器
intervalRef.current = setInterval(loadDashboard, REFRESH_INTERVAL)
// 清理函数:组件卸载时清除定时器和标记
return () => {
isMountedRef.current = false
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}
}, [loadDashboard])
// 缓存导航处理函数
const navigateTo = useCallback(
(path: string) => () => navigate(path),
[navigate]
)
// 渲染状态概览数值
const statusSummary = useMemo(() => {
if (!stats) return null
return STATUS_ITEMS.map((item) => (
<div key={item.key} className="text-center">
<p className={`text-lg font-bold ${item.colorClass}`}>
{(stats[item.key as keyof DashboardStats] as number) || 0}
</p>
<p className="text-[10px] text-slate-400">{item.label}</p>
</div>
))
}, [stats])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Activity className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
)
return <LoadingSpinner className="h-64" />
}
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-slate-200 dark:border-slate-800">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">部署总数</p>
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
{stats?.totalDeployments || 0}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<Rocket className="w-6 h-6 text-emerald-600" />
</div>
</div>
<div className="flex items-center gap-2 mt-4">
<TrendingUp className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-emerald-600">{stats?.running || 0} 个运行中</span>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 dark:border-slate-800">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">集群</p>
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
{stats?.clusterCount || 0}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Server className="w-6 h-6 text-blue-600" />
</div>
</div>
<div className="flex items-center gap-2 mt-4">
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-slate-500">已接入</span>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 dark:border-slate-800">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">主机</p>
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
{stats?.hostCount || 0}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Monitor className="w-6 h-6 text-purple-600" />
</div>
</div>
<div className="flex items-center gap-2 mt-4">
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-slate-500">SSH 就绪</span>
</div>
</CardContent>
</Card>
{STATS_CONFIG.slice(0, 3).map((config) => {
const Icon = config.icon
const SubIcon = config.subIcon
return (
<Card key={config.key} className="border-slate-200 dark:border-slate-800">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{config.label}</p>
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
{config.getValue(stats)}
</p>
</div>
<div
className={`w-12 h-12 rounded-xl ${config.iconBg} flex items-center justify-center`}
>
<Icon className={`w-6 h-6 ${config.iconColor}`} />
</div>
</div>
<div className="flex items-center gap-2 mt-4">
{SubIcon && <SubIcon className={`w-4 h-4 ${config.subColor}`} />}
<span className={`text-xs ${config.subTextColor}`}>
{config.subLabel?.(stats)}
</span>
</div>
</CardContent>
</Card>
)
})}
{/* Status Overview Card */}
<Card className="border-slate-200 dark:border-slate-800">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">状态</p>
<div className="flex gap-3 mt-1">
<div className="text-center">
<p className="text-lg font-bold text-emerald-600">{stats?.running || 0}</p>
<p className="text-[10px] text-slate-400">运行中</p>
</div>
<div className="text-center">
<p className="text-lg font-bold text-red-600">{stats?.failed || 0}</p>
<p className="text-[10px] text-slate-400">失败</p>
</div>
<div className="text-center">
<p className="text-lg font-bold text-amber-600">{stats?.pending || 0}</p>
<p className="text-[10px] text-slate-400">等待中</p>
</div>
</div>
<div className="flex gap-3 mt-1">{statusSummary}</div>
</div>
<div className="w-12 h-12 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
<Activity className="w-6 h-6 text-orange-600" />
@@ -186,118 +219,186 @@ export default function Dashboard() {
</Card>
</div>
{/* Recent Deployments */}
{/* Recent Deployments & Quick Resources */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">最近部署</CardTitle>
<button
onClick={() => navigate('/deployments')}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
查看全部
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{deployments.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-8">暂无部署</p>
) : (
deployments.map((d) => (
<div
key={d.id}
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
onClick={() => navigate('/deployments')}
>
<div className="flex items-center gap-3">
{getStatusIcon(d.status)}
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{d.name}</p>
<p className="text-xs text-slate-500">{d.mode} &middot; {d.namespace || 'loki'}</p>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(d.status)}`}>
{statusLabel(d.status)}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Recent Deployments */}
<RecentDeploymentsCard
deployments={deployments}
onViewAll={navigateTo('/deployments')}
/>
{/* Quick Resources */}
<div className="space-y-4">
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">集群</CardTitle>
<button
onClick={() => navigate('/clusters')}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
管理
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{clusters.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暂无集群</p>
) : (
clusters.slice(0, 3).map((c) => (
<div key={c.id} className="flex items-center justify-between p-2 rounded-md hover:bg-slate-50 dark:hover:bg-slate-800">
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-blue-500" />
<span className="text-sm">{c.name}</span>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(c.status)}`}>
{statusLabel(c.status)}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">主机</CardTitle>
<button
onClick={() => navigate('/hosts')}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
管理
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{hosts.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暂无主机</p>
) : (
hosts.slice(0, 3).map((h) => (
<div key={h.id} className="flex items-center justify-between p-2 rounded-md hover:bg-slate-50 dark:hover:bg-slate-800">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-purple-500" />
<span className="text-sm">{h.name}</span>
<span className="text-xs text-slate-400">{h.hostIP}</span>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(h.status)}`}>
{statusLabel(h.status)}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
<ResourceListCard
title="集群"
icon={<Server className="w-4 h-4 text-blue-500" />}
items={clusters.slice(0, 3)}
onManage={navigateTo('/clusters')}
getItemIcon={() => <Server className="w-4 h-4 text-blue-500" />}
getItemLabel={(c) => c.name}
getItemStatus={(c) => c.status}
/>
<ResourceListCard
title="主机"
icon={<Monitor className="w-4 h-4 text-purple-500" />}
items={hosts.slice(0, 3)}
onManage={navigateTo('/hosts')}
getItemIcon={() => <Monitor className="w-4 h-4 text-purple-500" />}
getItemLabel={(h) => h.name}
getItemStatus={(h) => h.status}
getItemExtra={(h) => h.hostIP}
/>
</div>
</div>
</div>
)
})
// 子组件:最近部署卡片 - 独立memo优化
const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
deployments,
onViewAll,
}: {
deployments: LokiDeployment[]
onViewAll: () => void
}) {
const navigate = useNavigate()
return (
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">最近部署</CardTitle>
<button
onClick={onViewAll}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
查看全部
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{deployments.length === 0 ? (
<EmptyState
icon={Rocket}
title="暂无部署"
description="创建你的第一个 Loki 部署"
/>
) : (
deployments.map((d) => (
<div
key={d.id}
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
onClick={() => navigate('/deployments')}
>
<div className="flex items-center gap-3 min-w-0">
<StatusIcon status={d.status} size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
{d.name}
</p>
<p className="text-xs text-slate-500">
{d.mode} &middot; {d.namespace || 'loki'}
</p>
</div>
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColorClass(
d.status
)}`}
>
{statusLabel(d.status)}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
)
})
// 子组件:资源列表卡片 - 独立memo优化
// 用具体结构类型而非泛型:memo 包裹泛型组件会导致 TS 无法从 props 推断 T,
// 退化为约束类型 {id:number},使 getItemLabel/Status 中访问 name/status 报错。
interface ResourceItem {
id: number
name: string
status: string
hostIP?: string
}
interface ResourceListCardProps {
title: string
icon: React.ReactNode
items: ResourceItem[]
onManage: () => void
getItemIcon: (item: ResourceItem) => React.ReactNode
getItemLabel: (item: ResourceItem) => string
getItemStatus: (item: ResourceItem) => string
getItemExtra?: (item: ResourceItem) => string | undefined
}
const ResourceListCard = memo(function ResourceListCard({
title,
icon,
items,
onManage,
getItemIcon,
getItemLabel,
getItemStatus,
getItemExtra,
}: ResourceListCardProps) {
return (
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
{icon}
{title}
</CardTitle>
<button
onClick={onManage}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
管理
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暂无{title}</p>
) : (
items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
{getItemIcon(item)}
<span className="text-sm truncate">{getItemLabel(item)}</span>
{getItemExtra?.(item) && (
<span className="text-xs text-slate-400 flex-shrink-0">
{getItemExtra(item)}
</span>
)}
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs flex-shrink-0 ${getStatusColorClass(
getItemStatus(item)
)}`}
>
{statusLabel(getItemStatus(item))}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
)
})
export default Dashboard
File diff suppressed because it is too large. Load diff
-20
View File
@@ -1,20 +0,0 @@
import { useState } from 'react'
import '../App.css'
export default function Home() {
const [count, setCount] = useState(0)
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
</>
)
}
+476 -348
View File
@@ -1,5 +1,14 @@
import { useState, useEffect } from 'react'
import { Monitor, Plus, RefreshCw, Trash2, Eye, Terminal, Key, Lock } from 'lucide-react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import {
Monitor,
Plus,
RefreshCw,
Trash2,
Eye,
Terminal,
Key,
Lock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,14 +21,29 @@ import {
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import { hostApi } from '@/srv/api'
import { statusLabel } from '@/lib/utils'
import { hostApi } from '@/api'
import { StatusBadge } from '@/components/StatusBadge'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { EmptyState } from '@/components/EmptyState'
import { PageHeader } from '@/components/PageHeader'
import { toast } from 'sonner'
import type { Host, HostInfo } from '@/types'
// 使用率颜色阈值
const usageColor = (v: number) => (v >= 90 ? 'bg-red-500' : v >= 70 ? 'bg-amber-500' : 'bg-emerald-500')
// 使用率颜色阈值 - 常量提取
const usageColor = (v: number) =>
v >= 90 ? 'bg-red-500' : v >= 70 ? 'bg-amber-500' : 'bg-emerald-500'
export default function Hosts() {
/**
* Hosts 页面 - 优化版本
*
* 优化点:
* 1. React.memo 包裹组件
* 2. 所有操作函数使用 useCallback 缓存
* 3. alert() 替换为 sonner toast
* 4. setTimeout 使用 ref 管理
* 5. 使用公共组件替代重复代码
*/
const Hosts = memo(function Hosts() {
const [hosts, setHosts] = useState<Host[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
@@ -46,48 +70,37 @@ export default function Hosts() {
const [labels, setLabels] = useState('')
const [submitting, setSubmitting] = useState(false)
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
loadHosts()
return () => {
timeoutsRef.current.forEach((id) => clearTimeout(id))
}
}, [])
const loadHosts = async () => {
const addTimeout = useCallback((cb: () => void, delay: number) => {
const id = setTimeout(cb, delay)
timeoutsRef.current.push(id)
return id
}, [])
const loadHosts = useCallback(async () => {
try {
const res = await hostApi.list(1, 100)
setHosts(res.list)
} catch (err) {
console.error('Failed to load hosts:', err)
toast.error('加载主机数据失败')
} finally {
setLoading(false)
}
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
await hostApi.create({
name,
hostIP,
port,
user,
authType,
password: authType === 'password' ? password : undefined,
privateKey: authType === 'key' ? privateKey : undefined,
keyPhrase: authType === 'key' ? keyPhrase : undefined,
labels,
})
setOpen(false)
resetForm()
loadHosts()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '添加主机失败'
alert(message)
} finally {
setSubmitting(false)
}
}
useEffect(() => {
loadHosts()
}, [loadHosts])
const resetForm = () => {
const resetForm = useCallback(() => {
setName('')
setHostIP('')
setPort(22)
@@ -97,49 +110,94 @@ export default function Hosts() {
setPrivateKey('')
setKeyPhrase('')
setLabels('')
}
}, [])
const handleDelete = async (id: number) => {
if (!confirm('确定删除此主机?')) return
try {
await hostApi.remove(id)
loadHosts()
} catch (err) {
console.error('Failed to delete host:', err)
}
}
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
await hostApi.create({
name,
hostIP,
port,
user,
authType,
password: authType === 'password' ? password : undefined,
privateKey: authType === 'key' ? privateKey : undefined,
keyPhrase: authType === 'key' ? keyPhrase : undefined,
labels,
})
setOpen(false)
resetForm()
loadHosts()
toast.success('主机添加成功')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '添加主机失败'
toast.error(message)
} finally {
setSubmitting(false)
}
},
[name, hostIP, port, user, authType, password, privateKey, keyPhrase, labels, resetForm, loadHosts]
)
const handleValidate = async (id: number) => {
setValidating(id)
try {
await hostApi.validate(id)
setTimeout(loadHosts, 3000)
} catch (err) {
console.error('Failed to validate host:', err)
} finally {
setTimeout(() => setValidating(null), 3000)
}
}
const handleDelete = useCallback(
async (id: number) => {
if (!confirm('确定删除此主机?')) return
try {
await hostApi.remove(id)
loadHosts()
toast.success('主机已删除')
} catch (err) {
console.error('Failed to delete host:', err)
toast.error('删除主机失败')
}
},
[loadHosts]
)
const viewHostInfo = async (host: Host) => {
setSelectedHost(host)
setDetailOpen(true)
setHostInfo(null)
try {
const info = await hostApi.getInfo(host.id)
setHostInfo(info)
} catch (err) {
console.error('Failed to get host info:', err)
}
}
const handleValidate = useCallback(
async (id: number) => {
setValidating(id)
try {
await hostApi.validate(id)
addTimeout(loadHosts, 3000)
toast.success('验证指令已发送')
} catch (err) {
console.error('Failed to validate host:', err)
toast.error('验证失败')
} finally {
addTimeout(() => setValidating(null), 3000)
}
},
[loadHosts, addTimeout]
)
const openTerminal = (host: Host) => {
const viewHostInfo = useCallback(
async (host: Host) => {
setSelectedHost(host)
setHostInfo(null)
setDetailOpen(true)
try {
const info = await hostApi.getInfo(host.id)
setHostInfo(info)
} catch (err) {
console.error('Failed to get host info:', err)
toast.error('加载主机详情失败')
}
},
[]
)
const openTerminal = useCallback((host: Host) => {
setSelectedHost(host)
setTerminalOpen(true)
setTerminalOutput(`已连接到 ${host.name} (${host.hostIP})\n输入命令后按"执行"...\n`)
}
setCommand('')
}, [])
const executeCommand = async () => {
const executeCommand = useCallback(async () => {
if (!selectedHost || !command.trim()) return
setExecuting(true)
setTerminalOutput((prev) => prev + `\n$ ${command}\n`)
@@ -153,239 +211,188 @@ export default function Hosts() {
setExecuting(false)
setCommand('')
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-emerald-100 text-emerald-700'
case 'error':
return 'bg-red-100 text-red-700'
case 'pending':
return 'bg-amber-100 text-amber-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
}, [selectedHost, command])
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">主机管理</h2>
<p className="text-sm text-slate-500">管理用于二进制方式部署 Loki 的 SSH 主机</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
添加主机
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 SSH 主机</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="h-name">名称 *</Label>
<Input id="h-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="loki-server-1" required />
</div>
<div className="space-y-2">
<Label htmlFor="h-ip">主机 IP *</Label>
<Input id="h-ip" value={hostIP} onChange={(e) => setHostIP(e.target.value)} placeholder="192.168.1.100" required />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="h-port">端口</Label>
<Input id="h-port" type="number" value={port} onChange={(e) => setPort(Number(e.target.value))} placeholder="22" />
</div>
<div className="space-y-2">
<Label htmlFor="h-user">用户名 *</Label>
<Input id="h-user" value={user} onChange={(e) => setUser(e.target.value)} placeholder="root" required />
</div>
</div>
<div className="space-y-2">
<Label>认证方式</Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="authType"
value="password"
checked={authType === 'password'}
onChange={() => setAuthType('password')}
<PageHeader
title="主机管理"
description="管理用于二进制方式部署 Loki 的 SSH 主机"
action={
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
添加主机
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 SSH 主机</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="h-name">名称 *</Label>
<Input
id="h-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="loki-server-1"
required
/>
<Lock className="w-4 h-4" />
<span className="text-sm">密码</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="authType"
value="key"
checked={authType === 'key'}
onChange={() => setAuthType('key')}
</div>
<div className="space-y-2">
<Label htmlFor="h-ip">主机 IP *</Label>
<Input
id="h-ip"
value={hostIP}
onChange={(e) => setHostIP(e.target.value)}
placeholder="192.168.1.100"
required
/>
<Key className="w-4 h-4" />
<span className="text-sm">SSH 密钥</span>
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="h-port">端口</Label>
<Input
id="h-port"
type="number"
value={port}
onChange={(e) => setPort(Number(e.target.value))}
placeholder="22"
/>
</div>
<div className="space-y-2">
<Label htmlFor="h-user">用户名 *</Label>
<Input
id="h-user"
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder="root"
required
/>
</div>
</div>
</div>
{authType === 'password' ? (
<div className="space-y-2">
<Label htmlFor="h-password">密码 *</Label>
<Label>认证方式</Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="authType"
value="password"
checked={authType === 'password'}
onChange={() => setAuthType('password')}
/>
<Lock className="w-4 h-4" />
<span className="text-sm">密码</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="authType"
value="key"
checked={authType === 'key'}
onChange={() => setAuthType('key')}
/>
<Key className="w-4 h-4" />
<span className="text-sm">SSH 密钥</span>
</label>
</div>
</div>
{authType === 'password' ? (
<div className="space-y-2">
<Label htmlFor="h-password">密码 *</Label>
<Input
id="h-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="SSH 登录密码"
required
/>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="h-key">私钥 *</Label>
<Textarea
id="h-key"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
required
className="min-h-[120px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="h-phrase">密钥口令(可选)</Label>
<Input
id="h-phrase"
type="password"
value={keyPhrase}
onChange={(e) => setKeyPhrase(e.target.value)}
placeholder="无口令则留空"
/>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="h-labels">标签(JSON)</Label>
<Input
id="h-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="SSH 登录密码"
required
id="h-labels"
value={labels}
onChange={(e) => setLabels(e.target.value)}
placeholder='["loki", "logging"]'
/>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="h-key">私钥 *</Label>
<Textarea
id="h-key"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
required
className="min-h-[120px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="h-phrase">密钥口令(可选)</Label>
<Input
id="h-phrase"
type="password"
value={keyPhrase}
onChange={(e) => setKeyPhrase(e.target.value)}
placeholder="无口令则留空"
/>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="h-labels">标签(JSON)</Label>
<Input
id="h-labels"
value={labels}
onChange={(e) => setLabels(e.target.value)}
placeholder='["loki", "logging"]'
/>
</div>
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
{submitting ? '添加中...' : '添加主机'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<Button
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-700"
disabled={submitting}
>
{submitting ? '添加中...' : '添加主机'}
</Button>
</form>
</DialogContent>
</Dialog>
}
/>
{/* Hosts Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
<LoadingSpinner className="h-64" />
) : hosts.length === 0 ? (
<Card className="border-dashed border-slate-300">
<CardContent className="p-12 text-center">
<Monitor className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 font-medium">暂无主机</p>
<p className="text-sm text-slate-400 mt-1">添加第一台 SSH 主机开始使用</p>
</CardContent>
</Card>
<EmptyState
icon={Monitor}
title="暂无主机"
description="添加第一台 SSH 主机开始使用"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{hosts.map((host) => (
<Card key={host.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Monitor className="w-5 h-5 text-purple-500" />
<h3 className="font-semibold text-slate-800 dark:text-white">{host.name}</h3>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(host.status)}`}>
{statusLabel(host.status)}
</span>
</div>
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
<div className="flex justify-between">
<span>IP 地址</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{host.hostIP}:{host.port}</span>
</div>
<div className="flex justify-between">
<span>用户名</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{host.user}</span>
</div>
<div className="flex justify-between">
<span>认证方式</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{host.authType === 'key' ? 'SSH 密钥' : '密码'}</span>
</div>
<div className="flex justify-between">
<span>系统</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{host.os || '未知'} ({host.arch || '未知'})</span>
</div>
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => viewHostInfo(host)}
>
<Eye className="w-3 h-3 mr-1" />
详情
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => openTerminal(host)}
>
<Terminal className="w-3 h-3 mr-1" />
终端
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleValidate(host.id)}
disabled={validating === host.id}
>
<RefreshCw className={`w-3 h-3 ${validating === host.id ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDelete(host.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
<HostCard
key={host.id}
host={host}
validating={validating}
onViewInfo={viewHostInfo}
onOpenTerminal={openTerminal}
onValidate={handleValidate}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Host Detail Dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5 text-purple-500" />
@@ -393,76 +400,9 @@ export default function Hosts() {
</DialogTitle>
</DialogHeader>
{hostInfo ? (
<div className="space-y-5">
{/* 资源使用率 */}
<div>
<h4 className="text-sm font-semibold mb-3">资源使用率</h4>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500">CPU</span>
<span className="font-medium">{hostInfo.cpuUsage.toFixed(1)}%</span>
</div>
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full ${usageColor(hostInfo.cpuUsage)} rounded-full transition-all`} style={{ width: `${Math.min(hostInfo.cpuUsage, 100)}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500">内存</span>
<span className="font-medium">{hostInfo.memUsage.toFixed(1)}% ({hostInfo.memUsed} / {hostInfo.memTotal})</span>
</div>
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full ${usageColor(hostInfo.memUsage)} rounded-full transition-all`} style={{ width: `${Math.min(hostInfo.memUsage, 100)}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500">磁盘</span>
<span className="font-medium">{hostInfo.diskUsage.toFixed(1)}% ({hostInfo.diskUsed} / {hostInfo.diskTotal})</span>
</div>
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full ${usageColor(hostInfo.diskUsage)} rounded-full transition-all`} style={{ width: `${Math.min(hostInfo.diskUsage, 100)}%` }} />
</div>
</div>
</div>
</div>
{/* 基本信息 */}
<div>
<h4 className="text-sm font-semibold mb-3">基本信息</h4>
<div className="grid grid-cols-2 gap-3">
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">主机名</p>
<p className="text-sm font-medium">{hostInfo.hostname || '未知'}</p>
</div>
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">系统</p>
<p className="text-sm font-medium">{hostInfo.os || '未知'} {hostInfo.version}</p>
</div>
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">架构</p>
<p className="text-sm font-medium">{hostInfo.arch || '未知'}</p>
</div>
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">内核</p>
<p className="text-sm font-medium">{hostInfo.kernel || '未知'}</p>
</div>
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">CPU 核数</p>
<p className="text-sm font-medium">{hostInfo.cpu || '未知'}</p>
</div>
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">运行时长</p>
<p className="text-sm font-medium truncate">{hostInfo.uptime || '未知'}</p>
</div>
</div>
</div>
</div>
<HostDetail info={hostInfo} />
) : (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
<LoadingSpinner className="h-32" />
)}
</DialogContent>
</Dialog>
@@ -502,4 +442,192 @@ export default function Hosts() {
</Dialog>
</div>
)
})
// 子组件:主机卡片
interface HostCardProps {
host: Host
validating: number | null
onViewInfo: (h: Host) => void
onOpenTerminal: (h: Host) => void
onValidate: (id: number) => void
onDelete: (id: number) => void
}
const HostCard = memo(function HostCard({
host,
validating,
onViewInfo,
onOpenTerminal,
onValidate,
onDelete,
}: HostCardProps) {
return (
<Card className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Monitor className="w-5 h-5 text-purple-500 flex-shrink-0" />
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
{host.name}
</h3>
</div>
<StatusBadge status={host.status} />
</div>
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
<InfoRow label="IP 地址" value={`${host.hostIP}:${host.port}`} />
<InfoRow label="用户名" value={host.user} />
<InfoRow
label="认证方式"
value={host.authType === 'key' ? 'SSH 密钥' : '密码'}
/>
<InfoRow
label="系统"
value={`${host.os || '未知'} (${host.arch || '未知'})`}
/>
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onViewInfo(host)}
>
<Eye className="w-3 h-3 mr-1" />
详情
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onOpenTerminal(host)}
>
<Terminal className="w-3 h-3 mr-1" />
终端
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onValidate(host.id)}
disabled={validating === host.id}
>
<RefreshCw
className={`w-3 h-3 ${validating === host.id ? 'animate-spin' : ''}`}
/>
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => onDelete(host.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
)
})
// 子组件:信息行
const InfoRow = memo(function InfoRow({
label,
value,
}: {
label: string
value: string
}) {
return (
<div className="flex justify-between">
<span>{label}</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{value}</span>
</div>
)
})
// 子组件:主机详情
const HostDetail = memo(function HostDetail({ info }: { info: HostInfo }) {
return (
<div className="space-y-5">
{/* 资源使用率 */}
<div>
<h4 className="text-sm font-semibold mb-3">资源使用率</h4>
<div className="space-y-3">
<UsageBar label="CPU" value={info.cpuUsage} />
<UsageBar
label="内存"
value={info.memUsage}
detail={`${info.memUsed} / ${info.memTotal}`}
/>
<UsageBar
label="磁盘"
value={info.diskUsage}
detail={`${info.diskUsed} / ${info.diskTotal}`}
/>
</div>
</div>
{/* 基本信息 */}
<div>
<h4 className="text-sm font-semibold mb-3">基本信息</h4>
<div className="grid grid-cols-2 gap-3">
<InfoCard label="主机名" value={info.hostname || '未知'} />
<InfoCard label="系统" value={`${info.os || '未知'} ${info.version}`} />
<InfoCard label="架构" value={info.arch || '未知'} />
<InfoCard label="内核" value={info.kernel || '未知'} />
<InfoCard label="CPU 核数" value={info.cpu || '未知'} />
<InfoCard label="运行时长" value={info.uptime || '未知'} />
</div>
</div>
</div>
)
})
// 使用率进度条
const UsageBar = memo(function UsageBar({
label,
value,
detail,
}: {
label: string
value: number
detail?: string
}) {
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500">{label}</span>
<span className="font-medium">
{value.toFixed(1)}% {detail && `(${detail})`}
</span>
</div>
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full ${usageColor(value)} rounded-full transition-all`}
style={{ width: `${Math.min(value, 100)}%` }}
/>
</div>
</div>
)
})
// 信息卡片
const InfoCard = memo(function InfoCard({
label,
value,
}: {
label: string
value: string
}) {
return (
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500">{label}</p>
<p className="text-sm font-medium truncate">{value}</p>
</div>
)
})
export default Hosts
+72 -36
View File
@@ -1,12 +1,22 @@
import { useState, useEffect } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Activity, Eye, EyeOff, Shield, ServerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi } from '@/srv/api'
import { authApi } from '@/api'
import { toast } from 'sonner'
export default function Login() {
/**
* Login 页面 - 优化版本
*
* 优化点:
* 1. React.memo 包裹组件
* 2. 表单处理使用 useCallback 缓存
* 3. 错误处理使用 sonner toast 替代 alert
* 4. 清理 useEffect 中的未使用变量
*/
const Login = memo(function Login() {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('admin123')
const [showPassword, setShowPassword] = useState(false)
@@ -24,50 +34,67 @@ export default function Login() {
}
// Quick backend check
checkBackend()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigate])
const checkBackend = async () => {
// 后端状态检查 - useCallback缓存
const checkBackend = useCallback(async () => {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 3000)
await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
// 用公开的健康检查端点探活;避免打 /auth/login(生产环境被 nginx 限流 5r/m,
// 反复刷新登录页会触发限流并锁死真正的登录请求)
const res = await fetch('/api/v1/health', {
method: 'GET',
signal: controller.signal,
})
clearTimeout(timeoutId)
// Even a 400 error means backend is up
setBackendStatus('online')
setBackendStatus(res.ok ? 'online' : 'offline')
} catch {
setBackendStatus('offline')
}
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
// 表单提交处理 - useCallback缓存
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const result = await authApi.login(username, password)
localStorage.setItem('token', result.token)
localStorage.setItem('user', JSON.stringify(result.user))
navigate('/')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '登录失败'
if (message.includes('Cannot connect')) {
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
setBackendStatus('offline')
} else if (message.includes('invalid') || message.includes('password') || message.includes('密码')) {
setError('用户名或密码错误')
} else {
setError(message)
try {
const result = await authApi.login(username, password)
localStorage.setItem('token', result.accessToken)
localStorage.setItem('refreshToken', result.refreshToken)
localStorage.setItem('user', JSON.stringify(result.user))
toast.success('登录成功')
navigate('/')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '登录失败'
if (message.includes('Cannot connect')) {
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
setBackendStatus('offline')
} else if (
message.includes('invalid') ||
message.includes('password') ||
message.includes('密码') ||
message.includes('Unauthorized')
) {
setError('用户名或密码错误')
} else {
setError(message)
}
} finally {
setLoading(false)
}
} finally {
setLoading(false)
}
}
},
[username, password, navigate]
)
// 密码显示切换 - useCallback缓存
const togglePassword = useCallback(() => {
setShowPassword((prev) => !prev)
}, [])
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
@@ -120,6 +147,7 @@ export default function Login() {
placeholder="请输入用户名"
required
className="h-10"
autoComplete="username"
/>
</div>
@@ -134,13 +162,19 @@ export default function Login() {
placeholder="请输入密码"
required
className="h-10 pr-10"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
onClick={togglePassword}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div>
@@ -163,4 +197,6 @@ export default function Login() {
</div>
</div>
)
}
})
export default Login
+80 -50
View File
@@ -1,44 +1,69 @@
import { useState } from 'react'
import { memo, useCallback, useState } from 'react'
import { KeyRound, Settings as SettingsIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { authApi } from '@/srv/api'
import { authApi } from '@/api'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
export default function Settings() {
// 系统信息常量
const SYSTEM_INFO = [
{ label: '版本', value: 'v1.0.0' },
{ label: '后端', value: 'Go + Gin + JSON 文件存储' },
{ label: '前端', value: 'React 19 + TypeScript + Tailwind CSS' },
{ label: '部署工具', value: 'kubectl + helm(镜像内置)' },
{ label: '简介', value: '轻量级 K8s Loki 部署平台', align: 'right' as const },
]
/**
* Settings 页面 - 优化版本
*
* 优化点:
* 1. React.memo 包裹组件
* 2. 表单处理使用 useCallback 缓存
* 3. alert() 替换为 sonner toast
* 4. 使用 PageHeader 公共组件
* 5. 提取常量避免重复创建
*/
const Settings = memo(function Settings() {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [changing, setChanging] = useState(false)
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
alert('两次输入的密码不一致')
return
}
setChanging(true)
try {
await authApi.changePassword(oldPassword, newPassword)
alert('密码修改成功')
setOldPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '密码修改失败'
alert(message)
} finally {
setChanging(false)
}
}
const handleChangePassword = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
toast.error('两次输入的密码不一致')
return
}
if (newPassword.length < 6) {
toast.error('新密码至少需要 6 位')
return
}
setChanging(true)
try {
await authApi.changePassword(oldPassword, newPassword)
toast.success('密码修改成功')
setOldPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '密码修改失败'
toast.error(message)
} finally {
setChanging(false)
}
},
[oldPassword, newPassword, confirmPassword]
)
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">系统设置</h2>
<p className="text-sm text-slate-500">修改登录密码与查看系统信息</p>
</div>
<PageHeader title="系统设置" description="修改登录密码与查看系统信息" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 修改密码 */}
@@ -85,7 +110,11 @@ export default function Settings() {
required
/>
</div>
<Button type="submit" className="bg-emerald-600 hover:bg-emerald-700" disabled={changing}>
<Button
type="submit"
className="bg-emerald-600 hover:bg-emerald-700"
disabled={changing}
>
{changing ? '修改中...' : '修改密码'}
</Button>
</form>
@@ -101,31 +130,32 @@ export default function Settings() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
<span className="text-sm text-slate-500">版本</span>
<span className="text-sm font-medium">v1.0.0</span>
</div>
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
<span className="text-sm text-slate-500">后端</span>
<span className="text-sm font-medium">Go + Gin + JSON 文件存储</span>
</div>
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
<span className="text-sm text-slate-500">前端</span>
<span className="text-sm font-medium">React + TypeScript + Tailwind CSS</span>
</div>
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
<span className="text-sm text-slate-500">部署工具</span>
<span className="text-sm font-medium">kubectl + helm(镜像内置)</span>
</div>
<div className="flex justify-between py-2">
<span className="text-sm text-slate-500">简介</span>
<span className="text-sm font-medium text-right">轻量级 K8s Loki 部署平台</span>
</div>
<div className="space-y-0">
{SYSTEM_INFO.map((item, index) => (
<div
key={item.label}
className={`flex justify-between py-2.5 ${
index < SYSTEM_INFO.length - 1
? 'border-b border-slate-100 dark:border-slate-800'
: ''
}`}
>
<span className="text-sm text-slate-500">{item.label}</span>
<span
className={`text-sm font-medium ${
item.align === 'right' ? 'text-right' : ''
}`}
>
{item.value}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)
}
})
export default Settings
+346 -255
View File
@@ -1,5 +1,13 @@
import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, Star, Cloud, Database, Package } from 'lucide-react'
import { memo, useCallback, useEffect, useState } from 'react'
import {
HardDrive,
Plus,
Trash2,
Star,
Cloud,
Database,
Package,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,17 +20,42 @@ import {
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import { storageApi } from '@/srv/api'
import { storageApi } from '@/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { EmptyState } from '@/components/EmptyState'
import { PageHeader } from '@/components/PageHeader'
import { toast } from 'sonner'
import type { StorageConfig } from '@/types'
const storageTypeIcons: Record<string, React.ReactNode> = {
// 存储类型图标映射 - 常量提取
const STORAGE_TYPE_ICONS: Record<string, React.ReactNode> = {
s3: <Cloud className="w-5 h-5 text-orange-500" />,
oss: <Cloud className="w-5 h-5 text-red-500" />,
minio: <Database className="w-5 h-5 text-blue-500" />,
rustfs: <Package className="w-5 h-5 text-cyan-500" />,
}
export default function Storage() {
// 存储类型颜色
const STORAGE_TYPE_COLORS: Record<string, string> = {
s3: 'bg-orange-100 text-orange-700',
oss: 'bg-red-100 text-red-700',
minio: 'bg-blue-100 text-blue-700',
rustfs: 'bg-cyan-100 text-cyan-700',
}
const STORAGE_TYPES = ['s3', 'oss', 'minio', 'rustfs'] as const
/**
* Storage 页面 - 优化版本
*
* 优化点:
* 1. React.memo 包裹组件
* 2. 所有操作函数使用 useCallback 缓存
* 3. alert() 替换为 sonner toast
* 4. 使用公共组件替代重复代码
* 5. 常量图标和颜色映射提取
*/
const Storage = memo(function Storage() {
const [storages, setStorages] = useState<StorageConfig[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
@@ -40,49 +73,23 @@ export default function Storage() {
const [isDefault, setIsDefault] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
loadStorages()
}, [])
const loadStorages = async () => {
const loadStorages = useCallback(async () => {
try {
const res = await storageApi.list(1, 100)
setStorages(res.list)
} catch (err) {
console.error('Failed to load storages:', err)
toast.error('加载存储配置失败')
} finally {
setLoading(false)
}
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
await storageApi.create({
name,
storageType,
endpoint,
bucket,
region,
accessKey,
secretKey,
pathPrefix,
insecure,
isDefault,
})
setOpen(false)
resetForm()
loadStorages()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建存储配置失败'
alert(message)
} finally {
setSubmitting(false)
}
}
useEffect(() => {
loadStorages()
}, [loadStorages])
const resetForm = () => {
const resetForm = useCallback(() => {
setName('')
setStorageType('minio')
setEndpoint('')
@@ -93,255 +100,339 @@ export default function Storage() {
setPathPrefix('loki')
setInsecure(false)
setIsDefault(false)
}
}, [])
const handleDelete = async (id: number) => {
if (!confirm('确定删除此存储配置?')) return
try {
await storageApi.remove(id)
loadStorages()
} catch (err) {
console.error('Failed to delete storage:', err)
}
}
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
await storageApi.create({
name,
storageType,
endpoint,
bucket,
region,
accessKey,
secretKey,
pathPrefix,
insecure,
isDefault,
})
setOpen(false)
resetForm()
loadStorages()
toast.success('存储配置添加成功')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建存储配置失败'
toast.error(message)
} finally {
setSubmitting(false)
}
},
[name, storageType, endpoint, bucket, region, accessKey, secretKey, pathPrefix, insecure, isDefault, resetForm, loadStorages]
)
const getTypeColor = (type: string) => {
switch (type) {
case 's3':
return 'bg-orange-100 text-orange-700'
case 'oss':
return 'bg-red-100 text-red-700'
case 'minio':
return 'bg-blue-100 text-blue-700'
case 'rustfs':
return 'bg-cyan-100 text-cyan-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
const handleDelete = useCallback(
async (id: number) => {
if (!confirm('确定删除此存储配置?')) return
try {
await storageApi.remove(id)
loadStorages()
toast.success('存储配置已删除')
} catch (err) {
console.error('Failed to delete storage:', err)
toast.error('删除存储配置失败')
}
},
[loadStorages]
)
const handleSetDefault = useCallback(
async (id: number) => {
try {
await storageApi.update(id, { isDefault: true })
loadStorages()
toast.success('已设为默认存储')
} catch (err) {
console.error('Failed to set default storage:', err)
toast.error('设置默认存储失败')
}
},
[loadStorages]
)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">存储配置</h2>
<p className="text-sm text-slate-500">为 Loki 配置对象存储后端</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
添加存储
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加存储配置</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="s-name">名称 *</Label>
<Input
id="s-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="production-minio"
required
/>
</div>
<div className="space-y-2">
<Label>存储类型</Label>
<div className="grid grid-cols-4 gap-2">
{(['s3', 'oss', 'minio', 'rustfs'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => setStorageType(type)}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border transition-all ${
storageType === type
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 hover:border-slate-300 text-slate-600'
}`}
>
{storageTypeIcons[type]}
<span className="text-xs font-medium uppercase">{type}</span>
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="s-endpoint">访问地址</Label>
<Input
id="s-endpoint"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder={storageType === 's3' ? 's3.amazonaws.com' : storageType === 'minio' ? 'http://minio.example.com:9000' : ''}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<PageHeader
title="存储配置"
description="为 Loki 配置对象存储后端"
action={
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
添加存储
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加存储配置</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="s-bucket">Bucket *</Label>
<Label htmlFor="s-name">名称 *</Label>
<Input
id="s-bucket"
value={bucket}
onChange={(e) => setBucket(e.target.value)}
placeholder="loki-logs"
id="s-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="production-minio"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-region">区域</Label>
<Label>存储类型</Label>
<div className="grid grid-cols-4 gap-2">
{STORAGE_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => setStorageType(type)}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border transition-all ${
storageType === type
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 hover:border-slate-300 text-slate-600'
}`}
>
{STORAGE_TYPE_ICONS[type]}
<span className="text-xs font-medium uppercase">{type}</span>
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="s-endpoint">访问地址</Label>
<Input
id="s-region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="us-east-1"
id="s-endpoint"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder={
storageType === 's3'
? 's3.amazonaws.com'
: storageType === 'minio'
? 'http://minio.example.com:9000'
: ''
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="s-bucket">Bucket *</Label>
<Input
id="s-bucket"
value={bucket}
onChange={(e) => setBucket(e.target.value)}
placeholder="loki-logs"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-region">区域</Label>
<Input
id="s-region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="us-east-1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="s-access">Access Key</Label>
<Input
id="s-access"
value={accessKey}
onChange={(e) => setAccessKey(e.target.value)}
placeholder="minioadmin"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-secret">Secret Key</Label>
<Input
id="s-secret"
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder="minioadmin"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="s-access">Access Key</Label>
<Label htmlFor="s-prefix">路径前缀</Label>
<Input
id="s-access"
value={accessKey}
onChange={(e) => setAccessKey(e.target.value)}
placeholder="minioadmin"
id="s-prefix"
value={pathPrefix}
onChange={(e) => setPathPrefix(e.target.value)}
placeholder="loki"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-secret">Secret Key</Label>
<Input
id="s-secret"
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder="minioadmin"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="s-prefix">路径前缀</Label>
<Input
id="s-prefix"
value={pathPrefix}
onChange={(e) => setPathPrefix(e.target.value)}
placeholder="loki"
/>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch id="s-insecure" checked={insecure} onCheckedChange={setInsecure} />
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
id="s-insecure"
checked={insecure}
onCheckedChange={setInsecure}
/>
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="s-default"
checked={isDefault}
onCheckedChange={setIsDefault}
/>
<Label htmlFor="s-default">设为默认</Label>
</div>
</div>
<div className="flex items-center gap-2">
<Switch id="s-default" checked={isDefault} onCheckedChange={setIsDefault} />
<Label htmlFor="s-default">设为默认</Label>
</div>
</div>
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
{submitting ? '添加中...' : '添加存储'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<Button
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-700"
disabled={submitting}
>
{submitting ? '添加中...' : '添加存储'}
</Button>
</form>
</DialogContent>
</Dialog>
}
/>
{/* Storage Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
</div>
<LoadingSpinner className="h-64" />
) : storages.length === 0 ? (
<Card className="border-dashed border-slate-300">
<CardContent className="p-12 text-center">
<HardDrive className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 font-medium">暂无存储配置</p>
<p className="text-sm text-slate-400 mt-1">添加第一个 Loki 存储后端</p>
</CardContent>
</Card>
<EmptyState
icon={HardDrive}
title="暂无存储配置"
description="添加第一个 Loki 存储后端"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{storages.map((storage) => (
<Card key={storage.id} className={`border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow ${storage.isDefault ? 'ring-1 ring-emerald-500' : ''}`}>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
{storageTypeIcons[storage.storageType] || <HardDrive className="w-5 h-5 text-slate-400" />}
<h3 className="font-semibold text-slate-800 dark:text-white">{storage.name}</h3>
</div>
<div className="flex items-center gap-1">
{storage.isDefault && (
<span className="px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-medium flex items-center gap-0.5">
<Star className="w-3 h-3" />
默认
</span>
)}
<span className={`px-2 py-0.5 rounded-full text-xs font-medium uppercase ${getTypeColor(storage.storageType)}`}>
{storage.storageType}
</span>
</div>
</div>
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
<div className="flex justify-between">
<span>访问地址</span>
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[150px]">{storage.endpoint || '默认'}</span>
</div>
<div className="flex justify-between">
<span>Bucket</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{storage.bucket}</span>
</div>
<div className="flex justify-between">
<span>区域</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{storage.region || '无'}</span>
</div>
<div className="flex justify-between">
<span>路径前缀</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{storage.pathPrefix || '无'}</span>
</div>
<div className="flex justify-between">
<span>不加密</span>
<span className="font-medium">{storage.insecure ? '是' : '否'}</span>
</div>
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={async () => {
await storageApi.update(storage.id, { isDefault: true })
loadStorages()
}}
disabled={storage.isDefault}
>
<Star className="w-3 h-3 mr-1" />
{storage.isDefault ? '默认' : '设为默认'}
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDelete(storage.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
<StorageCard
key={storage.id}
storage={storage}
onSetDefault={handleSetDefault}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)
})
// 子组件:存储卡片
interface StorageCardProps {
storage: StorageConfig
onSetDefault: (id: number) => void
onDelete: (id: number) => void
}
const StorageCard = memo(function StorageCard({
storage,
onSetDefault,
onDelete,
}: StorageCardProps) {
return (
<Card
className={`border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow ${
storage.isDefault ? 'ring-1 ring-emerald-500' : ''
}`}
>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
{STORAGE_TYPE_ICONS[storage.storageType] || (
<HardDrive className="w-5 h-5 text-slate-400" />
)}
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
{storage.name}
</h3>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{storage.isDefault && (
<span className="px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-medium flex items-center gap-0.5">
<Star className="w-3 h-3" />
默认
</span>
)}
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium uppercase ${
STORAGE_TYPE_COLORS[storage.storageType] || 'bg-slate-100 text-slate-600'
}`}
>
{storage.storageType}
</span>
</div>
</div>
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
<InfoRow label="访问地址" value={storage.endpoint || '默认'} />
<InfoRow label="Bucket" value={storage.bucket} />
<InfoRow label="区域" value={storage.region || '无'} />
<InfoRow label="路径前缀" value={storage.pathPrefix || '无'} />
<InfoRow label="不加密" value={storage.insecure ? '是' : '否'} />
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onSetDefault(storage.id)}
disabled={storage.isDefault}
>
<Star className="w-3 h-3 mr-1" />
{storage.isDefault ? '默认' : '设为默认'}
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => onDelete(storage.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
)
})
// 子组件:信息行
const InfoRow = memo(function InfoRow({
label,
value,
}: {
label: string
value: string
}) {
return (
<div className="flex justify-between">
<span>{label}</span>
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[150px]">
{value}
</span>
</div>
)
})
export default Storage
-24
View File
@@ -1,24 +0,0 @@
import { request } from './client';
import type { User } from '@/types';
export const authApi = {
login: (username: string, password: string) =>
request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
register: (username: string, password: string, nickname: string, role?: string) =>
request<User>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password, nickname, role: role || 'user' }),
}),
getCurrentUser: () => request<User>('/auth/user'),
changePassword: (oldPassword: string, newPassword: string) =>
request<{ message: string }>('/auth/password', {
method: 'POST',
body: JSON.stringify({ oldPassword, newPassword }),
}),
};
-49
View File
@@ -1,49 +0,0 @@
import type { ApiResponse } from '@/types';
const API_BASE = import.meta.env.VITE_API_URL || '';
function getToken(): string | null {
return localStorage.getItem('token');
}
export async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const fullUrl = `${API_BASE}/api/v1${url}`;
try {
const response = await fetch(fullUrl, { ...options, headers });
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
throw new Error('Unauthorized');
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
throw new Error(`Server returned non-JSON (${response.status}): ${text.substring(0, 200)}`);
}
const data = await response.json() as ApiResponse<T>;
if (data.code !== 0) {
throw new Error(data.message || 'Request failed');
}
return data.data;
} catch (err) {
if (err instanceof TypeError && err.message.includes('fetch')) {
throw new Error('Cannot connect to backend server. Please ensure the API server is running.');
}
throw err;
}
}
-23
View File
@@ -1,23 +0,0 @@
import { request } from './client';
import type { PageData, Cluster, ClusterInfo } from '@/types';
export const clusterApi = {
list: (page = 1, pageSize = 10) =>
request<PageData<Cluster>>(`/clusters?page=${page}&pageSize=${pageSize}`),
get: (id: number) => request<Cluster>(`/clusters/${id}`),
create: (data: {
name: string; description?: string; configType?: string;
apiServer?: string; kubeconfig?: string; token?: string; context?: string;
}) => request<Cluster>('/clusters', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Record<string, unknown>) =>
request<{ message: string }>(`/clusters/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
remove: (id: number) => request<{ message: string }>(`/clusters/${id}`, { method: 'DELETE' }),
validate: (id: number) => request<{ message: string }>(`/clusters/${id}/validate`, { method: 'POST' }),
getInfo: (id: number) => request<ClusterInfo>(`/clusters/${id}/info`),
};
-20
View File
@@ -1,20 +0,0 @@
import { request } from './client';
import type { PageData, Collector, CreateCollectorRequest } from '@/types';
export const collectorApi = {
list: (page = 1, pageSize = 100) =>
request<PageData<Collector>>(`/collectors?page=${page}&pageSize=${pageSize}`),
get: (id: number) => request<Collector>(`/collectors/${id}`),
create: (data: CreateCollectorRequest) =>
request<Collector>('/collectors', { method: 'POST', body: JSON.stringify(data) }),
remove: (id: number) => request<{ message: string }>(`/collectors/${id}`, { method: 'DELETE' }),
install: (id: number) =>
request<{ message: string }>(`/collectors/${id}/install`, { method: 'POST' }),
uninstall: (id: number) =>
request<{ message: string }>(`/collectors/${id}/uninstall`, { method: 'POST' }),
};
-28
View File
@@ -1,28 +0,0 @@
import { request } from './client';
import type { PageData, LokiDeployment, DeploymentLog, DashboardStats, CreateDeploymentRequest } from '@/types';
export const deploymentApi = {
list: (page = 1, pageSize = 10) =>
request<PageData<LokiDeployment>>(`/deployments?page=${page}&pageSize=${pageSize}`),
get: (id: number) => request<LokiDeployment>(`/deployments/${id}`),
create: (data: CreateDeploymentRequest) =>
request<LokiDeployment>('/deployments', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Record<string, unknown>) =>
request<{ message: string }>(`/deployments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
remove: (id: number) => request<{ message: string }>(`/deployments/${id}`, { method: 'DELETE' }),
deploy: (id: number) => request<{ message: string }>(`/deployments/${id}/deploy`, { method: 'POST' }),
stop: (id: number) => request<{ message: string }>(`/deployments/${id}/stop`, { method: 'POST' }),
restart: (id: number) => request<{ message: string }>(`/deployments/${id}/restart`, { method: 'POST' }),
logs: (id: number, page = 1, pageSize = 50) =>
request<PageData<DeploymentLog>>(`/deployments/${id}/logs?page=${page}&pageSize=${pageSize}`),
stats: () => request<DashboardStats>('/deployments/stats'),
};
-29
View File
@@ -1,29 +0,0 @@
import { request } from './client';
import type { PageData, Host, HostInfo } from '@/types';
export const hostApi = {
list: (page = 1, pageSize = 10) =>
request<PageData<Host>>(`/hosts?page=${page}&pageSize=${pageSize}`),
get: (id: number) => request<Host>(`/hosts/${id}`),
create: (data: {
name: string; hostIP: string; port?: number; user: string;
authType: 'password' | 'key'; password?: string; privateKey?: string;
keyPhrase?: string; os?: string; arch?: string; labels?: string;
}) => request<Host>('/hosts', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Record<string, unknown>) =>
request<{ message: string }>(`/hosts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
remove: (id: number) => request<{ message: string }>(`/hosts/${id}`, { method: 'DELETE' }),
validate: (id: number) => request<{ message: string }>(`/hosts/${id}/validate`, { method: 'POST' }),
getInfo: (id: number) => request<HostInfo>(`/hosts/${id}/info`),
execute: (id: number, command: string) =>
request<{ stdout: string; stderr: string }>(`/hosts/${id}/execute`, {
method: 'POST', body: JSON.stringify({ command }),
}),
};
-7
View File
@@ -1,7 +0,0 @@
export { request } from './client';
export { authApi } from './auth';
export { clusterApi } from './cluster';
export { hostApi } from './host';
export { storageApi } from './storage';
export { deploymentApi } from './deployment';
export { collectorApi } from './collector';
-23
View File
@@ -1,23 +0,0 @@
import { request } from './client';
import type { PageData, StorageConfig } from '@/types';
export const storageApi = {
list: (page = 1, pageSize = 10) =>
request<PageData<StorageConfig>>(`/storages?page=${page}&pageSize=${pageSize}`),
get: (id: number) => request<StorageConfig>(`/storages/${id}`),
create: (data: {
name: string; storageType: 's3' | 'oss' | 'minio' | 'rustfs';
endpoint?: string; bucket: string; region?: string;
accessKey?: string; secretKey?: string; pathPrefix?: string;
insecure?: boolean; isDefault?: boolean;
}) => request<StorageConfig>('/storages', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Record<string, unknown>) =>
request<{ message: string }>(`/storages/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
remove: (id: number) => request<{ message: string }>(`/storages/${id}`, { method: 'DELETE' }),
getDefault: () => request<StorageConfig>('/storages/default'),
};
+91
View File
@@ -0,0 +1,91 @@
# Nginx Load Balancer Configuration for LokiDeploy Production
# Simple round-robin load balancing between two app instances
events {
worker_connections 1024;
}
http {
# Upstream for LokiDeploy app instances
upstream lokideploy_backend {
server lokideploy-1:8080 weight=5;
server lokideploy-2:8080 weight=5;
# Health check for upstream servers (nginx-plus) or passive checks
keepalive 32;
}
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m;
# Logging format
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" rt=$request_time';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
server {
listen 8080;
server_name _;
# Health check endpoint for nginx itself
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Stricter rate limit for authentication
location /api/v1/auth/login {
limit_req zone=auth_limit burst=3 nodelay;
proxy_pass http://lokideploy_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 30s;
}
# General API rate limiting
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://lokideploy_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 30s;
}
# Static files and all other routes
location / {
proxy_pass http://lokideploy_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 30s;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
}
+31 -8
View File
@@ -116,6 +116,12 @@ start_backend() {
export SERVER_PORT=$BACKEND_PORT
export DATA_DIR="$SCRIPT_DIR/data"
export JWT_SECRET="${JWT_SECRET:-lokideploy-secret-key-change-in-production}"
# v2 安全模型:AES 加密需要独立密钥(用于主机密码/私钥/kubeconfig 等敏感字段)。
# config.go 在 ENCRYPTION_KEY 缺失或 <32 字符时会 log.Fatal 拒绝启动,故此处提供开发默认值。
if [ -z "${ENCRYPTION_KEY:-}" ]; then
warning "未设置 ENCRYPTION_KEY,使用开发默认值。生产环境请通过 .env 或环境变量提供独立随机密钥!"
export ENCRYPTION_KEY="lokideploy-dev-encryption-key-change-me!!"
fi
nohup "./$BACKEND_BIN" >> logs/backend.log 2>&1 &
echo $! > "$BACKEND_PID_FILE"
@@ -136,15 +142,35 @@ start_backend() {
return 0
}
# 等待进程退出:先 SIGTERM 优雅关停,超时再 SIGKILL
graceful_kill() {
local pid=$1
local name=$2
local grace=${3:-8}
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
return 0
fi
info "正在优雅停止 $name (PID: $pid)..."
kill -TERM "$pid" 2>/dev/null || true
local i=0
while kill -0 "$pid" 2>/dev/null && [ $i -lt $((grace * 2)) ]; do
sleep 0.5
i=$((i + 1))
done
if kill -0 "$pid" 2>/dev/null; then
warning "$name 未在 ${grace}s 内退出,强制终止..."
kill -KILL "$pid" 2>/dev/null || true
fi
success "$name 已停止"
}
stop_backend() {
if [ -f "$BACKEND_PID_FILE" ]; then
local pid=$(cat "$BACKEND_PID_FILE" 2>/dev/null || echo "")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
success "后端服务已停止"
fi
graceful_kill "$pid" "后端服务" 8
rm -f "$BACKEND_PID_FILE"
fi
# 兜底:清理仍占用端口的残留进程(非 PID 文件管理的)
kill_port $BACKEND_PORT
}
@@ -186,10 +212,7 @@ start_frontend() {
stop_frontend() {
if [ -f "$FRONTEND_PID_FILE" ]; then
local pid=$(cat "$FRONTEND_PID_FILE" 2>/dev/null || echo "")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
success "前端服务已停止"
fi
graceful_kill "$pid" "前端服务" 5
rm -f "$FRONTEND_PID_FILE"
fi
kill_port $FRONTEND_PORT