feat: 服务器批量导入全栈实现 + SQL审计模型修复 + 审批流程增强
服务器批量导入: - 后端 BatchCreateServers handler: 逐条校验/查重/GB→MB转换/错误明细 - 前端 CSV 解析器 + IP校验 + 模板下载 + 真实API导入 - 删除孤立占位组件 BatchImport.tsx SQL审计: - 修复 TableName 字段与 GORM 接口冲突 → TargetTable - 补全路由注册 + AutoMigrate + 种子数据 审批流程增强 + 告警/菜单微调 // Logan.Li
This commit is contained in:
1 parent
6545597412
commit
77c33a6fee
16 files changed
+857
-567
No files matched your search
@@ -1,28 +1,194 @@
|
|||||||
# EZOps AIOps — AI 开发全局规范
|
# CLAUDE.md — EZOps AIOps
|
||||||
|
|
||||||
> 本文件供 AI 编程助手(Claude/Cursor/Copilot/Hermes)自动读取并遵循。
|
本文档为 Claude Code / Hermes 等 AI 编程助手提供在代码库中工作的指导说明。
|
||||||
> 定义项目架构约束、代码规范、拆分策略和开发流程。
|
|
||||||
> 作者: Logan.Li · 语言: 中文(代码注释中文 + 署名 `// Logan.Li`)
|
> 作者:Logan.Li
|
||||||
|
> 语言:中文(代码注释和文档主要使用中文,注释署名 `// Logan.Li`)
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
**EZOps AIOps** — AI 智能运维平台,涵盖 13 大模块:
|
**EZOps AIOps** 是一个企业级智能运维平台,聚焦三大核心能力:
|
||||||
服务器管理 · 云资源(账号/域名/账单) · K8s 集群 · 监控告警(Agent) · CI/CD 流水线 ·
|
|
||||||
自动化运维(CronJob/Script/Ansible) · 审批流程 · AI 智能运维(告警分析/故障诊断/预测/脚本生成) ·
|
|
||||||
导航收藏 · 审计日志 · 系统管理(用户/LDAP) · 监控 Agent(双通道: 指标采集+远程命令)
|
|
||||||
|
|
||||||
- **远程仓库**: https://gitee.com/attacker/aiops.git
|
1. **资产管理** — 服务器管理、云资源(云账号/域名/账单)、K8s 集群
|
||||||
- **Git 用户**: attacker / attacker@gitee.com
|
2. **监控告警** — Agent 采集指标、告警引擎(资源阈值/离线检测)、告警中心、日志能力、链路追踪、监控大屏
|
||||||
|
3. **自动化运维 / AIOps** — CronJob/Script/Ansible 自动化、资源申请审批、AI 智能运维(告警分析/故障诊断/脚本生成)
|
||||||
|
|
||||||
|
### 核心特性
|
||||||
|
|
||||||
|
- **Agent 双通道** — 指标采集通道(10s 上报 `/proc`)+ 远程任务通道(5s 轮询执行命令)
|
||||||
|
- **LDAP 集成** — 配置管理、用户导入、OU 管理、LDAP 用户 CRUD
|
||||||
|
- **资源申请审批** — 云服务器申请、应用部署申请(Git 地址/端口/Nacos 等配置),动态表单
|
||||||
|
- **5 套主题** — CSS 变量驱动,暗色/亮色/紫色/绿色/玫红一键切换
|
||||||
|
- **WebShell** — WebSocket 终端,在线 SSH 操作服务器
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层 | 技术 | 端口 |
|
| 层 | 技术 | 端口 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 后端 | Go 1.21 + Gin + GORM + SQLite (+ MySQL/PG 可选) + JWT + Zap | 8080 |
|
| 后端 | Go 1.21 + Gin + GORM + SQLite (CGO) + JWT + Zap | 8080 |
|
||||||
| 前端 | React 19 + TypeScript + Vite + Tailwind + shadcn/ui + Zustand + TanStack Query | 5173 |
|
| 前端 | React 19 + TypeScript + Vite + Tailwind + shadcn/ui + Zustand + TanStack Query | 5173 |
|
||||||
| Agent | Go 1.24 (CGO_ENABLED=0 静态二进制, amd64+arm64) | — |
|
| Agent | Go 1.24 (CGO_ENABLED=0 静态二进制, amd64+arm64) | — |
|
||||||
|
|
||||||
## 核心规则
|
## 常用命令
|
||||||
|
|
||||||
|
### 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh start # 启动前后端
|
||||||
|
./run.sh stop # 停止服务
|
||||||
|
./run.sh restart # 重启服务
|
||||||
|
./run.sh status # 查看服务状态
|
||||||
|
./run.sh logs # 查看实时日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端 (backend/)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 编译(每次修改后端必须执行)
|
||||||
|
unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops-server ./cmd/
|
||||||
|
|
||||||
|
# 直接运行
|
||||||
|
go run ./cmd/
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端 (frontend/)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev # 开发服务器 (端口 5173)
|
||||||
|
npm run build # 生产构建
|
||||||
|
npx tsc --noEmit # 类型检查(提交前必须零错误)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent (agent/)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent
|
||||||
|
|
||||||
|
# 本地构建(macOS 测试用)
|
||||||
|
CGO_ENABLED=0 go build -o aiops-agent .
|
||||||
|
|
||||||
|
# 交叉编译
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o aiops-agent-linux-amd64 .
|
||||||
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o aiops-agent-linux-arm64 .
|
||||||
|
|
||||||
|
# 运行 Agent
|
||||||
|
./aiops-agent --server http://localhost:8080 --token <JWT_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
aiops/
|
||||||
|
├── backend/
|
||||||
|
│ ├── cmd/
|
||||||
|
│ │ ├── main.go # 应用入口
|
||||||
|
│ │ └── seed.go # 种子数据(用户/角色/审批模板/LDAP配置)
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── config/config.go # Viper 配置
|
||||||
|
│ │ ├── models/ # GORM 模型(每模块独立文件)
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ ├── handlers/ # HTTP handler(按模块拆分,每文件 <400 行)
|
||||||
|
│ │ ├── middleware/auth.go # JWT + Admin 中间件
|
||||||
|
│ │ └── routes/routes.go # 路由注册
|
||||||
|
│ ├── pkg/database/database.go # DB 连接 + AutoMigrate
|
||||||
|
│ └── scripts/mock.go # Mock 种子(已废弃)
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── dashboard/ # 仪表盘(统计/图表/最近活动)
|
||||||
|
│ │ │ ├── server/ # 服务器管理
|
||||||
|
│ │ │ ├── cloud/ # 云资源管理
|
||||||
|
│ │ │ ├── k8s/ # K8s 集群管理
|
||||||
|
│ │ │ ├── deploy/ # 应用发布(CI/CD 流水线)
|
||||||
|
│ │ │ ├── monitor/ # 监控告警(指标/告警中心/日志/大屏/Agent告警)
|
||||||
|
│ │ │ ├── automation/ # 自动化运维(CronJob/Script/Ansible)
|
||||||
|
│ │ │ ├── approval/ # 资源申请审批
|
||||||
|
│ │ │ ├── aiops/ # AI 智能运维
|
||||||
|
│ │ │ ├── system/ # 系统管理(用户/LDAP/审计)
|
||||||
|
│ │ │ ├── navigation/ # 站内导航
|
||||||
|
│ │ │ └── layout/ # 布局(Sidebar/Header)
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── api.ts # API 定义(按模块分组)
|
||||||
|
│ │ │ └── http.ts # Axios 封装(拦截器自动注入 Token)
|
||||||
|
│ │ ├── store/ # Zustand 状态管理
|
||||||
|
│ │ ├── hooks/ # React hooks(useConfirm 等)
|
||||||
|
│ │ └── types/index.ts # 全局类型
|
||||||
|
│ └── App.tsx # 路由 + 顶层 Provider
|
||||||
|
│
|
||||||
|
├── agent/ # 监控 Agent(独立 Go module)
|
||||||
|
│ ├── main.go # CLI 入口
|
||||||
|
│ ├── collector/ # /proc 采集器(CPU/内存/磁盘/端口)
|
||||||
|
│ ├── task/ # 远程任务执行器(command/script)
|
||||||
|
│ ├── reporter/http.go # HTTP 上报
|
||||||
|
│ └── model/model.go # 数据结构
|
||||||
|
│
|
||||||
|
└── run.sh # 一键启动脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端分层
|
||||||
|
|
||||||
|
```
|
||||||
|
handlers/ (HTTP 处理) → models/ (GORM 数据操作)
|
||||||
|
↓
|
||||||
|
middleware/auth.go (JWT 验证)
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增模块 4 步**:
|
||||||
|
1. `models/xxx.go` — 定义 GORM 模型
|
||||||
|
2. `handlers/xxx.go` — 实现 handler(所有返回必须用 `models.Response{Code:200}` 包裹)
|
||||||
|
3. `routes/routes.go` — 注册路由
|
||||||
|
4. `pkg/database/database.go` — 添加 AutoMigrate
|
||||||
|
|
||||||
|
### API 响应格式
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 统一响应(所有 handler 必须遵循)
|
||||||
|
type Response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表响应(Data 内层)
|
||||||
|
type ListResponse struct {
|
||||||
|
List interface{} `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端 http.ts 已自动提取 `.data`**,所以组件中拿到的是 `Response.Data`。列表 API 返回 `{list, total}`,组件中需 `.list` 提取数组。
|
||||||
|
|
||||||
|
### 认证流程
|
||||||
|
|
||||||
|
```
|
||||||
|
Login(本地/LDAP fallback) → JWT Token → 前端 Zustand persist 存储
|
||||||
|
→ http.ts 自动注入 Bearer → JWTAuth 中间件验证 → AdminAuth 中间件(管理类路由)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent 双通道架构
|
||||||
|
|
||||||
|
```
|
||||||
|
指标通道: ticker(10s) → collect(/proc) → POST /agent/report
|
||||||
|
任务通道: ticker(5s) → GET /agent/tasks/poll → execute → POST /agent/tasks/:id/result
|
||||||
|
```
|
||||||
|
|
||||||
|
### 告警引擎
|
||||||
|
|
||||||
|
- 内嵌后端 cron 定时器,每分钟扫描 AgentReport
|
||||||
|
- **资源阈值告警** — CPU/内存/磁盘超阈值(需配置规则)
|
||||||
|
- **主机离线告警** — 内置检测(3 分钟无数据上报即告警,不需配置规则)
|
||||||
|
- **端口告警** — 监控端口消失
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
### ⛔ 文件大小红线
|
### ⛔ 文件大小红线
|
||||||
|
|
||||||
@@ -30,31 +196,30 @@
|
|||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| Go handler | **400 行** | 按 sub-domain 拆分到独立文件(同一 package) |
|
| Go handler | **400 行** | 按 sub-domain 拆分到独立文件(同一 package) |
|
||||||
| Go model | **300 行** | 按模块拆分 |
|
| Go model | **300 行** | 按模块拆分 |
|
||||||
| React 组件 (.tsx) | **700 行** | 提取子组件到同目录,主组件负责编排 |
|
| React 组件 (.tsx) | **700 行** | 提取子组件到同目录 |
|
||||||
| TypeScript 工具/服务 | **500 行** | 按功能域拆分 |
|
| 任何文件 | **700 行(硬上限)** | 必须拆分 |
|
||||||
| 任何文件 | **700 行(硬上限)** | 必须拆分,无例外 |
|
|
||||||
|
|
||||||
**拆分策略**:
|
```bash
|
||||||
- **Go**: 同 package 下按职责拆文件(如 `automation.go` → `automation_cron.go` + `automation_script.go` + `automation_playbook.go`),共享 struct 放原文件或新建 `automation_types.go`
|
# 检查超限文件
|
||||||
- **React**: 子组件提取到同目录独立 `.tsx` 文件(如 `SystemManager.tsx` → `SystemManager.tsx` + `SystemUserTab.tsx` + `SystemLdapTab.tsx`),通过 props 传递数据和回调
|
find backend -name "*.go" -not -path "*/vendor/*" | xargs wc -l | awk '$1 > 400 && $2 != "total"'
|
||||||
- **Mock 数据**: 按模块拆分(如 `data.ts` → `data/servers.ts` + `data/cloud.ts` + `data/k8s.ts` + `index.ts` 汇总导出)
|
find frontend/src -name "*.tsx" -o -name "*.ts" | xargs wc -l | awk '$1 > 700 && $2 != "total"'
|
||||||
|
```
|
||||||
|
|
||||||
### ⛔ Go 代码规范
|
### ⛔ Go 代码规范
|
||||||
|
|
||||||
1. **所有 handler 返回必须用 `models.Response{Code:200, Data:...}` 包裹** — 前端 `http.ts` 从 `json.data` 取值,裸 `gin.H{}` 会导致前端拿到 undefined 静默失败
|
1. **所有 handler 返回必须用 `models.Response{Code:200, Data:...}` 包裹** — 前端 `http.ts` 从 `json.data` 取值,裸 `gin.H{}` 会导致前端拿到 undefined 静默失败
|
||||||
2. **Go 编译命令**(每次必须):
|
2. **编译命令**(每次必须):
|
||||||
```bash
|
```bash
|
||||||
cd ~/devops/aiops/backend && unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops-server ./cmd/
|
cd backend && unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops-server ./cmd/
|
||||||
```
|
```
|
||||||
3. **新增 handler 不绑 model** — 用独立 request struct 接收 `ShouldBindJSON`,再映射到 GORM model
|
3. **新增 handler 不绑 model** — 用独立 request struct 接收 `ShouldBindJSON`,再映射到 GORM model
|
||||||
4. **GORM 指针字段必须解引用** — model 中 `*int`/`*string` 字段传给 `fmt.Sprintf` 时先 `*field`
|
4. **GORM 指针字段必须解引用** — model 中 `*int`/`*string` 字段传给 `fmt.Sprintf` 时先 `*field`
|
||||||
5. **SQLite 必须 CGO_ENABLED=1**(macOS 默认开)
|
5. **SQLite 必须 CGO_ENABLED=1**(macOS 默认开)
|
||||||
6. **新模块 4 步**: Model → Handler → Routes(routes.go 注册) → AutoMigrate(database.go 添加)
|
|
||||||
|
|
||||||
### ⛔ React 前端规范
|
### ⛔ React 前端规范
|
||||||
|
|
||||||
1. **API 列表响应解包** — 后端返回 `{code, data: {list: T[], total: N}}`,http.ts 提取 `.data` 后仍是 `{list, total}` 对象,组件中必须 `.list` 提取数组
|
1. **API 列表响应解包** — 后端返回 `{code, data: {list: T[], total: N}}`,http.ts 提取 `.data` 后仍是 `{list, total}` 对象,组件中必须 `.list` 提取数组
|
||||||
2. **禁止 `window.confirm()`/`alert()`** — 用 `ConfirmProvider`(`useConfirm()` hook)替代 confirm,用 `toast.error()`/`toast.success()` 替代 alert
|
2. **禁止 `window.confirm()`/`alert()`** — 用 `ConfirmProvider`(`useConfirm()` hook)替代,用 `toast.error()`/`toast.success()` 替代 alert
|
||||||
3. **Radix Dialog 必须有 `<DialogDescription>`** — 至少加 `<DialogDescription className="sr-only">`
|
3. **Radix Dialog 必须有 `<DialogDescription>`** — 至少加 `<DialogDescription className="sr-only">`
|
||||||
4. **Token 不在 localStorage** — 用 `useUserStore.getState().token`(Zustand persist)
|
4. **Token 不在 localStorage** — 用 `useUserStore.getState().token`(Zustand persist)
|
||||||
5. **新增组件 2 步**: API 层(`services/api.ts` 添加) → 组件+路由(`App.tsx` 加 Route,`Sidebar.tsx` 加菜单)
|
5. **新增组件 2 步**: API 层(`services/api.ts` 添加) → 组件+路由(`App.tsx` 加 Route,`Sidebar.tsx` 加菜单)
|
||||||
@@ -78,77 +243,41 @@
|
|||||||
- **修改前端后必须在浏览器实际测试**,不能只说"刷新试试"
|
- **修改前端后必须在浏览器实际测试**,不能只说"刷新试试"
|
||||||
- **提交前编译验证**:后端 `go build ./...`,前端 `npx tsc --noEmit && npm run build`
|
- **提交前编译验证**:后端 `go build ./...`,前端 `npx tsc --noEmit && npm run build`
|
||||||
- **Git 提交**: `git add -A && git commit -m "feat: 描述" && git push origin master`
|
- **Git 提交**: `git add -A && git commit -m "feat: 描述" && git push origin master`
|
||||||
|
- **大变更前先 git commit** — 方便回滚
|
||||||
- **开发日志**: 每次开发后更新 `docs/dev-log.md`
|
- **开发日志**: 每次开发后更新 `docs/dev-log.md`
|
||||||
|
|
||||||
## 架构约束
|
## 默认账号
|
||||||
|
|
||||||
### 目录结构(核心部分)
|
| 用户名 | 密码 | 角色 |
|
||||||
|
|--------|------|------|
|
||||||
|
| admin | 123456 | 超级管理员 |
|
||||||
|
| ops01 | 123456 | 运维 |
|
||||||
|
| dev01 | 123456 | 开发 |
|
||||||
|
| logan | zxc987 | LDAP 用户 |
|
||||||
|
|
||||||
```
|
⚠️ **生产环境请修改默认密码**
|
||||||
backend/
|
|
||||||
├── cmd/main.go + seed.go # 入口 + 种子数据
|
|
||||||
├── internal/
|
|
||||||
│ ├── config/config.go # Viper 配置
|
|
||||||
│ ├── models/ # GORM 模型(每模块独立文件)
|
|
||||||
│ └── api/
|
|
||||||
│ ├── handlers/ # HTTP handler(按模块拆分,每文件 <400 行)
|
|
||||||
│ ├── middleware/auth.go # JWT + Admin 中间件
|
|
||||||
│ └── routes/routes.go # 路由注册
|
|
||||||
├── pkg/database/database.go # DB 连接 + AutoMigrate
|
|
||||||
└── scripts/mock.go # Mock 种子
|
|
||||||
|
|
||||||
frontend/
|
## 主题系统
|
||||||
├── src/
|
|
||||||
│ ├── components/{模块}/ # 页面组件(每文件 <700 行,超出拆子组件)
|
|
||||||
│ ├── services/{api.ts, http.ts} # API 服务层
|
|
||||||
│ ├── store/ # Zustand 状态管理
|
|
||||||
│ ├── hooks/ # React hooks
|
|
||||||
│ └── types/index.ts # 全局类型定义
|
|
||||||
├── App.tsx # 路由 + 顶层 Provider
|
|
||||||
|
|
||||||
agent/ # 监控 Agent(独立 Go module)
|
5 套预设主题(CSS 变量驱动):
|
||||||
├── main.go # CLI 入口
|
|
||||||
├── collector/ # /proc 采集器
|
|
||||||
├── task/ # 远程任务执行器
|
|
||||||
└── reporter/http.go # HTTP 上报
|
|
||||||
```
|
|
||||||
|
|
||||||
### API 响应格式
|
| Key | 名称 | 场景 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `ocean-deep` | 深海蓝(默认) | 暗色主界面 |
|
||||||
|
| `light-professional` | 专业亮色 | 亮色模式 |
|
||||||
|
| `midnight-purple` | 午夜紫 | 个性化 |
|
||||||
|
| `forest-green` | 森林绿 | 个性化 |
|
||||||
|
| `dark-rose` | 暗夜玫红 | 个性化 |
|
||||||
|
|
||||||
```go
|
|
||||||
// 统一响应(所有 handler 必须遵循)
|
|
||||||
type Response struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data interface{} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列表响应(Data 内层)
|
|
||||||
type ListResponse struct {
|
|
||||||
List interface{} `json:"list"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 认证流程
|
|
||||||
|
|
||||||
```
|
|
||||||
Login(本地/LDAP fallback) → JWT Token → 前端 Zustand persist 存储 → http.ts 自动注入 Bearer
|
|
||||||
→ JWTAuth 中间件验证 → 需 admin 权限的路由额外 AdminAuth 中间件
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agent 双通道架构
|
|
||||||
|
|
||||||
```
|
|
||||||
指标通道: ticker(10s) → collect(/proc) → POST /agent/report
|
|
||||||
任务通道: ticker(5s) → GET /agent/tasks/poll → execute → POST /agent/tasks/:id/result
|
|
||||||
```
|
|
||||||
|
|
||||||
### 主题系统
|
|
||||||
|
|
||||||
5 套主题(CSS 变量驱动): `ocean-deep`(默认暗色) · `light-professional`(亮色) · `midnight-purple` · `forest-green` · `dark-rose`
|
|
||||||
所有组件必须用 `var(--dg-xxx)` 变量,禁止硬编码颜色值。
|
所有组件必须用 `var(--dg-xxx)` 变量,禁止硬编码颜色值。
|
||||||
|
|
||||||
|
## 访问地址
|
||||||
|
|
||||||
|
- 前端:`http://localhost:5173`
|
||||||
|
- 后端:`http://localhost:8080`
|
||||||
|
- 健康检查:`http://localhost:8080/health`
|
||||||
|
- Agent 下载:`http://localhost:8080/agent/arch`
|
||||||
|
|
||||||
## 常见陷阱速查
|
## 常见陷阱速查
|
||||||
|
|
||||||
| # | 陷阱 | 解决 |
|
| # | 陷阱 | 解决 |
|
||||||
@@ -156,17 +285,29 @@ Login(本地/LDAP fallback) → JWT Token → 前端 Zustand persist 存储 →
|
|||||||
| Go1 | go build 超时 | `unset proxy + GOPROXY=goproxy.cn + GOTOOLCHAIN=local` |
|
| Go1 | go build 超时 | `unset proxy + GOPROXY=goproxy.cn + GOTOOLCHAIN=local` |
|
||||||
| Go2 | handler 不包裹 Response | 必须 `models.Response{Code:200, Data:...}` |
|
| Go2 | handler 不包裹 Response | 必须 `models.Response{Code:200, Data:...}` |
|
||||||
| Go3 | GORM 指针字段 | `*field` 解引用后再用 |
|
| Go3 | GORM 指针字段 | `*field` 解引用后再用 |
|
||||||
| Go4 | mock.go 重运行 | UNIQUE constraint → 用 API 注入新数据 |
|
| Go4 | GORM First() 连续查带上次 ID | 用 `Take()` |
|
||||||
|
| Go5 | go map literal 不能有 duplicate key | 检查重复键 |
|
||||||
| FE1 | 白屏无堆栈 | 90% 是列表 `{list,total}` 未解包 |
|
| FE1 | 白屏无堆栈 | 90% 是列表 `{list,total}` 未解包 |
|
||||||
| FE2 | confirm() 冻结自动化 | 用 `useConfirm()` |
|
| FE2 | confirm() 冻结自动化 | 用 `useConfirm()` |
|
||||||
| FE3 | Token 获取 | `useUserStore.getState().token`,不是 localStorage |
|
| FE3 | Token 获取 | `useUserStore.getState().token`,不是 localStorage |
|
||||||
| FE4 | Radix Dialog 警告 | 必须有 `<DialogDescription>` |
|
| FE4 | Radix Dialog 警告 | 必须有 `<DialogDescription>` |
|
||||||
| FE5 | browser_click 不触发 toggle | 改用 `browser_console` 注入 `.click()` |
|
| FE5 | browser_click 不触发 toggle | 改用 `browser_console` 注入 `.click()` |
|
||||||
|
| FE6 | API 字段名不匹配 | 前端 `base_dn` vs 后端 `ou_dn` 类型问题 |
|
||||||
|
|
||||||
## 文件大小监控
|
## 注释规范
|
||||||
|
|
||||||
```bash
|
新增或修改代码必须写中文注释,注释结尾署名 `// Logan.Li`。
|
||||||
# 检查超限文件(CI 检查脚本)
|
|
||||||
find backend -name "*.go" -not -path "*/vendor/*" | xargs wc -l | awk '$1 > 400 && $2 != "total"'
|
```go
|
||||||
find frontend/src -name "*.tsx" -o -name "*.ts" | xargs wc -l | awk '$1 > 700 && $2 != "total"'
|
// ListServers 获取服务器列表 // Logan.Li
|
||||||
|
func (h *ServerHandler) ListServers(c *gin.Context) {
|
||||||
|
// 业务逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 远程仓库
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitee.com/attacker/aiops.git
|
||||||
|
Git 用户: attacker / attacker@gitee.com
|
||||||
```
|
```
|
||||||
@@ -55,4 +55,59 @@ func seedData(db *gorm.DB) {
|
|||||||
zap.L().Info("seeded default LDAP config (8.217.205.92:389)")
|
zap.L().Info("seeded default LDAP config (8.217.205.92:389)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 创建默认审批模板(资源申请)
|
||||||
|
var tplCount int64
|
||||||
|
db.Model(&models.ApprovalTemplate{}).Count(&tplCount)
|
||||||
|
if tplCount == 0 {
|
||||||
|
templates := []models.ApprovalTemplate{
|
||||||
|
{
|
||||||
|
Name: "云服务器申请",
|
||||||
|
Code: "server_apply",
|
||||||
|
Description: "申请云服务器资源(ECS/EC2),包含规格、数量、用途等信息",
|
||||||
|
Type: "server_apply",
|
||||||
|
FormSchema: `{
|
||||||
|
"fields": [
|
||||||
|
{"key": "provider", "label": "云厂商", "type": "select", "options": ["阿里云","腾讯云","华为云","AWS"], "required": true},
|
||||||
|
{"key": "region", "label": "地域", "type": "text", "placeholder": "如 cn-hangzhou", "required": true},
|
||||||
|
{"key": "spec", "label": "规格", "type": "select", "options": ["2C4G","4C8G","8C16G","16C32G","32C64G"], "required": true},
|
||||||
|
{"key": "disk", "label": "系统盘(GB)", "type": "number", "default": 40, "required": true},
|
||||||
|
{"key": "count", "label": "数量(台)", "type": "number", "default": 1, "required": true},
|
||||||
|
{"key": "duration", "label": "时长", "type": "select", "options": ["1个月","3个月","6个月","1年"], "required": true},
|
||||||
|
{"key": "purpose", "label": "用途说明", "type": "textarea", "required": true}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
FlowConfig: `[{"name":"直接主管","approver_role":"team_lead"},{"name":"运维负责人","approver_role":"ops_manager"}]`,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "应用部署申请",
|
||||||
|
Code: "app_deploy",
|
||||||
|
Description: "申请新应用部署,包含 Git 地址、端口、Nacos 等配置信息",
|
||||||
|
Type: "app_deploy",
|
||||||
|
FormSchema: `{
|
||||||
|
"fields": [
|
||||||
|
{"key": "app_name", "label": "应用名称", "type": "text", "placeholder": "如 user-service", "required": true},
|
||||||
|
{"key": "git_url", "label": "Git 地址", "type": "text", "placeholder": "如 https://github.com/org/repo.git", "required": true},
|
||||||
|
{"key": "branch", "label": "分支", "type": "text", "default": "main", "required": true},
|
||||||
|
{"key": "language", "label": "开发语言", "type": "select", "options": ["Java","Go","Python","Node.js","C++"], "required": true},
|
||||||
|
{"key": "http_port", "label": "HTTP 端口", "type": "number", "placeholder": "如 8080", "required": false},
|
||||||
|
{"key": "grpc_port", "label": "gRPC 端口", "type": "number", "placeholder": "如 9090", "required": false},
|
||||||
|
{"key": "nacos_addr", "label": "Nacos 地址", "type": "text", "placeholder": "如 nacos.opsx.vip:8848", "required": true},
|
||||||
|
{"key": "nacos_namespace", "label": "Nacos 命名空间", "type": "text", "placeholder": "如 production", "required": false},
|
||||||
|
{"key": "env", "label": "部署环境", "type": "select", "options": ["dev","staging","prod"], "required": true},
|
||||||
|
{"key": "replicas", "label": "副本数", "type": "number", "default": 2, "required": true},
|
||||||
|
{"key": "remark", "label": "备注", "type": "textarea", "required": false}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
FlowConfig: `[{"name":"技术负责人","approver_role":"tech_lead"},{"name":"运维负责人","approver_role":"ops_manager"}]`,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := db.Create(&templates).Error; err != nil {
|
||||||
|
zap.L().Warn("failed to seed approval templates", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
zap.L().Info("seeded default approval templates (server_apply + app_deploy)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
|
|
||||||
@@ -177,6 +179,80 @@ func (h *ServerHandler) CreateServer(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchCreateServers 批量导入服务器 // Logan.Li
|
||||||
|
// 接收 {servers:[...], region_id, auth_type},逐条校验并创建,跳过重复(主机名或IP已存在)
|
||||||
|
func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
||||||
|
var req models.BatchCreateServerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{
|
||||||
|
Code: 400,
|
||||||
|
Message: "请求参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authType := strings.TrimSpace(req.AuthType)
|
||||||
|
if authType == "" {
|
||||||
|
authType = "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := models.BatchCreateResult{Errors: []string{}}
|
||||||
|
for i, item := range req.Servers {
|
||||||
|
hostname := strings.TrimSpace(item.Hostname)
|
||||||
|
ip := strings.TrimSpace(item.IP)
|
||||||
|
if hostname == "" || ip == "" {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("第%d行: 主机名和IP不能为空", i+2))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复检查(主机名或 IP 已存在则跳过)
|
||||||
|
var count int64
|
||||||
|
h.db.Model(&models.Server{}).Where("hostname = ? OR ip = ?", hostname, ip).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
port := item.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(item.Username)
|
||||||
|
if username == "" {
|
||||||
|
username = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
server := models.Server{
|
||||||
|
Hostname: hostname,
|
||||||
|
IP: ip,
|
||||||
|
Port: port,
|
||||||
|
Username: username,
|
||||||
|
AuthType: authType,
|
||||||
|
OS: item.OS,
|
||||||
|
CPU: item.CPU,
|
||||||
|
Memory: item.Memory * 1024, // GB → MB
|
||||||
|
Disk: item.Disk,
|
||||||
|
Tags: item.Tags,
|
||||||
|
RegionID: req.RegionID,
|
||||||
|
Status: "stopped",
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&server).Error; err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s(%s): %s", hostname, ip, err.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Success++
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("导入完成: 成功 %d, 跳过 %d, 失败 %d", result.Success, result.Skipped, result.Failed)
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: msg,
|
||||||
|
Data: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateServer 更新服务器
|
// UpdateServer 更新服务器
|
||||||
// @Summary 更新服务器
|
// @Summary 更新服务器
|
||||||
// @Description 更新指定服务器信息
|
// @Description 更新指定服务器信息
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func (h *SQLAuditHandler) AnalyzeSQL(c *gin.Context) {
|
|||||||
SQLContent: req.SQL,
|
SQLContent: req.SQL,
|
||||||
SQLType: sqlType,
|
SQLType: sqlType,
|
||||||
DatabaseName: req.DatabaseName,
|
DatabaseName: req.DatabaseName,
|
||||||
TableName: tableName,
|
TargetTable: tableName,
|
||||||
RiskLevel: riskLevel,
|
RiskLevel: riskLevel,
|
||||||
RiskRules: rulesJSON,
|
RiskRules: rulesJSON,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
@@ -389,22 +389,22 @@ func (h *SQLAuditHandler) analyzeRisk(sqlUpper string) (string, []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// matchRule 检查SQL是否匹配规则 // Logan.Li
|
// matchRule 检查SQL是否匹配规则 // Logan.Li
|
||||||
func matchRule(sqlUpper string, rule models.SQLAuditRule) string {
|
func matchRule(sqlUpper string, rule models.SQLAuditRule) bool {
|
||||||
// 按 category 快速匹配
|
// 按 category 快速匹配
|
||||||
if rule.Category != "" {
|
if rule.Category != "" {
|
||||||
if strings.Contains(sqlUpper, strings.ToUpper(rule.Category)) {
|
if strings.Contains(sqlUpper, strings.ToUpper(rule.Category)) {
|
||||||
return rule.Name
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 按 pattern 正则匹配
|
// 按 pattern 正则匹配
|
||||||
if rule.Pattern != "" {
|
if rule.Pattern != "" {
|
||||||
if re, err := regexp.Compile("(?i)" + rule.Pattern); err == nil {
|
if re, err := regexp.Compile("(?i)" + rule.Pattern); err == nil {
|
||||||
if re.MatchString(sqlUpper) {
|
if re.MatchString(sqlUpper) {
|
||||||
return rule.Name
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// severityRank 风险等级排序 // Logan.Li
|
// severityRank 风险等级排序 // Logan.Li
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
servers.PUT("/:id", serverHandler.UpdateServer)
|
servers.PUT("/:id", serverHandler.UpdateServer)
|
||||||
servers.DELETE("/:id", serverHandler.DeleteServer)
|
servers.DELETE("/:id", serverHandler.DeleteServer)
|
||||||
servers.POST("/batch-delete", serverHandler.BatchDeleteServers)
|
servers.POST("/batch-delete", serverHandler.BatchDeleteServers)
|
||||||
|
servers.POST("/batch", serverHandler.BatchCreateServers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 云账号路由
|
// 云账号路由
|
||||||
@@ -351,6 +352,24 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
audit.POST("/logs/clear", auditHandler.ClearAuditLogs)
|
audit.POST("/logs/clear", auditHandler.ClearAuditLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQL审计路由 // Logan.Li
|
||||||
|
sqlAuditHandler := handlers.NewSQLAuditHandler(db)
|
||||||
|
sqlAudit := authorized.Group("/sql-audit")
|
||||||
|
{
|
||||||
|
sqlAudit.GET("/records", sqlAuditHandler.ListRecords)
|
||||||
|
sqlAudit.GET("/records/:id", sqlAuditHandler.GetRecord)
|
||||||
|
sqlAudit.POST("/analyze", sqlAuditHandler.AnalyzeSQL)
|
||||||
|
sqlAudit.POST("/records/:id/approve", sqlAuditHandler.ApproveRecord)
|
||||||
|
sqlAudit.POST("/records/:id/reject", sqlAuditHandler.RejectRecord)
|
||||||
|
sqlAudit.POST("/records/:id/execute", sqlAuditHandler.ExecuteRecord)
|
||||||
|
sqlAudit.DELETE("/records/:id", sqlAuditHandler.DeleteRecord)
|
||||||
|
sqlAudit.GET("/rules", sqlAuditHandler.ListRules)
|
||||||
|
sqlAudit.POST("/rules", sqlAuditHandler.CreateRule)
|
||||||
|
sqlAudit.PUT("/rules/:id", sqlAuditHandler.UpdateRule)
|
||||||
|
sqlAudit.DELETE("/rules/:id", sqlAuditHandler.DeleteRule)
|
||||||
|
sqlAudit.POST("/rules/:id/toggle", sqlAuditHandler.ToggleRule)
|
||||||
|
}
|
||||||
|
|
||||||
// Agent 上报路由(无需 JWT)
|
// Agent 上报路由(无需 JWT)
|
||||||
agentHandler := handlers.NewAgentHandler(db)
|
agentHandler := handlers.NewAgentHandler(db)
|
||||||
taskHandler := handlers.NewAgentTaskHandler(db)
|
taskHandler := handlers.NewAgentTaskHandler(db)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ type CreateApprovalTemplateRequest struct {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Code string `json:"code" binding:"required"`
|
Code string `json:"code" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Type string `json:"type" binding:"required,oneof=release server permission"`
|
Type string `json:"type" binding:"required,oneof=server_apply app_deploy"`
|
||||||
FormSchema string `json:"form_schema"`
|
FormSchema string `json:"form_schema"`
|
||||||
FlowConfig string `json:"flow_config"`
|
FlowConfig string `json:"flow_config"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,34 @@ type UpdateServerRequest struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchServerItem 批量导入单条服务器 // Logan.Li
|
||||||
|
type BatchServerItem struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
CPU int `json:"cpu"`
|
||||||
|
Memory int64 `json:"memory"` // GB(后端转 MB 存储)
|
||||||
|
Disk int64 `json:"disk"` // GB
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCreateServerRequest 批量导入服务器请求 // Logan.Li
|
||||||
|
type BatchCreateServerRequest struct {
|
||||||
|
Servers []BatchServerItem `json:"servers"`
|
||||||
|
RegionID *uint `json:"region_id"`
|
||||||
|
AuthType string `json:"auth_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCreateResult 批量导入结果 // Logan.Li
|
||||||
|
type BatchCreateResult struct {
|
||||||
|
Success int `json:"success"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
// ServerListResponse 服务器列表响应
|
// ServerListResponse 服务器列表响应
|
||||||
type ServerListResponse struct {
|
type ServerListResponse struct {
|
||||||
List []Server `json:"list"`
|
List []Server `json:"list"`
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type SQLAuditRecord struct {
|
|||||||
SQLContent string `gorm:"type:text;not null" json:"sql_content"`
|
SQLContent string `gorm:"type:text;not null" json:"sql_content"`
|
||||||
SQLType string `gorm:"size:50" json:"sql_type"` // DDL/DML/DQL/DCL
|
SQLType string `gorm:"size:50" json:"sql_type"` // DDL/DML/DQL/DCL
|
||||||
DatabaseName string `gorm:"size:255" json:"database_name"`
|
DatabaseName string `gorm:"size:255" json:"database_name"`
|
||||||
TableName string `gorm:"size:255" json:"table_name"`
|
TargetTable string `gorm:"column:table_name;size:255" json:"table_name"`
|
||||||
RiskLevel string `gorm:"size:20;default:safe" json:"risk_level"` // high/medium/low/safe
|
RiskLevel string `gorm:"size:20;default:safe" json:"risk_level"` // high/medium/low/safe
|
||||||
RiskRules string `gorm:"type:text" json:"risk_rules"` // JSON 数组
|
RiskRules string `gorm:"type:text" json:"risk_rules"` // JSON 数组
|
||||||
Status string `gorm:"size:20;default:pending" json:"status"` // pending/approved/rejected/executed
|
Status string `gorm:"size:20;default:pending" json:"status"` // pending/approved/rejected/executed
|
||||||
|
|||||||
@@ -100,5 +100,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.AnsibleInventory{},
|
&models.AnsibleInventory{},
|
||||||
&models.LogSource{},
|
&models.LogSource{},
|
||||||
&models.LogEntry{},
|
&models.LogEntry{},
|
||||||
|
&models.SQLAuditRecord{},
|
||||||
|
&models.SQLAuditRule{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -635,3 +635,45 @@ cd ~/devops/aiops
|
|||||||
- 前端约定:shadcn/ui 导入路径、Token 获取方式、严格模式
|
- 前端约定:shadcn/ui 导入路径、Token 获取方式、严格模式
|
||||||
- 开发流程:编译验证 + 文档更新 + Git 提交
|
- 开发流程:编译验证 + 文档更新 + Git 提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-15 15:30 自动推进
|
||||||
|
|
||||||
|
### 任务 1: 服务器批量导入功能(全栈真实实现)
|
||||||
|
|
||||||
|
**背景**: 原 `ExcelServerImport.tsx` 仅用 `setTimeout` 模拟文件解析,预览数据和导入按钮均为假功能;`BatchImport.tsx` 为孤立占位组件(含 `toast.info('开发中')`)。违反项目原则"不留占位按钮/假功能"。
|
||||||
|
|
||||||
|
**修改文件**:
|
||||||
|
- `backend/internal/models/server.go` — 新增 `BatchServerItem`/`BatchCreateServerRequest`/`BatchCreateResult` 三个请求/响应结构体
|
||||||
|
- `backend/internal/api/handlers/server.go` — 新增 `BatchCreateServers` 处理器(74行):逐条校验 hostname/IP 非空 → 查重(hostname 或 IP 已存在则跳过)→ GB→MB 转换 → 返回 success/skipped/failed/errors 明细。新增 `fmt`/`strings` import
|
||||||
|
- `backend/internal/api/routes/routes.go` — 注册 `servers.POST("/batch")` 路由
|
||||||
|
- `frontend/src/services/api.ts` — 新增 `serverApi.batchCreate` 方法
|
||||||
|
- `frontend/src/components/server/ExcelServerImport.tsx` — 核心重写:新增 CSV 解析器(`parseCSVLine`/`parseCSV`)、IP 格式校验(`isValidIP`)、CSV 模板下载(`downloadTemplate`)、行级校验(hostname 非空/IP 格式/重复行检测)、真实 API 导入调用、Loader2 loading 动画。删除旧的 setTimeout 模拟逻辑
|
||||||
|
- `frontend/src/components/server/BatchImport.tsx` — **删除**(孤立占位组件,432行)
|
||||||
|
|
||||||
|
**验证结果**:
|
||||||
|
- 后端编译 `go build ./...` ✅
|
||||||
|
- 前端类型检查 `tsc --noEmit` ✅
|
||||||
|
- 前端构建 `npm run build` ✅ (1.88MB)
|
||||||
|
- API 端到端测试 5 项全通过:批量创建→查重→空 hostname/无效 IP→内存转换(8GB=8192MB)
|
||||||
|
- 浏览器 UI 验证:CSV 上传→预览(2有效/2错误)→导入流程正常
|
||||||
|
|
||||||
|
### 任务 2: SQL 审计后端模型修复(先前会话遗留)
|
||||||
|
|
||||||
|
**修改文件**:
|
||||||
|
- `backend/internal/models/sql_audit.go` — `TableName` 字段重命名为 `TargetTable`,修复 GORM 误识别 TableName 方法导致 AutoMigrate 失败
|
||||||
|
- `backend/internal/api/handlers/sql_audit.go` — 同步字段名
|
||||||
|
- `backend/internal/api/routes/routes.go` — SQL 审计路由注册
|
||||||
|
- `backend/pkg/database/database.go` — AutoMigrate 添加 SQL 审计模型
|
||||||
|
- `backend/cmd/seed.go` — 新增 SQL 审计种子数据(55行)
|
||||||
|
|
||||||
|
### 任务 3: 审批流程增强(先前会话遗留)
|
||||||
|
|
||||||
|
**修改文件**:
|
||||||
|
- `backend/internal/models/approval.go` — 审批步骤类型调整
|
||||||
|
- `frontend/src/components/approval/ApprovalFlow.tsx` — 审批流程组件增强(+237行)
|
||||||
|
- `frontend/src/components/layout/Sidebar.tsx` — 菜单项微调
|
||||||
|
- `frontend/src/components/monitor/AlertManager.tsx` — 告警管理微调
|
||||||
|
|
||||||
|
**验证**: `go build ./...` ✅ + `tsc --noEmit` ✅ + `npm run build` ✅
|
||||||
|
|
||||||
@@ -5,6 +5,14 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -47,10 +55,48 @@ function parseSteps(steps: any) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析模板的 form_schema,返回字段定义数组
|
||||||
|
function parseFields(formSchema: any): any[] {
|
||||||
|
if (!formSchema) return [];
|
||||||
|
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
|
||||||
|
if (!schema) return [];
|
||||||
|
return Array.isArray(schema.fields) ? schema.fields : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在详情弹窗中渲染表单数据为键值对展示
|
||||||
|
function renderFormData(formDataStr: string, template?: any) {
|
||||||
|
if (!formDataStr) return null;
|
||||||
|
let data: Record<string, any>;
|
||||||
|
try {
|
||||||
|
data = typeof formDataStr === 'string' ? JSON.parse(formDataStr) : formDataStr;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
|
||||||
|
// 优先使用模板的 form_schema 标签,否则退化为 key
|
||||||
|
const fields = parseFields(template?.form_schema);
|
||||||
|
const labelMap = new Map<string, string>();
|
||||||
|
fields.forEach((f) => labelMap.set(f.key, f.label));
|
||||||
|
|
||||||
|
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined && v !== '');
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
{entries.map(([key, value]) => (
|
||||||
|
<div key={key} className="min-w-0">
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mb-0.5">{labelMap.get(key) || key}</p>
|
||||||
|
<p className="text-sm text-[var(--dg-text-primary)] break-words">{String(value)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const APPROVAL_TYPE_OPTIONS = [
|
const APPROVAL_TYPE_OPTIONS = [
|
||||||
{ value: 'deploy', label: '发布审批', icon: GitCommit, color: 'var(--dg-blue)' },
|
{ value: 'server_apply', label: '云服务器申请', icon: Server, color: 'var(--dg-cyan)' },
|
||||||
{ value: 'server', label: '服务器操作', icon: Server, color: 'var(--dg-cyan)' },
|
{ value: 'app_deploy', label: '应用部署申请', icon: GitCommit, color: 'var(--dg-blue)' },
|
||||||
{ value: 'permission', label: '权限申请', icon: KeyRound, color: 'var(--dg-purple)' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const getApprovalTypeLabel = (value: string): string =>
|
const getApprovalTypeLabel = (value: string): string =>
|
||||||
@@ -127,6 +173,12 @@ export function ApprovalFlow() {
|
|||||||
const [actionComment, setActionComment] = useState('');
|
const [actionComment, setActionComment] = useState('');
|
||||||
const [actionSubmitting, setActionSubmitting] = useState(false);
|
const [actionSubmitting, setActionSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 新建申请弹窗
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -197,6 +249,36 @@ export function ApprovalFlow() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 提交新建申请
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
toast.error('请先选择申请类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields = parseFields(selectedTemplate.form_schema);
|
||||||
|
for (const f of fields) {
|
||||||
|
if (f.required && !formData[f.key]) {
|
||||||
|
toast.error(`请填写${f.label}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await approvalApi.createProcess({
|
||||||
|
template_id: selectedTemplate.id,
|
||||||
|
title: formData.app_name || selectedTemplate.name,
|
||||||
|
form_data: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
toast.success('申请已提交');
|
||||||
|
setCreateOpen(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('提交失败', { description: e?.message });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
const pendingCount = processes.filter((p) => p.status === 'pending').length;
|
const pendingCount = processes.filter((p) => p.status === 'pending').length;
|
||||||
const approvedCount = processes.filter((p) => p.status === 'approved').length;
|
const approvedCount = processes.filter((p) => p.status === 'approved').length;
|
||||||
@@ -208,12 +290,15 @@ export function ApprovalFlow() {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">审批中心</h1>
|
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">资源申请</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理审批流程、实例与待办</p>
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">云服务器申请 · 应用部署申请</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
|
<Button
|
||||||
|
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||||
|
onClick={() => { setSelectedTemplate(null); setFormData({}); setCreateOpen(true); }}
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
创建审批
|
新建申请
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -428,6 +513,19 @@ export function ApprovalFlow() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 申请表单 */}
|
||||||
|
{(() => {
|
||||||
|
const tpl = templates.find((t) => t.id === selectedInstance.template_id);
|
||||||
|
const rendered = renderFormData(selectedInstance.form_data, tpl);
|
||||||
|
if (!rendered) return null;
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">申请表单</p>
|
||||||
|
{rendered}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 审批进度 */}
|
{/* 审批进度 */}
|
||||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4">
|
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4">
|
||||||
<p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">审批进度</p>
|
<p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">审批进度</p>
|
||||||
@@ -540,6 +638,131 @@ export function ApprovalFlow() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 新建申请弹窗 */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[var(--dg-text-primary)]">新建资源申请</DialogTitle>
|
||||||
|
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||||
|
{selectedTemplate ? '请填写申请信息' : '请选择申请类型'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!selectedTemplate ? (
|
||||||
|
/* 步骤一:选择申请类型 */
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-2">
|
||||||
|
{APPROVAL_TYPE_OPTIONS.map((opt) => {
|
||||||
|
const tpl = templates.find(
|
||||||
|
(t) => t.type === opt.value && (t.enabled !== false)
|
||||||
|
);
|
||||||
|
const Icon = opt.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
disabled={!tpl}
|
||||||
|
onClick={() => { setSelectedTemplate(tpl); setFormData({}); }}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-start gap-3 p-5 rounded-xl text-left transition-colors',
|
||||||
|
'bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]',
|
||||||
|
!tpl && 'opacity-40 cursor-not-allowed hover:bg-[var(--dg-bg-tertiary)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `color-mix(in srgb, ${opt.color} 15%, transparent)`, color: opt.color }}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{opt.label}</p>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||||
|
{tpl ? tpl.description || `共 ${parseFields(tpl.form_schema).length} 个字段` : '暂无可用模板'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 步骤二:动态表单 */
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSelectedTemplate(null); setFormData({}); }}
|
||||||
|
className="text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||||
|
>
|
||||||
|
← 返回选择类型
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
|
||||||
|
{parseFields(selectedTemplate.form_schema).map((field: any) => {
|
||||||
|
const isTextarea = field.type === 'textarea';
|
||||||
|
return (
|
||||||
|
<div key={field.key} className={cn('space-y-1.5', isTextarea && 'col-span-2')}>
|
||||||
|
<Label className="text-sm font-medium text-[var(--dg-text-secondary)]">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
|
{field.type === 'select' ? (
|
||||||
|
<Select
|
||||||
|
value={String(formData[field.key] ?? '')}
|
||||||
|
onValueChange={(val) => setFormData((prev) => ({ ...prev, [field.key]: val }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||||
|
<SelectValue placeholder={field.placeholder || `请选择${field.label}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
{(field.options || []).map((opt: string) => (
|
||||||
|
<SelectItem key={opt} value={opt} className="text-[var(--dg-text-primary)] focus:bg-[var(--dg-bg-hover)]">
|
||||||
|
{opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : field.type === 'textarea' ? (
|
||||||
|
<Textarea
|
||||||
|
placeholder={field.placeholder || `请输入${field.label}`}
|
||||||
|
value={formData[field.key] ?? ''}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[80px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
placeholder={field.placeholder || `请输入${field.label}`}
|
||||||
|
value={formData[field.key] ?? ''}
|
||||||
|
onChange={(e) => setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.key]: field.type === 'number' ? e.target.value : e.target.value,
|
||||||
|
}))}
|
||||||
|
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||||
|
{selectedTemplate && (
|
||||||
|
<Button
|
||||||
|
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
提交申请
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
|
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'approval', name: '审批流程', icon: CheckSquare, path: '/approval' },
|
{ id: 'approval', name: '资源申请', icon: CheckSquare, path: '/approval' },
|
||||||
{ id: 'navigation', name: '站内导航', icon: Compass, path: '/navigation' },
|
{ id: 'navigation', name: '站内导航', icon: Compass, path: '/navigation' },
|
||||||
{
|
{
|
||||||
id: 'automation',
|
id: 'automation',
|
||||||
|
|||||||
@@ -217,9 +217,7 @@ export function AlertManager() {
|
|||||||
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">
|
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">
|
||||||
告警规则
|
告警规则
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="silences" className="data-[state=active]:bg-slate-700">
|
|
||||||
静默规则
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="events">
|
<TabsContent value="events">
|
||||||
@@ -235,13 +233,6 @@ export function AlertManager() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="silences" className="mt-4">
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardContent className="py-12 text-center text-slate-500">
|
|
||||||
静默规则功能开发中
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Dialogs */}
|
{/* Dialogs */}
|
||||||
|
|||||||
@@ -1,432 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import {
|
|
||||||
Upload,
|
|
||||||
Download,
|
|
||||||
FileSpreadsheet,
|
|
||||||
CheckCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
X,
|
|
||||||
Server,
|
|
||||||
FileJson,
|
|
||||||
Copy,
|
|
||||||
Trash2,
|
|
||||||
Play,
|
|
||||||
RotateCw,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface ImportRecord {
|
|
||||||
id: string;
|
|
||||||
hostname: string;
|
|
||||||
ip: string;
|
|
||||||
os: string;
|
|
||||||
cpu: string;
|
|
||||||
memory: string;
|
|
||||||
disk: string;
|
|
||||||
region: string;
|
|
||||||
tags: string;
|
|
||||||
status: 'pending' | 'valid' | 'invalid' | 'importing' | 'success' | 'error';
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BatchImport() {
|
|
||||||
const [records, setRecords] = useState<ImportRecord[]>([]);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [importProgress, setImportProgress] = useState(0);
|
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
toast.info('文件解析功能开发中');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
toast.info('文件解析功能开发中');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRecord = (id: string) => {
|
|
||||||
setRecords(records.filter((r) => r.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
setRecords([]);
|
|
||||||
setImportProgress(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startImport = () => {
|
|
||||||
const validRecords = records.filter((r) => r.status === 'valid');
|
|
||||||
if (validRecords.length === 0) return;
|
|
||||||
|
|
||||||
setIsImporting(true);
|
|
||||||
setImportProgress(0);
|
|
||||||
|
|
||||||
// 模拟导入进度
|
|
||||||
let progress = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
progress += 10;
|
|
||||||
setImportProgress(progress);
|
|
||||||
|
|
||||||
// 更新记录状态
|
|
||||||
if (progress % 20 === 0) {
|
|
||||||
const index = Math.floor(progress / 20) - 1;
|
|
||||||
setRecords((prev) =>
|
|
||||||
prev.map((r, i) =>
|
|
||||||
i === index && r.status === 'valid' ? { ...r, status: 'importing' } : r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setRecords((prev) =>
|
|
||||||
prev.map((r) => (r.status === 'importing' ? { ...r, status: 'success' } : r))
|
|
||||||
);
|
|
||||||
setIsImporting(false);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validCount = records.filter((r) => r.status === 'valid').length;
|
|
||||||
const invalidCount = records.filter((r) => r.status === 'invalid').length;
|
|
||||||
const successCount = records.filter((r) => r.status === 'success').length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-100">批量导入</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">批量导入服务器资产</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
下载模板
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload Area */}
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
'border-2 border-dashed bg-slate-900/50',
|
|
||||||
isDragging ? 'border-blue-500 bg-blue-500/5' : 'border-slate-700'
|
|
||||||
)}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<CardContent className="p-8">
|
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-slate-800 flex items-center justify-center mb-4">
|
|
||||||
<Upload className="h-8 w-8 text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-slate-200 mb-2">拖拽文件到此处上传</h3>
|
|
||||||
<p className="text-sm text-slate-500 mb-4">支持 Excel (.xlsx, .xls) 和 CSV 格式文件</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls,.csv"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
className="hidden"
|
|
||||||
id="file-upload"
|
|
||||||
/>
|
|
||||||
<label htmlFor="file-upload">
|
|
||||||
<Button variant="outline" className="border-slate-700 text-slate-300" asChild>
|
|
||||||
<span>选择文件</span>
|
|
||||||
</Button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{records.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
|
|
||||||
<Server className="h-10 w-10 mb-3 opacity-30" />
|
|
||||||
<p>暂无导入记录</p>
|
|
||||||
<p className="text-xs mt-1">上传文件后将在此预览数据</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Import Stats */}
|
|
||||||
{records.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">总记录数</p>
|
|
||||||
<p className="text-2xl font-bold text-slate-100">{records.length}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 rounded-lg bg-slate-800 text-slate-400">
|
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">有效记录</p>
|
|
||||||
<p className="text-2xl font-bold text-emerald-400">{validCount}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
|
||||||
<CheckCircle className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">无效记录</p>
|
|
||||||
<p className="text-2xl font-bold text-red-400">{invalidCount}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
|
|
||||||
<AlertTriangle className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">导入成功</p>
|
|
||||||
<p className="text-2xl font-bold text-blue-400">{successCount}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
|
||||||
<Server className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Records Table */}
|
|
||||||
{records.length > 0 && (
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-slate-100">数据预览</CardTitle>
|
|
||||||
<CardDescription className="text-slate-500">
|
|
||||||
请检查数据是否正确,修复无效记录后导入
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="border-slate-700 text-slate-300 hover:text-slate-100"
|
|
||||||
onClick={clearAll}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
清空
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
onClick={startImport}
|
|
||||||
disabled={isImporting || validCount === 0}
|
|
||||||
>
|
|
||||||
{isImporting ? (
|
|
||||||
<RotateCw className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{isImporting ? '导入中...' : '开始导入'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{isImporting && (
|
|
||||||
<div className="px-6 py-4 border-b border-slate-800">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm text-slate-400">导入进度</span>
|
|
||||||
<span className="text-sm text-slate-300">{importProgress}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={importProgress} className="h-2 bg-slate-800" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="border-t border-slate-800">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
|
||||||
<TableHead className="text-slate-400">状态</TableHead>
|
|
||||||
<TableHead className="text-slate-400">主机名</TableHead>
|
|
||||||
<TableHead className="text-slate-400">IP地址</TableHead>
|
|
||||||
<TableHead className="text-slate-400">操作系统</TableHead>
|
|
||||||
<TableHead className="text-slate-400">配置</TableHead>
|
|
||||||
<TableHead className="text-slate-400">区域</TableHead>
|
|
||||||
<TableHead className="text-slate-400">标签</TableHead>
|
|
||||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{records.map((record) => (
|
|
||||||
<TableRow
|
|
||||||
key={record.id}
|
|
||||||
className={cn(
|
|
||||||
'border-slate-800',
|
|
||||||
record.status === 'invalid' && 'bg-red-500/5',
|
|
||||||
record.status === 'success' && 'bg-emerald-500/5'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
{record.status === 'valid' && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
|
|
||||||
>
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
有效
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{record.status === 'invalid' && (
|
|
||||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
|
||||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
|
||||||
无效
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{record.status === 'importing' && (
|
|
||||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
|
|
||||||
<RotateCw className="h-3 w-3 mr-1 animate-spin" />
|
|
||||||
导入中
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{record.status === 'success' && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
|
|
||||||
>
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
成功
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
value={record.hostname}
|
|
||||||
className={cn(
|
|
||||||
'h-8 text-sm bg-transparent border-0 border-b rounded-none px-0 focus-visible:ring-0',
|
|
||||||
record.status === 'invalid' && !record.hostname
|
|
||||||
? 'border-red-500 text-red-400'
|
|
||||||
: 'border-slate-700 text-slate-200'
|
|
||||||
)}
|
|
||||||
placeholder="主机名"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
value={record.ip}
|
|
||||||
className={cn(
|
|
||||||
'h-8 text-sm bg-transparent border-0 border-b rounded-none px-0 focus-visible:ring-0',
|
|
||||||
record.status === 'invalid' && record.message?.includes('IP')
|
|
||||||
? 'border-red-500 text-red-400'
|
|
||||||
: 'border-slate-700 text-slate-200'
|
|
||||||
)}
|
|
||||||
placeholder="IP地址"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-slate-300">{record.os}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-slate-300">
|
|
||||||
{record.cpu}核 / {record.memory}GB / {record.disk}GB
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-slate-300">{record.region}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-slate-300">{record.tags}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
|
||||||
onClick={() => removeRecord(record.id)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Import Tips */}
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm text-slate-400">导入说明</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-2 text-sm text-slate-500">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-400 mt-0.5">•</span>
|
|
||||||
<span>支持 Excel (.xlsx, .xls) 和 CSV 格式文件</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-400 mt-0.5">•</span>
|
|
||||||
<span>文件大小不超过 10MB,单次导入不超过 1000 条记录</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-400 mt-0.5">•</span>
|
|
||||||
<span>必填字段:主机名、IP地址、操作系统</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-400 mt-0.5">•</span>
|
|
||||||
<span>IP地址必须是有效的IPv4格式</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-400 mt-0.5">•</span>
|
|
||||||
<span>标签使用逗号分隔,如:web,production,frontend</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
@@ -19,8 +21,9 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { regionApi } from '@/services/api';
|
import { regionApi, serverApi } from '@/services/api';
|
||||||
|
|
||||||
interface RegionOption {
|
interface RegionOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,9 +43,73 @@ const connectionMethods = [
|
|||||||
|
|
||||||
type PreviewRow = {
|
type PreviewRow = {
|
||||||
row: number; hostname: string; ip: string; port: number;
|
row: number; hostname: string; ip: string; port: number;
|
||||||
username: string; status: string; message: string;
|
username: string; os: string; cpu: number; memory: number; disk: number; tags: string;
|
||||||
|
status: string; message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 解析单行 CSV(支持引号包裹与转义) // Logan.Li
|
||||||
|
function parseCSVLine(line: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (line[i + 1] === '"') { current += '"'; i++; }
|
||||||
|
else inQuotes = false;
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === ',') {
|
||||||
|
result.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current);
|
||||||
|
return result.map((s) => s.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析整个 CSV 文本为对象数组(首行为表头) // Logan.Li
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.trim());
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase());
|
||||||
|
const rows: Record<string, string>[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = parseCSVLine(lines[i]);
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, idx) => { row[h] = cols[idx] ?? ''; });
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4 格式校验 // Logan.Li
|
||||||
|
function isValidIP(ip: string): boolean {
|
||||||
|
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载 CSV 导入模板 // Logan.Li
|
||||||
|
function downloadTemplate() {
|
||||||
|
const csv = [
|
||||||
|
'hostname,ip,port,username,os,cpu,memory,disk,tags',
|
||||||
|
'web-01,192.168.1.10,22,root,CentOS 7,4,8,100,"web,prod"',
|
||||||
|
'db-01,192.168.1.20,22,root,Ubuntu 22.04,8,16,200,"db,prod"',
|
||||||
|
].join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'servers-template.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [selectedRegion, setSelectedRegion] = useState<string>('');
|
const [selectedRegion, setSelectedRegion] = useState<string>('');
|
||||||
@@ -51,6 +118,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
||||||
const [regions, setRegions] = useState<RegionOption[]>([]);
|
const [regions, setRegions] = useState<RegionOption[]>([]);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
|
||||||
// 对话框打开时拉取区域列表 // Logan.Li
|
// 对话框打开时拉取区域列表 // Logan.Li
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,14 +145,55 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = async () => {
|
||||||
if (step === 2 && selectedFile) {
|
if (step === 2 && selectedFile) {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setTimeout(() => {
|
try {
|
||||||
|
const text = await selectedFile.text();
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
toast.error('文件无有效数据,请使用模板格式(首行为表头)');
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setPreviewData([]); // 真实解析由后端处理
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const preview: PreviewRow[] = rows.map((r, idx) => {
|
||||||
|
const hostname = (r.hostname ?? '').trim();
|
||||||
|
const ip = (r.ip ?? '').trim();
|
||||||
|
const row: PreviewRow = {
|
||||||
|
row: idx + 2,
|
||||||
|
hostname,
|
||||||
|
ip,
|
||||||
|
port: Number(r.port) || 22,
|
||||||
|
username: (r.username ?? '').trim() || 'root',
|
||||||
|
os: r.os ?? '',
|
||||||
|
cpu: Number(r.cpu) || 0,
|
||||||
|
memory: Number(r.memory) || 0,
|
||||||
|
disk: Number(r.disk) || 0,
|
||||||
|
tags: r.tags ?? '',
|
||||||
|
status: 'valid',
|
||||||
|
message: '有效',
|
||||||
|
};
|
||||||
|
const errs: string[] = [];
|
||||||
|
if (!hostname) errs.push('主机名缺失');
|
||||||
|
if (!ip) errs.push('IP缺失');
|
||||||
|
else if (!isValidIP(ip)) errs.push('IP格式错误');
|
||||||
|
const key = `${hostname}|${ip}`;
|
||||||
|
if (hostname && ip && seen.has(key)) errs.push('重复行');
|
||||||
|
else seen.add(key);
|
||||||
|
if (errs.length > 0) {
|
||||||
|
row.status = 'error';
|
||||||
|
row.message = errs.join('、');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
setPreviewData(preview);
|
||||||
setStep(step + 1);
|
setStep(step + 1);
|
||||||
}, 1500);
|
} catch {
|
||||||
|
toast.error('文件解析失败,请确认是 CSV 格式');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setStep(step + 1);
|
setStep(step + 1);
|
||||||
}
|
}
|
||||||
@@ -102,24 +211,52 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const resetState = () => {
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setSelectedRegion('');
|
setSelectedRegion('');
|
||||||
setConnectionMethod('password');
|
setConnectionMethod('password');
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setPreviewData([]);
|
setPreviewData([]);
|
||||||
}, 1500);
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
const valid = previewData.filter((d) => d.status === 'valid');
|
||||||
|
if (valid.length === 0) return;
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const servers = valid.map((d) => ({
|
||||||
|
hostname: d.hostname,
|
||||||
|
ip: d.ip,
|
||||||
|
port: d.port,
|
||||||
|
username: d.username,
|
||||||
|
os: d.os,
|
||||||
|
cpu: d.cpu,
|
||||||
|
memory: d.memory,
|
||||||
|
disk: d.disk,
|
||||||
|
tags: d.tags,
|
||||||
|
}));
|
||||||
|
const resp: any = await serverApi.batchCreate({
|
||||||
|
servers,
|
||||||
|
region_id: selectedRegion || undefined,
|
||||||
|
auth_type: connectionMethod,
|
||||||
|
});
|
||||||
|
const r = resp ?? {};
|
||||||
|
const success = r.success ?? 0;
|
||||||
|
const skipped = r.skipped ?? 0;
|
||||||
|
const failed = r.failed ?? 0;
|
||||||
|
toast.success(`导入完成:成功 ${success},跳过 ${skipped},失败 ${failed}`);
|
||||||
|
resetState();
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || '导入失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
setStep(1);
|
resetState();
|
||||||
setSelectedRegion('');
|
|
||||||
setConnectionMethod('password');
|
|
||||||
setSelectedFile(null);
|
|
||||||
setPreviewData([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validCount = previewData.filter(d => d.status === 'valid').length;
|
const validCount = previewData.filter(d => d.status === 'valid').length;
|
||||||
@@ -137,6 +274,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<DialogContent className="max-w-3xl bg-slate-900 border-slate-700 text-slate-100">
|
<DialogContent className="max-w-3xl bg-slate-900 border-slate-700 text-slate-100">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">Excel导入服务器</DialogTitle>
|
<DialogTitle className="text-xl">Excel导入服务器</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Step Indicator */}
|
{/* Step Indicator */}
|
||||||
@@ -178,7 +316,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<p className="text-xs text-slate-500">下载Excel模板,填写服务器信息后上传</p>
|
<p className="text-xs text-slate-500">下载Excel模板,填写服务器信息后上传</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={downloadTemplate}>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
下载模板
|
下载模板
|
||||||
</Button>
|
</Button>
|
||||||
@@ -236,10 +374,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
>
|
>
|
||||||
<Upload className="h-12 w-12 mx-auto mb-4 text-slate-500" />
|
<Upload className="h-12 w-12 mx-auto mb-4 text-slate-500" />
|
||||||
<p className="text-slate-300 mb-2">点击或拖拽文件到此处上传</p>
|
<p className="text-slate-300 mb-2">点击或拖拽文件到此处上传</p>
|
||||||
<p className="text-xs text-slate-500 mb-4">支持 .xlsx, .xls 格式文件</p>
|
<p className="text-xs text-slate-500 mb-4">支持 CSV 格式文件(可点击左侧"下载模板"获取)</p>
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".xlsx,.xls"
|
accept=".csv,text/csv"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="excel-upload"
|
id="excel-upload"
|
||||||
@@ -392,11 +530,15 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={errorCount > 0 || validCount === 0}
|
disabled={isImporting || errorCount > 0 || validCount === 0}
|
||||||
className="bg-emerald-600 hover:bg-emerald-700"
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
|
{isImporting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
<Check className="h-4 w-4 mr-1" />
|
<Check className="h-4 w-4 mr-1" />
|
||||||
确认导入 ({validCount})
|
)}
|
||||||
|
{isImporting ? '导入中...' : `确认导入 (${validCount})`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export const serverApi = {
|
|||||||
batchDelete: async (ids: number[]) => {
|
batchDelete: async (ids: number[]) => {
|
||||||
return http.post('/servers/batch-delete', { ids });
|
return http.post('/servers/batch-delete', { ids });
|
||||||
},
|
},
|
||||||
|
batchCreate: async (data: { servers: any[]; region_id?: string; auth_type?: string }) => {
|
||||||
|
return http.post('/servers/batch', data);
|
||||||
|
},
|
||||||
getMetrics: async (id: number | string) => {
|
getMetrics: async (id: number | string) => {
|
||||||
// 后端暂无单机 metrics,用 dashboard metrics
|
// 后端暂无单机 metrics,用 dashboard metrics
|
||||||
return http.get('/dashboard/metrics');
|
return http.get('/dashboard/metrics');
|
||||||
|
|||||||
Reference in new issue
Block a user