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
+7626
-2282
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/
|
data/
|
||||||
*.pid
|
*.pid
|
||||||
|
|
||||||
|
# Secrets — never commit
|
||||||
|
.env
|
||||||
|
secrets/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.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
|
FROM node:20-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Install dependencies separately to leverage layer caching
|
||||||
COPY frontend/package*.json ./
|
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/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Build stage - Backend
|
# ---------------------------------------------------------------
|
||||||
|
# Stage 2: Backend Builder (Go)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
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
|
ENV GOTOOLCHAIN=local
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# Download dependencies (cached layer)
|
||||||
COPY backend/go.* ./
|
COPY backend/go.* ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source and build static binary
|
||||||
COPY backend/ ./
|
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
|
ARG TARGETARCH=amd64
|
||||||
# 安装 kubectl(兼容大多数集群版本,±1 版本原则)
|
|
||||||
RUN curl -fsSL -o /usr/local/bin/kubectl \
|
# ---- Install kubectl with checksum verification ----
|
||||||
"https://dl.k8s.io/release/v1.31.0/bin/linux/${TARGETARCH}/kubectl" \
|
# P1: curl downloads must have integrity verification
|
||||||
&& chmod +x /usr/local/bin/kubectl
|
ENV KUBECTL_VERSION=v1.31.0
|
||||||
# 安装 helm
|
ENV KUBECTL_SHA256_AMD64=e3c5b9c15e96cd8d70b3fdf87f38197d0b92e01fb920b5d8a40a3f4c5cb4e84a
|
||||||
RUN mkdir -p /tmp/helm \
|
ENV KUBECTL_SHA256_ARM64=f9147d84b7dc0630cf4f9d7769c36e65a0885bbc0a34c5dc65b9d93c287d83b9
|
||||||
&& curl -fsSL "https://get.helm.sh/helm-v3.16.3-linux-${TARGETARCH}.tar.gz" \
|
RUN set -eux; \
|
||||||
| tar -xz -C /tmp/helm \
|
if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
&& mv "/tmp/helm/linux-${TARGETARCH}/helm" /usr/local/bin/helm \
|
KUBECTL_SHA256="$KUBECTL_SHA256_AMD64"; \
|
||||||
&& chmod +x /usr/local/bin/helm \
|
elif [ "$TARGETARCH" = "arm64" ]; then \
|
||||||
&& rm -rf /tmp/helm
|
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
|
WORKDIR /app
|
||||||
|
RUN chown lokideploy:lokideploy /app
|
||||||
|
|
||||||
# Copy frontend build
|
# ---- Copy application files with correct ownership (P1) ----
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
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
|
# Create data directory with non-root ownership
|
||||||
COPY --from=backend-builder /app/backend/bin/lokideploy ./lokideploy
|
RUN mkdir -p /app/data && chown -R lokideploy:lokideploy /app/data
|
||||||
RUN chmod +x ./lokideploy
|
|
||||||
|
|
||||||
# Create data directory
|
# Create .kube directory for non-root user (P0: not /root/.kube)
|
||||||
RUN mkdir -p /app/data
|
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 DATA_DIR=/app/data
|
||||||
ENV SERVER_PORT=8080
|
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
|
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"]
|
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
|
```bash
|
||||||
git clone https://github.com/your/lokideploy.git
|
git clone https://github.com/your/lokideploy.git
|
||||||
cd lokideploy
|
cd lokideploy
|
||||||
|
|
||||||
# Build everything
|
# 设置密钥(见第 1 步)
|
||||||
bash build.sh
|
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
|
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
|
```bash
|
||||||
# Terminal 1 - Backend
|
# 容器级健康检查(内部)
|
||||||
cd backend
|
docker inspect --format='{{.State.Health.Status}}' lokideploy
|
||||||
go run .
|
|
||||||
|
|
||||||
# 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
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev # 打开 http://localhost:3000,API 代理到 localhost:8080
|
||||||
# Opens http://localhost:3000, proxies API to 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
|
| HTTP/REST + JWT Bearer Token
|
||||||
v
|
v
|
||||||
Backend (Go + Gin) [Port 8080]
|
后端(Go + Gin) [:8080]
|
||||||
|
|
|
|
||||||
+---> JSON File DB (data/lokideploy.json)
|
+---> JSON 文件数据库(data/lokideploy.json)
|
||||||
+---> K8s CLI (kubectl / helm)
|
+---> K8s CLI(kubectl / helm)
|
||||||
+---> SSH Client (golang.org/x/crypto/ssh)
|
+---> SSH 客户端(golang.org/x/crypto/ssh)
|
||||||
+---> Loki Deployer (Helm Chart / YAML / Binary)
|
+---> Loki 部署器(Helm Chart / YAML / Binary)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
### 后端分层
|
||||||
|
|
||||||
Base URL: `/api/v1`
|
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**
|
||||||
|
|
||||||
| Method | Endpoint | Auth | Description |
|
- `config/` — `GetConfig()` 单例,读取环境变量,密钥缺失或长度不足时直接 `log.Fatal`。
|
||||||
|--------|----------|------|-------------|
|
- `routers/router.go` — 所有路由注册在 `/api/v1`;注册 SPA 静态资源并用 `NoRoute` 回退到
|
||||||
| POST | `/auth/login` | No | Login, get JWT token |
|
`index.html` 支持前端路由。
|
||||||
| GET | `/auth/user` | Yes | Current user info |
|
- `controllers/` — 薄 Gin handler,每资源一个文件;绑定/校验入参、调用 service、用
|
||||||
| POST | `/auth/password` | Yes | Change password |
|
`models.SanitizedJSON(...)` 包装返回。
|
||||||
| GET/POST | `/clusters` | Yes | Cluster CRUD |
|
- `services/` — 业务逻辑。部署流水线:
|
||||||
| POST | `/clusters/:id/validate` | Yes | Validate cluster |
|
`DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按模式(`k8s-helm` / `k8s-yaml` /
|
||||||
| GET/POST | `/hosts` | Yes | Host CRUD |
|
`host-binary`)分发到 `kubectl`/`helm` 或 SSH。另有 `K8sClient`、`ssh`、`storage`、
|
||||||
| POST | `/hosts/:id/execute` | Yes | Remote command |
|
`cluster_info` 与较新的 **collector(日志采集器)** 模块。
|
||||||
| GET/POST | `/storages` | Yes | Storage config CRUD |
|
- `middleware/jwt.go` — `JWTAuth()`、`AdminAuth()`。
|
||||||
| GET/POST | `/deployments` | Yes | Deployment CRUD |
|
- `utils/` — `response.go`(响应封装)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。
|
||||||
| POST | `/deployments/:id/deploy` | Yes | Start deployment |
|
- `seed/` — 数据库为空时写入默认 admin 用户。
|
||||||
| POST | `/deployments/:id/stop` | Yes | Stop deployment |
|
|
||||||
| GET | `/deployments/:id/logs` | Yes | Deployment logs |
|
|
||||||
| GET | `/deployments/stats` | Yes | Statistics |
|
|
||||||
|
|
||||||
## Deployment Modes
|
### 数据层 — JSON 文件数据库
|
||||||
|
|
||||||
| Mode | Target | Description |
|
持久化是 **单个 JSON 文件** `$DATA_DIR/lokideploy.json`(默认 `./data/`),**没有 SQL 数据库**。
|
||||||
|------|--------|-------------|
|
`repository.JsonDB` 是与文件对应的内存结构:
|
||||||
| `k8s-helm` | K8s Cluster | Official Grafana Helm chart |
|
|
||||||
| `k8s-yaml` | K8s Cluster | Raw Kubernetes manifests |
|
|
||||||
| `host-binary` | Linux Host | Binary + systemd via SSH |
|
|
||||||
|
|
||||||
## 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` |
|
| S3 | `s3.amazonaws.com` |
|
||||||
| OSS | `oss-cn-hangzhou.aliyuncs.com` |
|
| OSS | `oss-cn-hangzhou.aliyuncs.com` |
|
||||||
| MinIO | `http://minio:9000` |
|
| MinIO | `http://minio:9000` |
|
||||||
| RustFS | `http://rustfs:8080` |
|
| 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
|
| `JWT_SECRET` | `$(openssl rand -hex 32)` | JWT 签名密钥(**必需**,>= 32 字符) |
|
||||||
- **RBAC** role-based access control
|
| `ENCRYPTION_KEY` | `$(openssl rand -hex 32)` | AES 凭证加密密钥(**必需**,>= 32 字节) |
|
||||||
- API responses sanitized (no passwords, kubeconfig, or keys exposed)
|
|
||||||
|
|
||||||
## 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
|
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 文件
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -15,21 +17,46 @@ type Config struct {
|
|||||||
ServerPort string
|
ServerPort string
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
EncryptionKey string // 独立加密密钥,必须与JWT_SECRET分离
|
||||||
DataDir string
|
DataDir string
|
||||||
KubeDir string
|
KubeDir string
|
||||||
|
LogLevel string // 新增:日志级别 debug/info/warn/error
|
||||||
|
HostKeyDir string // 新增:SSH主机密钥存储目录
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfig() *Config {
|
func GetConfig() *Config {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
dataDir := getEnv("DATA_DIR", "./data")
|
dataDir := getEnv("DATA_DIR", "./data")
|
||||||
|
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
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{
|
instance = &Config{
|
||||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||||
DatabasePath: filepath.Join(dataDir, "lokideploy.json"),
|
DatabasePath: filepath.Join(dataDir, "lokideploy.json"),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "lokideploy-secret-key-change-in-production"),
|
JWTSecret: jwtSecret,
|
||||||
|
EncryptionKey: encKey,
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
KubeDir: filepath.Join(dataDir, "kubeconfigs"),
|
KubeDir: filepath.Join(dataDir, "kubeconfigs"),
|
||||||
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
|
HostKeyDir: filepath.Join(dataDir, "known_hosts"),
|
||||||
}
|
}
|
||||||
_ = os.MkdirAll(instance.KubeDir, 0755)
|
|
||||||
|
_ = os.MkdirAll(instance.KubeDir, 0700)
|
||||||
|
_ = os.MkdirAll(instance.HostKeyDir, 0700)
|
||||||
})
|
})
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-24
@@ -1,7 +1,9 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"lokideploy/dto"
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/pkg/jwt"
|
||||||
"lokideploy/services"
|
"lokideploy/services"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
|
|
||||||
@@ -16,38 +18,43 @@ func NewAuthController() *AuthController {
|
|||||||
return &AuthController{service: services.NewAuthService()}
|
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) {
|
func (ctrl *AuthController) Login(c *gin.Context) {
|
||||||
var req LoginRequest
|
var req dto.LoginRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, token, err := ctrl.service.Login(req.Username, req.Password)
|
user, tokenPair, err := ctrl.service.Login(req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Unauthorized(c, "用户名或密码错误")
|
utils.Unauthorized(c, "用户名或密码错误")
|
||||||
return
|
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) {
|
func (ctrl *AuthController) Register(c *gin.Context) {
|
||||||
var req RegisterRequest
|
var req dto.RegisterRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -63,7 +70,7 @@ func (ctrl *AuthController) Register(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *AuthController) GetUser(c *gin.Context) {
|
func (ctrl *AuthController) GetUser(c *gin.Context) {
|
||||||
claims, ok := utils.GetUserFromContext(c)
|
claims, ok := jwt.GetUserFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
utils.Unauthorized(c, "")
|
utils.Unauthorized(c, "")
|
||||||
return
|
return
|
||||||
@@ -77,11 +84,11 @@ func (ctrl *AuthController) GetUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *AuthController) ChangePassword(c *gin.Context) {
|
func (ctrl *AuthController) ChangePassword(c *gin.Context) {
|
||||||
var req ChangePasswordRequest
|
var req dto.ChangePasswordRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims, ok := utils.GetUserFromContext(c)
|
claims, ok := jwt.GetUserFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
utils.Unauthorized(c, "")
|
utils.Unauthorized(c, "")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"lokideploy/dto"
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
"lokideploy/services"
|
"lokideploy/services"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
@@ -16,18 +17,8 @@ func NewClusterController() *ClusterController {
|
|||||||
return &ClusterController{service: services.NewClusterService()}
|
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) {
|
func (ctrl *ClusterController) Create(c *gin.Context) {
|
||||||
var req CreateClusterRequest
|
var req dto.CreateClusterRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"lokideploy/dto"
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
"lokideploy/services"
|
"lokideploy/services"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
@@ -16,20 +17,8 @@ func NewCollectorController() *CollectorController {
|
|||||||
return &CollectorController{service: services.NewCollectorService()}
|
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) {
|
func (ctrl *CollectorController) Create(c *gin.Context) {
|
||||||
var req CreateCollectorRequest
|
var req dto.CreateCollectorRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"lokideploy/dto"
|
||||||
|
"lokideploy/models"
|
||||||
"lokideploy/services"
|
"lokideploy/services"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
|
|
||||||
@@ -15,25 +17,8 @@ func NewDeploymentController() *DeploymentController {
|
|||||||
return &DeploymentController{service: services.NewDeploymentService()}
|
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) {
|
func (ctrl *DeploymentController) Create(c *gin.Context) {
|
||||||
var req CreateDeploymentRequest
|
var req dto.CreateDeploymentRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,7 +42,7 @@ func (ctrl *DeploymentController) Create(c *gin.Context) {
|
|||||||
utils.Error(c, 400, err.Error())
|
utils.Error(c, 400, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, deployment)
|
utils.Success(c, models.SanitizedJSON(deployment))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *DeploymentController) Get(c *gin.Context) {
|
func (ctrl *DeploymentController) Get(c *gin.Context) {
|
||||||
@@ -71,7 +56,7 @@ func (ctrl *DeploymentController) Get(c *gin.Context) {
|
|||||||
utils.NotFound(c, "部署不存在")
|
utils.NotFound(c, "部署不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, deployment)
|
utils.Success(c, models.SanitizedJSON(deployment))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *DeploymentController) List(c *gin.Context) {
|
func (ctrl *DeploymentController) List(c *gin.Context) {
|
||||||
@@ -82,7 +67,7 @@ func (ctrl *DeploymentController) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.Success(c, utils.PageData{
|
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
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"lokideploy/dto"
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/pkg/crypto"
|
||||||
"lokideploy/services"
|
"lokideploy/services"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
|
|
||||||
@@ -16,22 +18,8 @@ func NewHostController() *HostController {
|
|||||||
return &HostController{service: services.NewHostService()}
|
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) {
|
func (ctrl *HostController) Create(c *gin.Context) {
|
||||||
var req CreateHostRequest
|
var req dto.CreateHostRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -125,7 +113,7 @@ func (ctrl *HostController) GetInfo(c *gin.Context) {
|
|||||||
|
|
||||||
password := host.Password
|
password := host.Password
|
||||||
if len(password) > 4 && password[:4] == "ENC:" {
|
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)
|
client, err := services.NewSSHClient(host.HostIP, host.Port, host.User, password)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"lokideploy/dto"
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
"lokideploy/services"
|
"lokideploy/services"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
@@ -16,21 +17,8 @@ func NewStorageController() *StorageController {
|
|||||||
return &StorageController{service: services.NewStorageService()}
|
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) {
|
func (ctrl *StorageController) Create(c *gin.Context) {
|
||||||
var req CreateStorageRequest
|
var req dto.CreateStorageRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
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"`
|
||||||
|
}
|
||||||
+38
-4
@@ -1,10 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lokideploy/config"
|
"lokideploy/config"
|
||||||
"lokideploy/models"
|
"lokideploy/repository"
|
||||||
"lokideploy/routers"
|
"lokideploy/routers"
|
||||||
"lokideploy/seed"
|
"lokideploy/seed"
|
||||||
)
|
)
|
||||||
@@ -13,7 +19,7 @@ func main() {
|
|||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
log.Println("Initializing database...")
|
log.Println("Initializing database...")
|
||||||
if err := models.InitDB(); err != nil {
|
if err := repository.InitDB(); err != nil {
|
||||||
log.Fatalf("Failed to init DB: %v", err)
|
log.Fatalf("Failed to init DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,12 +27,40 @@ func main() {
|
|||||||
seed.Seed()
|
seed.Seed()
|
||||||
|
|
||||||
r := routers.SetupRouter()
|
r := routers.SetupRouter()
|
||||||
|
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("LokiDeploy starting on port %s", cfg.ServerPort)
|
||||||
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
|
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
|
||||||
log.Printf("Default login: admin / admin123")
|
log.Printf("Default login: admin / admin123")
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
if err := r.Run(":" + cfg.ServerPort); err != nil {
|
|
||||||
log.Fatalf("Server failed: %v", err)
|
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 (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"lokideploy/pkg/jwt"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -24,13 +25,20 @@ func JWTAuth() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := utils.ParseToken(parts[1])
|
claims, err := jwt.ParseToken(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Unauthorized(c, "invalid or expired token")
|
utils.Unauthorized(c, "invalid or expired token")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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("user", claims)
|
||||||
c.Set("userId", claims.UserID)
|
c.Set("userId", claims.UserID)
|
||||||
c.Set("username", claims.Username)
|
c.Set("username", claims.Username)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokideploy/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
type Cluster struct {
|
type Cluster struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ConfigType string `json:"configType"` // kubeconfig | token(空值视为 kubeconfig,向后兼容)
|
ConfigType string `json:"configType"` // kubeconfig | token
|
||||||
APIServer string `json:"apiServer"` // 展示用
|
APIServer string `json:"apiServer"` // 展示用
|
||||||
KubeConfig string `json:"kubeconfig"` // 统一存储(token 接入时存构造出的等价 kubeconfig)
|
KubeConfig string `json:"kubeconfig"` // 统一存储(加密存储时带ENC:前缀)
|
||||||
KubeConfigPath string `json:"kubeconfigPath"`
|
KubeConfigPath string `json:"kubeconfigPath"`
|
||||||
Context string `json:"context"`
|
Context string `json:"context"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
@@ -36,3 +40,24 @@ func (c *Cluster) Sanitized() map[string]interface{} {
|
|||||||
"updatedAt": c.UpdatedAt,
|
"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()
|
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
|
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 {
|
type Host struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -9,9 +25,9 @@ type Host struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
AuthType string `json:"authType"`
|
AuthType string `json:"authType"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"` // 加密存储(ENC:前缀)
|
||||||
PrivateKey string `json:"privateKey"`
|
PrivateKey string `json:"privateKey"` // 加密存储(ENC:前缀)
|
||||||
KeyPhrase string `json:"keyPhrase"`
|
KeyPhrase string `json:"keyPhrase"` // 加密存储(ENC:前缀)
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -36,3 +52,70 @@ func (h *Host) Sanitized() map[string]interface{} {
|
|||||||
"updatedAt": h.UpdatedAt,
|
"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
|
package models
|
||||||
|
|
||||||
// Sanitizer interface for models that can sanitize themselves
|
// Sanitizer interface for models that can sanitize themselves
|
||||||
|
// 用于从API响应中移除敏感字段
|
||||||
type Sanitizer interface {
|
type Sanitizer interface {
|
||||||
Sanitized() map[string]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 {
|
func SanitizedJSON(v any) any {
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
// Single objects
|
// Single objects: 尝试Sanitizer接口
|
||||||
case User:
|
case Sanitizer:
|
||||||
return val.Sanitized()
|
return val.Sanitized()
|
||||||
case *User:
|
case *User:
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return val.Sanitized()
|
return val.Sanitized()
|
||||||
case Cluster:
|
|
||||||
return val.Sanitized()
|
|
||||||
case *Cluster:
|
case *Cluster:
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return val.Sanitized()
|
return val.Sanitized()
|
||||||
case Host:
|
|
||||||
return val.Sanitized()
|
|
||||||
case *Host:
|
case *Host:
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return val.Sanitized()
|
return val.Sanitized()
|
||||||
case StorageConfig:
|
|
||||||
return val.Sanitized()
|
|
||||||
case *StorageConfig:
|
case *StorageConfig:
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return val.Sanitized()
|
return val.Sanitized()
|
||||||
case Collector:
|
|
||||||
return val.Sanitized()
|
|
||||||
case *Collector:
|
case *Collector:
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return val.Sanitized()
|
return val.Sanitized()
|
||||||
|
case *LokiDeployment:
|
||||||
|
if val == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val.Sanitized()
|
||||||
// Slices
|
// Slices
|
||||||
case []User:
|
case []User:
|
||||||
result := make([]map[string]interface{}, len(val))
|
result := make([]map[string]interface{}, len(val))
|
||||||
@@ -75,7 +74,20 @@ func SanitizedJSON(v any) any {
|
|||||||
result[i] = item.Sanitized()
|
result[i] = item.Sanitized()
|
||||||
}
|
}
|
||||||
return result
|
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:
|
default:
|
||||||
|
// 尝试通过反射处理指针类型
|
||||||
return v
|
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
|
package routers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lokideploy/controllers"
|
"lokideploy/controllers"
|
||||||
"lokideploy/middleware"
|
"lokideploy/middleware"
|
||||||
@@ -33,8 +35,25 @@ func SetupRouter() *gin.Engine {
|
|||||||
// API v1
|
// API v1
|
||||||
api := r.Group("/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
|
// Public
|
||||||
api.POST("/auth/login", authCtrl.Login)
|
api.POST("/auth/login", authCtrl.Login)
|
||||||
|
api.POST("/auth/refresh", authCtrl.RefreshToken)
|
||||||
|
|
||||||
// Auth required
|
// Auth required
|
||||||
authorized := api.Group("")
|
authorized := api.Group("")
|
||||||
@@ -84,7 +103,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs)
|
authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs)
|
||||||
authorized.GET("/deployments/stats", deploymentCtrl.GetStats)
|
authorized.GET("/deployments/stats", deploymentCtrl.GetStats)
|
||||||
|
|
||||||
// Collectors(日志采集器 / 部署客户端)
|
// Collectors
|
||||||
authorized.GET("/collectors", collectorCtrl.List)
|
authorized.GET("/collectors", collectorCtrl.List)
|
||||||
authorized.GET("/collectors/:id", collectorCtrl.Get)
|
authorized.GET("/collectors/:id", collectorCtrl.Get)
|
||||||
authorized.POST("/collectors", collectorCtrl.Create)
|
authorized.POST("/collectors", collectorCtrl.Create)
|
||||||
@@ -98,6 +117,21 @@ func SetupRouter() *gin.Engine {
|
|||||||
|
|
||||||
if distPath != "" {
|
if distPath != "" {
|
||||||
r.Static("/assets", filepath.Join(distPath, "assets"))
|
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) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||||
c.JSON(404, gin.H{"code": 404, "message": "API endpoint not found"})
|
c.JSON(404, gin.H{"code": 404, "message": "API endpoint not found"})
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ package seed
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
"lokideploy/utils"
|
"lokideploy/pkg/crypto"
|
||||||
|
"lokideploy/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Seed() {
|
func Seed() {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
hasUsers := len(db.Users) > 0
|
hasUsers := len(db.Users) > 0
|
||||||
db.RUnlock()
|
db.RUnlock()
|
||||||
|
|
||||||
if !hasUsers {
|
if !hasUsers {
|
||||||
hash, _ := utils.HashPassword("admin123")
|
hash, _ := crypto.HashPassword("admin123")
|
||||||
models.GetDB().CreateUser(&models.User{
|
repository.GetDB().CreateUser(&models.User{
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: hash,
|
Password: hash,
|
||||||
Nickname: "Administrator",
|
Nickname: "Administrator",
|
||||||
|
|||||||
+19
-16
@@ -2,6 +2,9 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/pkg/crypto"
|
||||||
|
"lokideploy/pkg/jwt"
|
||||||
|
"lokideploy/repository"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,45 +14,45 @@ func NewAuthService() *AuthService {
|
|||||||
return &AuthService{}
|
return &AuthService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) Login(username, password string) (*models.User, string, error) {
|
func (s *AuthService) Login(username, password string) (*models.User, *jwt.TokenPair, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Users {
|
for i := range db.Users {
|
||||||
if db.Users[i].Username == username {
|
if db.Users[i].Username == username {
|
||||||
if !utils.VerifyPassword(password, db.Users[i].Password) {
|
if !crypto.VerifyPassword(password, db.Users[i].Password) {
|
||||||
return nil, "", utils.ErrInvalidCredentials
|
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 {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
ID: models.GetDB().NextID("users"),
|
ID: repository.GetDB().NextID("users"),
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: hash,
|
Password: hash,
|
||||||
Nickname: nickname,
|
Nickname: nickname,
|
||||||
Role: role,
|
Role: role,
|
||||||
}
|
}
|
||||||
if err := models.GetDB().CreateUser(user); err != nil {
|
if err := repository.GetDB().CreateUser(user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
|
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Users {
|
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 {
|
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i := range db.Users {
|
for i := range db.Users {
|
||||||
if db.Users[i].ID == userID {
|
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
|
return utils.ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
hash, err := utils.HashPassword(newPassword)
|
hash, err := crypto.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
db.Users[i].Password = hash
|
db.Users[i].Password = hash
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -7,7 +7,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"lokideploy/config"
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/repository"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,8 +20,8 @@ func NewClusterService() *ClusterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveKubeConfig(name, content string) (string, error) {
|
func SaveKubeConfig(name, content string) (string, error) {
|
||||||
dir := filepath.Join("data", "kubeconfigs")
|
dir := config.GetConfig().KubeDir
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, fmt.Sprintf("%s.yaml", name))
|
path := filepath.Join(dir, fmt.Sprintf("%s.yaml", name))
|
||||||
@@ -37,7 +39,9 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco
|
|||||||
|
|
||||||
finalKubeconfig := kubeconfig
|
finalKubeconfig := kubeconfig
|
||||||
if configType == "token" {
|
if configType == "token" {
|
||||||
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token)
|
// 安全默认:不嵌入 CA、不跳过 TLS 验证,依赖系统 CA 池。
|
||||||
|
// 自签证书的集群请改用 kubeconfig 模式(其内含 CA 证书)。
|
||||||
|
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", false)
|
||||||
}
|
}
|
||||||
// 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取
|
// 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取
|
||||||
if apiServer == "" {
|
if apiServer == "" {
|
||||||
@@ -50,7 +54,7 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco
|
|||||||
}
|
}
|
||||||
|
|
||||||
cluster := models.Cluster{
|
cluster := models.Cluster{
|
||||||
ID: models.GetDB().NextID("clusters"),
|
ID: repository.GetDB().NextID("clusters"),
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: description,
|
Description: description,
|
||||||
ConfigType: configType,
|
ConfigType: configType,
|
||||||
@@ -63,18 +67,18 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
db.Clusters = append(db.Clusters, cluster)
|
db.Clusters = append(db.Clusters, cluster)
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
|
|
||||||
go s.Validate(cluster.ID)
|
go s.Validate(cluster.ID)
|
||||||
return &cluster, nil
|
return &cluster, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClusterService) Get(id uint) (*models.Cluster, error) {
|
func (s *ClusterService) Get(id uint) (*models.Cluster, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Clusters {
|
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) {
|
func (s *ClusterService) List(page, pageSize int) ([]models.Cluster, int64, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
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 {
|
func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i := range db.Clusters {
|
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()
|
db.Clusters[i].UpdatedAt = time.Now()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +159,7 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClusterService) Delete(id uint) error {
|
func (s *ClusterService) Delete(id uint) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i, c := range db.Clusters {
|
for i, c := range db.Clusters {
|
||||||
@@ -164,7 +168,7 @@ func (s *ClusterService) Delete(id uint) error {
|
|||||||
_ = os.Remove(c.KubeConfigPath)
|
_ = os.Remove(c.KubeConfigPath)
|
||||||
}
|
}
|
||||||
db.Clusters = append(db.Clusters[:i], db.Clusters[i+1:]...)
|
db.Clusters = append(db.Clusters[:i], db.Clusters[i+1:]...)
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/repository"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ type CreateCollectorRequest struct {
|
|||||||
|
|
||||||
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
|
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
|
||||||
func (s *CollectorService) fillAssociations(c *models.Collector) {
|
func (s *CollectorService) fillAssociations(c *models.Collector) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Deployments {
|
for i := range db.Deployments {
|
||||||
@@ -107,7 +108,7 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
|
|||||||
}
|
}
|
||||||
|
|
||||||
collector := models.Collector{
|
collector := models.Collector{
|
||||||
ID: models.GetDB().NextID("collectors"),
|
ID: repository.GetDB().NextID("collectors"),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
LokiDeploymentID: req.LokiDeploymentID,
|
LokiDeploymentID: req.LokiDeploymentID,
|
||||||
@@ -129,18 +130,18 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
|
|||||||
}
|
}
|
||||||
collector.Config = s.generateConfigSnapshot(&collector)
|
collector.Config = s.generateConfigSnapshot(&collector)
|
||||||
|
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
db.Collectors = append(db.Collectors, collector)
|
db.Collectors = append(db.Collectors, collector)
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
|
|
||||||
go s.Install(collector.ID)
|
go s.Install(collector.ID)
|
||||||
return &collector, nil
|
return &collector, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CollectorService) Get(id uint) (*models.Collector, error) {
|
func (s *CollectorService) Get(id uint) (*models.Collector, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
var found *models.Collector
|
var found *models.Collector
|
||||||
for i := range db.Collectors {
|
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) {
|
func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
total := int64(len(db.Collectors))
|
total := int64(len(db.Collectors))
|
||||||
start := (page - 1) * pageSize
|
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 {
|
func (s *CollectorService) Update(id uint, updates map[string]interface{}) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i := range db.Collectors {
|
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].Labels = v
|
||||||
}
|
}
|
||||||
db.Collectors[i].UpdatedAt = time.Now()
|
db.Collectors[i].UpdatedAt = time.Now()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +215,7 @@ func (s *CollectorService) Delete(id uint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.ErrNotFound
|
return utils.ErrNotFound
|
||||||
}
|
}
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
for i, c := range db.Collectors {
|
for i, c := range db.Collectors {
|
||||||
if c.ID == id {
|
if c.ID == id {
|
||||||
@@ -223,7 +224,7 @@ func (s *CollectorService) Delete(id uint) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
// 异步卸载(用拷贝的 collector,避免删除后取不到)
|
// 异步卸载(用拷贝的 collector,避免删除后取不到)
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
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")
|
kubeFile, err := os.CreateTemp("", "kube-*.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, fmt.Errorf("create kubeconfig temp file failed: %w", err)
|
||||||
}
|
}
|
||||||
kubeFile.WriteString(kubeconfig)
|
if _, err := kubeFile.WriteString(kubeconfig); err != nil {
|
||||||
kubeFile.Close()
|
kubeFile.Close()
|
||||||
|
os.Remove(kubeFile.Name())
|
||||||
|
return nil, nil, fmt.Errorf("write kubeconfig temp file failed: %w", err)
|
||||||
|
}
|
||||||
|
kubeFile.Close()
|
||||||
|
os.Chmod(kubeFile.Name(), 0600)
|
||||||
env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
|
env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
|
||||||
cleanup = func() { os.Remove(kubeFile.Name()) }
|
cleanup = func() { os.Remove(kubeFile.Name()) }
|
||||||
return env, cleanup, nil
|
return env, cleanup, nil
|
||||||
@@ -73,6 +78,7 @@ func (d *CollectorDeployer) Install(ctx context.Context) error {
|
|||||||
|
|
||||||
// Uninstall 按采集器类型分发卸载
|
// Uninstall 按采集器类型分发卸载
|
||||||
func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
|
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 {
|
switch d.collector.Type {
|
||||||
case "host-promtail":
|
case "host-promtail":
|
||||||
return d.uninstallHost(ctx)
|
return d.uninstallHost(ctx)
|
||||||
@@ -85,6 +91,7 @@ func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== host-promtail:SSH 安装 Promtail 二进制 + systemd =====
|
// ===== host-promtail:SSH 安装 Promtail 二进制 + systemd =====
|
||||||
|
|
||||||
func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error {
|
func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error {
|
||||||
host := d.collector.Host
|
host := d.collector.Host
|
||||||
if host == nil {
|
if host == nil {
|
||||||
@@ -100,7 +107,7 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
// 架构
|
// 检测架构
|
||||||
archOut, _, _ := client.Execute(ctx, "uname -m")
|
archOut, _, _ := client.Execute(ctx, "uname -m")
|
||||||
arch := strings.TrimSpace(archOut)
|
arch := strings.TrimSpace(archOut)
|
||||||
switch arch {
|
switch arch {
|
||||||
@@ -112,41 +119,70 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
|
|||||||
return fmt.Errorf("不支持的主机架构: %s", arch)
|
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
|
// 下载 Promtail
|
||||||
d.log("info", fmt.Sprintf("下载 Promtail(%s)", arch), "")
|
d.log("info", fmt.Sprintf("下载 Promtail %s(%s)", version, arch), "")
|
||||||
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v3.0.0/promtail-linux-%s.zip", arch)
|
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/promtail-linux-%s.zip", version, 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)
|
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 {
|
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")
|
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
|
||||||
|
|
||||||
// 配置
|
// 生成并上传配置
|
||||||
logPath := d.collector.LogPath
|
logPath := d.collector.LogPath
|
||||||
if logPath == "" {
|
if logPath == "" {
|
||||||
logPath = "/var/log/*.log"
|
logPath = "/var/log/*.log"
|
||||||
}
|
}
|
||||||
conf := PromtailConfig(endpoint, logPath)
|
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]
|
service := `[Unit]
|
||||||
Description=Promtail Log Collector
|
Description=Promtail Log Collector
|
||||||
After=network.target
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
LimitNOFILE=65536
|
||||||
|
StandardOutput=append:/var/log/promtail.log
|
||||||
|
StandardError=append:/var/log/promtail.log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`
|
`
|
||||||
client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service))
|
d.log("info", "安装 systemd 服务", "")
|
||||||
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil {
|
if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service)); err != nil {
|
||||||
return fmt.Errorf("启动 Promtail 失败: %v", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,11 +197,15 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== k8s-daemonset:helm 安装 Promtail DaemonSet =====
|
// ===== k8s-daemonset:helm 安装 Promtail DaemonSet =====
|
||||||
|
|
||||||
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
|
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
|
||||||
cluster := d.collector.Cluster
|
cluster := d.collector.Cluster
|
||||||
if cluster == nil {
|
if cluster == nil {
|
||||||
@@ -183,27 +223,27 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
values := PromtailHelmValues(endpoint)
|
values := PromtailHelmValues(endpoint)
|
||||||
valuesFile, err := os.CreateTemp("", "promtail-values-*.yaml")
|
valuesFile, valuesCleanup, err := writeTempFile("promtail-values-*.yaml", values, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(valuesFile.Name())
|
defer valuesCleanup()
|
||||||
valuesFile.WriteString(values)
|
|
||||||
valuesFile.Close()
|
|
||||||
|
|
||||||
d.log("info", fmt.Sprintf("helm install promtail(命名空间 %s)", ns), "")
|
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 := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts")
|
||||||
repo.Env = env
|
repo.Env = env
|
||||||
repo.CombinedOutput()
|
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",
|
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
|
cmd.Env = env
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -229,6 +269,7 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar =====
|
// ===== k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar =====
|
||||||
|
|
||||||
func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) error {
|
func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) error {
|
||||||
cluster := d.collector.Cluster
|
cluster := d.collector.Cluster
|
||||||
if cluster == nil {
|
if cluster == nil {
|
||||||
@@ -275,27 +316,31 @@ func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string)
|
|||||||
lokiHost, lokiPort := parseLokiURL(endpoint)
|
lokiHost, lokiPort := parseLokiURL(endpoint)
|
||||||
conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns)
|
conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns)
|
||||||
cm := FluentbitConfigMapYAML(conf, ns)
|
cm := FluentbitConfigMapYAML(conf, ns)
|
||||||
cmFile, _ := os.CreateTemp("", "fluent-bit-cm-*.yaml")
|
cmFile, cmCleanup, err := writeTempFile("fluent-bit-cm-*.yaml", cm, 0600)
|
||||||
defer os.Remove(cmFile.Name())
|
if err != nil {
|
||||||
cmFile.WriteString(cm)
|
return err
|
||||||
cmFile.Close()
|
}
|
||||||
|
defer cmCleanup()
|
||||||
|
|
||||||
d.log("info", "部署 Fluent Bit ConfigMap", "")
|
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
|
applyCm.Env = env
|
||||||
if out, err := applyCm.CombinedOutput(); err != nil {
|
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
|
// 4. patch 注入 sidecar
|
||||||
d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "")
|
d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "")
|
||||||
patchFile, _ := os.CreateTemp("", "sidecar-patch-*.yaml")
|
patchFile, patchCleanup, err := writeTempFile("sidecar-patch-*.yaml", SidecarPatchYAML, 0600)
|
||||||
defer os.Remove(patchFile.Name())
|
if err != nil {
|
||||||
patchFile.WriteString(SidecarPatchYAML)
|
return err
|
||||||
patchFile.Close()
|
}
|
||||||
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
|
defer patchCleanup()
|
||||||
|
|
||||||
|
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile)
|
||||||
patch.Env = env
|
patch.Env = env
|
||||||
if out, err := patch.CombinedOutput(); err != nil {
|
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
|
// 5. 等待 rollout
|
||||||
@@ -320,11 +365,13 @@ func (d *CollectorDeployer) uninstallSidecar(ctx context.Context) error {
|
|||||||
if ns == "" {
|
if ns == "" {
|
||||||
ns = "default"
|
ns = "default"
|
||||||
}
|
}
|
||||||
patchFile, _ := os.CreateTemp("", "sidecar-rm-*.yaml")
|
patchFile, patchCleanup, err := writeTempFile("sidecar-rm-*.yaml", SidecarRemovePatchYAML, 0600)
|
||||||
defer os.Remove(patchFile.Name())
|
if err != nil {
|
||||||
patchFile.WriteString(SidecarRemovePatchYAML)
|
return err
|
||||||
patchFile.Close()
|
}
|
||||||
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
|
defer patchCleanup()
|
||||||
|
|
||||||
|
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile)
|
||||||
patch.Env = env
|
patch.Env = env
|
||||||
patch.CombinedOutput()
|
patch.CombinedOutput()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -6,17 +6,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath)
|
// PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath)
|
||||||
|
// 生产级配置:持久化位置文件、批处理、重试、CRI日志格式解析
|
||||||
func PromtailConfig(lokiEndpoint, logPath string) string {
|
func PromtailConfig(lokiEndpoint, logPath string) string {
|
||||||
return fmt.Sprintf(`server:
|
return fmt.Sprintf(`server:
|
||||||
http_listen_port: 9080
|
http_listen_port: 9080
|
||||||
grpc_listen_port: 0
|
grpc_listen_port: 0
|
||||||
log_level: info
|
log_level: info
|
||||||
|
log_format: logfmt
|
||||||
|
|
||||||
positions:
|
positions:
|
||||||
filename: /tmp/positions.yaml
|
filename: /var/lib/promtail/positions.yaml
|
||||||
|
sync_period: 10s
|
||||||
|
|
||||||
clients:
|
clients:
|
||||||
- url: %s/loki/api/v1/push
|
- 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:
|
scrape_configs:
|
||||||
- job_name: app
|
- job_name: app
|
||||||
@@ -26,28 +44,104 @@ scrape_configs:
|
|||||||
job: app
|
job: app
|
||||||
host: ${HOSTNAME}
|
host: ${HOSTNAME}
|
||||||
__path__: %s
|
__path__: %s
|
||||||
|
pipeline_stages:
|
||||||
|
- cri: {}
|
||||||
|
- match:
|
||||||
|
selector: '{job="app"}'
|
||||||
|
stages:
|
||||||
|
- timestamp:
|
||||||
|
source: time
|
||||||
|
format: RFC3339Nano
|
||||||
|
- limit:
|
||||||
|
rate: 1000
|
||||||
|
burst: 10000
|
||||||
|
drop: true
|
||||||
`, lokiEndpoint, logPath)
|
`, lokiEndpoint, logPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromtailHelmValues DaemonSet 模式 Promtail Helm values
|
// PromtailHelmValues DaemonSet 模式 Promtail Helm values(生产级)
|
||||||
|
// 包含:cri解析、kubernetes标签、批处理配置、资源限制
|
||||||
func PromtailHelmValues(lokiEndpoint string) string {
|
func PromtailHelmValues(lokiEndpoint string) string {
|
||||||
return fmt.Sprintf(`config:
|
return fmt.Sprintf(`config:
|
||||||
clients:
|
clients:
|
||||||
- url: %s/loki/api/v1/push
|
- 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:
|
daemonset:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
serviceAccount:
|
|
||||||
create: true
|
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 200m
|
cpu: 200m
|
||||||
memory: 128Mi
|
memory: 256Mi
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
memory: 64Mi
|
memory: 128Mi
|
||||||
|
|
||||||
extraVolumes:
|
extraVolumes:
|
||||||
- name: varlog
|
- name: varlog
|
||||||
@@ -56,6 +150,9 @@ extraVolumes:
|
|||||||
- name: varlibdockercontainers
|
- name: varlibdockercontainers
|
||||||
hostPath:
|
hostPath:
|
||||||
path: /var/lib/docker/containers
|
path: /var/lib/docker/containers
|
||||||
|
- name: promtail-run
|
||||||
|
hostPath:
|
||||||
|
path: /run/promtail
|
||||||
|
|
||||||
extraVolumeMounts:
|
extraVolumeMounts:
|
||||||
- name: varlog
|
- name: varlog
|
||||||
@@ -64,33 +161,63 @@ extraVolumeMounts:
|
|||||||
- name: varlibdockercontainers
|
- name: varlibdockercontainers
|
||||||
mountPath: /var/lib/docker/containers
|
mountPath: /var/lib/docker/containers
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
- name: promtail-run
|
||||||
|
mountPath: /run/promtail
|
||||||
`, lokiEndpoint)
|
`, lokiEndpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
|
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
|
||||||
|
// 生产级配置:kubernetes过滤器、内存缓冲、健康检查
|
||||||
func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string {
|
func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string {
|
||||||
return fmt.Sprintf(`[SERVICE]
|
return fmt.Sprintf(`[SERVICE]
|
||||||
Flush 5
|
Flush 1
|
||||||
Log_Level info
|
Log_Level info
|
||||||
HTTP_Server On
|
HTTP_Server On
|
||||||
HTTP_Listen 0.0.0.0
|
HTTP_Listen 0.0.0.0
|
||||||
HTTP_Port 2020
|
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]
|
[INPUT]
|
||||||
Name tail
|
Name tail
|
||||||
Path %s
|
Path %s
|
||||||
Tag app.%s
|
Tag app.%s
|
||||||
Refresh_Interval 5
|
Refresh_Interval 5
|
||||||
Mem_Buf_Limit 5MB
|
Mem_Buf_Limit 10MB
|
||||||
Skip_Long_Lines On
|
Skip_Long_Lines On
|
||||||
Read_from_Head True
|
Read_from_Head True
|
||||||
DB /tmp/flb.db
|
DB /tmp/flb.db
|
||||||
|
DB.Sync Normal
|
||||||
|
Buffer_Max_Size 1MB
|
||||||
|
storage.type filesystem
|
||||||
|
|
||||||
[FILTER]
|
[FILTER]
|
||||||
Name modify
|
Name modify
|
||||||
Match app.*
|
Match app.*
|
||||||
Add namespace %s
|
Add namespace %s
|
||||||
Add app %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]
|
[OUTPUT]
|
||||||
Name loki
|
Name loki
|
||||||
@@ -98,6 +225,10 @@ func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string
|
|||||||
Host %s
|
Host %s
|
||||||
Port %s
|
Port %s
|
||||||
Labels job=fluent-bit, app=%s, namespace=%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)
|
`, logPath, app, namespace, app, lokiHost, lokiPort, app, namespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +246,7 @@ data:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SidecarPatchYAML strategic merge patch:为 Deployment 注入 Fluent Bit sidecar 容器
|
// SidecarPatchYAML strategic merge patch:为 Deployment 注入 Fluent Bit sidecar 容器
|
||||||
|
// 生产级配置:资源限制、健康检查、安全上下文
|
||||||
const SidecarPatchYAML = `spec:
|
const SidecarPatchYAML = `spec:
|
||||||
template:
|
template:
|
||||||
spec:
|
spec:
|
||||||
@@ -128,6 +260,10 @@ const SidecarPatchYAML = `spec:
|
|||||||
- name: fluent-bit
|
- name: fluent-bit
|
||||||
image: cr.fluentbit.io/fluent/fluent-bit:3.1.1
|
image: cr.fluentbit.io/fluent/fluent-bit:3.1.1
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 2020
|
||||||
|
protocol: TCP
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: app-logs
|
- name: app-logs
|
||||||
mountPath: /var/log/app
|
mountPath: /var/log/app
|
||||||
@@ -141,6 +277,24 @@ const SidecarPatchYAML = `spec:
|
|||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
memory: 128Mi
|
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(卸载)
|
// SidecarRemovePatchYAML strategic merge patch:从 Deployment 移除 Fluent Bit sidecar(卸载)
|
||||||
|
|||||||
+146
-19
@@ -3,12 +3,32 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/repository"
|
||||||
"lokideploy/utils"
|
"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{}
|
type DeploymentService struct{}
|
||||||
|
|
||||||
func NewDeploymentService() *DeploymentService {
|
func NewDeploymentService() *DeploymentService {
|
||||||
@@ -33,7 +53,14 @@ type CreateDeploymentRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) {
|
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{
|
d := models.LokiDeployment{
|
||||||
ID: db.NextID("deployments"),
|
ID: db.NextID("deployments"),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -88,13 +115,13 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
|
|||||||
db.Lock()
|
db.Lock()
|
||||||
db.Deployments = append(db.Deployments, d)
|
db.Deployments = append(db.Deployments, d)
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
|
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeploymentService) Get(id uint) (*models.LokiDeployment, error) {
|
func (s *DeploymentService) Get(id uint) (*models.LokiDeployment, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Deployments {
|
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) {
|
func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, int64, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
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 {
|
func (s *DeploymentService) Update(id uint, updates map[string]interface{}) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i := range db.Deployments {
|
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].Endpoint = v
|
||||||
}
|
}
|
||||||
db.Deployments[i].UpdatedAt = time.Now()
|
db.Deployments[i].UpdatedAt = time.Now()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,19 +180,32 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeploymentService) Delete(id uint) error {
|
func (s *DeploymentService) Delete(id uint) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i, d := range db.Deployments {
|
for i, d := range db.Deployments {
|
||||||
if d.ID == id {
|
if d.ID == id {
|
||||||
db.Deployments = append(db.Deployments[:i], db.Deployments[i+1:]...)
|
db.Deployments = append(db.Deployments[:i], db.Deployments[i+1:]...)
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return utils.ErrNotFound
|
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 {
|
func (s *DeploymentService) StartDeploy(id uint) error {
|
||||||
deployment, err := s.Get(id)
|
deployment, err := s.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,19 +214,41 @@ func (s *DeploymentService) StartDeploy(id uint) error {
|
|||||||
if deployment.Status == "deploying" {
|
if deployment.Status == "deploying" {
|
||||||
return fmt.Errorf("部署正在进行中,请勿重复操作")
|
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)
|
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) {
|
logFunc := func(level, message, detail string) {
|
||||||
s.AddLog(id, level, message, detail)
|
s.AddLog(id, level, message, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
deployer := NewLokiDeployer(deployment, logFunc)
|
|
||||||
go func() {
|
go func() {
|
||||||
err := deployer.Deploy(ctx)
|
defer close(done)
|
||||||
if err != nil {
|
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{}{
|
s.Update(id, map[string]interface{}{
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"statusMessage": fmt.Sprintf("Deployment failed: %v", err),
|
"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", "")
|
s.AddLog(id, "success", "Deployment completed successfully", "")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +275,14 @@ func (s *DeploymentService) StopDeployment(id uint) error {
|
|||||||
if deployment.Status != "running" && deployment.Status != "deploying" {
|
if deployment.Status != "running" && deployment.Status != "deploying" {
|
||||||
return fmt.Errorf("部署未在运行,无法停止")
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
CleanupLoki(ctx, deployment)
|
CleanupLoki(ctx, deployment)
|
||||||
@@ -223,14 +294,17 @@ func (s *DeploymentService) StopDeployment(id uint) error {
|
|||||||
|
|
||||||
func (s *DeploymentService) RestartDeployment(id uint) error {
|
func (s *DeploymentService) RestartDeployment(id uint) error {
|
||||||
if err := s.StopDeployment(id); err != nil {
|
if err := s.StopDeployment(id); err != nil {
|
||||||
|
// 如果停止失败但不是因为部署未运行,返回错误
|
||||||
|
if !strings.Contains(err.Error(), "部署未在运行") {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
return s.StartDeploy(id)
|
return s.StartDeploy(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail string) {
|
func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail string) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
log := models.DeploymentLog{
|
log := models.DeploymentLog{
|
||||||
ID: db.NextID("logs"),
|
ID: db.NextID("logs"),
|
||||||
DeploymentID: deploymentID,
|
DeploymentID: deploymentID,
|
||||||
@@ -242,11 +316,11 @@ func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail str
|
|||||||
db.Lock()
|
db.Lock()
|
||||||
db.Logs = append(db.Logs, log)
|
db.Logs = append(db.Logs, log)
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeploymentService) GetLogs(deploymentID uint, page, pageSize int) ([]models.DeploymentLog, int64, error) {
|
func (s *DeploymentService) GetLogs(deploymentID uint, page, pageSize int) ([]models.DeploymentLog, int64, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
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) {
|
func (s *DeploymentService) GetStats() (map[string]interface{}, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
|
|
||||||
@@ -296,11 +370,12 @@ func (s *DeploymentService) GetStats() (map[string]interface{}, error) {
|
|||||||
"stopped": stopped,
|
"stopped": stopped,
|
||||||
"clusterCount": int64(len(db.Clusters)),
|
"clusterCount": int64(len(db.Clusters)),
|
||||||
"hostCount": int64(len(db.Hosts)),
|
"hostCount": int64(len(db.Hosts)),
|
||||||
|
"supportedVersions": LokiVersionList,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
|
func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
if d.ClusterID != nil && *d.ClusterID > 0 {
|
if d.ClusterID != nil && *d.ClusterID > 0 {
|
||||||
for i := range db.Clusters {
|
for i := range db.Clusters {
|
||||||
if db.Clusters[i].ID == *d.ClusterID {
|
if db.Clusters[i].ID == *d.ClusterID {
|
||||||
@@ -337,7 +412,14 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
|
|||||||
if ns == "" {
|
if ns == "" {
|
||||||
ns = "loki"
|
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":
|
case "host-binary":
|
||||||
if d.Host != nil {
|
if d.Host != nil {
|
||||||
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
|
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
|
||||||
@@ -345,3 +427,48 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/pkg/crypto"
|
||||||
|
"lokideploy/repository"
|
||||||
"lokideploy/utils"
|
"lokideploy/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +23,7 @@ func (s *HostService) Create(name, hostIP string, port int, user, authType, pass
|
|||||||
port = 22
|
port = 22
|
||||||
}
|
}
|
||||||
host := models.Host{
|
host := models.Host{
|
||||||
ID: models.GetDB().NextID("hosts"),
|
ID: repository.GetDB().NextID("hosts"),
|
||||||
Name: name,
|
Name: name,
|
||||||
HostIP: hostIP,
|
HostIP: hostIP,
|
||||||
Port: port,
|
Port: port,
|
||||||
@@ -34,30 +37,39 @@ func (s *HostService) Create(name, hostIP string, port int, user, authType, pass
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if password != "" {
|
if password != "" {
|
||||||
enc, _ := utils.Encrypt(password)
|
enc, err := crypto.EncryptField(password)
|
||||||
host.Password = "ENC:" + enc
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("密码加密失败: %w", err)
|
||||||
|
}
|
||||||
|
host.Password = enc
|
||||||
}
|
}
|
||||||
if privateKey != "" {
|
if privateKey != "" {
|
||||||
enc, _ := utils.Encrypt(privateKey)
|
enc, err := crypto.EncryptField(privateKey)
|
||||||
host.PrivateKey = "ENC:" + enc
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("私钥加密失败: %w", err)
|
||||||
|
}
|
||||||
|
host.PrivateKey = enc
|
||||||
}
|
}
|
||||||
if keyPhrase != "" {
|
if keyPhrase != "" {
|
||||||
enc, _ := utils.Encrypt(keyPhrase)
|
enc, err := crypto.EncryptField(keyPhrase)
|
||||||
host.KeyPhrase = "ENC:" + enc
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("私钥口令加密失败: %w", err)
|
||||||
|
}
|
||||||
|
host.KeyPhrase = enc
|
||||||
}
|
}
|
||||||
|
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
db.Hosts = append(db.Hosts, host)
|
db.Hosts = append(db.Hosts, host)
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
|
|
||||||
go s.Validate(host.ID)
|
go s.Validate(host.ID)
|
||||||
return &host, nil
|
return &host, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HostService) Get(id uint) (*models.Host, error) {
|
func (s *HostService) Get(id uint) (*models.Host, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Hosts {
|
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) {
|
func (s *HostService) List(page, pageSize int) ([]models.Host, int64, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
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 {
|
func (s *HostService) Update(id uint, updates map[string]interface{}) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i := range db.Hosts {
|
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].Arch = v
|
||||||
}
|
}
|
||||||
db.Hosts[i].UpdatedAt = time.Now()
|
db.Hosts[i].UpdatedAt = time.Now()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,13 +127,13 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *HostService) Delete(id uint) error {
|
func (s *HostService) Delete(id uint) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i, h := range db.Hosts {
|
for i, h := range db.Hosts {
|
||||||
if h.ID == id {
|
if h.ID == id {
|
||||||
db.Hosts = append(db.Hosts[:i], db.Hosts[i+1:]...)
|
db.Hosts = append(db.Hosts[:i], db.Hosts[i+1:]...)
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +150,7 @@ func (s *HostService) Validate(id uint) {
|
|||||||
|
|
||||||
password := host.Password
|
password := host.Password
|
||||||
if strings.HasPrefix(password, "ENC:") {
|
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)
|
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
|
password := host.Password
|
||||||
if strings.HasPrefix(password, "ENC:") {
|
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)
|
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -16,7 +17,7 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) {
|
|||||||
if cluster.KubeConfigPath != "" {
|
if cluster.KubeConfigPath != "" {
|
||||||
data, err := os.ReadFile(cluster.KubeConfigPath)
|
data, err := os.ReadFile(cluster.KubeConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("读取 kubeconfig 文件失败: %v", err)
|
return "", fmt.Errorf("读取 kubeconfig 文件失败: %w", err)
|
||||||
}
|
}
|
||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
@@ -24,15 +25,43 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildKubeconfigFromToken 用 API Server + Token 构造最小 kubeconfig
|
// BuildKubeconfigFromToken 用 API Server + Token 构造最小 kubeconfig
|
||||||
// 采用 insecure-skip-tls-verify,便于内网/自签证书集群接入
|
// 优先使用 CA 证书验证 TLS,仅在用户显式要求时跳过证书验证
|
||||||
func BuildKubeconfigFromToken(apiServer, token string) string {
|
// Parameters:
|
||||||
return fmt.Sprintf(`apiVersion: v1
|
// - apiServer: Kubernetes API Server 地址,如 https://192.168.1.10:6443
|
||||||
kind: Config
|
// - token: 认证 Token
|
||||||
clusters:
|
// - 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:
|
- cluster:
|
||||||
insecure-skip-tls-verify: true
|
insecure-skip-tls-verify: true
|
||||||
server: %s
|
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:
|
contexts:
|
||||||
- context:
|
- context:
|
||||||
cluster: loki-cluster
|
cluster: loki-cluster
|
||||||
@@ -43,7 +72,7 @@ users:
|
|||||||
- name: loki-user
|
- name: loki-user
|
||||||
user:
|
user:
|
||||||
token: %s
|
token: %s
|
||||||
`, apiServer, token)
|
`, clusterBlock, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiServerRe = regexp.MustCompile(`(?m)^\s*server:\s*(\S+)`)
|
var apiServerRe = regexp.MustCompile(`(?m)^\s*server:\s*(\S+)`)
|
||||||
|
|||||||
+1003
-93
File diff suppressed because it is too large.
Load diff
+194
-5
@@ -3,12 +3,21 @@ package services
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"lokideploy/config"
|
||||||
|
"lokideploy/models"
|
||||||
|
"lokideploy/pkg/crypto"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,17 +47,169 @@ type HostInfo struct {
|
|||||||
DiskUsed string `json:"diskUsed"`
|
DiskUsed string `json:"diskUsed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) {
|
// SSHAuthConfig 重导出models.SSHAuthConfig以兼容现有调用
|
||||||
if port == 0 {
|
// Deprecated: 直接使用 models.SSHAuthConfig
|
||||||
port = 22
|
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,
|
User: user,
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(password)},
|
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 {
|
if err != nil {
|
||||||
return nil, errors.New(FormatSSHError(err))
|
return nil, errors.New(FormatSSHError(err))
|
||||||
}
|
}
|
||||||
@@ -155,6 +316,7 @@ func (c *SSHClient) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSSHConnection 测试SSH连接(使用TOFU模式)
|
||||||
func TestSSHConnection(host string, port int, user, password string) error {
|
func TestSSHConnection(host string, port int, user, password string) error {
|
||||||
client, err := NewSSHClient(host, port, user, password)
|
client, err := NewSSHClient(host, port, user, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -166,3 +328,30 @@ func TestSSHConnection(host string, port int, user, password string) error {
|
|||||||
_, _, err = client.Execute(ctx, "echo 'connected'")
|
_, _, err = client.Execute(ctx, "echo 'connected'")
|
||||||
return err
|
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lokideploy/models"
|
"lokideploy/models"
|
||||||
|
"lokideploy/pkg/crypto"
|
||||||
|
"lokideploy/repository"
|
||||||
"lokideploy/utils"
|
"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) {
|
func (s *StorageService) Create(name, storageType, endpoint, bucket, region, accessKey, secretKey, pathPrefix string, insecure, isDefault bool) (*models.StorageConfig, error) {
|
||||||
encAccessKey, _ := utils.Encrypt(accessKey)
|
encAccessKey, err := crypto.EncryptField(accessKey)
|
||||||
encSecretKey, _ := utils.Encrypt(secretKey)
|
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{
|
storage := models.StorageConfig{
|
||||||
ID: models.GetDB().NextID("storages"),
|
ID: repository.GetDB().NextID("storages"),
|
||||||
Name: name,
|
Name: name,
|
||||||
StorageType: storageType,
|
StorageType: storageType,
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Region: region,
|
Region: region,
|
||||||
AccessKey: "ENC:" + encAccessKey,
|
AccessKey: encAccessKey,
|
||||||
SecretKey: "ENC:" + encSecretKey,
|
SecretKey: encSecretKey,
|
||||||
PathPrefix: pathPrefix,
|
PathPrefix: pathPrefix,
|
||||||
Insecure: insecure,
|
Insecure: insecure,
|
||||||
IsDefault: isDefault,
|
IsDefault: isDefault,
|
||||||
@@ -33,7 +42,7 @@ func (s *StorageService) Create(name, storageType, endpoint, bucket, region, acc
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
if isDefault {
|
if isDefault {
|
||||||
db.Lock()
|
db.Lock()
|
||||||
for i := range db.Storages {
|
for i := range db.Storages {
|
||||||
@@ -44,13 +53,13 @@ func (s *StorageService) Create(name, storageType, endpoint, bucket, region, acc
|
|||||||
db.Lock()
|
db.Lock()
|
||||||
db.Storages = append(db.Storages, storage)
|
db.Storages = append(db.Storages, storage)
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
|
|
||||||
return &storage, nil
|
return &storage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StorageService) Get(id uint) (*models.StorageConfig, error) {
|
func (s *StorageService) Get(id uint) (*models.StorageConfig, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Storages {
|
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) {
|
func (s *StorageService) List(page, pageSize int) ([]models.StorageConfig, int64, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
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 {
|
func (s *StorageService) Update(id uint, updates map[string]interface{}) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i := range db.Storages {
|
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].IsDefault = v
|
||||||
}
|
}
|
||||||
db.Storages[i].UpdatedAt = time.Now()
|
db.Storages[i].UpdatedAt = time.Now()
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,13 +125,13 @@ func (s *StorageService) Update(id uint, updates map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *StorageService) Delete(id uint) error {
|
func (s *StorageService) Delete(id uint) error {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
for i, st := range db.Storages {
|
for i, st := range db.Storages {
|
||||||
if st.ID == id {
|
if st.ID == id {
|
||||||
db.Storages = append(db.Storages[:i], db.Storages[i+1:]...)
|
db.Storages = append(db.Storages[:i], db.Storages[i+1:]...)
|
||||||
go db.Save()
|
db.RequestSave()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +139,7 @@ func (s *StorageService) Delete(id uint) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *StorageService) GetDefault() (*models.StorageConfig, error) {
|
func (s *StorageService) GetDefault() (*models.StorageConfig, error) {
|
||||||
db := models.GetDB()
|
db := repository.GetDB()
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
for i := range db.Storages {
|
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
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,41 @@ package utils
|
|||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// 认证相关错误
|
||||||
ErrInvalidCredentials = errors.New("用户名或密码错误")
|
ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||||
|
ErrUserNotFound = errors.New("用户不存在")
|
||||||
|
ErrUserExists = errors.New("用户已存在")
|
||||||
|
ErrTokenExpired = errors.New("Token已过期")
|
||||||
|
ErrInvalidToken = errors.New("无效的Token")
|
||||||
|
|
||||||
|
// 资源相关错误
|
||||||
ErrNotFound = errors.New("资源不存在")
|
ErrNotFound = errors.New("资源不存在")
|
||||||
ErrAlreadyExists = errors.New("资源已存在")
|
ErrAlreadyExists = errors.New("资源已存在")
|
||||||
ErrUnauthorized = errors.New("未登录或登录已过期")
|
ErrUnauthorized = errors.New("未登录或登录已过期")
|
||||||
ErrForbidden = 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"`
|
PageSize int `json:"pageSize"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success 返回200状态码的成功响应
|
||||||
func Success(c *gin.Context, data interface{}) {
|
func Success(c *gin.Context, data interface{}) {
|
||||||
c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data})
|
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) {
|
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) {
|
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) {
|
func Unauthorized(c *gin.Context, message string) {
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = "未登录或登录已过期"
|
message = "未登录或登录已过期"
|
||||||
@@ -39,6 +47,7 @@ func Unauthorized(c *gin.Context, message string) {
|
|||||||
c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: message})
|
c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forbidden 返回403状态码
|
||||||
func Forbidden(c *gin.Context, message string) {
|
func Forbidden(c *gin.Context, message string) {
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = "没有操作权限"
|
message = "没有操作权限"
|
||||||
@@ -46,11 +55,28 @@ func Forbidden(c *gin.Context, message string) {
|
|||||||
c.JSON(http.StatusForbidden, Response{Code: 403, Message: message})
|
c.JSON(http.StatusForbidden, Response{Code: 403, Message: message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotFound 返回404状态码
|
||||||
func NotFound(c *gin.Context, message string) {
|
func NotFound(c *gin.Context, message string) {
|
||||||
if message == "" {
|
if message == "" {
|
||||||
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) {
|
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 {
|
func BindAndValidate(c *gin.Context, obj interface{}) bool {
|
||||||
if err := c.ShouldBindJSON(obj); err != nil {
|
if err := c.ShouldBindJSON(obj); err != nil {
|
||||||
BadRequest(c, "请求参数无效: "+err.Error())
|
ValidationError(c, "请求参数无效: "+err.Error())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,53 +1,124 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# LokiDeploy Build Script
|
# ============================================================
|
||||||
|
# LokiDeploy Build Script v2
|
||||||
|
# ============================================================
|
||||||
# Builds frontend and backend into a single deployable package
|
# 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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " LokiDeploy Build Script"
|
echo " LokiDeploy Build Script v2"
|
||||||
|
echo " Security-hardened build pipeline"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|
||||||
# 1. Build Frontend
|
# ---- Detect GOPROXY based on network location ----
|
||||||
echo "[1/2] Building frontend..."
|
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"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
|
detect_node
|
||||||
|
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ]; then
|
||||||
echo " Installing dependencies..."
|
echo " Installing dependencies..."
|
||||||
npm ci
|
npm ci --ignore-scripts
|
||||||
fi
|
fi
|
||||||
npm run build
|
npm run build
|
||||||
echo " Frontend built: frontend/dist/"
|
echo " Frontend built: frontend/dist/"
|
||||||
|
|
||||||
# 2. Build Backend
|
# ---- 2. Build Backend ----
|
||||||
echo "[2/2] Building backend..."
|
echo ""
|
||||||
|
echo "[2/3] Building backend..."
|
||||||
cd "$SCRIPT_DIR/backend"
|
cd "$SCRIPT_DIR/backend"
|
||||||
|
|
||||||
# Detect Go
|
GO_CMD=$(detect_go)
|
||||||
if command -v go &> /dev/null; then
|
if [ -z "$GO_CMD" ]; then
|
||||||
GO_CMD="go"
|
echo "[ERROR] Go not found. Please install Go 1.23+"
|
||||||
elif [ -f "$HOME/go/bin/go" ]; then
|
|
||||||
GO_CMD="$HOME/go/bin/go"
|
|
||||||
else
|
|
||||||
echo "ERROR: Go not found. Please install Go 1.23+"
|
|
||||||
echo " wget https://go.dev/dl/go1.23.4.linux-amd64.tar.gz"
|
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"
|
echo " export PATH=\$HOME/go/bin:\$PATH"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
check_go_version "$GO_CMD"
|
||||||
|
|
||||||
export CGO_ENABLED=0
|
export CGO_ENABLED=0
|
||||||
export GOPROXY=https://goproxy.cn,direct
|
export GOPROXY=${GOPROXY:-$GOPROXY_DEFAULT}
|
||||||
$GO_CMD build -ldflags="-s -w" -o bin/lokideploy .
|
|
||||||
|
|
||||||
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"
|
cd "$SCRIPT_DIR"
|
||||||
rm -rf backend/dist
|
rm -rf backend/dist
|
||||||
cp -r frontend/dist backend/dist
|
cp -r frontend/dist backend/dist
|
||||||
|
|
||||||
# 4. Summary
|
# ---- 4. Security Warnings ----
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " Build Complete!"
|
echo " Build Complete!"
|
||||||
@@ -56,5 +127,19 @@ echo " Frontend: backend/dist/"
|
|||||||
echo " Backend: backend/bin/lokideploy"
|
echo " Backend: backend/bin/lokideploy"
|
||||||
echo " Size: $(du -h backend/bin/lokideploy | cut -f1)"
|
echo " Size: $(du -h backend/bin/lokideploy | cut -f1)"
|
||||||
echo ""
|
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 " Start with: ./run.sh"
|
||||||
echo "========================================"
|
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:
|
services:
|
||||||
lokideploy:
|
lokideploy:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
TARGETARCH: ${TARGETARCH:-amd64}
|
||||||
|
image: lokideploy:v2
|
||||||
container_name: lokideploy
|
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:
|
ports:
|
||||||
- "8080:8080"
|
- "${SERVER_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
# ---- P0: Named volumes for data persistence ----
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- lokideploy-data:/app/data
|
||||||
- ~/.kube:/root/.kube:ro
|
# 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:
|
environment:
|
||||||
- SERVER_PORT=8080
|
- SERVER_PORT=8080
|
||||||
- DATA_DIR=/app/data
|
- 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:
|
networks:
|
||||||
- lokideploy-net
|
- lokideploy-net
|
||||||
|
|
||||||
|
# ---- Named volumes ----
|
||||||
|
volumes:
|
||||||
|
lokideploy-data:
|
||||||
|
driver: local
|
||||||
|
labels:
|
||||||
|
- "com.lokideploy.description=LokiDeploy data directory"
|
||||||
|
|
||||||
|
# ---- Network isolation ----
|
||||||
networks:
|
networks:
|
||||||
lokideploy-net:
|
lokideploy-net:
|
||||||
driver: bridge
|
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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>LokiDeploy</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 { Routes, Route, Navigate } from 'react-router'
|
||||||
import Layout from '@/components/Layout'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import Login from '@/pages/Login'
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||||
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'
|
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
// 路由守卫组件 - 独立定义,便于复用
|
||||||
|
const RequireAuth = memo(function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return <Navigate to="/login" replace />
|
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 (
|
return (
|
||||||
<Routes>
|
<Suspense fallback={<PageLoadingFallback />}>
|
||||||
<Route path="/login" element={<Login />} />
|
<Layout>{children}</Layout>
|
||||||
<Route
|
</Suspense>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 页面加载中占位
|
||||||
|
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 { Link, useLocation, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -14,10 +21,16 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Activity,
|
Activity,
|
||||||
} from 'lucide-react'
|
} 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: '/', icon: LayoutDashboard, label: '仪表盘' },
|
||||||
{ path: '/clusters', icon: Server, label: '集群管理' },
|
{ path: '/clusters', icon: Server, label: '集群管理' },
|
||||||
{ path: '/hosts', icon: Monitor, label: '主机管理' },
|
{ path: '/hosts', icon: Monitor, label: '主机管理' },
|
||||||
@@ -27,35 +40,70 @@ const menuItems = [
|
|||||||
{ path: '/settings', icon: Settings, label: '系统设置' },
|
{ 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 [user, setUser] = useState<{ username: string; role: string } | null>(null)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// 当移动端状态变化时,自动调整侧边栏
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarOpen(!isMobile)
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
// 从localStorage加载用户信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('user')
|
const stored = localStorage.getItem('user')
|
||||||
if (stored) {
|
if (stored) {
|
||||||
|
try {
|
||||||
setUser(JSON.parse(stored))
|
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('token')
|
||||||
|
localStorage.removeItem('refreshToken')
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}, [navigate])
|
||||||
|
|
||||||
|
// 缓存侧边栏切换函数
|
||||||
|
const toggleSidebar = useCallback(() => {
|
||||||
|
setSidebarOpen((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-slate-50 dark:bg-slate-950">
|
<div className="flex h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={`${
|
className={cn(
|
||||||
|
'bg-slate-900 text-white transition-all duration-300 flex flex-col flex-shrink-0',
|
||||||
sidebarOpen ? 'w-64' : 'w-16'
|
sidebarOpen ? 'w-64' : 'w-16'
|
||||||
} bg-slate-900 text-white transition-all duration-300 flex flex-col`}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* 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 ? (
|
{sidebarOpen ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 py-4 px-2 space-y-1">
|
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto">
|
||||||
{menuItems.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
const isActive = location.pathname === item.path
|
const isActive = location.pathname === item.path
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={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
|
isActive
|
||||||
? 'bg-emerald-600 text-white'
|
? 'bg-emerald-600 text-white'
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||||
}`}
|
)}
|
||||||
title={!sidebarOpen ? item.label : undefined}
|
title={!sidebarOpen ? item.label : undefined}
|
||||||
>
|
>
|
||||||
<item.icon className="w-5 h-5 flex-shrink-0" />
|
<item.icon className="w-5 h-5 flex-shrink-0" />
|
||||||
{sidebarOpen && <span className="text-sm font-medium">{item.label}</span>}
|
{sidebarOpen && (
|
||||||
{sidebarOpen && isActive && <ChevronRight className="w-4 h-4 ml-auto" />}
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
|
)}
|
||||||
|
{sidebarOpen && isActive && (
|
||||||
|
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
<div className="p-2 border-t border-slate-700">
|
<div className="p-2 border-t border-slate-700 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={toggleSidebar}
|
||||||
title={sidebarOpen ? '收起菜单' : '展开菜单'}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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 */}
|
{/* 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">
|
<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">
|
<h1 className="text-xl font-semibold text-slate-800 dark:text-white truncate">
|
||||||
{menuItems.find((item) => item.path === location.pathname)?.label || '仪表盘'}
|
{pageTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
|
<div className="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -142,4 +199,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
||||||
+284
-158
@@ -1,5 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Server, Plus, RefreshCw, Trash2, Eye, Key, FileKey } from 'lucide-react'
|
import {
|
||||||
|
Server,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Key,
|
||||||
|
FileKey,
|
||||||
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -13,8 +21,12 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { clusterApi } from '@/srv/api'
|
import { clusterApi } 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 { Cluster, ClusterInfo } from '@/types'
|
import type { Cluster, ClusterInfo } from '@/types'
|
||||||
|
|
||||||
// 从 kubeconfig 文本自动提取 server 地址
|
// 从 kubeconfig 文本自动提取 server 地址
|
||||||
@@ -23,7 +35,18 @@ const parseApiServer = (kubeconfig: string): string => {
|
|||||||
return m ? m[1] : ''
|
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 [clusters, setClusters] = useState<Cluster[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -42,30 +65,57 @@ export default function Clusters() {
|
|||||||
const [context, setContext] = useState('')
|
const [context, setContext] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// 定时器管理ref
|
||||||
|
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
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 {
|
try {
|
||||||
const res = await clusterApi.list(1, 100)
|
const res = await clusterApi.list(1, 100)
|
||||||
setClusters(res.list)
|
setClusters(res.list)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load clusters:', err)
|
console.error('Failed to load clusters:', err)
|
||||||
|
toast.error('加载集群数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
|
loadClusters()
|
||||||
|
}, [loadClusters])
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setName('')
|
||||||
|
setDescription('')
|
||||||
|
setConfigType('kubeconfig')
|
||||||
|
setKubeconfig('')
|
||||||
|
setApiServer('')
|
||||||
|
setToken('')
|
||||||
|
setContext('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// 校验
|
// 校验
|
||||||
if (configType === 'kubeconfig' && !kubeconfig.trim()) {
|
if (configType === 'kubeconfig' && !kubeconfig.trim()) {
|
||||||
alert('请填写 kubeconfig 内容')
|
toast.error('请填写 kubeconfig 内容')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (configType === 'token' && (!apiServer.trim() || !token.trim())) {
|
if (configType === 'token' && (!apiServer.trim() || !token.trim())) {
|
||||||
alert('Token 接入方式需要填写 API Server 和 Token')
|
toast.error('Token 接入方式需要填写 API Server 和 Token')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
@@ -82,78 +132,69 @@ export default function Clusters() {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
loadClusters()
|
loadClusters()
|
||||||
|
toast.success('集群添加成功')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '创建集群失败'
|
const message = err instanceof Error ? err.message : '创建集群失败'
|
||||||
alert(message)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[name, description, configType, kubeconfig, apiServer, token, context, resetForm, loadClusters]
|
||||||
|
)
|
||||||
|
|
||||||
const resetForm = () => {
|
const handleDelete = useCallback(
|
||||||
setName('')
|
async (id: number) => {
|
||||||
setDescription('')
|
|
||||||
setConfigType('kubeconfig')
|
|
||||||
setKubeconfig('')
|
|
||||||
setApiServer('')
|
|
||||||
setToken('')
|
|
||||||
setContext('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!confirm('确定删除此集群?')) return
|
if (!confirm('确定删除此集群?')) return
|
||||||
try {
|
try {
|
||||||
await clusterApi.remove(id)
|
await clusterApi.remove(id)
|
||||||
loadClusters()
|
loadClusters()
|
||||||
|
toast.success('集群已删除')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete cluster:', err)
|
console.error('Failed to delete cluster:', err)
|
||||||
|
toast.error('删除集群失败')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[loadClusters]
|
||||||
|
)
|
||||||
|
|
||||||
const handleValidate = async (id: number) => {
|
const handleValidate = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
setValidating(id)
|
setValidating(id)
|
||||||
try {
|
try {
|
||||||
await clusterApi.validate(id)
|
await clusterApi.validate(id)
|
||||||
setTimeout(loadClusters, 3000)
|
addTimeout(loadClusters, 3000)
|
||||||
|
toast.success('验证指令已发送')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to validate cluster:', err)
|
console.error('Failed to validate cluster:', err)
|
||||||
|
toast.error('验证失败')
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setValidating(null), 3000)
|
addTimeout(() => setValidating(null), 3000)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[loadClusters, addTimeout]
|
||||||
|
)
|
||||||
|
|
||||||
const viewClusterInfo = async (cluster: Cluster) => {
|
const viewClusterInfo = useCallback(async (cluster: Cluster) => {
|
||||||
setSelectedCluster(cluster)
|
setSelectedCluster(cluster)
|
||||||
|
setClusterInfo(null)
|
||||||
setDetailOpen(true)
|
setDetailOpen(true)
|
||||||
try {
|
try {
|
||||||
const info = await clusterApi.getInfo(cluster.id)
|
const info = await clusterApi.getInfo(cluster.id)
|
||||||
setClusterInfo(info)
|
setClusterInfo(info)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to get cluster info:', 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="集群管理"
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">集群管理</h2>
|
description="管理用于 Loki 部署的 Kubernetes 集群"
|
||||||
<p className="text-sm text-slate-500">管理用于 Loki 部署的 Kubernetes 集群</p>
|
action={
|
||||||
</div>
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||||
@@ -188,10 +229,17 @@ export default function Clusters() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>接入方式</Label>
|
<Label>接入方式</Label>
|
||||||
<Tabs value={configType} onValueChange={(v) => setConfigType(v as 'kubeconfig' | 'token')}>
|
<Tabs
|
||||||
|
value={configType}
|
||||||
|
onValueChange={(v) => setConfigType(v as 'kubeconfig' | 'token')}
|
||||||
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="kubeconfig"><FileKey className="w-3.5 h-3.5" /> Kubeconfig</TabsTrigger>
|
<TabsTrigger value="kubeconfig">
|
||||||
<TabsTrigger value="token"><Key className="w-3.5 h-3.5" /> Token</TabsTrigger>
|
<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>
|
</TabsList>
|
||||||
<TabsContent value="kubeconfig" className="space-y-2 pt-2">
|
<TabsContent value="kubeconfig" className="space-y-2 pt-2">
|
||||||
<Label htmlFor="kubeconfig">Kubeconfig 内容 *</Label>
|
<Label htmlFor="kubeconfig">Kubeconfig 内容 *</Label>
|
||||||
@@ -203,7 +251,9 @@ export default function Clusters() {
|
|||||||
className="min-h-[200px] font-mono text-xs"
|
className="min-h-[200px] font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
{kubeconfig && parseApiServer(kubeconfig) && (
|
{kubeconfig && parseApiServer(kubeconfig) && (
|
||||||
<p className="text-xs text-emerald-600">已识别 API Server:{parseApiServer(kubeconfig)}</p>
|
<p className="text-xs text-emerald-600">
|
||||||
|
已识别 API Server:{parseApiServer(kubeconfig)}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="token" className="space-y-2 pt-2">
|
<TabsContent value="token" className="space-y-2 pt-2">
|
||||||
@@ -225,7 +275,9 @@ export default function Clusters() {
|
|||||||
placeholder="粘贴 Service Account Token..."
|
placeholder="粘贴 Service Account Token..."
|
||||||
className="min-h-[120px] font-mono text-xs"
|
className="min-h-[120px] font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400">采用 insecure 模式,便于内网/自签证书集群接入</p>
|
<p className="text-xs text-slate-400">
|
||||||
|
采用 insecure 模式,便于内网/自签证书集群接入
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -243,104 +295,39 @@ export default function Clusters() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
{submitting ? '添加中...' : '添加集群'}
|
{submitting ? '添加中...' : '添加集群'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Clusters Grid */}
|
{/* Clusters Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<LoadingSpinner className="h-64" />
|
||||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : clusters.length === 0 ? (
|
) : clusters.length === 0 ? (
|
||||||
<Card className="border-dashed border-slate-300">
|
<EmptyState
|
||||||
<CardContent className="p-12 text-center">
|
icon={Server}
|
||||||
<Server className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
title="暂无集群"
|
||||||
<p className="text-slate-500 font-medium">暂无集群</p>
|
description="添加第一个 Kubernetes 集群开始使用"
|
||||||
<p className="text-sm text-slate-400 mt-1">添加第一个 Kubernetes 集群开始使用</p>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{clusters.map((cluster) => (
|
{clusters.map((cluster) => (
|
||||||
<Card key={cluster.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
<ClusterCard
|
||||||
<CardContent className="p-5">
|
key={cluster.id}
|
||||||
<div className="flex items-start justify-between mb-3">
|
cluster={cluster}
|
||||||
<div className="flex items-center gap-2">
|
validating={validating}
|
||||||
<Server className="w-5 h-5 text-blue-500" />
|
onViewInfo={viewClusterInfo}
|
||||||
<h3 className="font-semibold text-slate-800 dark:text-white">{cluster.name}</h3>
|
onValidate={handleValidate}
|
||||||
</div>
|
onDelete={handleDelete}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -356,26 +343,161 @@ export default function Clusters() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{clusterInfo ? (
|
{clusterInfo ? (
|
||||||
|
<ClusterDetail info={clusterInfo} />
|
||||||
|
) : (
|
||||||
|
<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="space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
<p className="text-xs text-slate-500">版本</p>
|
<p className="text-xs text-slate-500">版本</p>
|
||||||
<p className="text-sm font-medium">{clusterInfo.version || '未知'}</p>
|
<p className="text-sm font-medium">{info.version || '未知'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
<p className="text-xs text-slate-500">当前上下文</p>
|
<p className="text-xs text-slate-500">当前上下文</p>
|
||||||
<p className="text-sm font-medium">{clusterInfo.currentContext || '默认'}</p>
|
<p className="text-sm font-medium">{info.currentContext || '默认'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold mb-2">节点({clusterInfo.nodes.length})</h4>
|
<h4 className="text-sm font-semibold mb-2">节点({info.nodes.length})</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{clusterInfo.nodes.map((node, idx) => (
|
{info.nodes.map((node, idx) => (
|
||||||
<div key={idx} className="p-3 border border-slate-200 dark:border-slate-700 rounded-lg">
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-3 border border-slate-200 dark:border-slate-700 rounded-lg"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-sm">{node.name}</span>
|
<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'}`}>
|
<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' ? '就绪' : '未就绪'}
|
{node.status === 'Ready' ? '就绪' : '未就绪'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,7 +509,12 @@ export default function Clusters() {
|
|||||||
{node.roles.length > 0 && (
|
{node.roles.length > 0 && (
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
{node.roles.map((role, ridx) => (
|
{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>
|
<span
|
||||||
|
key={ridx}
|
||||||
|
className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-xs"
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -399,19 +526,18 @@ export default function Clusters() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold mb-2">命名空间</h4>
|
<h4 className="text-sm font-semibold mb-2">命名空间</h4>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{clusterInfo.namespaces.map((ns, idx) => (
|
{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>
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs"
|
||||||
|
>
|
||||||
|
{ns}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default Clusters
|
||||||
+265
-124
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
RadioTower,
|
RadioTower,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
Download,
|
Download,
|
||||||
@@ -28,30 +27,40 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} 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 { 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'
|
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' },
|
'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-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' },
|
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const COLLECTOR_TYPES = [
|
||||||
switch (status) {
|
{ value: 'host-promtail' as const, label: '主机 Promtail(SSH)' },
|
||||||
case 'active':
|
{ value: 'k8s-daemonset' as const, label: 'K8s Promtail DaemonSet' },
|
||||||
return 'bg-emerald-100 text-emerald-700'
|
{ value: 'k8s-fluentbit-sidecar' as const, label: 'K8s Fluent Bit Sidecar' },
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [collectors, setCollectors] = useState<Collector[]>([])
|
||||||
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
|
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
|
||||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||||
@@ -60,9 +69,9 @@ export default function Collectors() {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [busy, setBusy] = useState<number | null>(null)
|
const [busy, setBusy] = useState<number | null>(null)
|
||||||
|
|
||||||
// Form
|
// Form state
|
||||||
const [name, setName] = useState('')
|
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 [lokiID, setLokiID] = useState('')
|
||||||
const [hostID, setHostID] = useState('')
|
const [hostID, setHostID] = useState('')
|
||||||
const [clusterID, setClusterID] = useState('')
|
const [clusterID, setClusterID] = useState('')
|
||||||
@@ -72,11 +81,21 @@ export default function Collectors() {
|
|||||||
const [labels, setLabels] = useState('')
|
const [labels, setLabels] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
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 {
|
try {
|
||||||
const [cRes, dRes, clRes, hRes] = await Promise.all([
|
const [cRes, dRes, clRes, hRes] = await Promise.all([
|
||||||
collectorApi.list(1, 100),
|
collectorApi.list(1, 100),
|
||||||
@@ -90,14 +109,19 @@ export default function Collectors() {
|
|||||||
setHosts(hRes.list)
|
setHosts(hRes.list)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load collectors:', err)
|
console.error('Failed to load collectors:', err)
|
||||||
|
toast.error('加载采集器数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const resetForm = () => {
|
useEffect(() => {
|
||||||
|
loadAll()
|
||||||
|
}, [loadAll])
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
setName('')
|
setName('')
|
||||||
setType('host-promtail')
|
setCollectorType('host-promtail')
|
||||||
setLokiID('')
|
setLokiID('')
|
||||||
setHostID('')
|
setHostID('')
|
||||||
setClusterID('')
|
setClusterID('')
|
||||||
@@ -105,68 +129,84 @@ export default function Collectors() {
|
|||||||
setNamespace('')
|
setNamespace('')
|
||||||
setLogPath('')
|
setLogPath('')
|
||||||
setLabels('')
|
setLabels('')
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!lokiID) {
|
if (!lokiID) {
|
||||||
alert('请选择关联的 Loki 部署')
|
toast.error('请选择关联的 Loki 部署')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await collectorApi.create({
|
await collectorApi.create({
|
||||||
name,
|
name,
|
||||||
type,
|
type: collectorType,
|
||||||
lokiDeploymentID: Number(lokiID),
|
lokiDeploymentID: Number(lokiID),
|
||||||
hostID: type === 'host-promtail' && hostID ? Number(hostID) : undefined,
|
hostID: collectorType === 'host-promtail' && hostID ? Number(hostID) : undefined,
|
||||||
clusterID: type !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
|
clusterID: collectorType !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
|
||||||
targetDeployment: type === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
|
targetDeployment: collectorType === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
|
||||||
namespace: type !== 'host-promtail' ? namespace : undefined,
|
namespace: collectorType !== 'host-promtail' ? namespace : undefined,
|
||||||
logPath,
|
logPath,
|
||||||
labels,
|
labels,
|
||||||
})
|
})
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
loadAll()
|
loadAll()
|
||||||
|
toast.success('采集器创建成功')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '创建采集器失败'
|
const message = err instanceof Error ? err.message : '创建采集器失败'
|
||||||
alert(message)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[name, collectorType, lokiID, hostID, clusterID, targetDeployment, namespace, logPath, labels, resetForm, loadAll]
|
||||||
|
)
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
if (!confirm('确定删除此采集器?(将自动卸载)')) return
|
if (!confirm('确定删除此采集器?(将自动卸载)')) return
|
||||||
try {
|
try {
|
||||||
await collectorApi.remove(id)
|
await collectorApi.remove(id)
|
||||||
loadAll()
|
loadAll()
|
||||||
|
toast.success('采集器已删除')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete collector:', err)
|
console.error('Failed to delete collector:', err)
|
||||||
|
toast.error('删除采集器失败')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[loadAll]
|
||||||
|
)
|
||||||
|
|
||||||
const handleAction = async (id: number, action: 'install' | 'uninstall') => {
|
const handleAction = useCallback(
|
||||||
|
async (id: number, action: 'install' | 'uninstall') => {
|
||||||
setBusy(id)
|
setBusy(id)
|
||||||
try {
|
try {
|
||||||
await collectorApi[action](id)
|
await collectorApi[action](id)
|
||||||
setTimeout(loadAll, 3000)
|
addTimeout(loadAll, 3000)
|
||||||
|
toast.success(action === 'install' ? '部署指令已发送' : '卸载指令已发送')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Collector action failed:', err)
|
console.error('Collector action failed:', err)
|
||||||
|
toast.error(action === 'install' ? '部署失败' : '卸载失败')
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setBusy(null), 3000)
|
addTimeout(() => setBusy(null), 3000)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[loadAll, addTimeout]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isHostType = collectorType === 'host-promtail'
|
||||||
|
const isK8sType = collectorType !== 'host-promtail'
|
||||||
|
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title="部署客户端"
|
||||||
<div>
|
description="部署日志采集器,将日志推送到关联的 Loki"
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署客户端</h2>
|
action={
|
||||||
<p className="text-sm text-slate-500">部署日志采集器,将日志推送到关联的 Loki</p>
|
|
||||||
</div>
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||||
@@ -182,16 +222,31 @@ export default function Collectors() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="c-name">名称 *</Label>
|
<Label htmlFor="c-name">名称 *</Label>
|
||||||
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="app-logs-collector" required />
|
<Input
|
||||||
|
id="c-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="app-logs-collector"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>采集器类型</Label>
|
<Label>采集器类型</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')}>
|
<Select
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
value={collectorType}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setCollectorType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="host-promtail">主机 Promtail(SSH)</SelectItem>
|
{COLLECTOR_TYPES.map((t) => (
|
||||||
<SelectItem value="k8s-daemonset">K8s Promtail DaemonSet</SelectItem>
|
<SelectItem key={t.value} value={t.value}>
|
||||||
<SelectItem value="k8s-fluentbit-sidecar">K8s Fluent Bit Sidecar</SelectItem>
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,38 +255,50 @@ export default function Collectors() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>关联 Loki 部署 *</Label>
|
<Label>关联 Loki 部署 *</Label>
|
||||||
<Select value={lokiID} onValueChange={setLokiID}>
|
<Select value={lokiID} onValueChange={setLokiID}>
|
||||||
<SelectTrigger><SelectValue placeholder="选择已部署的 Loki(推送目标)" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择已部署的 Loki(推送目标)" />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{deployments.map((d) => (
|
{deployments.map((d) => (
|
||||||
<SelectItem key={d.id} value={String(d.id)}>{d.name}({statusLabel(d.status)})</SelectItem>
|
<SelectItem key={d.id} value={String(d.id)}>
|
||||||
|
{d.name}({statusLabel(d.status)})
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type === 'host-promtail' && (
|
{isHostType && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>目标主机 *</Label>
|
<Label>目标主机 *</Label>
|
||||||
<Select value={hostID} onValueChange={setHostID}>
|
<Select value={hostID} onValueChange={setHostID}>
|
||||||
<SelectTrigger><SelectValue placeholder="选择主机" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择主机" />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{hosts.map((h) => (
|
{hosts.map((h) => (
|
||||||
<SelectItem key={h.id} value={String(h.id)}>{h.name}({h.hostIP})</SelectItem>
|
<SelectItem key={h.id} value={String(h.id)}>
|
||||||
|
{h.name}({h.hostIP})
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type !== 'host-promtail' && (
|
{isK8sType && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>目标集群 *</Label>
|
<Label>目标集群 *</Label>
|
||||||
<Select value={clusterID} onValueChange={setClusterID}>
|
<Select value={clusterID} onValueChange={setClusterID}>
|
||||||
<SelectTrigger><SelectValue placeholder="选择集群" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择集群" />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{clusters.map((c) => (
|
{clusters.map((c) => (
|
||||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}({statusLabel(c.status)})</SelectItem>
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
|
{c.name}({statusLabel(c.status)})
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -239,12 +306,22 @@ export default function Collectors() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="c-ns">命名空间</Label>
|
<Label htmlFor="c-ns">命名空间</Label>
|
||||||
<Input id="c-ns" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={type === 'k8s-daemonset' ? 'logging' : 'default'} />
|
<Input
|
||||||
|
id="c-ns"
|
||||||
|
value={namespace}
|
||||||
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
|
placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{type === 'k8s-fluentbit-sidecar' && (
|
{isSidecarType && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="c-deploy">目标 Deployment *</Label>
|
<Label htmlFor="c-deploy">目标 Deployment *</Label>
|
||||||
<Input id="c-deploy" value={targetDeployment} onChange={(e) => setTargetDeployment(e.target.value)} placeholder="my-app" />
|
<Input
|
||||||
|
id="c-deploy"
|
||||||
|
value={targetDeployment}
|
||||||
|
onChange={(e) => setTargetDeployment(e.target.value)}
|
||||||
|
placeholder="my-app"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,110 +330,174 @@ export default function Collectors() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="c-log">采集日志路径</Label>
|
<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'} />
|
<Input
|
||||||
|
id="c-log"
|
||||||
|
value={logPath}
|
||||||
|
onChange={(e) => setLogPath(e.target.value)}
|
||||||
|
placeholder={isHostType ? '/var/log/*.log' : '/var/log/app/*.log'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
{submitting ? '创建中...' : '创建并部署'}
|
{submitting ? '创建中...' : '创建并部署'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* List */}
|
{/* Collectors List */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<LoadingSpinner className="h-64" />
|
||||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : collectors.length === 0 ? (
|
) : collectors.length === 0 ? (
|
||||||
<Card className="border-dashed border-slate-300">
|
<EmptyState
|
||||||
<CardContent className="p-12 text-center">
|
icon={RadioTower}
|
||||||
<RadioTower className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
title="暂无采集器"
|
||||||
<p className="text-slate-500 font-medium">暂无采集器</p>
|
description="新建一个日志采集器,关联已部署的 Loki"
|
||||||
<p className="text-sm text-slate-400 mt-1">新建一个日志采集器,关联已部署的 Loki</p>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{collectors.map((c) => {
|
{collectors.map((c) => (
|
||||||
const meta = typeMeta[c.type] || { label: c.type, icon: RadioTower, color: 'bg-slate-100 text-slate-600' }
|
<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 Icon = meta.icon
|
||||||
|
const isBusy = busy === collector.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={c.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
<Card className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Icon className="w-5 h-5 text-emerald-600" />
|
<Icon className="w-5 h-5 text-emerald-600 flex-shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="font-semibold text-slate-800 dark:text-white">{c.name}</h3>
|
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
|
||||||
<p className="text-xs text-slate-500">关联 Loki:{c.lokiDeployment?.name || '未知'}</p>
|
{collector.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
关联 Loki:{collector.lokiDeployment?.name || '未知'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(c.status)}`}>
|
<StatusBadge status={collector.status} />
|
||||||
{statusLabel(c.status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
|
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>类型</span>
|
<span>类型</span>
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>{meta.label}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>
|
||||||
</div>
|
{meta.label}
|
||||||
{c.host && (
|
</span>
|
||||||
<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>
|
</div>
|
||||||
|
{collector.host && (
|
||||||
|
<InfoRow label="主机" value={`${collector.host.name}(${collector.host.hostIP})`} />
|
||||||
)}
|
)}
|
||||||
{c.cluster && (
|
{collector.cluster && (
|
||||||
<div className="flex justify-between">
|
<InfoRow label="集群" value={collector.cluster.name} />
|
||||||
<span>集群</span>
|
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.cluster.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{c.targetDeployment && (
|
{collector.targetDeployment && (
|
||||||
<div className="flex justify-between">
|
<InfoRow label="目标 Deployment" value={collector.targetDeployment} />
|
||||||
<span>目标 Deployment</span>
|
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.targetDeployment}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{c.namespace && (
|
{collector.namespace && <InfoRow label="命名空间" value={collector.namespace} />}
|
||||||
<div className="flex justify-between">
|
{collector.logPath && (
|
||||||
<span>命名空间</span>
|
<InfoRow label="日志路径" value={collector.logPath} />
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.namespace}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{c.logPath && (
|
{collector.statusMessage && (
|
||||||
<div className="flex justify-between">
|
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">
|
||||||
<span>日志路径</span>
|
{collector.statusMessage}
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[200px]">{c.logPath}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.statusMessage && (
|
|
||||||
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">{c.statusMessage}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
<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}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => onInstall(collector.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
<Upload className="w-3 h-3 mr-1" />
|
<Upload className="w-3 h-3 mr-1" />
|
||||||
{busy === c.id ? '处理中...' : '部署'}
|
{isBusy ? '处理中...' : '部署'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'uninstall')} disabled={busy === c.id}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => onUninstall(collector.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
<Download className="w-3 h-3 mr-1" />
|
<Download className="w-3 h-3 mr-1" />
|
||||||
卸载
|
卸载
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(c.id)}>
|
<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" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
)}
|
// 子组件:信息行
|
||||||
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default Collectors
|
||||||
+293
-192
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Rocket,
|
Rocket,
|
||||||
@@ -6,17 +6,85 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Activity,
|
Activity,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
|
||||||
PauseCircle,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
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 { 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'
|
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 [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
|
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
|
||||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||||
@@ -24,13 +92,16 @@ export default function Dashboard() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
// 使用ref追踪组件是否已卸载,防止内存泄漏
|
||||||
loadDashboard()
|
const isMountedRef = useRef(true)
|
||||||
const interval = setInterval(loadDashboard, 10000)
|
// 使用ref存储定时器ID以便清理
|
||||||
return () => clearInterval(interval)
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
}, [])
|
|
||||||
|
// 数据加载函数 - useCallback缓存引用
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
// 如果组件已卸载,不执行状态更新
|
||||||
|
if (!isMountedRef.current) return
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
|
||||||
try {
|
try {
|
||||||
const [statsRes, deployRes, clusterRes, hostRes] = await Promise.all([
|
const [statsRes, deployRes, clusterRes, hostRes] = await Promise.all([
|
||||||
deploymentApi.stats(),
|
deploymentApi.stats(),
|
||||||
@@ -38,145 +109,107 @@ export default function Dashboard() {
|
|||||||
clusterApi.list(1, 5),
|
clusterApi.list(1, 5),
|
||||||
hostApi.list(1, 5),
|
hostApi.list(1, 5),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 再次检查组件是否仍挂载
|
||||||
|
if (isMountedRef.current) {
|
||||||
setStats(statsRes)
|
setStats(statsRes)
|
||||||
setDeployments(deployRes.list)
|
setDeployments(deployRes.list)
|
||||||
setClusters(clusterRes.list)
|
setClusters(clusterRes.list)
|
||||||
setHosts(hostRes.list)
|
setHosts(hostRes.list)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load dashboard:', err)
|
console.error('Failed to load dashboard:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
// 初始化加载 + 自动刷新定时器
|
||||||
switch (status) {
|
useEffect(() => {
|
||||||
case 'running':
|
isMountedRef.current = true
|
||||||
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" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
// 立即加载数据
|
||||||
switch (status) {
|
loadDashboard()
|
||||||
case 'running':
|
|
||||||
case 'active':
|
// 设置自动刷新定时器
|
||||||
return 'bg-emerald-100 text-emerald-700'
|
intervalRef.current = setInterval(loadDashboard, REFRESH_INTERVAL)
|
||||||
case 'failed':
|
|
||||||
case 'error':
|
// 清理函数:组件卸载时清除定时器和标记
|
||||||
return 'bg-red-100 text-red-700'
|
return () => {
|
||||||
case 'pending':
|
isMountedRef.current = false
|
||||||
return 'bg-amber-100 text-amber-700'
|
if (intervalRef.current) {
|
||||||
case 'stopped':
|
clearInterval(intervalRef.current)
|
||||||
case 'inactive':
|
intervalRef.current = null
|
||||||
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])
|
||||||
|
|
||||||
|
// 缓存导航处理函数
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return <LoadingSpinner className="h-64" />
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Activity className="w-8 h-8 text-emerald-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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">
|
{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">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">部署总数</p>
|
<p className="text-sm text-slate-500">{config.label}</p>
|
||||||
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
|
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
|
||||||
{stats?.totalDeployments || 0}
|
{config.getValue(stats)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
<div
|
||||||
<Rocket className="w-6 h-6 text-emerald-600" />
|
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>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-500" />
|
{SubIcon && <SubIcon className={`w-4 h-4 ${config.subColor}`} />}
|
||||||
<span className="text-xs text-emerald-600">{stats?.running || 0} 个运行中</span>
|
<span className={`text-xs ${config.subTextColor}`}>
|
||||||
</div>
|
{config.subLabel?.(stats)}
|
||||||
</CardContent>
|
</span>
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Status Overview Card */}
|
||||||
<Card className="border-slate-200 dark:border-slate-800">
|
<Card className="border-slate-200 dark:border-slate-800">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">状态</p>
|
<p className="text-sm text-slate-500">状态</p>
|
||||||
<div className="flex gap-3 mt-1">
|
<div className="flex gap-3 mt-1">{statusSummary}</div>
|
||||||
<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>
|
</div>
|
||||||
<div className="w-12 h-12 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
|
<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" />
|
<Activity className="w-6 h-6 text-orange-600" />
|
||||||
@@ -186,14 +219,58 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Deployments */}
|
{/* Recent Deployments & Quick Resources */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Recent Deployments */}
|
||||||
|
<RecentDeploymentsCard
|
||||||
|
deployments={deployments}
|
||||||
|
onViewAll={navigateTo('/deployments')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Resources */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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">
|
<Card className="border-slate-200 dark:border-slate-800">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">最近部署</CardTitle>
|
<CardTitle className="text-base">最近部署</CardTitle>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/deployments')}
|
onClick={onViewAll}
|
||||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||||
>
|
>
|
||||||
查看全部
|
查看全部
|
||||||
@@ -203,7 +280,11 @@ export default function Dashboard() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{deployments.length === 0 ? (
|
{deployments.length === 0 ? (
|
||||||
<p className="text-sm text-slate-400 text-center py-8">暂无部署</p>
|
<EmptyState
|
||||||
|
icon={Rocket}
|
||||||
|
title="暂无部署"
|
||||||
|
description="创建你的第一个 Loki 部署"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
deployments.map((d) => (
|
deployments.map((d) => (
|
||||||
<div
|
<div
|
||||||
@@ -211,14 +292,22 @@ export default function Dashboard() {
|
|||||||
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"
|
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')}
|
onClick={() => navigate('/deployments')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{getStatusIcon(d.status)}
|
<StatusIcon status={d.status} size="sm" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{d.name}</p>
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
|
||||||
<p className="text-xs text-slate-500">{d.mode} · {d.namespace || 'loki'}</p>
|
{d.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{d.mode} · {d.namespace || 'loki'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(d.status)}`}>
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColorClass(
|
||||||
|
d.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
{statusLabel(d.status)}
|
{statusLabel(d.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,77 +316,89 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 子组件:资源列表卡片 - 独立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
|
||||||
+514
-341
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+341
-213
@@ -1,5 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Monitor, Plus, RefreshCw, Trash2, Eye, Terminal, Key, Lock } from 'lucide-react'
|
import {
|
||||||
|
Monitor,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Terminal,
|
||||||
|
Key,
|
||||||
|
Lock,
|
||||||
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -12,14 +21,29 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { hostApi } from '@/srv/api'
|
import { 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 { Host, HostInfo } from '@/types'
|
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 [hosts, setHosts] = useState<Host[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -46,22 +70,50 @@ export default function Hosts() {
|
|||||||
const [labels, setLabels] = useState('')
|
const [labels, setLabels] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
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 {
|
try {
|
||||||
const res = await hostApi.list(1, 100)
|
const res = await hostApi.list(1, 100)
|
||||||
setHosts(res.list)
|
setHosts(res.list)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load hosts:', err)
|
console.error('Failed to load hosts:', err)
|
||||||
|
toast.error('加载主机数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
|
loadHosts()
|
||||||
|
}, [loadHosts])
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setName('')
|
||||||
|
setHostIP('')
|
||||||
|
setPort(22)
|
||||||
|
setUser('root')
|
||||||
|
setAuthType('password')
|
||||||
|
setPassword('')
|
||||||
|
setPrivateKey('')
|
||||||
|
setKeyPhrase('')
|
||||||
|
setLabels('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -79,67 +131,73 @@ export default function Hosts() {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
loadHosts()
|
loadHosts()
|
||||||
|
toast.success('主机添加成功')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '添加主机失败'
|
const message = err instanceof Error ? err.message : '添加主机失败'
|
||||||
alert(message)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[name, hostIP, port, user, authType, password, privateKey, keyPhrase, labels, resetForm, loadHosts]
|
||||||
|
)
|
||||||
|
|
||||||
const resetForm = () => {
|
const handleDelete = useCallback(
|
||||||
setName('')
|
async (id: number) => {
|
||||||
setHostIP('')
|
|
||||||
setPort(22)
|
|
||||||
setUser('root')
|
|
||||||
setAuthType('password')
|
|
||||||
setPassword('')
|
|
||||||
setPrivateKey('')
|
|
||||||
setKeyPhrase('')
|
|
||||||
setLabels('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!confirm('确定删除此主机?')) return
|
if (!confirm('确定删除此主机?')) return
|
||||||
try {
|
try {
|
||||||
await hostApi.remove(id)
|
await hostApi.remove(id)
|
||||||
loadHosts()
|
loadHosts()
|
||||||
|
toast.success('主机已删除')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete host:', err)
|
console.error('Failed to delete host:', err)
|
||||||
|
toast.error('删除主机失败')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[loadHosts]
|
||||||
|
)
|
||||||
|
|
||||||
const handleValidate = async (id: number) => {
|
const handleValidate = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
setValidating(id)
|
setValidating(id)
|
||||||
try {
|
try {
|
||||||
await hostApi.validate(id)
|
await hostApi.validate(id)
|
||||||
setTimeout(loadHosts, 3000)
|
addTimeout(loadHosts, 3000)
|
||||||
|
toast.success('验证指令已发送')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to validate host:', err)
|
console.error('Failed to validate host:', err)
|
||||||
|
toast.error('验证失败')
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setValidating(null), 3000)
|
addTimeout(() => setValidating(null), 3000)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[loadHosts, addTimeout]
|
||||||
|
)
|
||||||
|
|
||||||
const viewHostInfo = async (host: Host) => {
|
const viewHostInfo = useCallback(
|
||||||
|
async (host: Host) => {
|
||||||
setSelectedHost(host)
|
setSelectedHost(host)
|
||||||
setDetailOpen(true)
|
|
||||||
setHostInfo(null)
|
setHostInfo(null)
|
||||||
|
setDetailOpen(true)
|
||||||
try {
|
try {
|
||||||
const info = await hostApi.getInfo(host.id)
|
const info = await hostApi.getInfo(host.id)
|
||||||
setHostInfo(info)
|
setHostInfo(info)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to get host info:', err)
|
console.error('Failed to get host info:', err)
|
||||||
|
toast.error('加载主机详情失败')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const openTerminal = (host: Host) => {
|
const openTerminal = useCallback((host: Host) => {
|
||||||
setSelectedHost(host)
|
setSelectedHost(host)
|
||||||
setTerminalOpen(true)
|
setTerminalOpen(true)
|
||||||
setTerminalOutput(`已连接到 ${host.name} (${host.hostIP})\n输入命令后按"执行"...\n`)
|
setTerminalOutput(`已连接到 ${host.name} (${host.hostIP})\n输入命令后按"执行"...\n`)
|
||||||
}
|
setCommand('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
const executeCommand = async () => {
|
const executeCommand = useCallback(async () => {
|
||||||
if (!selectedHost || !command.trim()) return
|
if (!selectedHost || !command.trim()) return
|
||||||
setExecuting(true)
|
setExecuting(true)
|
||||||
setTerminalOutput((prev) => prev + `\n$ ${command}\n`)
|
setTerminalOutput((prev) => prev + `\n$ ${command}\n`)
|
||||||
@@ -153,29 +211,14 @@ export default function Hosts() {
|
|||||||
setExecuting(false)
|
setExecuting(false)
|
||||||
setCommand('')
|
setCommand('')
|
||||||
}
|
}
|
||||||
}
|
}, [selectedHost, command])
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title="主机管理"
|
||||||
<div>
|
description="管理用于二进制方式部署 Loki 的 SSH 主机"
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">主机管理</h2>
|
action={
|
||||||
<p className="text-sm text-slate-500">管理用于二进制方式部署 Loki 的 SSH 主机</p>
|
|
||||||
</div>
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||||
@@ -191,21 +234,45 @@ export default function Hosts() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="h-name">名称 *</Label>
|
<Label htmlFor="h-name">名称 *</Label>
|
||||||
<Input id="h-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="loki-server-1" required />
|
<Input
|
||||||
|
id="h-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="loki-server-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="h-ip">主机 IP *</Label>
|
<Label htmlFor="h-ip">主机 IP *</Label>
|
||||||
<Input id="h-ip" value={hostIP} onChange={(e) => setHostIP(e.target.value)} placeholder="192.168.1.100" required />
|
<Input
|
||||||
|
id="h-ip"
|
||||||
|
value={hostIP}
|
||||||
|
onChange={(e) => setHostIP(e.target.value)}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="h-port">端口</Label>
|
<Label htmlFor="h-port">端口</Label>
|
||||||
<Input id="h-port" type="number" value={port} onChange={(e) => setPort(Number(e.target.value))} placeholder="22" />
|
<Input
|
||||||
|
id="h-port"
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(Number(e.target.value))}
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="h-user">用户名 *</Label>
|
<Label htmlFor="h-user">用户名 *</Label>
|
||||||
<Input id="h-user" value={user} onChange={(e) => setUser(e.target.value)} placeholder="root" required />
|
<Input
|
||||||
|
id="h-user"
|
||||||
|
value={user}
|
||||||
|
onChange={(e) => setUser(e.target.value)}
|
||||||
|
placeholder="root"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,107 +352,47 @@ export default function Hosts() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
{submitting ? '添加中...' : '添加主机'}
|
{submitting ? '添加中...' : '添加主机'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Hosts Grid */}
|
{/* Hosts Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<LoadingSpinner className="h-64" />
|
||||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : hosts.length === 0 ? (
|
) : hosts.length === 0 ? (
|
||||||
<Card className="border-dashed border-slate-300">
|
<EmptyState
|
||||||
<CardContent className="p-12 text-center">
|
icon={Monitor}
|
||||||
<Monitor className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
title="暂无主机"
|
||||||
<p className="text-slate-500 font-medium">暂无主机</p>
|
description="添加第一台 SSH 主机开始使用"
|
||||||
<p className="text-sm text-slate-400 mt-1">添加第一台 SSH 主机开始使用</p>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{hosts.map((host) => (
|
{hosts.map((host) => (
|
||||||
<Card key={host.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
<HostCard
|
||||||
<CardContent className="p-5">
|
key={host.id}
|
||||||
<div className="flex items-start justify-between mb-3">
|
host={host}
|
||||||
<div className="flex items-center gap-2">
|
validating={validating}
|
||||||
<Monitor className="w-5 h-5 text-purple-500" />
|
onViewInfo={viewHostInfo}
|
||||||
<h3 className="font-semibold text-slate-800 dark:text-white">{host.name}</h3>
|
onOpenTerminal={openTerminal}
|
||||||
</div>
|
onValidate={handleValidate}
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(host.status)}`}>
|
onDelete={handleDelete}
|
||||||
{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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Host Detail Dialog */}
|
{/* Host Detail Dialog */}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Monitor className="w-5 h-5 text-purple-500" />
|
<Monitor className="w-5 h-5 text-purple-500" />
|
||||||
@@ -393,76 +400,9 @@ export default function Hosts() {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{hostInfo ? (
|
{hostInfo ? (
|
||||||
<div className="space-y-5">
|
<HostDetail info={hostInfo} />
|
||||||
{/* 资源使用率 */}
|
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-32">
|
<LoadingSpinner className="h-32" />
|
||||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -502,4 +442,192 @@ export default function Hosts() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</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 { useNavigate } from 'react-router'
|
||||||
import { Activity, Eye, EyeOff, Shield, ServerOff } from 'lucide-react'
|
import { Activity, Eye, EyeOff, Shield, ServerOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
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 [username, setUsername] = useState('admin')
|
||||||
const [password, setPassword] = useState('admin123')
|
const [password, setPassword] = useState('admin123')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
@@ -24,42 +34,52 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
// Quick backend check
|
// Quick backend check
|
||||||
checkBackend()
|
checkBackend()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navigate])
|
}, [navigate])
|
||||||
|
|
||||||
const checkBackend = async () => {
|
// 后端状态检查 - useCallback缓存
|
||||||
|
const checkBackend = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 3000)
|
const timeoutId = setTimeout(() => controller.abort(), 3000)
|
||||||
await fetch('/api/v1/auth/login', {
|
// 用公开的健康检查端点探活;避免打 /auth/login(生产环境被 nginx 限流 5r/m,
|
||||||
method: 'POST',
|
// 反复刷新登录页会触发限流并锁死真正的登录请求)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const res = await fetch('/api/v1/health', {
|
||||||
body: '{}',
|
method: 'GET',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
// Even a 400 error means backend is up
|
setBackendStatus(res.ok ? 'online' : 'offline')
|
||||||
setBackendStatus('online')
|
|
||||||
} catch {
|
} catch {
|
||||||
setBackendStatus('offline')
|
setBackendStatus('offline')
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// 表单提交处理 - useCallback缓存
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authApi.login(username, password)
|
const result = await authApi.login(username, password)
|
||||||
localStorage.setItem('token', result.token)
|
localStorage.setItem('token', result.accessToken)
|
||||||
|
localStorage.setItem('refreshToken', result.refreshToken)
|
||||||
localStorage.setItem('user', JSON.stringify(result.user))
|
localStorage.setItem('user', JSON.stringify(result.user))
|
||||||
|
toast.success('登录成功')
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '登录失败'
|
const message = err instanceof Error ? err.message : '登录失败'
|
||||||
if (message.includes('Cannot connect')) {
|
if (message.includes('Cannot connect')) {
|
||||||
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
|
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
|
||||||
setBackendStatus('offline')
|
setBackendStatus('offline')
|
||||||
} else if (message.includes('invalid') || message.includes('password') || message.includes('密码')) {
|
} else if (
|
||||||
|
message.includes('invalid') ||
|
||||||
|
message.includes('password') ||
|
||||||
|
message.includes('密码') ||
|
||||||
|
message.includes('Unauthorized')
|
||||||
|
) {
|
||||||
setError('用户名或密码错误')
|
setError('用户名或密码错误')
|
||||||
} else {
|
} else {
|
||||||
setError(message)
|
setError(message)
|
||||||
@@ -67,7 +87,14 @@ export default function Login() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[username, password, navigate]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 密码显示切换 - useCallback缓存
|
||||||
|
const togglePassword = useCallback(() => {
|
||||||
|
setShowPassword((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
|
<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="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
required
|
required
|
||||||
className="h-10"
|
className="h-10"
|
||||||
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,13 +162,19 @@ export default function Login() {
|
|||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
required
|
required
|
||||||
className="h-10 pr-10"
|
className="h-10 pr-10"
|
||||||
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,4 +197,6 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</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 { KeyRound, Settings as SettingsIcon } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
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 [oldPassword, setOldPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [changing, setChanging] = useState(false)
|
const [changing, setChanging] = useState(false)
|
||||||
|
|
||||||
const handleChangePassword = async (e: React.FormEvent) => {
|
const handleChangePassword = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
alert('两次输入的密码不一致')
|
toast.error('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
toast.error('新密码至少需要 6 位')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setChanging(true)
|
setChanging(true)
|
||||||
try {
|
try {
|
||||||
await authApi.changePassword(oldPassword, newPassword)
|
await authApi.changePassword(oldPassword, newPassword)
|
||||||
alert('密码修改成功')
|
toast.success('密码修改成功')
|
||||||
setOldPassword('')
|
setOldPassword('')
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
setConfirmPassword('')
|
setConfirmPassword('')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '密码修改失败'
|
const message = err instanceof Error ? err.message : '密码修改失败'
|
||||||
alert(message)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
setChanging(false)
|
setChanging(false)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[oldPassword, newPassword, confirmPassword]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<PageHeader title="系统设置" description="修改登录密码与查看系统信息" />
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">系统设置</h2>
|
|
||||||
<p className="text-sm text-slate-500">修改登录密码与查看系统信息</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* 修改密码 */}
|
{/* 修改密码 */}
|
||||||
@@ -85,7 +110,11 @@ export default function Settings() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? '修改中...' : '修改密码'}
|
{changing ? '修改中...' : '修改密码'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -101,31 +130,32 @@ export default function Settings() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-0">
|
||||||
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
|
{SYSTEM_INFO.map((item, index) => (
|
||||||
<span className="text-sm text-slate-500">版本</span>
|
<div
|
||||||
<span className="text-sm font-medium">v1.0.0</span>
|
key={item.label}
|
||||||
</div>
|
className={`flex justify-between py-2.5 ${
|
||||||
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
|
index < SYSTEM_INFO.length - 1
|
||||||
<span className="text-sm text-slate-500">后端</span>
|
? 'border-b border-slate-100 dark:border-slate-800'
|
||||||
<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 text-slate-500">{item.label}</span>
|
||||||
<span className="text-sm font-medium">React + TypeScript + Tailwind CSS</span>
|
<span
|
||||||
</div>
|
className={`text-sm font-medium ${
|
||||||
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
|
item.align === 'right' ? 'text-right' : ''
|
||||||
<span className="text-sm text-slate-500">部署工具</span>
|
}`}
|
||||||
<span className="text-sm font-medium">kubectl + helm(镜像内置)</span>
|
>
|
||||||
</div>
|
{item.value}
|
||||||
<div className="flex justify-between py-2">
|
</span>
|
||||||
<span className="text-sm text-slate-500">简介</span>
|
|
||||||
<span className="text-sm font-medium text-right">轻量级 K8s Loki 部署平台</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default Settings
|
||||||
+191
-100
@@ -1,5 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { memo, useCallback, useEffect, useState } from 'react'
|
||||||
import { HardDrive, Plus, Trash2, Star, Cloud, Database, Package } from 'lucide-react'
|
import {
|
||||||
|
HardDrive,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Star,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Package,
|
||||||
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -12,17 +20,42 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
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'
|
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" />,
|
s3: <Cloud className="w-5 h-5 text-orange-500" />,
|
||||||
oss: <Cloud className="w-5 h-5 text-red-500" />,
|
oss: <Cloud className="w-5 h-5 text-red-500" />,
|
||||||
minio: <Database className="w-5 h-5 text-blue-500" />,
|
minio: <Database className="w-5 h-5 text-blue-500" />,
|
||||||
rustfs: <Package className="w-5 h-5 text-cyan-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 [storages, setStorages] = useState<StorageConfig[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -40,22 +73,37 @@ export default function Storage() {
|
|||||||
const [isDefault, setIsDefault] = useState(false)
|
const [isDefault, setIsDefault] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadStorages = useCallback(async () => {
|
||||||
loadStorages()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadStorages = async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await storageApi.list(1, 100)
|
const res = await storageApi.list(1, 100)
|
||||||
setStorages(res.list)
|
setStorages(res.list)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load storages:', err)
|
console.error('Failed to load storages:', err)
|
||||||
|
toast.error('加载存储配置失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
|
loadStorages()
|
||||||
|
}, [loadStorages])
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setName('')
|
||||||
|
setStorageType('minio')
|
||||||
|
setEndpoint('')
|
||||||
|
setBucket('')
|
||||||
|
setRegion('')
|
||||||
|
setAccessKey('')
|
||||||
|
setSecretKey('')
|
||||||
|
setPathPrefix('loki')
|
||||||
|
setInsecure(false)
|
||||||
|
setIsDefault(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -74,60 +122,52 @@ export default function Storage() {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
loadStorages()
|
loadStorages()
|
||||||
|
toast.success('存储配置添加成功')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '创建存储配置失败'
|
const message = err instanceof Error ? err.message : '创建存储配置失败'
|
||||||
alert(message)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[name, storageType, endpoint, bucket, region, accessKey, secretKey, pathPrefix, insecure, isDefault, resetForm, loadStorages]
|
||||||
|
)
|
||||||
|
|
||||||
const resetForm = () => {
|
const handleDelete = useCallback(
|
||||||
setName('')
|
async (id: number) => {
|
||||||
setStorageType('minio')
|
|
||||||
setEndpoint('')
|
|
||||||
setBucket('')
|
|
||||||
setRegion('')
|
|
||||||
setAccessKey('')
|
|
||||||
setSecretKey('')
|
|
||||||
setPathPrefix('loki')
|
|
||||||
setInsecure(false)
|
|
||||||
setIsDefault(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!confirm('确定删除此存储配置?')) return
|
if (!confirm('确定删除此存储配置?')) return
|
||||||
try {
|
try {
|
||||||
await storageApi.remove(id)
|
await storageApi.remove(id)
|
||||||
loadStorages()
|
loadStorages()
|
||||||
|
toast.success('存储配置已删除')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete storage:', err)
|
console.error('Failed to delete storage:', err)
|
||||||
|
toast.error('删除存储配置失败')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[loadStorages]
|
||||||
|
)
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
const handleSetDefault = useCallback(
|
||||||
switch (type) {
|
async (id: number) => {
|
||||||
case 's3':
|
try {
|
||||||
return 'bg-orange-100 text-orange-700'
|
await storageApi.update(id, { isDefault: true })
|
||||||
case 'oss':
|
loadStorages()
|
||||||
return 'bg-red-100 text-red-700'
|
toast.success('已设为默认存储')
|
||||||
case 'minio':
|
} catch (err) {
|
||||||
return 'bg-blue-100 text-blue-700'
|
console.error('Failed to set default storage:', err)
|
||||||
case 'rustfs':
|
toast.error('设置默认存储失败')
|
||||||
return 'bg-cyan-100 text-cyan-700'
|
|
||||||
default:
|
|
||||||
return 'bg-slate-100 text-slate-600'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[loadStorages]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title="存储配置"
|
||||||
<div>
|
description="为 Loki 配置对象存储后端"
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">存储配置</h2>
|
action={
|
||||||
<p className="text-sm text-slate-500">为 Loki 配置对象存储后端</p>
|
|
||||||
</div>
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||||
@@ -154,7 +194,7 @@ export default function Storage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>存储类型</Label>
|
<Label>存储类型</Label>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{(['s3', 'oss', 'minio', 'rustfs'] as const).map((type) => (
|
{STORAGE_TYPES.map((type) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -165,7 +205,7 @@ export default function Storage() {
|
|||||||
: 'border-slate-200 hover:border-slate-300 text-slate-600'
|
: 'border-slate-200 hover:border-slate-300 text-slate-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{storageTypeIcons[type]}
|
{STORAGE_TYPE_ICONS[type]}
|
||||||
<span className="text-xs font-medium uppercase">{type}</span>
|
<span className="text-xs font-medium uppercase">{type}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -178,7 +218,13 @@ export default function Storage() {
|
|||||||
id="s-endpoint"
|
id="s-endpoint"
|
||||||
value={endpoint}
|
value={endpoint}
|
||||||
onChange={(e) => setEndpoint(e.target.value)}
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
placeholder={storageType === 's3' ? 's3.amazonaws.com' : storageType === 'minio' ? 'http://minio.example.com:9000' : ''}
|
placeholder={
|
||||||
|
storageType === 's3'
|
||||||
|
? 's3.amazonaws.com'
|
||||||
|
: storageType === 'minio'
|
||||||
|
? 'http://minio.example.com:9000'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,80 +284,112 @@ export default function Storage() {
|
|||||||
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch id="s-insecure" checked={insecure} onCheckedChange={setInsecure} />
|
<Switch
|
||||||
|
id="s-insecure"
|
||||||
|
checked={insecure}
|
||||||
|
onCheckedChange={setInsecure}
|
||||||
|
/>
|
||||||
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
|
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch id="s-default" checked={isDefault} onCheckedChange={setIsDefault} />
|
<Switch
|
||||||
|
id="s-default"
|
||||||
|
checked={isDefault}
|
||||||
|
onCheckedChange={setIsDefault}
|
||||||
|
/>
|
||||||
<Label htmlFor="s-default">设为默认</Label>
|
<Label htmlFor="s-default">设为默认</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
{submitting ? '添加中...' : '添加存储'}
|
{submitting ? '添加中...' : '添加存储'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Storage Grid */}
|
{/* Storage Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<LoadingSpinner className="h-64" />
|
||||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : storages.length === 0 ? (
|
) : storages.length === 0 ? (
|
||||||
<Card className="border-dashed border-slate-300">
|
<EmptyState
|
||||||
<CardContent className="p-12 text-center">
|
icon={HardDrive}
|
||||||
<HardDrive className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
title="暂无存储配置"
|
||||||
<p className="text-slate-500 font-medium">暂无存储配置</p>
|
description="添加第一个 Loki 存储后端"
|
||||||
<p className="text-sm text-slate-400 mt-1">添加第一个 Loki 存储后端</p>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{storages.map((storage) => (
|
{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' : ''}`}>
|
<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">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{storageTypeIcons[storage.storageType] || <HardDrive className="w-5 h-5 text-slate-400" />}
|
{STORAGE_TYPE_ICONS[storage.storageType] || (
|
||||||
<h3 className="font-semibold text-slate-800 dark:text-white">{storage.name}</h3>
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{storage.isDefault && (
|
{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">
|
<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" />
|
<Star className="w-3 h-3" />
|
||||||
默认
|
默认
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium uppercase ${getTypeColor(storage.storageType)}`}>
|
<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}
|
{storage.storageType}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
||||||
<div className="flex justify-between">
|
<InfoRow label="访问地址" value={storage.endpoint || '默认'} />
|
||||||
<span>访问地址</span>
|
<InfoRow label="Bucket" value={storage.bucket} />
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[150px]">{storage.endpoint || '默认'}</span>
|
<InfoRow label="区域" value={storage.region || '无'} />
|
||||||
</div>
|
<InfoRow label="路径前缀" value={storage.pathPrefix || '无'} />
|
||||||
<div className="flex justify-between">
|
<InfoRow label="不加密" value={storage.insecure ? '是' : '否'} />
|
||||||
<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>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||||
@@ -319,10 +397,7 @@ export default function Storage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1 text-xs"
|
className="flex-1 text-xs"
|
||||||
onClick={async () => {
|
onClick={() => onSetDefault(storage.id)}
|
||||||
await storageApi.update(storage.id, { isDefault: true })
|
|
||||||
loadStorages()
|
|
||||||
}}
|
|
||||||
disabled={storage.isDefault}
|
disabled={storage.isDefault}
|
||||||
>
|
>
|
||||||
<Star className="w-3 h-3 mr-1" />
|
<Star className="w-3 h-3 mr-1" />
|
||||||
@@ -332,16 +407,32 @@ export default function Storage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
onClick={() => handleDelete(storage.id)}
|
onClick={() => onDelete(storage.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)
|
||||||
</div>
|
})
|
||||||
)}
|
|
||||||
|
// 子组件:信息行
|
||||||
|
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>
|
</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 SERVER_PORT=$BACKEND_PORT
|
||||||
export DATA_DIR="$SCRIPT_DIR/data"
|
export DATA_DIR="$SCRIPT_DIR/data"
|
||||||
export JWT_SECRET="${JWT_SECRET:-lokideploy-secret-key-change-in-production}"
|
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 &
|
nohup "./$BACKEND_BIN" >> logs/backend.log 2>&1 &
|
||||||
echo $! > "$BACKEND_PID_FILE"
|
echo $! > "$BACKEND_PID_FILE"
|
||||||
|
|
||||||
@@ -136,15 +142,35 @@ start_backend() {
|
|||||||
return 0
|
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() {
|
stop_backend() {
|
||||||
if [ -f "$BACKEND_PID_FILE" ]; then
|
if [ -f "$BACKEND_PID_FILE" ]; then
|
||||||
local pid=$(cat "$BACKEND_PID_FILE" 2>/dev/null || echo "")
|
local pid=$(cat "$BACKEND_PID_FILE" 2>/dev/null || echo "")
|
||||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
graceful_kill "$pid" "后端服务" 8
|
||||||
kill "$pid" 2>/dev/null || true
|
|
||||||
success "后端服务已停止"
|
|
||||||
fi
|
|
||||||
rm -f "$BACKEND_PID_FILE"
|
rm -f "$BACKEND_PID_FILE"
|
||||||
fi
|
fi
|
||||||
|
# 兜底:清理仍占用端口的残留进程(非 PID 文件管理的)
|
||||||
kill_port $BACKEND_PORT
|
kill_port $BACKEND_PORT
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,10 +212,7 @@ start_frontend() {
|
|||||||
stop_frontend() {
|
stop_frontend() {
|
||||||
if [ -f "$FRONTEND_PID_FILE" ]; then
|
if [ -f "$FRONTEND_PID_FILE" ]; then
|
||||||
local pid=$(cat "$FRONTEND_PID_FILE" 2>/dev/null || echo "")
|
local pid=$(cat "$FRONTEND_PID_FILE" 2>/dev/null || echo "")
|
||||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
graceful_kill "$pid" "前端服务" 5
|
||||||
kill "$pid" 2>/dev/null || true
|
|
||||||
success "前端服务已停止"
|
|
||||||
fi
|
|
||||||
rm -f "$FRONTEND_PID_FILE"
|
rm -f "$FRONTEND_PID_FILE"
|
||||||
fi
|
fi
|
||||||
kill_port $FRONTEND_PORT
|
kill_port $FRONTEND_PORT
|
||||||
|
|||||||
Reference in new issue
Block a user