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:
1 parent
fb8d10231a
commit
327e282356
93 files changed
+8433
-3089
No files matched your search
@@ -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/
|
||||
@@ -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
|
||||
@@ -10,6 +10,10 @@ backend/dist/
|
||||
data/
|
||||
*.pid
|
||||
|
||||
# Secrets — never commit
|
||||
.env
|
||||
secrets/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -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`)。
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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+)`)
|
||||
|
||||
+1008
-98
File diff suppressed because it is too large.
Load diff
+194
-5
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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("解密操作失败")
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 "========================================"
|
||||
@@ -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
@@ -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"
|
||||
Executable
+97
@@ -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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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} · {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} · {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
|
||||
+639
-466
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user